From c6e298ab2726943faea7d596a6b0e4154962eaeb Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 6 Aug 2023 16:36:26 +0000 Subject: [PATCH 001/367] fix: cleanup ingest code --- .../graph/connectivity/cross_edges.py | 24 ++++--------------- pychunkedgraph/graph/misc.py | 1 - .../ingest/create/abstract_layers.py | 21 ++-------------- pychunkedgraph/ingest/create/atomic_layer.py | 4 ++-- 4 files changed, 9 insertions(+), 41 deletions(-) diff --git a/pychunkedgraph/graph/connectivity/cross_edges.py b/pychunkedgraph/graph/connectivity/cross_edges.py index 8aa52a9f1..d69759bbf 100644 --- a/pychunkedgraph/graph/connectivity/cross_edges.py +++ b/pychunkedgraph/graph/connectivity/cross_edges.py @@ -1,10 +1,9 @@ -import time +# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel + import math import multiprocessing as mp from collections import defaultdict -from typing import Optional from typing import Sequence -from typing import List from typing import Dict import numpy as np @@ -13,9 +12,7 @@ from .. import attributes from ..types import empty_2d from ..utils import basetypes -from ..utils import serializers from ..chunkedgraph import ChunkedGraph -from ..utils.generic import get_valid_timestamp from ..utils.generic import filter_failed_node_ids from ..chunks.atomic import get_touching_atomic_chunks from ..chunks.atomic import get_bounding_atomic_chunks @@ -30,14 +27,12 @@ def get_children_chunk_cross_edges( The edges are between node IDs in the given layer (not atomic). """ atomic_chunks = get_touching_atomic_chunks(cg.meta, layer, chunk_coord) - if not len(atomic_chunks): + if len(atomic_chunks) == 0: return [] - print(f"touching atomic chunk count {len(atomic_chunks)}") if not use_threads: return _get_children_chunk_cross_edges(cg, atomic_chunks, layer - 1) - print("get_children_chunk_cross_edges, atomic chunks", len(atomic_chunks)) with mp.Manager() as manager: edge_ids_shared = manager.list() edge_ids_shared.append(empty_2d) @@ -69,9 +64,6 @@ def _get_children_chunk_cross_edges_helper(args) -> None: def _get_children_chunk_cross_edges(cg, atomic_chunks, layer) -> None: - print( - f"_get_children_chunk_cross_edges {layer} atomic_chunks count {len(atomic_chunks)}" - ) cross_edges = [empty_2d] for layer2_chunk in atomic_chunks: edges = _read_atomic_chunk_cross_edges(cg, layer2_chunk, layer) @@ -80,11 +72,10 @@ def _get_children_chunk_cross_edges(cg, atomic_chunks, layer) -> None: cross_edges = np.concatenate(cross_edges) if not cross_edges.size: return empty_2d - print(f"getting roots at stop_layer {layer} {cross_edges.shape}") + cross_edges[:, 0] = cg.get_roots(cross_edges[:, 0], stop_layer=layer, ceil=False) cross_edges[:, 1] = cg.get_roots(cross_edges[:, 1], stop_layer=layer, ceil=False) result = np.unique(cross_edges, axis=0) if cross_edges.size else empty_2d - print(f"_get_children_chunk_cross_edges done {result.shape}") return result @@ -118,16 +109,13 @@ def get_chunk_nodes_cross_edge_layer( return_type dict {node_id: layer} the lowest layer (>= current layer) at which a node_id is part of a cross edge """ - print("get_bounding_atomic_chunks") atomic_chunks = get_bounding_atomic_chunks(cg.meta, layer, chunk_coord) - print("get_bounding_atomic_chunks complete") - if not len(atomic_chunks): + if len(atomic_chunks) == 0: return {} if not use_threads: return _get_chunk_nodes_cross_edge_layer(cg, atomic_chunks, layer) - print("divide tasks") cg_info = cg.get_serialized_info() manager = mp.Manager() ids_l_shared = manager.list() @@ -139,7 +127,6 @@ def get_chunk_nodes_cross_edge_layer( multi_args.append( (ids_l_shared, layers_l_shared, cg_info, atomic_chunks, layer) ) - print("divide tasks complete") multiprocess_func( _get_chunk_nodes_cross_edge_layer_helper, @@ -149,7 +136,6 @@ def get_chunk_nodes_cross_edge_layer( node_layer_d_shared = manager.dict() _find_min_layer(node_layer_d_shared, ids_l_shared, layers_l_shared) - print("_find_min_layer complete") return node_layer_d_shared diff --git a/pychunkedgraph/graph/misc.py b/pychunkedgraph/graph/misc.py index b33e8a6fd..873422db1 100644 --- a/pychunkedgraph/graph/misc.py +++ b/pychunkedgraph/graph/misc.py @@ -202,7 +202,6 @@ def get_contact_sites( # Load edges of these cs_svs edges_cs_svs_rows = cg.client.read_nodes( node_ids=u_cs_svs, - # columns=[attributes.Connectivity.Partner, attributes.Connectivity.Connected], ) pre_cs_edges = [] for ri in edges_cs_svs_rows.items(): diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 529a6846f..1973daacc 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -1,15 +1,14 @@ +# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel + """ Functions for creating parents in level 3 and above """ -import time import math import datetime import multiprocessing as mp -from collections import defaultdict from typing import Optional from typing import Sequence -from typing import List import numpy as np from multiwrapper import multiprocessing_utils as mu @@ -44,11 +43,6 @@ def add_layer( cg, layer_id, parent_coords, use_threads=n_threads > 1 ) - print("children_coords", children_coords.size, layer_id, parent_coords) - print( - "n e", len(children_ids), len(edge_ids), layer_id, parent_coords, - ) - node_layers = cg.get_chunk_layers(children_ids) edge_layers = cg.get_chunk_layers(np.unique(edge_ids)) assert np.all(node_layers < layer_id), "invalid node layers" @@ -62,7 +56,6 @@ def add_layer( edge_ids.extend(add_edge_ids) graph, _, _, graph_ids = flatgraph.build_gt_graph(edge_ids, make_directed=True) ccs = flatgraph.connected_components(graph) - print("ccs", len(ccs)) _write_connected_components( cg, layer_id, @@ -84,7 +77,6 @@ def _read_children_chunks( children_ids.append(_read_chunk([], cg, layer_id - 1, child_coord)) return np.concatenate(children_ids) - print("_read_children_chunks") with mp.Manager() as manager: children_ids_shared = manager.list() multi_args = [] @@ -102,7 +94,6 @@ def _read_children_chunks( multi_args, n_threads=min(len(multi_args), mp.cpu_count()), ) - print("_read_children_chunks done") return np.concatenate(children_ids_shared) @@ -113,7 +104,6 @@ def _read_chunk_helper(args): def _read_chunk(children_ids_shared, cg: ChunkedGraph, layer_id: int, chunk_coord): - print(f"_read_chunk {layer_id}, {chunk_coord}") x, y, z = chunk_coord range_read = cg.range_read_chunk( cg.get_chunk_id(layer=layer_id, x=x, y=y, z=z), @@ -129,7 +119,6 @@ def _read_chunk(children_ids_shared, cg: ChunkedGraph, layer_id: int, chunk_coor row_ids = filter_failed_node_ids(row_ids, segment_ids, max_children_ids) children_ids_shared.append(row_ids) - print(f"_read_chunk {layer_id}, {chunk_coord} done {len(row_ids)}") return row_ids @@ -147,13 +136,10 @@ def _write_connected_components( node_layer_d_shared = {} if layer_id < cg.meta.layer_count: - print("getting node_layer_d_shared") node_layer_d_shared = get_chunk_nodes_cross_edge_layer( cg, layer_id, parent_coords, use_threads=use_threads ) - print("node_layer_d_shared", len(node_layer_d_shared)) - ccs_with_node_ids = [] for cc in ccs: ccs_with_node_ids.append(graph_ids[cc]) @@ -186,7 +172,6 @@ def _write_connected_components( def _write_components_helper(args): - print("running _write_components_helper") cg_info, layer_id, parent_coords, ccs, node_layer_d_shared, time_stamp = args cg = ChunkedGraph(**cg_info) _write(cg, layer_id, parent_coords, ccs, node_layer_d_shared, time_stamp) @@ -241,7 +226,5 @@ def _write( if len(rows) > 100000: cg.client.write(rows) - print("wrote rows", len(rows), layer_id, parent_coords) rows = [] cg.client.write(rows) - print("wrote rows", len(rows), layer_id, parent_coords) diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index 4fa1f1688..d87638b26 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -1,14 +1,14 @@ +# pylint: disable=invalid-name, missing-function-docstring, import-outside-toplevel + """ Functions for creating atomic nodes and their level 2 abstract parents """ import datetime from typing import Dict -from typing import List from typing import Optional from typing import Sequence -import pytz import numpy as np from ...graph import attributes From b053e960f3c014ad128963824144cf7c4b8c1cf2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 6 Aug 2023 16:38:22 +0000 Subject: [PATCH 002/367] add ttl column family --- pychunkedgraph/graph/client/bigtable/client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 5b86826bd..486cbdd73 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel, line-too-long, protected-access, arguments-differ, arguments-renamed, logging-fstring-interpolation +# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel, line-too-long, protected-access, arguments-differ, arguments-renamed, logging-fstring-interpolation, too-many-arguments import sys import time @@ -15,11 +15,12 @@ from google.api_core.exceptions import Aborted from google.api_core.exceptions import DeadlineExceeded from google.api_core.exceptions import ServiceUnavailable +from google.cloud.bigtable.column_family import MaxAgeGCRule +from google.cloud.bigtable.column_family import MaxVersionsGCRule from google.cloud.bigtable.table import Table from google.cloud.bigtable.row_set import RowSet from google.cloud.bigtable.row_data import PartialRowData from google.cloud.bigtable.row_filters import RowFilter -from google.cloud.bigtable.column_family import MaxVersionsGCRule from . import utils from . import BigTableConfig @@ -637,6 +638,8 @@ def _create_column_families(self): f.create() f = self._table.column_family("3") f.create() + f = self._table.column_family("4", gc_rule=MaxAgeGCRule(datetime.timedelta(days=1))) + f.create() def _get_ids_range(self, key: bytes, size: int) -> typing.Tuple: """Returns a range (min, max) of IDs for a given `key`.""" From d78d01de1253379bba87ada16e54aece80376eb5 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 6 Aug 2023 16:40:55 +0000 Subject: [PATCH 003/367] fix: new l2 cx edge attribute --- pychunkedgraph/graph/attributes.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index 3e48d204a..ea03d2216 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -1,6 +1,9 @@ +# pylint: disable=invalid-name, missing-docstring, protected-access, raise-missing-from + # TODO design to use these attributes across different clients # `family_id` is specific to bigtable +from enum import Enum from typing import NamedTuple from .utils import serializers @@ -101,8 +104,8 @@ class Connectivity: serializer=serializers.NumPyArray(dtype=basetypes.EDGE_AREA), ) - CrossChunkEdge = _AttributeArray( - pattern=b"atomic_cross_edges_%d", + L2CrossChunkEdge = _AttributeArray( + pattern=b"l2_cross_edge_%d", family_id="3", serializer=serializers.NumPyArray( dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 @@ -115,6 +118,14 @@ class Connectivity: serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), ) + CrossChunkEdge = _AttributeArray( + pattern=b"atomic_cross_edges_%d", + family_id="4", + serializer=serializers.NumPyArray( + dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 + ), + ) + class Hierarchy: Child = _Attribute( @@ -157,8 +168,6 @@ class GraphVersion: class OperationLogs: key = b"ioperations" - from enum import Enum - class StatusCodes(Enum): SUCCESS = 0 # all is well, new changes persisted CREATED = 1 # log record created in storage From 17d4d6bdec0c7b19e7e2dc94453615b99e7e6512 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 6 Aug 2023 20:02:44 +0000 Subject: [PATCH 004/367] feat: post process sv cross edges --- pychunkedgraph/graph/attributes.py | 6 +-- .../graph/client/bigtable/client.py | 4 +- pychunkedgraph/ingest/create/atomic_layer.py | 54 ++++++++++++++++++- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index ea03d2216..b58a6f0f8 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -106,7 +106,7 @@ class Connectivity: L2CrossChunkEdge = _AttributeArray( pattern=b"l2_cross_edge_%d", - family_id="3", + family_id="4", serializer=serializers.NumPyArray( dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 ), @@ -114,13 +114,13 @@ class Connectivity: FakeEdges = _Attribute( key=b"fake_edges", - family_id="3", + family_id="4", serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), ) CrossChunkEdge = _AttributeArray( pattern=b"atomic_cross_edges_%d", - family_id="4", + family_id="3", serializer=serializers.NumPyArray( dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 ), diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 486cbdd73..19a08b9a8 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -636,9 +636,9 @@ def _create_column_families(self): f.create() f = self._table.column_family("2") f.create() - f = self._table.column_family("3") + f = self._table.column_family("3", gc_rule=MaxAgeGCRule(datetime.timedelta(days=1))) f.create() - f = self._table.column_family("4", gc_rule=MaxAgeGCRule(datetime.timedelta(days=1))) + f = self._table.column_family("4") f.create() def _get_ids_range(self, key: bytes, size: int) -> typing.Tuple: diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index d87638b26..a59bc9f20 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -101,7 +101,13 @@ def _get_remapping(chunk_edges_d: dict): def _process_component( - cg, chunk_edges_d, parent_id, node_ids, sparse_indices, remapping, time_stamp, + cg, + chunk_edges_d, + parent_id, + node_ids, + sparse_indices, + remapping, + time_stamp, ): nodes = [] chunk_out_edges = [] # out = between + cross @@ -145,3 +151,49 @@ def _get_outgoing_edges(node_id, chunk_edges_d, sparse_indices, remapping): # edges that this node is part of chunk_out_edges = np.concatenate([chunk_out_edges, edges[row_ids]]) return chunk_out_edges + + +def postprocess_atomic_chunk( + cg: ChunkedGraph, + chunk_coord: np.ndarray, + time_stamp: Optional[datetime.datetime] = None, +): + time_stamp = get_valid_timestamp(time_stamp) + + chunk_id = cg.get_chunk_id( + layer=2, x=chunk_coord[0], y=chunk_coord[1], z=chunk_coord[2] + ) + + properties = [ + attributes.Connectivity.CrossChunkEdge[l] for l in range(2, cg.meta.layer_count) + ] + + chunk_rr = cg.range_read_chunk( + chunk_id, properties=properties, time_stamp=time_stamp + ) + + result = {} + for l2id, raw_cx_edges in chunk_rr.items(): + try: + cx_edges = { + prop.index: val[0].value.copy() for prop, val in raw_cx_edges.items() + } + result[l2id] = cx_edges + except KeyError: + continue + + nodes = [] + val_dicts = [] + for l2id, cx_edges in result.items(): + val_dict = {} + for layer, edges in cx_edges.items(): + l2_edges = np.zeros_like(edges) + l2_edges[:, 0] = l2id + l2_edges[:, 1] = cg.get_parents(edges[:, 1]) + col = attributes.Connectivity.L2CrossChunkEdge[layer] + val_dict[col] = np.unique(l2_edges, axis=0) + val_dicts.append(val_dict) + + r_key = serializers.serialize_uint64(l2id) + nodes.append(cg.client.mutate_row(r_key, val_dict, time_stamp=time_stamp)) + cg.client.write(nodes) From bf3f502aadd63e8cbb3207d04bb7571f21d72600 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 11 Aug 2023 15:11:02 +0000 Subject: [PATCH 005/367] fix: use longer expiry for debugging --- pychunkedgraph/graph/attributes.py | 12 ++++++------ pychunkedgraph/graph/client/bigtable/client.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index b58a6f0f8..a3cf4a99c 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -104,6 +104,12 @@ class Connectivity: serializer=serializers.NumPyArray(dtype=basetypes.EDGE_AREA), ) + FakeEdges = _Attribute( + key=b"fake_edges", + family_id="4", + serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), + ) + L2CrossChunkEdge = _AttributeArray( pattern=b"l2_cross_edge_%d", family_id="4", @@ -112,12 +118,6 @@ class Connectivity: ), ) - FakeEdges = _Attribute( - key=b"fake_edges", - family_id="4", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), - ) - CrossChunkEdge = _AttributeArray( pattern=b"atomic_cross_edges_%d", family_id="3", diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 19a08b9a8..135ad9d07 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -636,7 +636,7 @@ def _create_column_families(self): f.create() f = self._table.column_family("2") f.create() - f = self._table.column_family("3", gc_rule=MaxAgeGCRule(datetime.timedelta(days=1))) + f = self._table.column_family("3", gc_rule=MaxAgeGCRule(datetime.timedelta(days=365))) f.create() f = self._table.column_family("4") f.create() From e7517829b98e8848803143f479ffa964d15bd27a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 11 Aug 2023 15:55:37 +0000 Subject: [PATCH 006/367] feat(ingest): read l2 cross edges --- pychunkedgraph/graph/connectivity/cross_edges.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/graph/connectivity/cross_edges.py b/pychunkedgraph/graph/connectivity/cross_edges.py index d69759bbf..99dc8df7f 100644 --- a/pychunkedgraph/graph/connectivity/cross_edges.py +++ b/pychunkedgraph/graph/connectivity/cross_edges.py @@ -82,7 +82,7 @@ def _get_children_chunk_cross_edges(cg, atomic_chunks, layer) -> None: def _read_atomic_chunk_cross_edges( cg, chunk_coord: Sequence[int], cross_edge_layer: int ) -> np.ndarray: - cross_edge_col = attributes.Connectivity.CrossChunkEdge[cross_edge_layer] + cross_edge_col = attributes.Connectivity.L2CrossChunkEdge[cross_edge_layer] range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, [cross_edge_layer]) parent_neighboring_chunk_supervoxels_d = defaultdict(list) @@ -170,7 +170,7 @@ def _read_atomic_chunk_cross_edge_nodes(cg, chunk_coord, cross_edge_layers): range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, cross_edge_layers) for l2id in l2ids: for layer in cross_edge_layers: - if attributes.Connectivity.CrossChunkEdge[layer] in range_read[l2id]: + if attributes.Connectivity.L2CrossChunkEdge[layer] in range_read[l2id]: node_layer_d[l2id] = layer break return node_layer_d @@ -190,7 +190,7 @@ def _read_atomic_chunk(cg, chunk_coord, layers): range_read = cg.range_read_chunk( cg.get_chunk_id(layer=2, x=x, y=y, z=z), properties=[child_col] - + [attributes.Connectivity.CrossChunkEdge[l] for l in layers], + + [attributes.Connectivity.L2CrossChunkEdge[l] for l in layers], ) row_ids = [] From 0a8a0b32a50fa206a8a3d1c45bbaf94da8f9892f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 12 Aug 2023 16:41:25 +0000 Subject: [PATCH 007/367] feat(ingest): postprocess job handling --- pychunkedgraph/ingest/cli.py | 25 ++++++++-- pychunkedgraph/ingest/cluster.py | 79 +++++++++++++------------------- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 7668e8f24..ed0c3a3d6 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name, missing-function-docstring, import-outside-toplevel + """ cli for running ingest """ @@ -10,6 +12,7 @@ from flask.cli import AppGroup from rq import Queue +from .cluster import enqueue_atomic_tasks from .manager import IngestionManager from .utils import bootstrap from .cluster import randomize_grid_points @@ -45,8 +48,6 @@ def ingest_graph( Main ingest command. Takes ingest config from a yaml file and queues atomic tasks. """ - from .cluster import enqueue_atomic_tasks - with open(dataset, "r") as stream: config = yaml.safe_load(stream) @@ -62,6 +63,16 @@ def ingest_graph( enqueue_atomic_tasks(IngestionManager(ingest_config, meta)) +@ingest_cli.command("postprocess") +def postprocess(): + """ + Run postprocessing step on level 2 chunks. + """ + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + enqueue_atomic_tasks(imanager, postprocess=True) + + @ingest_cli.command("imanager") @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True)) @@ -143,7 +154,15 @@ def ingest_status(): """Print ingest status to console by layer.""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - layers = range(2, imanager.cg_meta.layer_count + 1) + + layer = 2 + completed = redis.scard(f"{layer}c") + print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_count}") + + completed = redis.scard(f"pp{layer}c") + print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_count} [postprocess]") + + layers = range(3, imanager.cg_meta.layer_count + 1) for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts): completed = redis.scard(f"{layer}c") print(f"{layer}\t: {completed} / {layer_count}") diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index cf9417024..768c474ce 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name, missing-function-docstring, import-outside-toplevel + """ Ingest / create chunkedgraph with workers. """ @@ -11,6 +13,7 @@ from .common import get_atomic_chunk_data from .ran_agglomeration import get_active_edges from .create.atomic_layer import add_atomic_edges +from .create.atomic_layer import postprocess_atomic_chunk from .create.abstract_layers import add_layer from ..graph.meta import ChunkedGraphMeta from ..graph.chunks.hierarchy import get_children_chunk_coords @@ -18,44 +21,16 @@ from ..utils.redis import get_redis_connection -def _post_task_completion(imanager: IngestionManager, layer: int, coords: np.ndarray): - from os import environ - +def _post_task_completion( + imanager: IngestionManager, + layer: int, + coords: np.ndarray, + postprocess: bool = False, +): chunk_str = "_".join(map(str, coords)) # mark chunk as completed - "c" - imanager.redis.sadd(f"{layer}c", chunk_str) - - if environ.get("DO_NOT_AUTOQUEUE_PARENT_CHUNKS", None) is not None: - return - - parent_layer = layer + 1 - if parent_layer > imanager.cg_meta.layer_count: - return - - parent_coords = np.array(coords, int) // imanager.cg_meta.graph_config.FANOUT - parent_id_str = chunk_id_str(parent_layer, parent_coords) - imanager.redis.sadd(parent_id_str, chunk_str) - - parent_chunk_str = "_".join(map(str, parent_coords)) - if not imanager.redis.hget(parent_layer, parent_chunk_str): - # cache children chunk count - # checked by tracker worker to enqueue parent chunk - children_count = len( - get_children_chunk_coords(imanager.cg_meta, parent_layer, parent_coords) - ) - imanager.redis.hset(parent_layer, parent_chunk_str, children_count) - - tracker_queue = imanager.get_task_queue(f"t{layer}") - tracker_queue.enqueue( - enqueue_parent_task, - job_id=f"t{layer}_{chunk_str}", - job_timeout=f"30s", - result_ttl=0, - args=( - parent_layer, - parent_coords, - ), - ) + pprocess = "_pprocess" if postprocess else "" + imanager.redis.sadd(f"{layer}c{pprocess}", chunk_str) def enqueue_parent_task( @@ -127,7 +102,7 @@ def randomize_grid_points(X: int, Y: int, Z: int) -> Tuple[int, int, int]: yield np.unravel_index(index, (X, Y, Z)) -def enqueue_atomic_tasks(imanager: IngestionManager): +def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): from os import environ from time import sleep from rq import Queue as RQueue @@ -138,13 +113,18 @@ def enqueue_atomic_tasks(imanager: IngestionManager): atomic_chunk_bounds = imanager.cg_meta.layer_chunk_bounds[2] chunk_coords = randomize_grid_points(*atomic_chunk_bounds) chunk_count = imanager.cg_meta.layer_chunk_counts[0] - print(f"total chunk count: {chunk_count}, queuing...") - batch_size = int(environ.get("L2JOB_BATCH_SIZE", 1000)) + pprocess = "" + if postprocess: + pprocess = "_pprocess" + print("postprocessing l2 chunks") + + queue_name = f"{imanager.config.CLUSTER.ATOMIC_Q_NAME}{pprocess}" + q = imanager.get_task_queue(queue_name) job_datas = [] + batch_size = int(environ.get("L2JOB_BATCH_SIZE", 1000)) for chunk_coord in chunk_coords: - q = imanager.get_task_queue(imanager.config.CLUSTER.ATOMIC_Q_NAME) # buffer for optimal use of redis memory if len(q) > imanager.config.CLUSTER.ATOMIC_Q_LIMIT: print(f"Sleeping {imanager.config.CLUSTER.ATOMIC_Q_INTERVAL}s...") @@ -152,13 +132,13 @@ def enqueue_atomic_tasks(imanager: IngestionManager): x, y, z = chunk_coord chunk_str = f"{x}_{y}_{z}" - if imanager.redis.sismember("2c", chunk_str): + if imanager.redis.sismember(f"2c{pprocess}", chunk_str): # already done, skip continue job_datas.append( RQueue.prepare_data( _create_atomic_chunk, - args=(chunk_coord,), + args=(chunk_coord, postprocess), timeout=environ.get("L2JOB_TIMEOUT", "3m"), result_ttl=0, job_id=chunk_id_str(2, chunk_coord), @@ -170,21 +150,26 @@ def enqueue_atomic_tasks(imanager: IngestionManager): q.enqueue_many(job_datas) -def _create_atomic_chunk(coords: Sequence[int]): +def _create_atomic_chunk(coords: Sequence[int], postprocess: bool = False): """Creates single atomic chunk""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) coords = np.array(list(coords), dtype=int) - chunk_edges_all, mapping = get_atomic_chunk_data(imanager, coords) - chunk_edges_active, isolated_ids = get_active_edges(chunk_edges_all, mapping) - add_atomic_edges(imanager.cg, coords, chunk_edges_active, isolated=isolated_ids) + + if postprocess: + postprocess_atomic_chunk(imanager.cg, coords) + else: + chunk_edges_all, mapping = get_atomic_chunk_data(imanager, coords) + chunk_edges_active, isolated_ids = get_active_edges(chunk_edges_all, mapping) + add_atomic_edges(imanager.cg, coords, chunk_edges_active, isolated=isolated_ids) + if imanager.config.TEST_RUN: # print for debugging for k, v in chunk_edges_all.items(): print(k, len(v)) for k, v in chunk_edges_active.items(): print(f"active_{k}", len(v)) - _post_task_completion(imanager, 2, coords) + _post_task_completion(imanager, 2, coords, postprocess=postprocess) def _get_test_chunks(meta: ChunkedGraphMeta): From aa9a93d41827f7ac760d10546b8af2b93d727563 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 12 Aug 2023 17:14:37 +0000 Subject: [PATCH 008/367] fix(ingest): status --- pychunkedgraph/ingest/cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index ed0c3a3d6..8cf081952 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -157,13 +157,13 @@ def ingest_status(): layer = 2 completed = redis.scard(f"{layer}c") - print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_count}") + print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]}") completed = redis.scard(f"pp{layer}c") - print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_count} [postprocess]") + print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]} [postprocess]") - layers = range(3, imanager.cg_meta.layer_count + 1) - for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts): + layers = range(3, imanager.cg_meta.layer_count) + for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts[1:]): completed = redis.scard(f"{layer}c") print(f"{layer}\t: {completed} / {layer_count}") From 11992ad86648ab1225f2ece4c9dffdb09f91d7ad Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 12 Aug 2023 17:20:15 +0000 Subject: [PATCH 009/367] fix: timedelta import --- pychunkedgraph/graph/client/bigtable/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 135ad9d07..788c76a8e 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -4,8 +4,8 @@ import time import typing import logging -import datetime from datetime import datetime +from datetime import timedelta import numpy as np from multiwrapper import multiprocessing_utils as mu @@ -636,7 +636,7 @@ def _create_column_families(self): f.create() f = self._table.column_family("2") f.create() - f = self._table.column_family("3", gc_rule=MaxAgeGCRule(datetime.timedelta(days=365))) + f = self._table.column_family("3", gc_rule=MaxAgeGCRule(timedelta(days=365))) f.create() f = self._table.column_family("4") f.create() From a0d3efe7ff30bc5ce6733c49ceb44ac7104182c7 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 12 Aug 2023 19:31:05 +0000 Subject: [PATCH 010/367] fix(ingest): status --- pychunkedgraph/ingest/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 8cf081952..aedcb6d97 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -159,10 +159,10 @@ def ingest_status(): completed = redis.scard(f"{layer}c") print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]}") - completed = redis.scard(f"pp{layer}c") + completed = redis.scard(f"{layer}c_pprocess") print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]} [postprocess]") - layers = range(3, imanager.cg_meta.layer_count) + layers = range(3, imanager.cg_meta.layer_count + 1) for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts[1:]): completed = redis.scard(f"{layer}c") print(f"{layer}\t: {completed} / {layer_count}") From a199c5a08ea21476eac91597810f7aabe4eec071 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 12 Aug 2023 19:35:53 +0000 Subject: [PATCH 011/367] fix(ingest): use hypenated names for valid dns --- pychunkedgraph/ingest/cli.py | 2 +- pychunkedgraph/ingest/cluster.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index aedcb6d97..145c9bea6 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -159,7 +159,7 @@ def ingest_status(): completed = redis.scard(f"{layer}c") print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]}") - completed = redis.scard(f"{layer}c_pprocess") + completed = redis.scard(f"{layer}c-postprocess") print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]} [postprocess]") layers = range(3, imanager.cg_meta.layer_count + 1) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 768c474ce..2b7927869 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -29,7 +29,7 @@ def _post_task_completion( ): chunk_str = "_".join(map(str, coords)) # mark chunk as completed - "c" - pprocess = "_pprocess" if postprocess else "" + pprocess = "-postprocess" if postprocess else "" imanager.redis.sadd(f"{layer}c{pprocess}", chunk_str) @@ -117,7 +117,7 @@ def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): pprocess = "" if postprocess: - pprocess = "_pprocess" + pprocess = "-postprocess" print("postprocessing l2 chunks") queue_name = f"{imanager.config.CLUSTER.ATOMIC_Q_NAME}{pprocess}" From 50a344bd11a5d1ab830a728a4cecbe82b74dcd2b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 19:39:35 +0000 Subject: [PATCH 012/367] fix: rename attr; better var names --- pychunkedgraph/graph/attributes.py | 6 ++-- .../ingest/create/abstract_layers.py | 32 +++++++++++-------- pychunkedgraph/ingest/create/atomic_layer.py | 6 ++-- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index a3cf4a99c..b0f18c2ec 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -110,15 +110,15 @@ class Connectivity: serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), ) - L2CrossChunkEdge = _AttributeArray( - pattern=b"l2_cross_edge_%d", + CrossChunkEdge = _AttributeArray( + pattern=b"cross_edge_%d", family_id="4", serializer=serializers.NumPyArray( dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 ), ) - CrossChunkEdge = _AttributeArray( + AtomicCrossChunkEdge = _AttributeArray( pattern=b"atomic_cross_edges_%d", family_id="3", serializer=serializers.NumPyArray( diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 1973daacc..215929c41 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -56,12 +56,15 @@ def add_layer( edge_ids.extend(add_edge_ids) graph, _, _, graph_ids = flatgraph.build_gt_graph(edge_ids, make_directed=True) ccs = flatgraph.connected_components(graph) + connected_components = [] + for cc in ccs: + connected_components.append(graph_ids[cc]) + _write_connected_components( cg, layer_id, parent_coords, - ccs, - graph_ids, + connected_components, get_valid_timestamp(time_stamp), n_threads > 1, ) @@ -126,12 +129,11 @@ def _write_connected_components( cg: ChunkedGraph, layer_id: int, parent_coords, - ccs, - graph_ids, + connected_components: list, time_stamp, use_threads=True, ) -> None: - if not ccs: + if len(connected_components) == 0: return node_layer_d_shared = {} @@ -140,24 +142,20 @@ def _write_connected_components( cg, layer_id, parent_coords, use_threads=use_threads ) - ccs_with_node_ids = [] - for cc in ccs: - ccs_with_node_ids.append(graph_ids[cc]) - if not use_threads: _write( cg, layer_id, parent_coords, - ccs_with_node_ids, + connected_components, node_layer_d_shared, time_stamp, use_threads=use_threads, ) return - task_size = int(math.ceil(len(ccs_with_node_ids) / mp.cpu_count() / 10)) - chunked_ccs = chunked(ccs_with_node_ids, task_size) + task_size = int(math.ceil(len(connected_components) / mp.cpu_count() / 10)) + chunked_ccs = chunked(connected_components, task_size) cg_info = cg.get_serialized_info() multi_args = [] for ccs in chunked_ccs: @@ -178,11 +176,17 @@ def _write_components_helper(args): def _write( - cg, layer_id, parent_coords, ccs, node_layer_d_shared, time_stamp, use_threads=True + cg, + layer_id, + parent_coords, + connected_components, + node_layer_d_shared, + time_stamp, + use_threads=True, ): parent_layer_ids = range(layer_id, cg.meta.layer_count + 1) cc_connections = {l: [] for l in parent_layer_ids} - for node_ids in ccs: + for node_ids in connected_components: layer = layer_id if len(node_ids) == 1: layer = node_layer_d_shared.get(node_ids[0], cg.meta.layer_count) diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index a59bc9f20..42b6a01b5 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -126,7 +126,7 @@ def _process_component( for cc_layer in u_cce_layers: layer_out_edges = chunk_out_edges[cce_layers == cc_layer] if layer_out_edges.size: - col = attributes.Connectivity.CrossChunkEdge[cc_layer] + col = attributes.Connectivity.AtomicCrossChunkEdge[cc_layer] val_dict[col] = layer_out_edges r_key = serializers.serialize_uint64(parent_id) @@ -165,7 +165,7 @@ def postprocess_atomic_chunk( ) properties = [ - attributes.Connectivity.CrossChunkEdge[l] for l in range(2, cg.meta.layer_count) + attributes.Connectivity.AtomicCrossChunkEdge[l] for l in range(2, cg.meta.layer_count) ] chunk_rr = cg.range_read_chunk( @@ -190,7 +190,7 @@ def postprocess_atomic_chunk( l2_edges = np.zeros_like(edges) l2_edges[:, 0] = l2id l2_edges[:, 1] = cg.get_parents(edges[:, 1]) - col = attributes.Connectivity.L2CrossChunkEdge[layer] + col = attributes.Connectivity.CrossChunkEdge[layer] val_dict[col] = np.unique(l2_edges, axis=0) val_dicts.append(val_dict) From 9c5e82746de91a53af729590f261c3cd002b1d69 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 19:40:48 +0000 Subject: [PATCH 013/367] fix: rename attr; better var names --- pychunkedgraph/graph/edits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index be2eee1c6..4cb536ea7 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -565,7 +565,7 @@ def _get_atomic_cross_edges_val_dict(self): for id_ in new_ids: val_dict = {} for layer, edges in atomic_cross_edges_d[id_].items(): - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + val_dict[attributes.Connectivity.AtomicCrossChunkEdge[layer]] = edges val_dicts[id_] = val_dict return val_dicts From f44c75a91f1a87028a05e30c1ddc171b6f479e62 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 20:15:31 +0000 Subject: [PATCH 014/367] fix: add more docs; better var names --- .../graph/connectivity/cross_edges.py | 79 ++++++++++++++----- 1 file changed, 58 insertions(+), 21 deletions(-) diff --git a/pychunkedgraph/graph/connectivity/cross_edges.py b/pychunkedgraph/graph/connectivity/cross_edges.py index 99dc8df7f..d2dbcbb8c 100644 --- a/pychunkedgraph/graph/connectivity/cross_edges.py +++ b/pychunkedgraph/graph/connectivity/cross_edges.py @@ -64,6 +64,11 @@ def _get_children_chunk_cross_edges_helper(args) -> None: def _get_children_chunk_cross_edges(cg, atomic_chunks, layer) -> None: + """ + Non parallelized version + Cross edges that connect children chunks. + The edges are between node IDs in the given layer (not atomic). + """ cross_edges = [empty_2d] for layer2_chunk in atomic_chunks: edges = _read_atomic_chunk_cross_edges(cg, layer2_chunk, layer) @@ -82,7 +87,11 @@ def _get_children_chunk_cross_edges(cg, atomic_chunks, layer) -> None: def _read_atomic_chunk_cross_edges( cg, chunk_coord: Sequence[int], cross_edge_layer: int ) -> np.ndarray: - cross_edge_col = attributes.Connectivity.L2CrossChunkEdge[cross_edge_layer] + """ + Returns cross edges between l2 nodes in current chunk and + l1 supervoxels from neighbor chunks. + """ + cross_edge_col = attributes.Connectivity.CrossChunkEdge[cross_edge_layer] range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, [cross_edge_layer]) parent_neighboring_chunk_supervoxels_d = defaultdict(list) @@ -93,8 +102,7 @@ def _read_atomic_chunk_cross_edges( parent_neighboring_chunk_supervoxels_d[l2id] = edges[:, 1] cross_edges = [empty_2d] - for l2id in parent_neighboring_chunk_supervoxels_d: - nebor_svs = parent_neighboring_chunk_supervoxels_d[l2id] + for l2id, nebor_svs in parent_neighboring_chunk_supervoxels_d.items(): chunk_parent_ids = np.array([l2id] * len(nebor_svs), dtype=basetypes.NODE_ID) cross_edges.append(np.vstack([chunk_parent_ids, nebor_svs]).T) cross_edges = np.concatenate(cross_edges) @@ -118,14 +126,14 @@ def get_chunk_nodes_cross_edge_layer( cg_info = cg.get_serialized_info() manager = mp.Manager() - ids_l_shared = manager.list() - layers_l_shared = manager.list() + node_ids_shared = manager.list() + node_layers_shared = manager.list() task_size = int(math.ceil(len(atomic_chunks) / mp.cpu_count() / 10)) chunked_l2chunk_list = chunked(atomic_chunks, task_size) multi_args = [] for atomic_chunks in chunked_l2chunk_list: multi_args.append( - (ids_l_shared, layers_l_shared, cg_info, atomic_chunks, layer) + (node_ids_shared, node_layers_shared, cg_info, atomic_chunks, layer) ) multiprocess_func( @@ -135,24 +143,28 @@ def get_chunk_nodes_cross_edge_layer( ) node_layer_d_shared = manager.dict() - _find_min_layer(node_layer_d_shared, ids_l_shared, layers_l_shared) + _find_min_layer(node_layer_d_shared, node_ids_shared, node_layers_shared) return node_layer_d_shared def _get_chunk_nodes_cross_edge_layer_helper(args): - ids_l_shared, layers_l_shared, cg_info, atomic_chunks, layer = args + node_ids_shared, node_layers_shared, cg_info, atomic_chunks, layer = args cg = ChunkedGraph(**cg_info) node_layer_d = _get_chunk_nodes_cross_edge_layer(cg, atomic_chunks, layer) - ids_l_shared.append(np.fromiter(node_layer_d.keys(), dtype=basetypes.NODE_ID)) - layers_l_shared.append(np.fromiter(node_layer_d.values(), dtype=np.uint8)) + node_ids_shared.append(np.fromiter(node_layer_d.keys(), dtype=basetypes.NODE_ID)) + node_layers_shared.append(np.fromiter(node_layer_d.values(), dtype=np.uint8)) def _get_chunk_nodes_cross_edge_layer(cg, atomic_chunks, layer): + """ + Non parallelized version + gets nodes in a chunk that are part of cross chunk edges + return_type dict {node_id: layer} + the lowest layer (>= current layer) at which a node_id is part of a cross edge + """ atomic_node_layer_d = {} for atomic_chunk in atomic_chunks: - chunk_node_layer_d = _read_atomic_chunk_cross_edge_nodes( - cg, atomic_chunk, range(layer, cg.meta.layer_count + 1) - ) + chunk_node_layer_d = _read_atomic_chunk_cross_edge_nodes(cg, atomic_chunk, layer) atomic_node_layer_d.update(chunk_node_layer_d) l2ids = np.fromiter(atomic_node_layer_d.keys(), dtype=basetypes.NODE_ID) @@ -165,32 +177,57 @@ def _get_chunk_nodes_cross_edge_layer(cg, atomic_chunks, layer): return node_layer_d -def _read_atomic_chunk_cross_edge_nodes(cg, chunk_coord, cross_edge_layers): +def _read_atomic_chunk_cross_edge_nodes(cg, chunk_coord, layer): + """ + the lowest layer at which an l2 node is part of a cross edge + """ node_layer_d = {} - range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, cross_edge_layers) + relevant_layers = range(layer, cg.meta.layer_count + 1) + range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, relevant_layers) for l2id in l2ids: - for layer in cross_edge_layers: - if attributes.Connectivity.L2CrossChunkEdge[layer] in range_read[l2id]: + for layer in relevant_layers: + if attributes.Connectivity.CrossChunkEdge[layer] in range_read[l2id]: node_layer_d[l2id] = layer break return node_layer_d -def _find_min_layer(node_layer_d_shared, ids_l_shared, layers_l_shared): - node_ids = np.concatenate(ids_l_shared) - layers = np.concatenate(layers_l_shared) +def _find_min_layer(node_layer_d_shared, node_ids_shared, node_layers_shared): + """ + `node_layer_d_shared`: DictProxy + + `node_ids_shared`: ListProxy + + `node_layers_shared`: ListProxy + + Due to parallelization, there will be multiple values for min_layer of a node. + We need to find the global min_layer after all multiprocesses return. + For eg: + At some indices p and q, there will be a node_id x + i.e. `node_ids_shared[p] == node_ids_shared[q]` + + and node_layers_shared[p] != node_layers_shared[q] + so we need: + `node_layer_d_shared[x] = min(node_layers_shared[p], node_layers_shared[q])` + """ + node_ids = np.concatenate(node_ids_shared) + layers = np.concatenate(node_layers_shared) for i, node_id in enumerate(node_ids): layer = node_layer_d_shared.get(node_id, layers[i]) node_layer_d_shared[node_id] = min(layer, layers[i]) def _read_atomic_chunk(cg, chunk_coord, layers): + """ + read entire atomic chunk; all nodes and their relevant cross edges + filter out invalid nodes generated by failed tasks + """ x, y, z = chunk_coord child_col = attributes.Hierarchy.Child range_read = cg.range_read_chunk( cg.get_chunk_id(layer=2, x=x, y=y, z=z), properties=[child_col] - + [attributes.Connectivity.L2CrossChunkEdge[l] for l in layers], + + [attributes.Connectivity.CrossChunkEdge[l] for l in layers], ) row_ids = [] From 68dd790b6cc3e6a105e42d9d185813f6e55442be Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 20:22:40 +0000 Subject: [PATCH 015/367] fix: move cross_edges module to ingest module; only used in ingest --- pychunkedgraph/graph/chunks/atomic.py | 6 +++--- pychunkedgraph/ingest/create/abstract_layers.py | 4 ++-- .../create}/cross_edges.py | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) rename pychunkedgraph/{graph/connectivity => ingest/create}/cross_edges.py (95%) diff --git a/pychunkedgraph/graph/chunks/atomic.py b/pychunkedgraph/graph/chunks/atomic.py index e3de065ff..b609f4cfb 100644 --- a/pychunkedgraph/graph/chunks/atomic.py +++ b/pychunkedgraph/graph/chunks/atomic.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name, missing-docstring + from typing import List from typing import Sequence from itertools import product @@ -6,8 +8,6 @@ from .utils import get_bounding_children_chunks from ..meta import ChunkedGraphMeta -from ..utils.generic import get_valid_timestamp -from ..utils import basetypes def get_touching_atomic_chunks( @@ -27,7 +27,7 @@ def get_touching_atomic_chunks( chunk_offset = chunk_coords * atomic_chunk_count mid = (atomic_chunk_count // 2) - 1 - # TODO (akhileshh) convert this for loop to numpy + # TODO (akhileshh) convert this for loop to numpy; # relevant chunks along touching planes at center for axis_1, axis_2 in product(*[range(atomic_chunk_count)] * 2): # x-y plane diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 215929c41..c5a78d2ca 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -23,8 +23,8 @@ from ...graph.utils.generic import get_valid_timestamp from ...graph.utils.generic import filter_failed_node_ids from ...graph.chunks.hierarchy import get_children_chunk_coords -from ...graph.connectivity.cross_edges import get_children_chunk_cross_edges -from ...graph.connectivity.cross_edges import get_chunk_nodes_cross_edge_layer +from .cross_edges import get_children_chunk_cross_edges +from .cross_edges import get_chunk_nodes_cross_edge_layer def add_layer( diff --git a/pychunkedgraph/graph/connectivity/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py similarity index 95% rename from pychunkedgraph/graph/connectivity/cross_edges.py rename to pychunkedgraph/ingest/create/cross_edges.py index d2dbcbb8c..481a5b6e5 100644 --- a/pychunkedgraph/graph/connectivity/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel +# pylint: disable=invalid-name, missing-docstring import math import multiprocessing as mp @@ -9,13 +9,13 @@ import numpy as np from multiwrapper.multiprocessing_utils import multiprocess_func -from .. import attributes -from ..types import empty_2d -from ..utils import basetypes -from ..chunkedgraph import ChunkedGraph -from ..utils.generic import filter_failed_node_ids -from ..chunks.atomic import get_touching_atomic_chunks -from ..chunks.atomic import get_bounding_atomic_chunks +from ...graph import attributes +from ...graph.types import empty_2d +from ...graph.utils import basetypes +from ...graph.chunkedgraph import ChunkedGraph +from ...graph.utils.generic import filter_failed_node_ids +from ...graph.chunks.atomic import get_touching_atomic_chunks +from ...graph.chunks.atomic import get_bounding_atomic_chunks from ...utils.general import chunked From 7a2726312b7caa66a508ae256d709bbc6f8d5c72 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 20:49:13 +0000 Subject: [PATCH 016/367] fix: reduce mem use; var names; remove unused code --- pychunkedgraph/ingest/cli.py | 74 ++++++------------- pychunkedgraph/ingest/cluster.py | 47 +----------- .../ingest/create/abstract_layers.py | 23 ++---- pychunkedgraph/ingest/manager.py | 6 +- tracker.py | 22 ------ 5 files changed, 38 insertions(+), 134 deletions(-) delete mode 100644 tracker.py diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 145c9bea6..486224cec 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-function-docstring, import-outside-toplevel +# pylint: disable=invalid-name, missing-function-docstring, unspecified-encoding """ cli for running ingest @@ -12,10 +12,14 @@ from flask.cli import AppGroup from rq import Queue +from .cluster import create_atomic_chunk +from .cluster import create_parent_chunk from .cluster import enqueue_atomic_tasks +from .cluster import randomize_grid_points from .manager import IngestionManager from .utils import bootstrap -from .cluster import randomize_grid_points +from .utils import chunk_id_str +from .create.abstract_layers import add_layer from ..graph.chunkedgraph import ChunkedGraph from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys @@ -90,7 +94,7 @@ def pickle_imanager(graph_id: str, dataset: click.Path, raw: bool): meta, ingest_config, _ = bootstrap(graph_id, config=config, raw=raw) imanager = IngestionManager(ingest_config, meta) - imanager.redis + imanager.redis # pylint: disable=pointless-statement @ingest_cli.command("layer") @@ -100,11 +104,6 @@ def queue_layer(parent_layer): Queue all chunk tasks at a given layer. Must be used when all the chunks at `parent_layer - 1` have completed. """ - from itertools import product - import numpy as np - from .cluster import create_parent_chunk - from .utils import chunk_id_str - assert parent_layer > 2, "This command is for layers 3 and above." redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) @@ -115,38 +114,15 @@ def queue_layer(parent_layer): bounds = imanager.cg_meta.layer_chunk_bounds[parent_layer] chunk_coords = randomize_grid_points(*bounds) - def get_chunks_not_done(coords: list) -> list: - """check for set membership in redis in batches""" - coords_strs = ["_".join(map(str, coord)) for coord in coords] - try: - completed = imanager.redis.smismember(f"{parent_layer}c", coords_strs) - except Exception: - return coords - return [coord for coord, c in zip(coords, completed) if not c] - - batch_size = int(environ.get("JOB_BATCH_SIZE", 10000)) - batches = chunked(chunk_coords, batch_size) - q = imanager.get_task_queue(f"l{parent_layer}") - - for batch in batches: - _coords = get_chunks_not_done(batch) - # buffer for optimal use of redis memory - if len(q) > int(environ.get("QUEUE_SIZE", 100000)): - interval = int(environ.get("QUEUE_INTERVAL", 300)) - sleep(interval) - - job_datas = [] - for chunk_coord in _coords: - job_datas.append( - Queue.prepare_data( - create_parent_chunk, - args=(parent_layer, chunk_coord), - result_ttl=0, - job_id=chunk_id_str(parent_layer, chunk_coord), - timeout=f"{int(parent_layer * parent_layer)}m", - ) - ) - q.enqueue_many(job_datas) + for coords in chunk_coords: + task_q = imanager.get_task_queue(f"l{parent_layer}") + task_q.enqueue( + create_parent_chunk, + job_id=chunk_id_str(parent_layer, coords), + job_timeout=f"{int(parent_layer * parent_layer)}m", + result_ttl=0, + args=(parent_layer, coords), + ) @ingest_cli.command("status") @@ -156,16 +132,16 @@ def ingest_status(): imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) layer = 2 - completed = redis.scard(f"{layer}c") - print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]}") + done = redis.scard(f"{layer}c") + print(f"{layer}\t: {done} / {imanager.cg_meta.layer_chunk_counts[0]}") - completed = redis.scard(f"{layer}c-postprocess") - print(f"{layer}\t: {completed} / {imanager.cg_meta.layer_chunk_counts[0]} [postprocess]") + done = redis.scard(f"{layer}c-postprocess") + print(f"{layer}\t: {done} / {imanager.cg_meta.layer_chunk_counts[0]} [postprocess]") layers = range(3, imanager.cg_meta.layer_count + 1) for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts[1:]): - completed = redis.scard(f"{layer}c") - print(f"{layer}\t: {completed} / {layer_count}") + done = redis.scard(f"{layer}c") + print(f"{layer}\t: {done} / {layer_count}") @ingest_cli.command("chunk") @@ -173,17 +149,13 @@ def ingest_status(): @click.argument("chunk_info", nargs=4, type=int) def ingest_chunk(queue: str, chunk_info): """Manually queue chunk when a job is stuck for whatever reason.""" - from .cluster import _create_atomic_chunk - from .cluster import create_parent_chunk - from .utils import chunk_id_str - redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) layer = chunk_info[0] coords = chunk_info[1:] queue = imanager.get_task_queue(queue) if layer == 2: - func = _create_atomic_chunk + func = create_atomic_chunk args = (coords,) else: func = create_parent_chunk diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 2b7927869..9394c4e26 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -33,49 +33,6 @@ def _post_task_completion( imanager.redis.sadd(f"{layer}c{pprocess}", chunk_str) -def enqueue_parent_task( - parent_layer: int, - parent_coords: Sequence[int], -): - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - parent_id_str = chunk_id_str(parent_layer, parent_coords) - parent_chunk_str = "_".join(map(str, parent_coords)) - - children_done = redis.scard(parent_id_str) - # if zero then this key was deleted and parent already queued. - if children_done == 0: - print("parent already queued.") - return - - # if the previous layer is complete - # no need to check children progress for each parent chunk - child_layer = parent_layer - 1 - child_layer_done = redis.scard(f"{child_layer}c") - child_layer_count = imanager.cg_meta.layer_chunk_counts[child_layer - 2] - child_layer_finished = child_layer_done == child_layer_count - - if not child_layer_finished: - children_count = int(redis.hget(parent_layer, parent_chunk_str).decode("utf-8")) - if children_done != children_count: - print("children not done.") - return - - queue = imanager.get_task_queue(f"l{parent_layer}") - queue.enqueue( - create_parent_chunk, - job_id=parent_id_str, - job_timeout=f"{int(parent_layer * parent_layer)}m", - result_ttl=0, - args=( - parent_layer, - parent_coords, - ), - ) - redis.hdel(parent_layer, parent_chunk_str) - redis.delete(parent_id_str) - - def create_parent_chunk( parent_layer: int, parent_coords: Sequence[int], @@ -137,7 +94,7 @@ def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): continue job_datas.append( RQueue.prepare_data( - _create_atomic_chunk, + create_atomic_chunk, args=(chunk_coord, postprocess), timeout=environ.get("L2JOB_TIMEOUT", "3m"), result_ttl=0, @@ -150,7 +107,7 @@ def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): q.enqueue_many(job_datas) -def _create_atomic_chunk(coords: Sequence[int], postprocess: bool = False): +def create_atomic_chunk(coords: Sequence[int], postprocess: bool = False): """Creates single atomic chunk""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index c5a78d2ca..8912a2d53 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -39,26 +39,20 @@ def add_layer( if not children_coords.size: children_coords = get_children_chunk_coords(cg.meta, layer_id, parent_coords) children_ids = _read_children_chunks(cg, layer_id, children_coords, n_threads > 1) - edge_ids = get_children_chunk_cross_edges( + cross_edges = get_children_chunk_cross_edges( cg, layer_id, parent_coords, use_threads=n_threads > 1 ) node_layers = cg.get_chunk_layers(children_ids) - edge_layers = cg.get_chunk_layers(np.unique(edge_ids)) + edge_layers = cg.get_chunk_layers(np.unique(cross_edges)) assert np.all(node_layers < layer_id), "invalid node layers" assert np.all(edge_layers < layer_id), "invalid edge layers" - # Extract connected components - # isolated_node_mask = ~np.in1d(children_ids, np.unique(edge_ids)) - # add_node_ids = children_ids[isolated_node_mask].squeeze() - add_edge_ids = np.vstack([children_ids, children_ids]).T - - edge_ids = list(edge_ids) - edge_ids.extend(add_edge_ids) - graph, _, _, graph_ids = flatgraph.build_gt_graph(edge_ids, make_directed=True) - ccs = flatgraph.connected_components(graph) - connected_components = [] - for cc in ccs: - connected_components.append(graph_ids[cc]) + + cross_edges = list(cross_edges) + cross_edges.extend(np.vstack([children_ids, children_ids]).T) # add self-edges + graph, _, _, graph_ids = flatgraph.build_gt_graph(cross_edges, make_directed=True) + raw_ccs = flatgraph.connected_components(graph) # connected components with indices + connected_components = [graph_ids[cc] for cc in raw_ccs] _write_connected_components( cg, @@ -68,7 +62,6 @@ def add_layer( get_valid_timestamp(time_stamp), n_threads > 1, ) - return f"{layer_id}_{'_'.join(map(str, parent_coords))}" def _read_children_chunks( diff --git a/pychunkedgraph/ingest/manager.py b/pychunkedgraph/ingest/manager.py index f5f870810..55e7d253f 100644 --- a/pychunkedgraph/ingest/manager.py +++ b/pychunkedgraph/ingest/manager.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name, missing-docstring + import pickle from . import IngestConfig @@ -15,7 +17,9 @@ def __init__(self, config: IngestConfig, chunkedgraph_meta: ChunkedGraphMeta): self._cg = None self._redis = None self._task_queues = {} - self.redis # initiate and cache info + + # initiate redis and cache info + self.redis # pylint: disable=pointless-statement @property def config(self): diff --git a/tracker.py b/tracker.py deleted file mode 100644 index d2ae63cb3..000000000 --- a/tracker.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys -from rq import Connection, Worker - -# Preload libraries from pychunkedgraph.ingest.cluster -from typing import Sequence, Tuple - -import numpy as np - -from pychunkedgraph.ingest.utils import chunk_id_str -from pychunkedgraph.ingest.manager import IngestionManager -from pychunkedgraph.ingest.common import get_atomic_chunk_data -from pychunkedgraph.ingest.ran_agglomeration import get_active_edges -from pychunkedgraph.ingest.create.atomic_layer import add_atomic_edges -from pychunkedgraph.ingest.create.abstract_layers import add_layer -from pychunkedgraph.graph.meta import ChunkedGraphMeta -from pychunkedgraph.graph.chunks.hierarchy import get_children_chunk_coords -from pychunkedgraph.utils.redis import keys as r_keys -from pychunkedgraph.utils.redis import get_redis_connection - -qs = sys.argv[1:] -w = Worker(qs, connection=get_redis_connection()) -w.work() \ No newline at end of file From 68e2f8e86ae04db17dc8d1d9708a12f0a10a7f15 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 21:06:45 +0000 Subject: [PATCH 017/367] fix: adds cg typehint --- pychunkedgraph/ingest/create/cross_edges.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pychunkedgraph/ingest/create/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py index 481a5b6e5..78b7309fe 100644 --- a/pychunkedgraph/ingest/create/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -20,7 +20,7 @@ def get_children_chunk_cross_edges( - cg, layer, chunk_coord, *, use_threads=True + cg: ChunkedGraph, layer, chunk_coord, *, use_threads=True ) -> np.ndarray: """ Cross edges that connect children chunks. @@ -63,7 +63,7 @@ def _get_children_chunk_cross_edges_helper(args) -> None: edge_ids_shared.append(_get_children_chunk_cross_edges(cg, atomic_chunks, layer)) -def _get_children_chunk_cross_edges(cg, atomic_chunks, layer) -> None: +def _get_children_chunk_cross_edges(cg: ChunkedGraph, atomic_chunks, layer) -> None: """ Non parallelized version Cross edges that connect children chunks. @@ -85,7 +85,7 @@ def _get_children_chunk_cross_edges(cg, atomic_chunks, layer) -> None: def _read_atomic_chunk_cross_edges( - cg, chunk_coord: Sequence[int], cross_edge_layer: int + cg: ChunkedGraph, chunk_coord: Sequence[int], cross_edge_layer: int ) -> np.ndarray: """ Returns cross edges between l2 nodes in current chunk and @@ -110,7 +110,7 @@ def _read_atomic_chunk_cross_edges( def get_chunk_nodes_cross_edge_layer( - cg, layer: int, chunk_coord: Sequence[int], use_threads=True + cg: ChunkedGraph, layer: int, chunk_coord: Sequence[int], use_threads=True ) -> Dict: """ gets nodes in a chunk that are part of cross chunk edges @@ -155,7 +155,7 @@ def _get_chunk_nodes_cross_edge_layer_helper(args): node_layers_shared.append(np.fromiter(node_layer_d.values(), dtype=np.uint8)) -def _get_chunk_nodes_cross_edge_layer(cg, atomic_chunks, layer): +def _get_chunk_nodes_cross_edge_layer(cg: ChunkedGraph, atomic_chunks, layer): """ Non parallelized version gets nodes in a chunk that are part of cross chunk edges @@ -164,7 +164,9 @@ def _get_chunk_nodes_cross_edge_layer(cg, atomic_chunks, layer): """ atomic_node_layer_d = {} for atomic_chunk in atomic_chunks: - chunk_node_layer_d = _read_atomic_chunk_cross_edge_nodes(cg, atomic_chunk, layer) + chunk_node_layer_d = _read_atomic_chunk_cross_edge_nodes( + cg, atomic_chunk, layer + ) atomic_node_layer_d.update(chunk_node_layer_d) l2ids = np.fromiter(atomic_node_layer_d.keys(), dtype=basetypes.NODE_ID) @@ -177,7 +179,7 @@ def _get_chunk_nodes_cross_edge_layer(cg, atomic_chunks, layer): return node_layer_d -def _read_atomic_chunk_cross_edge_nodes(cg, chunk_coord, layer): +def _read_atomic_chunk_cross_edge_nodes(cg: ChunkedGraph, chunk_coord, layer): """ the lowest layer at which an l2 node is part of a cross edge """ @@ -217,7 +219,7 @@ def _find_min_layer(node_layer_d_shared, node_ids_shared, node_layers_shared): node_layer_d_shared[node_id] = min(layer, layers[i]) -def _read_atomic_chunk(cg, chunk_coord, layers): +def _read_atomic_chunk(cg: ChunkedGraph, chunk_coord, layers): """ read entire atomic chunk; all nodes and their relevant cross edges filter out invalid nodes generated by failed tasks From 3ceee805890353a5e6ab9116643d0717985bd446 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 21:32:58 +0000 Subject: [PATCH 018/367] fix: reduce loc --- .../ingest/create/abstract_layers.py | 60 ++++++------------- pychunkedgraph/ingest/create/cross_edges.py | 2 +- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 8912a2d53..31610aeab 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -49,9 +49,9 @@ def add_layer( assert np.all(edge_layers < layer_id), "invalid edge layers" cross_edges = list(cross_edges) - cross_edges.extend(np.vstack([children_ids, children_ids]).T) # add self-edges + cross_edges.extend(np.vstack([children_ids, children_ids]).T) # add self-edges graph, _, _, graph_ids = flatgraph.build_gt_graph(cross_edges, make_directed=True) - raw_ccs = flatgraph.connected_components(graph) # connected components with indices + raw_ccs = flatgraph.connected_components(graph) # connected components with indices connected_components = [graph_ids[cc] for cc in raw_ccs] _write_connected_components( @@ -119,42 +119,26 @@ def _read_chunk(children_ids_shared, cg: ChunkedGraph, layer_id: int, chunk_coor def _write_connected_components( - cg: ChunkedGraph, - layer_id: int, - parent_coords, - connected_components: list, - time_stamp, - use_threads=True, -) -> None: - if len(connected_components) == 0: + cg, layer, pcoords, components, cross_edges, time_stamp, use_threads=True +): + if len(components) == 0: return - node_layer_d_shared = {} - if layer_id < cg.meta.layer_count: - node_layer_d_shared = get_chunk_nodes_cross_edge_layer( - cg, layer_id, parent_coords, use_threads=use_threads - ) + node_layer_d = {} + if layer < cg.meta.layer_count: + node_layer_d = get_chunk_nodes_cross_edge_layer(cg, layer, pcoords, use_threads) if not use_threads: - _write( - cg, - layer_id, - parent_coords, - connected_components, - node_layer_d_shared, - time_stamp, - use_threads=use_threads, - ) + _write(cg, layer, pcoords, components, cross_edges, node_layer_d, time_stamp) return - task_size = int(math.ceil(len(connected_components) / mp.cpu_count() / 10)) - chunked_ccs = chunked(connected_components, task_size) + task_size = int(math.ceil(len(components) / mp.cpu_count() / 10)) + chunked_ccs = chunked(components, task_size) cg_info = cg.get_serialized_info() multi_args = [] for ccs in chunked_ccs: - multi_args.append( - (cg_info, layer_id, parent_coords, ccs, node_layer_d_shared, time_stamp) - ) + args = (cg_info, layer, pcoords, ccs, cross_edges, node_layer_d, time_stamp) + multi_args.append(args) mu.multiprocess_func( _write_components_helper, multi_args, @@ -163,26 +147,20 @@ def _write_connected_components( def _write_components_helper(args): - cg_info, layer_id, parent_coords, ccs, node_layer_d_shared, time_stamp = args + cg_info, layer, pcoords, ccs, cross_edges, node_layer_d, time_stamp = args cg = ChunkedGraph(**cg_info) - _write(cg, layer_id, parent_coords, ccs, node_layer_d_shared, time_stamp) + _write(cg, layer, pcoords, ccs, cross_edges, node_layer_d, time_stamp) def _write( - cg, - layer_id, - parent_coords, - connected_components, - node_layer_d_shared, - time_stamp, - use_threads=True, + cg, layer_id, parent_coords, components, cross_edges, node_layer_d, time_stamp ): parent_layer_ids = range(layer_id, cg.meta.layer_count + 1) cc_connections = {l: [] for l in parent_layer_ids} - for node_ids in connected_components: + for node_ids in components: layer = layer_id if len(node_ids) == 1: - layer = node_layer_d_shared.get(node_ids[0], cg.meta.layer_count) + layer = node_layer_d.get(node_ids[0], cg.meta.layer_count) cc_connections[layer].append(node_ids) rows = [] @@ -199,7 +177,7 @@ def _write( reserved_parent_ids = cg.id_client.create_node_ids( parent_chunk_id, size=len(cc_connections[parent_layer_id]), - root_chunk=parent_layer_id == cg.meta.layer_count and use_threads, + root_chunk=parent_layer_id == cg.meta.layer_count, ) for i_cc, node_ids in enumerate(cc_connections[parent_layer_id]): diff --git a/pychunkedgraph/ingest/create/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py index 78b7309fe..b7a888b27 100644 --- a/pychunkedgraph/ingest/create/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -24,7 +24,7 @@ def get_children_chunk_cross_edges( ) -> np.ndarray: """ Cross edges that connect children chunks. - The edges are between node IDs in the given layer (not atomic). + The edges are between node IDs in the given layer. """ atomic_chunks = get_touching_atomic_chunks(cg.meta, layer, chunk_coord) if len(atomic_chunks) == 0: From ef995cdd5eee9a7e4d36036e4529682402bd9e21 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 21:36:46 +0000 Subject: [PATCH 019/367] fix: use shorter name --- .../ingest/create/abstract_layers.py | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 31610aeab..107ac5714 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -39,18 +39,18 @@ def add_layer( if not children_coords.size: children_coords = get_children_chunk_coords(cg.meta, layer_id, parent_coords) children_ids = _read_children_chunks(cg, layer_id, children_coords, n_threads > 1) - cross_edges = get_children_chunk_cross_edges( + cx_edges = get_children_chunk_cross_edges( cg, layer_id, parent_coords, use_threads=n_threads > 1 ) node_layers = cg.get_chunk_layers(children_ids) - edge_layers = cg.get_chunk_layers(np.unique(cross_edges)) + edge_layers = cg.get_chunk_layers(np.unique(cx_edges)) assert np.all(node_layers < layer_id), "invalid node layers" assert np.all(edge_layers < layer_id), "invalid edge layers" - cross_edges = list(cross_edges) - cross_edges.extend(np.vstack([children_ids, children_ids]).T) # add self-edges - graph, _, _, graph_ids = flatgraph.build_gt_graph(cross_edges, make_directed=True) + cx_edges = list(cx_edges) + cx_edges.extend(np.vstack([children_ids, children_ids]).T) # add self-edges + graph, _, _, graph_ids = flatgraph.build_gt_graph(cx_edges, make_directed=True) raw_ccs = flatgraph.connected_components(graph) # connected components with indices connected_components = [graph_ids[cc] for cc in raw_ccs] @@ -59,6 +59,7 @@ def add_layer( layer_id, parent_coords, connected_components, + cx_edges, get_valid_timestamp(time_stamp), n_threads > 1, ) @@ -119,7 +120,7 @@ def _read_chunk(children_ids_shared, cg: ChunkedGraph, layer_id: int, chunk_coor def _write_connected_components( - cg, layer, pcoords, components, cross_edges, time_stamp, use_threads=True + cg, layer, pcoords, components, cx_edges, time_stamp, use_threads=True ): if len(components) == 0: return @@ -129,7 +130,7 @@ def _write_connected_components( node_layer_d = get_chunk_nodes_cross_edge_layer(cg, layer, pcoords, use_threads) if not use_threads: - _write(cg, layer, pcoords, components, cross_edges, node_layer_d, time_stamp) + _write(cg, layer, pcoords, components, cx_edges, node_layer_d, time_stamp) return task_size = int(math.ceil(len(components) / mp.cpu_count() / 10)) @@ -137,7 +138,7 @@ def _write_connected_components( cg_info = cg.get_serialized_info() multi_args = [] for ccs in chunked_ccs: - args = (cg_info, layer, pcoords, ccs, cross_edges, node_layer_d, time_stamp) + args = (cg_info, layer, pcoords, ccs, cx_edges, node_layer_d, time_stamp) multi_args.append(args) mu.multiprocess_func( _write_components_helper, @@ -147,14 +148,12 @@ def _write_connected_components( def _write_components_helper(args): - cg_info, layer, pcoords, ccs, cross_edges, node_layer_d, time_stamp = args + cg_info, layer, pcoords, ccs, cx_edges, node_layer_d, time_stamp = args cg = ChunkedGraph(**cg_info) - _write(cg, layer, pcoords, ccs, cross_edges, node_layer_d, time_stamp) + _write(cg, layer, pcoords, ccs, cx_edges, node_layer_d, time_stamp) -def _write( - cg, layer_id, parent_coords, components, cross_edges, node_layer_d, time_stamp -): +def _write(cg, layer_id, parent_coords, components, cx_edges, node_layer_d, time_stamp): parent_layer_ids = range(layer_id, cg.meta.layer_count + 1) cc_connections = {l: [] for l in parent_layer_ids} for node_ids in components: From c9633a47df2ae222c893010f62b32554461a4b71 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 22:13:44 +0000 Subject: [PATCH 020/367] feat: cache cx edges at each layer --- .../ingest/create/abstract_layers.py | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 107ac5714..148a370ba 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -9,6 +9,7 @@ import multiprocessing as mp from typing import Optional from typing import Sequence +from collections import defaultdict import numpy as np from multiwrapper import multiprocessing_utils as mu @@ -153,7 +154,15 @@ def _write_components_helper(args): _write(cg, layer, pcoords, ccs, cx_edges, node_layer_d, time_stamp) -def _write(cg, layer_id, parent_coords, components, cx_edges, node_layer_d, time_stamp): +def _write( + cg: ChunkedGraph, + layer_id, + parent_coords, + components, + cx_edges, + node_layer_d, + time_stamp, +): parent_layer_ids = range(layer_id, cg.meta.layer_count + 1) cc_connections = {l: [] for l in parent_layer_ids} for node_ids in components: @@ -180,24 +189,28 @@ def _write(cg, layer_id, parent_coords, components, cx_edges, node_layer_d, time ) for i_cc, node_ids in enumerate(cc_connections[parent_layer_id]): - parent_id = reserved_parent_ids[i_cc] - for node_id in node_ids: - rows.append( - cg.client.mutate_row( - serializers.serialize_uint64(node_id), - {attributes.Hierarchy.Parent: parent_id}, - time_stamp=time_stamp, - ) - ) - - rows.append( - cg.client.mutate_row( - serializers.serialize_uint64(parent_id), - {attributes.Hierarchy.Child: node_ids}, - time_stamp=time_stamp, - ) - ) + node_cx_edges_d = defaultdict(lambda: types.empty_2d) + for node in node_ids: + mask0 = cx_edges[:, 0] == node + mask1 = cx_edges[:, 1] == node + node_cx_edges_d[node] = cx_edges[mask0 | mask1] + parent_id = reserved_parent_ids[i_cc] + for node in node_ids: + row_id = serializers.serialize_uint64(node) + val_dict = {attributes.Hierarchy.Parent: parent_id} + + node_cx_edges = node_cx_edges_d[node] + cx_layers = cg.get_cross_chunk_edges_layer(node_cx_edges) + for layer in set(cx_layers): + layer_mask = cx_layers == layer + col = attributes.Connectivity.CrossChunkEdge[layer] + val_dict[col] = node_cx_edges[layer_mask] + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) + + row_id = serializers.serialize_uint64(parent_id) + val_dict = {attributes.Hierarchy.Child: node_ids} + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) if len(rows) > 100000: cg.client.write(rows) rows = [] From c5664feb9ab6937e4e72582bd9b5cda796706f66 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 22:24:49 +0000 Subject: [PATCH 021/367] fix: convert array type --- pychunkedgraph/ingest/create/abstract_layers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 148a370ba..f1341419d 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -176,7 +176,7 @@ def _write( parent_chunk_id = cg.get_chunk_id(layer=layer_id, x=x, y=y, z=z) parent_chunk_id_dict = cg.get_parent_chunk_id_dict(parent_chunk_id) - # Iterate through layers + cx_edges = np.array(cx_edges, dtype=basetypes.NODE_ID) for parent_layer_id in parent_layer_ids: if len(cc_connections[parent_layer_id]) == 0: continue From 6eb5a70c71ad22b89f6d154b7d7a2b3b90e341b8 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 22:50:51 +0000 Subject: [PATCH 022/367] fix: use atomic edges during ingest --- pychunkedgraph/graph/cache.py | 1 + pychunkedgraph/ingest/create/cross_edges.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index f60b6ca92..8c824c732 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -1,3 +1,4 @@ +# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel """ Cache nodes, parents, children and cross edges. """ diff --git a/pychunkedgraph/ingest/create/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py index b7a888b27..c7f45e9eb 100644 --- a/pychunkedgraph/ingest/create/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -91,7 +91,7 @@ def _read_atomic_chunk_cross_edges( Returns cross edges between l2 nodes in current chunk and l1 supervoxels from neighbor chunks. """ - cross_edge_col = attributes.Connectivity.CrossChunkEdge[cross_edge_layer] + cross_edge_col = attributes.Connectivity.AtomicCrossChunkEdge[cross_edge_layer] range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, [cross_edge_layer]) parent_neighboring_chunk_supervoxels_d = defaultdict(list) @@ -188,7 +188,7 @@ def _read_atomic_chunk_cross_edge_nodes(cg: ChunkedGraph, chunk_coord, layer): range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, relevant_layers) for l2id in l2ids: for layer in relevant_layers: - if attributes.Connectivity.CrossChunkEdge[layer] in range_read[l2id]: + if attributes.Connectivity.AtomicCrossChunkEdge[layer] in range_read[l2id]: node_layer_d[l2id] = layer break return node_layer_d @@ -229,7 +229,7 @@ def _read_atomic_chunk(cg: ChunkedGraph, chunk_coord, layers): range_read = cg.range_read_chunk( cg.get_chunk_id(layer=2, x=x, y=y, z=z), properties=[child_col] - + [attributes.Connectivity.CrossChunkEdge[l] for l in layers], + + [attributes.Connectivity.AtomicCrossChunkEdge[l] for l in layers], ) row_ids = [] From e28a38265eb1d0d9c97be4aee5029f6fb4c84487 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 23:23:18 +0000 Subject: [PATCH 023/367] fix: tests --- pychunkedgraph/ingest/create/abstract_layers.py | 5 +++-- pychunkedgraph/ingest/create/cross_edges.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index f1341419d..63b613ae6 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -131,7 +131,7 @@ def _write_connected_components( node_layer_d = get_chunk_nodes_cross_edge_layer(cg, layer, pcoords, use_threads) if not use_threads: - _write(cg, layer, pcoords, components, cx_edges, node_layer_d, time_stamp) + _write(cg, layer, pcoords, components, cx_edges, node_layer_d, time_stamp, use_threads) return task_size = int(math.ceil(len(components) / mp.cpu_count() / 10)) @@ -162,6 +162,7 @@ def _write( cx_edges, node_layer_d, time_stamp, + use_threads=True, ): parent_layer_ids = range(layer_id, cg.meta.layer_count + 1) cc_connections = {l: [] for l in parent_layer_ids} @@ -185,7 +186,7 @@ def _write( reserved_parent_ids = cg.id_client.create_node_ids( parent_chunk_id, size=len(cc_connections[parent_layer_id]), - root_chunk=parent_layer_id == cg.meta.layer_count, + root_chunk=parent_layer_id == cg.meta.layer_count and use_threads, ) for i_cc, node_ids in enumerate(cc_connections[parent_layer_id]): diff --git a/pychunkedgraph/ingest/create/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py index c7f45e9eb..5f0ebf8df 100644 --- a/pychunkedgraph/ingest/create/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -184,7 +184,7 @@ def _read_atomic_chunk_cross_edge_nodes(cg: ChunkedGraph, chunk_coord, layer): the lowest layer at which an l2 node is part of a cross edge """ node_layer_d = {} - relevant_layers = range(layer, cg.meta.layer_count + 1) + relevant_layers = range(layer, cg.meta.layer_count) range_read, l2ids = _read_atomic_chunk(cg, chunk_coord, relevant_layers) for l2id in l2ids: for layer in relevant_layers: From e0f8390d65d5928c73524c374cf9ee5f88e47a92 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 23:55:02 +0000 Subject: [PATCH 024/367] fix: remove postprocess step --- pychunkedgraph/ingest/cli.py | 22 +--------- pychunkedgraph/ingest/cluster.py | 31 +++++-------- pychunkedgraph/ingest/create/atomic_layer.py | 46 -------------------- 3 files changed, 12 insertions(+), 87 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 486224cec..2ad51ca18 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -67,16 +67,6 @@ def ingest_graph( enqueue_atomic_tasks(IngestionManager(ingest_config, meta)) -@ingest_cli.command("postprocess") -def postprocess(): - """ - Run postprocessing step on level 2 chunks. - """ - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - enqueue_atomic_tasks(imanager, postprocess=True) - - @ingest_cli.command("imanager") @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True)) @@ -130,16 +120,8 @@ def ingest_status(): """Print ingest status to console by layer.""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - - layer = 2 - done = redis.scard(f"{layer}c") - print(f"{layer}\t: {done} / {imanager.cg_meta.layer_chunk_counts[0]}") - - done = redis.scard(f"{layer}c-postprocess") - print(f"{layer}\t: {done} / {imanager.cg_meta.layer_chunk_counts[0]} [postprocess]") - - layers = range(3, imanager.cg_meta.layer_count + 1) - for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts[1:]): + layers = range(2, imanager.cg_meta.layer_count + 1) + for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts): done = redis.scard(f"{layer}c") print(f"{layer}\t: {done} / {layer_count}") diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 9394c4e26..b952ae0ba 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -13,7 +13,6 @@ from .common import get_atomic_chunk_data from .ran_agglomeration import get_active_edges from .create.atomic_layer import add_atomic_edges -from .create.atomic_layer import postprocess_atomic_chunk from .create.abstract_layers import add_layer from ..graph.meta import ChunkedGraphMeta from ..graph.chunks.hierarchy import get_children_chunk_coords @@ -25,12 +24,10 @@ def _post_task_completion( imanager: IngestionManager, layer: int, coords: np.ndarray, - postprocess: bool = False, ): chunk_str = "_".join(map(str, coords)) # mark chunk as completed - "c" - pprocess = "-postprocess" if postprocess else "" - imanager.redis.sadd(f"{layer}c{pprocess}", chunk_str) + imanager.redis.sadd(f"{layer}c", chunk_str) def create_parent_chunk( @@ -59,7 +56,7 @@ def randomize_grid_points(X: int, Y: int, Z: int) -> Tuple[int, int, int]: yield np.unravel_index(index, (X, Y, Z)) -def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): +def enqueue_atomic_tasks(imanager: IngestionManager): from os import environ from time import sleep from rq import Queue as RQueue @@ -72,12 +69,7 @@ def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): chunk_count = imanager.cg_meta.layer_chunk_counts[0] print(f"total chunk count: {chunk_count}, queuing...") - pprocess = "" - if postprocess: - pprocess = "-postprocess" - print("postprocessing l2 chunks") - - queue_name = f"{imanager.config.CLUSTER.ATOMIC_Q_NAME}{pprocess}" + queue_name = f"{imanager.config.CLUSTER.ATOMIC_Q_NAME}" q = imanager.get_task_queue(queue_name) job_datas = [] batch_size = int(environ.get("L2JOB_BATCH_SIZE", 1000)) @@ -89,13 +81,13 @@ def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): x, y, z = chunk_coord chunk_str = f"{x}_{y}_{z}" - if imanager.redis.sismember(f"2c{pprocess}", chunk_str): + if imanager.redis.sismember(f"2c", chunk_str): # already done, skip continue job_datas.append( RQueue.prepare_data( create_atomic_chunk, - args=(chunk_coord, postprocess), + args=(chunk_coord,), timeout=environ.get("L2JOB_TIMEOUT", "3m"), result_ttl=0, job_id=chunk_id_str(2, chunk_coord), @@ -107,18 +99,15 @@ def enqueue_atomic_tasks(imanager: IngestionManager, postprocess: bool = False): q.enqueue_many(job_datas) -def create_atomic_chunk(coords: Sequence[int], postprocess: bool = False): +def create_atomic_chunk(coords: Sequence[int]): """Creates single atomic chunk""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) coords = np.array(list(coords), dtype=int) - if postprocess: - postprocess_atomic_chunk(imanager.cg, coords) - else: - chunk_edges_all, mapping = get_atomic_chunk_data(imanager, coords) - chunk_edges_active, isolated_ids = get_active_edges(chunk_edges_all, mapping) - add_atomic_edges(imanager.cg, coords, chunk_edges_active, isolated=isolated_ids) + chunk_edges_all, mapping = get_atomic_chunk_data(imanager, coords) + chunk_edges_active, isolated_ids = get_active_edges(chunk_edges_all, mapping) + add_atomic_edges(imanager.cg, coords, chunk_edges_active, isolated=isolated_ids) if imanager.config.TEST_RUN: # print for debugging @@ -126,7 +115,7 @@ def create_atomic_chunk(coords: Sequence[int], postprocess: bool = False): print(k, len(v)) for k, v in chunk_edges_active.items(): print(f"active_{k}", len(v)) - _post_task_completion(imanager, 2, coords, postprocess=postprocess) + _post_task_completion(imanager, 2, coords) def _get_test_chunks(meta: ChunkedGraphMeta): diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index 42b6a01b5..054a82840 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -151,49 +151,3 @@ def _get_outgoing_edges(node_id, chunk_edges_d, sparse_indices, remapping): # edges that this node is part of chunk_out_edges = np.concatenate([chunk_out_edges, edges[row_ids]]) return chunk_out_edges - - -def postprocess_atomic_chunk( - cg: ChunkedGraph, - chunk_coord: np.ndarray, - time_stamp: Optional[datetime.datetime] = None, -): - time_stamp = get_valid_timestamp(time_stamp) - - chunk_id = cg.get_chunk_id( - layer=2, x=chunk_coord[0], y=chunk_coord[1], z=chunk_coord[2] - ) - - properties = [ - attributes.Connectivity.AtomicCrossChunkEdge[l] for l in range(2, cg.meta.layer_count) - ] - - chunk_rr = cg.range_read_chunk( - chunk_id, properties=properties, time_stamp=time_stamp - ) - - result = {} - for l2id, raw_cx_edges in chunk_rr.items(): - try: - cx_edges = { - prop.index: val[0].value.copy() for prop, val in raw_cx_edges.items() - } - result[l2id] = cx_edges - except KeyError: - continue - - nodes = [] - val_dicts = [] - for l2id, cx_edges in result.items(): - val_dict = {} - for layer, edges in cx_edges.items(): - l2_edges = np.zeros_like(edges) - l2_edges[:, 0] = l2id - l2_edges[:, 1] = cg.get_parents(edges[:, 1]) - col = attributes.Connectivity.CrossChunkEdge[layer] - val_dict[col] = np.unique(l2_edges, axis=0) - val_dicts.append(val_dict) - - r_key = serializers.serialize_uint64(l2id) - nodes.append(cg.client.mutate_row(r_key, val_dict, time_stamp=time_stamp)) - cg.client.write(nodes) From 5178e93c706ab8f325cda518caf2f6d3b6326f87 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 20 Aug 2023 23:57:00 +0000 Subject: [PATCH 025/367] fix: raises specific error --- pychunkedgraph/ingest/cluster.py | 2 +- pychunkedgraph/ingest/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index b952ae0ba..a5c6a9861 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -81,7 +81,7 @@ def enqueue_atomic_tasks(imanager: IngestionManager): x, y, z = chunk_coord chunk_str = f"{x}_{y}_{z}" - if imanager.redis.sismember(f"2c", chunk_str): + if imanager.redis.sismember("2c", chunk_str): # already done, skip continue job_datas.append( diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index fa7ef7a3c..1c3236561 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -1,6 +1,6 @@ +# pylint: disable=invalid-name, missing-docstring from typing import Tuple - from . import ClusterIngestConfig from . import IngestConfig from ..graph.meta import ChunkedGraphMeta @@ -72,4 +72,4 @@ def postprocess_edge_data(im, edge_dict): return new_edge_dict else: - raise Exception(f"Unknown data_version: {data_version}") + raise ValueError(f"Unknown data_version: {data_version}") From 0034d0c615e370ab7949eab1d878839f558d3a2c Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 21 Aug 2023 00:17:51 +0000 Subject: [PATCH 026/367] fix: removes dangerous default value --- pychunkedgraph/graph/chunkedgraph.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 210bff50b..2630d8250 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -551,7 +551,7 @@ def get_subgraph( node_id_or_ids: typing.Union[np.uint64, typing.Iterable], bbox: typing.Optional[typing.Sequence[typing.Sequence[int]]] = None, bbox_is_coordinate: bool = False, - return_layers: typing.List = [2], + return_layers: typing.List = None, nodes_only: bool = False, edges_only: bool = False, leaves_only: bool = False, @@ -563,6 +563,9 @@ def get_subgraph( from .subgraph import get_subgraph_nodes from .subgraph import get_subgraph_edges_and_leaves + if return_layers is None: + return_layers = [2] + if nodes_only: return get_subgraph_nodes( self, @@ -581,7 +584,7 @@ def get_subgraph_nodes( node_id_or_ids: typing.Union[np.uint64, typing.Iterable], bbox: typing.Optional[typing.Sequence[typing.Sequence[int]]] = None, bbox_is_coordinate: bool = False, - return_layers: typing.List = [2], + return_layers: typing.List = None, serializable: bool = False, return_flattened: bool = False, ) -> typing.Tuple[typing.Dict, typing.Dict, Edges]: @@ -591,6 +594,9 @@ def get_subgraph_nodes( """ from .subgraph import get_subgraph_nodes + if return_layers is None: + return_layers = [2] + return get_subgraph_nodes( self, node_id_or_ids, From 965aa61731a66fde9efe7c62baf25a341404d724 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 21 Aug 2023 01:02:21 +0000 Subject: [PATCH 027/367] wip: read from cached edges --- pychunkedgraph/graph/attributes.py | 2 +- pychunkedgraph/graph/chunkedgraph.py | 87 ++++++++-------------------- pychunkedgraph/graph/edges/utils.py | 39 +------------ pychunkedgraph/graph/edits.py | 11 +--- pychunkedgraph/graph/operation.py | 4 +- pychunkedgraph/graph/subgraph.py | 36 +++++------- 6 files changed, 49 insertions(+), 130 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index b0f18c2ec..958913119 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -111,7 +111,7 @@ class Connectivity: ) CrossChunkEdge = _AttributeArray( - pattern=b"cross_edge_%d", + pattern=b"cross_edges_%d", family_id="4", serializer=serializers.NumPyArray( dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 2630d8250..83c543b6e 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-docstring, too-many-lines, import-outside-toplevel +# pylint: disable=invalid-name, missing-docstring, too-many-lines, import-outside-toplevel, unsupported-binary-operation import time import typing @@ -112,13 +112,15 @@ def range_read_chunk( """Read all nodes in a chunk.""" layer = self.get_chunk_layer(chunk_id) root_chunk = layer == self.meta.layer_count - max_node_id = self.id_client.get_max_node_id(chunk_id=chunk_id, root_chunk=root_chunk) + max_id = self.id_client.get_max_node_id( + chunk_id=chunk_id, root_chunk=root_chunk + ) if layer == 1: - max_node_id = chunk_id | self.get_segment_id_limit(chunk_id) # pylint: disable=unsupported-binary-operation + max_id = chunk_id | self.get_segment_id_limit(chunk_id) return self.client.read_nodes( start_id=self.get_node_id(np.uint64(0), chunk_id=chunk_id), - end_id=max_node_id, + end_id=max_id, end_id_inclusive=True, properties=properties, end_time=time_stamp, @@ -293,7 +295,7 @@ def _get_children_multiple( def get_atomic_cross_edges( self, l2_ids: typing.Iterable, *, raw_only=False ) -> typing.Dict[np.uint64, typing.Dict[int, typing.Iterable]]: - """Returns cross edges for level 2 IDs.""" + """Returns atomic cross edges for level 2 IDs.""" if raw_only or not self.cache: node_edges_d_d = self.client.read_nodes( node_ids=l2_ids, @@ -314,67 +316,30 @@ def get_atomic_cross_edges( return result return self.cache.atomic_cross_edges_multiple(l2_ids) - def get_cross_chunk_edges( - self, node_ids: typing.Iterable, uplift=True, all_layers=False - ) -> typing.Dict[np.uint64, typing.Dict[int, typing.Iterable]]: + def get_cross_chunk_edges(self, node_ids: typing.Iterable) -> typing.Dict: """ - Cross chunk edges for `node_id` at `node_layer`. - The edges are between node IDs at the `node_layer`, not atomic cross edges. - Returns dict {layer_id: cross_edges} - The first layer (>= `node_layer`) with atleast one cross chunk edge. - For current use-cases, other layers are not relevant. - - For performance, only children that lie along chunk boundary are considered. - Cross edges that belong to inner level 2 IDs are subsumed within the chunk. - This is because cross edges are stored only in level 2 IDs. + Returns cross edges for `node_ids`. + A dict of the form `{node_id: {layer: cross_edges}}` """ result = {} node_ids = np.array(node_ids, dtype=basetypes.NODE_ID) - if not node_ids.size: + if node_ids.size == 0: return result - - node_l2ids_d = {} - layers_ = self.get_chunk_layers(node_ids) - for l in set(layers_): - node_l2ids_d.update(self._get_bounding_l2_children(node_ids[layers_ == l])) - l2_edges_d_d = self.get_atomic_cross_edges( - np.concatenate(list(node_l2ids_d.values())) - ) - for node_id in node_ids: - l2_edges_ds = [l2_edges_d_d[l2_id] for l2_id in node_l2ids_d[node_id]] - if all_layers: - result[node_id] = edge_utils.concatenate_cross_edge_dicts(l2_edges_ds) - else: - result[node_id] = self._get_min_layer_cross_edges( - node_id, l2_edges_ds, uplift=uplift - ) + attrs = [ + attributes.Connectivity.CrossChunkEdge[l] + for l in range(2, self.meta.layer_count) + ] + node_edges_d_d = self.client.read_nodes(node_ids=node_ids, properties=attrs) + for id_ in node_ids: + try: + result[id_] = { + prop.index: val[0].value.copy() + for prop, val in node_edges_d_d[id_].items() + } + except KeyError: + result[id_] = {} return result - def _get_min_layer_cross_edges( - self, - node_id: basetypes.NODE_ID, - l2id_atomic_cross_edges_ds: typing.Iterable, - uplift=True, - ) -> typing.Dict[int, typing.Iterable]: - """ - Find edges at relevant min_layer >= node_layer. - `l2id_atomic_cross_edges_ds` is a list of atomic cross edges of - level 2 IDs that are descendants of `node_id`. - """ - min_layer, edges = edge_utils.filter_min_layer_cross_edges_multiple( - self.meta, l2id_atomic_cross_edges_ds, self.get_chunk_layer(node_id) - ) - if self.get_chunk_layer(node_id) < min_layer: - # cross edges irrelevant - return {self.get_chunk_layer(node_id): types.empty_2d} - if not uplift: - return {min_layer: edges} - node_root_id = node_id - node_root_id = self.get_root(node_id, stop_layer=min_layer, ceil=False) - edges[:, 0] = node_root_id - edges[:, 1] = self.get_roots(edges[:, 1], stop_layer=min_layer, ceil=False) - return {min_layer: np.unique(edges, axis=0) if edges.size else types.empty_2d} - def get_roots( self, node_ids: typing.Sequence[np.uint64], @@ -698,9 +663,7 @@ def get_l2_agglomerations( sv_parent_d.update(dict(zip(svs.tolist(), [l2id] * len(svs)))) in_edges, out_edges, cross_edges = edge_utils.categorize_edges_v2( - self.meta, - all_chunk_edges, - sv_parent_d + self.meta, all_chunk_edges, sv_parent_d ) agglomeration_d = get_agglomerations( diff --git a/pychunkedgraph/graph/edges/utils.py b/pychunkedgraph/graph/edges/utils.py index 034ca6ebc..94641343a 100644 --- a/pychunkedgraph/graph/edges/utils.py +++ b/pychunkedgraph/graph/edges/utils.py @@ -8,16 +8,17 @@ from typing import Tuple from typing import Iterable from typing import Optional +from collections import defaultdict import fastremap import numpy as np from . import Edges from . import EDGE_TYPES -from ..types import empty_2d from ..utils import basetypes from ..chunks import utils as chunk_utils from ..meta import ChunkedGraphMeta +from ...utils.general import in2d def concatenate_chunk_edges(chunk_edge_dicts: Iterable) -> Dict: @@ -47,10 +48,7 @@ def concatenate_chunk_edges(chunk_edge_dicts: Iterable) -> Dict: def concatenate_cross_edge_dicts(edges_ds: Iterable[Dict]) -> Dict: """Combines cross chunk edge dicts of form {layer id : edge list}.""" - from collections import defaultdict - result_d = defaultdict(list) - for edges_d in edges_ds: for layer, edges in edges_d.items(): result_d[layer].append(edges) @@ -152,40 +150,7 @@ def get_cross_chunk_edges_layer(meta: ChunkedGraphMeta, cross_edges: Iterable): return cross_chunk_edge_layers -def filter_min_layer_cross_edges( - meta: ChunkedGraphMeta, cross_edges_d: Dict, node_layer: int = 2 -) -> Tuple[int, Iterable]: - """ - Given a dict of cross chunk edges {layer: edges} - Return the first layer with cross edges. - """ - for layer in range(node_layer, meta.layer_count): - edges_ = cross_edges_d.get(layer, empty_2d) - if edges_.size: - return (layer, edges_) - return (meta.layer_count, edges_) - - -def filter_min_layer_cross_edges_multiple( - meta: ChunkedGraphMeta, l2id_atomic_cross_edges_ds: Iterable, node_layer: int = 2 -) -> Tuple[int, Iterable]: - """ - Given a list of dicts of cross chunk edges [{layer: edges}] - Return the first layer with cross edges. - """ - min_layer = meta.layer_count - for edges_d in l2id_atomic_cross_edges_ds: - layer_, _ = filter_min_layer_cross_edges(meta, edges_d, node_layer=node_layer) - min_layer = min(min_layer, layer_) - edges = [empty_2d] - for edges_d in l2id_atomic_cross_edges_ds: - edges.append(edges_d.get(min_layer, empty_2d)) - return min_layer, np.concatenate(edges) - - def get_edges_status(cg, edges: Iterable, time_stamp: Optional[float] = None): - from ...utils.general import in2d - coords0 = chunk_utils.get_chunk_coordinates_multiple(cg.meta, edges[:, 0]) coords1 = chunk_utils.get_chunk_coordinates_multiple(cg.meta, edges[:, 1]) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 4cb536ea7..6d823e720 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -142,7 +142,6 @@ def check_fake_edges( ) ) assert len(roots) == 2, "edges must be from 2 roots" - print("found inactive", len(inactive_edges)) return inactive_edges, [] rows = [] @@ -177,7 +176,6 @@ def check_fake_edges( time_stamp=time_stamp, ) ) - print("no inactive", len(atomic_edges)) return atomic_edges, rows @@ -249,8 +247,7 @@ def _process_l2_agglomeration( atomic_cross_edges_d: Dict[int, np.ndarray], ): """ - For a given L2 id, remove given edges - and calculate new connected components. + For a given L2 id, remove given edges; calculate new connected components. """ chunk_edges = agg.in_edges.get_pairs() cross_edges = np.concatenate([types.empty_2d, *atomic_cross_edges_d.values()]) @@ -312,7 +309,7 @@ def remove_edges( ccs, graph_ids, cross_edges = _process_l2_agglomeration( l2_agg, removed_edges, atomic_cross_edges_d[id_] ) - # calculated here to avoid repeat computation in loop + # done here to avoid repeat computation in loop cross_edge_layers = cg.get_cross_chunk_edges_layer(cross_edges) new_parent_ids = cg.id_client.create_node_ids( l2id_chunk_id_d[l2_agg.node_id], len(ccs) @@ -413,9 +410,7 @@ def _get_connected_components( self.cg.graph_id, self._operation_id, ): - self._cross_edges_d.update( - self.cg.get_cross_chunk_edges(not_cached, all_layers=True) - ) + self._cross_edges_d.update(self.cg.get_cross_chunk_edges(not_cached)) sv_parent_d, sv_cross_edges = self._map_sv_to_parent(node_ids, layer) get_sv_parents = np.vectorize(sv_parent_d.get, otypes=[np.uint64]) diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 68abc17bc..98ed651a9 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-docstring, too-many-lines, protected-access +# pylint: disable=invalid-name, missing-docstring, too-many-lines, protected-access, broad_exception_raised from abc import ABC, abstractmethod from collections import namedtuple @@ -472,7 +472,7 @@ def execute( exception=repr(err), ) self.cg.client.write([log_record_error]) - raise Exception(err) + raise Exception(err) from err with TimeIt(f"{op_type}.write", self.cg.graph_id, lock.operation_id): result = self._write( diff --git a/pychunkedgraph/graph/subgraph.py b/pychunkedgraph/graph/subgraph.py index ab2593175..5b50b7c43 100644 --- a/pychunkedgraph/graph/subgraph.py +++ b/pychunkedgraph/graph/subgraph.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name, missing-docstring + from typing import List from typing import Dict from typing import Tuple @@ -30,9 +32,7 @@ def __init__(self, meta, node_ids, return_layers, serializable): # "Frontier" of nodes that cg.get_children will be called on self.cur_nodes = np.array(list(node_ids), dtype=np.uint64) # Mapping of current frontier to self.node_ids - self.cur_nodes_to_original_nodes = dict( - zip(self.cur_nodes, self.cur_nodes) - ) + self.cur_nodes_to_original_nodes = dict(zip(self.cur_nodes, self.cur_nodes)) self.stop_layer = max(1, min(return_layers)) self.create_initial_node_to_subgraph() @@ -107,13 +107,11 @@ def flatten_subgraph(self): for node_id in self.node_ids: for return_layer in self.return_layers: node_key = self.get_dict_key(node_id) - children_at_layer = self.node_to_subgraph[node_key][ - return_layer - ] + children_at_layer = self.node_to_subgraph[node_key][return_layer] if len(children_at_layer) > 0: - self.node_to_subgraph[node_key][ - return_layer - ] = np.concatenate(children_at_layer) + self.node_to_subgraph[node_key][return_layer] = np.concatenate( + children_at_layer + ) else: self.node_to_subgraph[node_key][return_layer] = empty_1d @@ -123,10 +121,12 @@ def get_subgraph_nodes( node_id_or_ids: Union[np.uint64, Iterable], bbox: Optional[Sequence[Sequence[int]]] = None, bbox_is_coordinate: bool = False, - return_layers: List = [2], + return_layers: List = None, serializable: bool = False, - return_flattened: bool = False + return_flattened: bool = False, ) -> Tuple[Dict, Dict, Edges]: + if return_layers is None: + return_layers = [2] single = False node_ids = node_id_or_ids bbox = normalize_bounding_box(cg.meta, bbox, bbox_is_coordinate) @@ -139,7 +139,7 @@ def get_subgraph_nodes( bounding_box=bbox, return_layers=return_layers, serializable=serializable, - return_flattened=return_flattened + return_flattened=return_flattened, ) if single: if serializable: @@ -183,7 +183,7 @@ def _get_subgraph_multiple_nodes( bounding_box: Optional[Sequence[Sequence[int]]], return_layers: Sequence[int], serializable: bool = False, - return_flattened: bool = False + return_flattened: bool = False, ): from collections import ChainMap from multiwrapper.multiprocessing_utils import n_cpus @@ -223,9 +223,7 @@ def _get_subgraph_multiple_nodes_threaded( subgraph = SubgraphProgress(cg.meta, node_ids, return_layers, serializable) while not subgraph.done_processing(): - this_n_threads = min( - [int(len(subgraph.cur_nodes) // 50000) + 1, n_cpus] - ) + this_n_threads = min([int(len(subgraph.cur_nodes) // 50000) + 1, n_cpus]) cur_nodes_child_maps = multithread_func( _get_subgraph_multiple_nodes_threaded, np.array_split(subgraph.cur_nodes, this_n_threads), @@ -239,8 +237,6 @@ def _get_subgraph_multiple_nodes_threaded( for node_id in node_ids: subgraph.node_to_subgraph[ _get_dict_key(node_id) - ] = subgraph.node_to_subgraph[_get_dict_key(node_id)][ - return_layers[0] - ] + ] = subgraph.node_to_subgraph[_get_dict_key(node_id)][return_layers[0]] - return subgraph.node_to_subgraph \ No newline at end of file + return subgraph.node_to_subgraph From 8b4d9d702468efcd23d70d99a9b52693a10d15d1 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 21 Aug 2023 03:24:03 +0000 Subject: [PATCH 028/367] wip: edits refactor --- pychunkedgraph/graph/cache.py | 30 ++++--- pychunkedgraph/graph/chunkedgraph.py | 75 +++++++++-------- pychunkedgraph/graph/edits.py | 119 +++++++++------------------ 3 files changed, 92 insertions(+), 132 deletions(-) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index 8c824c732..4e5ed17c1 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -31,26 +31,24 @@ def __init__(self, cg): self._parent_vec = np.vectorize(self.parent, otypes=[np.uint64]) self._children_vec = np.vectorize(self.children, otypes=[np.ndarray]) - self._atomic_cross_edges_vec = np.vectorize( - self.atomic_cross_edges, otypes=[dict] - ) + self._cross_chunk_edges_vec = np.vectorize(self.cross_chunk_edges, otypes=[dict]) # no limit because we don't want to lose new IDs self.parents_cache = LRUCache(maxsize=maxsize) self.children_cache = LRUCache(maxsize=maxsize) - self.atomic_cx_edges_cache = LRUCache(maxsize=maxsize) + self.cross_chunk_edges_cache = LRUCache(maxsize=maxsize) def __len__(self): return ( len(self.parents_cache) + len(self.children_cache) - + len(self.atomic_cx_edges_cache) + + len(self.cross_chunk_edges_cache) ) def clear(self): self.parents_cache.clear() self.children_cache.clear() - self.atomic_cx_edges_cache.clear() + self.cross_chunk_edges_cache.clear() def parent(self, node_id: np.uint64, *, time_stamp: datetime = None): @cached(cache=self.parents_cache, key=lambda node_id: node_id) @@ -68,15 +66,15 @@ def children_decorated(node_id): return children_decorated(node_id) - def atomic_cross_edges(self, node_id): - @cached(cache=self.atomic_cx_edges_cache, key=lambda node_id: node_id) - def atomic_cross_edges_decorated(node_id): - edges = self._cg.get_atomic_cross_edges( + def cross_chunk_edges(self, node_id): + @cached(cache=self.cross_chunk_edges_cache, key=lambda node_id: node_id) + def cross_edges_decorated(node_id): + edges = self._cg.get_cross_chunk_edges( np.array([node_id], dtype=NODE_ID), raw_only=True ) return edges[node_id] - return atomic_cross_edges_decorated(node_id) + return cross_edges_decorated(node_id) def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None): if not node_ids.size: @@ -105,20 +103,20 @@ def children_multiple(self, node_ids: np.ndarray, *, flatten=False): return np.concatenate([*result.values()]) return result - def atomic_cross_edges_multiple(self, node_ids: np.ndarray): + def cross_chunk_edges_multiple(self, node_ids: np.ndarray): result = {} if not node_ids.size: return result mask = np.in1d( - node_ids, np.fromiter(self.atomic_cx_edges_cache.keys(), dtype=NODE_ID) + node_ids, np.fromiter(self.cross_chunk_edges_cache.keys(), dtype=NODE_ID) ) - cached_edges_ = self._atomic_cross_edges_vec(node_ids[mask]) + cached_edges_ = self._cross_chunk_edges_vec(node_ids[mask]) result.update( {id_: edges_ for id_, edges_ in zip(node_ids[mask], cached_edges_)} ) - result.update(self._cg.get_atomic_cross_edges(node_ids[~mask], raw_only=True)) + result.update(self._cg.get_cross_chunk_edges(node_ids[~mask], raw_only=True)) update( - self.atomic_cx_edges_cache, + self.cross_chunk_edges_cache, node_ids[~mask], [result[k] for k in node_ids[~mask]], ) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 83c543b6e..1cdecd77a 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -292,45 +292,20 @@ def _get_children_multiple( } return self.cache.children_multiple(node_ids) - def get_atomic_cross_edges( - self, l2_ids: typing.Iterable, *, raw_only=False - ) -> typing.Dict[np.uint64, typing.Dict[int, typing.Iterable]]: - """Returns atomic cross edges for level 2 IDs.""" - if raw_only or not self.cache: - node_edges_d_d = self.client.read_nodes( - node_ids=l2_ids, - properties=[ - attributes.Connectivity.CrossChunkEdge[l] - for l in range(2, max(3, self.meta.layer_count)) - ], - ) - result = {} - for id_ in l2_ids: - try: - result[id_] = { - prop.index: val[0].value.copy() - for prop, val in node_edges_d_d[id_].items() - } - except KeyError: - result[id_] = {} - return result - return self.cache.atomic_cross_edges_multiple(l2_ids) - - def get_cross_chunk_edges(self, node_ids: typing.Iterable) -> typing.Dict: + def get_atomic_cross_edges(self, l2_ids: typing.Iterable) -> typing.Dict: """ - Returns cross edges for `node_ids`. - A dict of the form `{node_id: {layer: cross_edges}}` + Returns atomic cross edges for level 2 IDs. + A dict of the form `{l2id: {layer: atomic_cross_edges}}`. """ + node_edges_d_d = self.client.read_nodes( + node_ids=l2_ids, + properties=[ + attributes.Connectivity.AtomicCrossChunkEdge[l] + for l in range(2, self.meta.layer_count) + ], + ) result = {} - node_ids = np.array(node_ids, dtype=basetypes.NODE_ID) - if node_ids.size == 0: - return result - attrs = [ - attributes.Connectivity.CrossChunkEdge[l] - for l in range(2, self.meta.layer_count) - ] - node_edges_d_d = self.client.read_nodes(node_ids=node_ids, properties=attrs) - for id_ in node_ids: + for id_ in l2_ids: try: result[id_] = { prop.index: val[0].value.copy() @@ -340,6 +315,34 @@ def get_cross_chunk_edges(self, node_ids: typing.Iterable) -> typing.Dict: result[id_] = {} return result + def get_cross_chunk_edges( + self, node_ids: typing.Iterable, *, raw_only=False + ) -> typing.Dict: + """ + Returns cross edges for `node_ids`. + A dict of the form `{node_id: {layer: cross_edges}}`. + """ + if raw_only or not self.cache: + result = {} + node_ids = np.array(node_ids, dtype=basetypes.NODE_ID) + if node_ids.size == 0: + return result + attrs = [ + attributes.Connectivity.CrossChunkEdge[l] + for l in range(2, self.meta.layer_count) + ] + node_edges_d_d = self.client.read_nodes(node_ids=node_ids, properties=attrs) + for id_ in node_ids: + try: + result[id_] = { + prop.index: val[0].value.copy() + for prop, val in node_edges_d_d[id_].items() + } + except KeyError: + result[id_] = {} + return result + return self.cache.cross_chunk_edges_multiple(node_ids) + def get_roots( self, node_ids: typing.Sequence[np.uint64], diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 6d823e720..68a8c9b3b 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -38,9 +38,9 @@ def _analyze_affected_edges( cg, atomic_edges: Iterable[np.ndarray], parent_ts: datetime.datetime = None ) -> Tuple[Iterable, Dict]: """ - Determine if atomic edges are within the chunk. - If not, they are cross edges between two L2 IDs in adjacent chunks. - Returns edges between L2 IDs and atomic cross edges. + Returns l2 edges within chunk and adds self edges for nodes in cross chunk edges. + + Also returns new cross edges dicts for nodes crossing chunk boundary. """ supervoxels = np.unique(atomic_edges) parents = cg.get_parents(supervoxels, time_stamp=parent_ts) @@ -51,19 +51,18 @@ def _analyze_affected_edges( for edge_ in atomic_edges[edge_layers == 1] ] - # cross chunk edges - atomic_cross_edges_d = defaultdict(lambda: defaultdict(list)) + cross_edges_d = defaultdict(lambda: defaultdict(list)) for layer in range(2, cg.meta.layer_count): layer_edges = atomic_edges[edge_layers == layer] if not layer_edges.size: continue for edge in layer_edges: - parent_1 = sv_parent_d[edge[0]] - parent_2 = sv_parent_d[edge[1]] - atomic_cross_edges_d[parent_1][layer].append(edge) - atomic_cross_edges_d[parent_2][layer].append(edge[::-1]) - parent_edges.extend([[parent_1, parent_1], [parent_2, parent_2]]) - return (parent_edges, atomic_cross_edges_d) + parent0 = sv_parent_d[edge[0]] + parent1 = sv_parent_d[edge[1]] + cross_edges_d[parent0][layer].append([parent0, parent1]) + cross_edges_d[parent1][layer].append([parent1, parent0]) + parent_edges.extend([[parent0, parent0], [parent1, parent1]]) + return parent_edges, cross_edges_d def _get_relevant_components(edges: np.ndarray, supervoxels: np.ndarray) -> Tuple: @@ -89,9 +88,7 @@ def merge_preprocess( parent_ts: datetime.datetime = None, ) -> np.ndarray: """ - Determine if a fake edge needs to be added. - Get subgraph within the bounding box - Add fake edge if there are no inactive edges between two components. + Check and return inactive edges in the subgraph. """ edge_layers = cg.get_cross_chunk_edges_layer(subgraph_edges) active_edges = [types.empty_2d] @@ -146,6 +143,7 @@ def check_fake_edges( rows = [] supervoxels = atomic_edges.ravel() + # fake edges are stored with l2 chunks chunk_ids = cg.get_chunk_ids_from_node_ids( cg.get_parents(supervoxels, time_stamp=parent_ts) ) @@ -188,21 +186,19 @@ def add_edges( parent_ts: datetime.datetime = None, allow_same_segment_merge=False, ): - edges, l2_atomic_cross_edges_d = _analyze_affected_edges( + edges, l2_cross_edges_d = _analyze_affected_edges( cg, atomic_edges, parent_ts=parent_ts ) l2ids = np.unique(edges) if not allow_same_segment_merge: - assert ( - np.unique(cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts)).size - == 2 - ), "L2 IDs must belong to different roots." + roots = cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts) + assert np.unique(roots).size == 2, "L2 IDs must belong to different roots." new_old_id_d, old_new_id_d, old_hierarchy_d = _init_old_hierarchy( cg, l2ids, parent_ts=parent_ts ) atomic_children_d = cg.get_children(l2ids) - atomic_cross_edges_d = merge_cross_edge_dicts( - cg.get_atomic_cross_edges(l2ids), l2_atomic_cross_edges_d + cross_edges_d = merge_cross_edge_dicts( + cg.get_cross_chunk_edges(l2ids), l2_cross_edges_d ) graph, _, _, graph_ids = flatgraph.build_gt_graph(edges, make_directed=True) @@ -214,8 +210,8 @@ def add_edges( cg.cache.children_cache[new_id] = np.concatenate( [atomic_children_d[l2id] for l2id in l2ids_] ) - cg.cache.atomic_cx_edges_cache[new_id] = concatenate_cross_edge_dicts( - [atomic_cross_edges_d[l2id] for l2id in l2ids_] + cg.cache.cross_chunk_edges_cache[new_id] = concatenate_cross_edge_dicts( + [cross_edges_d[l2id] for l2id in l2ids_] ) cache_utils.update( cg.cache.parents_cache, cg.cache.children_cache[new_id], new_id @@ -300,14 +296,14 @@ def remove_edges( cg, l2ids, parent_ts=parent_ts ) l2id_chunk_id_d = dict(zip(l2ids.tolist(), cg.get_chunk_ids_from_node_ids(l2ids))) - atomic_cross_edges_d = cg.get_atomic_cross_edges(l2ids) + cross_edges_d = cg.get_cross_chunk_edges(l2ids) removed_edges = np.concatenate([atomic_edges, atomic_edges[:, ::-1]], axis=0) new_l2_ids = [] for id_ in l2ids: l2_agg = l2id_agglomeration_d[id_] ccs, graph_ids, cross_edges = _process_l2_agglomeration( - l2_agg, removed_edges, atomic_cross_edges_d[id_] + l2_agg, removed_edges, cross_edges_d[id_] ) # done here to avoid repeat computation in loop cross_edge_layers = cg.get_cross_chunk_edges_layer(cross_edges) @@ -386,60 +382,27 @@ def _get_old_ids(self, new_ids): ] return np.concatenate(old_ids) - def _map_sv_to_parent(self, node_ids, layer, node_map=None): - sv_parent_d = {} - sv_cross_edges = [types.empty_2d] - if node_map is None: - node_map = {} - for id_ in node_ids: - id_eff = node_map.get(id_, id_) - edges_ = self._cross_edges_d[id_].get(layer, types.empty_2d) - sv_parent_d.update(dict(zip(edges_[:, 0], [id_eff] * len(edges_)))) - sv_cross_edges.append(edges_) - return sv_parent_d, np.concatenate(sv_cross_edges) - - def _get_connected_components( - self, node_ids: np.ndarray, layer: int, lower_layer_ids: np.ndarray - ): - _node_ids = np.concatenate([node_ids, lower_layer_ids]) - cached = np.fromiter(self._cross_edges_d.keys(), dtype=basetypes.NODE_ID) - not_cached = _node_ids[~np.in1d(_node_ids, cached)] - + def _get_connected_components(self, node_ids: np.ndarray, layer: int): with TimeIt( f"get_cross_chunk_edges.{layer}", self.cg.graph_id, self._operation_id, ): - self._cross_edges_d.update(self.cg.get_cross_chunk_edges(not_cached)) - - sv_parent_d, sv_cross_edges = self._map_sv_to_parent(node_ids, layer) - get_sv_parents = np.vectorize(sv_parent_d.get, otypes=[np.uint64]) - try: - cross_edges = get_sv_parents(sv_cross_edges) - except TypeError: # NoneType error - # if there is a missing parent, try including lower layer ids - # this can happen due to skip connections - - # we want to map all these lower IDs to the current layer - lower_layer_to_layer = self.cg.get_roots( - lower_layer_ids, stop_layer=layer, ceil=False - ) - node_map = {k: v for k, v in zip(lower_layer_ids, lower_layer_to_layer)} - sv_parent_d, sv_cross_edges = self._map_sv_to_parent( - _node_ids, layer, node_map=node_map - ) - get_sv_parents = np.vectorize(sv_parent_d.get, otypes=[np.uint64]) - cross_edges = get_sv_parents(sv_cross_edges) + cross_edges_d = self.cg.get_cross_chunk_edges(node_ids) + self._cross_edges_d.update(cross_edges_d) + + cross_edges = [types.empty_2d] + for id_ in node_ids: + edges_ = self._cross_edges_d[id_].get(layer, types.empty_2d) + cross_edges.append(edges_) - cross_edges = np.concatenate([cross_edges, np.vstack([node_ids, node_ids]).T]) + cross_edges = np.concatenate([*cross_edges, np.vstack([node_ids, node_ids]).T]) graph, _, _, graph_ids = flatgraph.build_gt_graph( cross_edges, make_directed=True ) return flatgraph.connected_components(graph), graph_ids - def _get_layer_node_ids( - self, new_ids: np.ndarray, layer: int - ) -> Tuple[np.ndarray, np.ndarray]: + def _get_layer_node_ids(self, new_ids: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: # get old identities of new IDs old_ids = self._get_old_ids(new_ids) # get their parents, then children of those parents @@ -458,9 +421,7 @@ def _get_layer_node_ids( ] + [node_ids[~mask], new_ids] ) - node_ids = np.unique(node_ids) - layer_mask = self.cg.get_chunk_layers(node_ids) == layer - return node_ids[layer_mask], node_ids[~layer_mask] + return np.unique(node_ids) def _create_new_parents(self, layer: int): """ @@ -473,10 +434,8 @@ def _create_new_parents(self, layer: int): update parent old IDs """ new_ids = self._new_ids_d[layer] - layer_node_ids, lower_layer_ids = self._get_layer_node_ids(new_ids, layer) - components, graph_ids = self._get_connected_components( - layer_node_ids, layer, lower_layer_ids - ) + layer_node_ids = self._get_layer_node_ids(new_ids) + components, graph_ids = self._get_connected_components(layer_node_ids, layer) for cc_indices in components: parent_layer = layer + 1 cc_ids = graph_ids[cc_indices] @@ -553,20 +512,20 @@ def _update_root_id_lineage(self): ) return rows - def _get_atomic_cross_edges_val_dict(self): + def _get_cross_edges_val_dict(self): new_ids = np.array(self._new_ids_d[2], dtype=basetypes.NODE_ID) val_dicts = {} - atomic_cross_edges_d = self.cg.get_atomic_cross_edges(new_ids) + cross_edges_d = self.cg.get_cross_chunk_edges(new_ids) for id_ in new_ids: val_dict = {} - for layer, edges in atomic_cross_edges_d[id_].items(): - val_dict[attributes.Connectivity.AtomicCrossChunkEdge[layer]] = edges + for layer, edges in cross_edges_d[id_].items(): + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges val_dicts[id_] = val_dict return val_dicts def create_new_entries(self) -> List: rows = [] - val_dicts = self._get_atomic_cross_edges_val_dict() + val_dicts = self._get_cross_edges_val_dict() for layer in range(2, self.cg.meta.layer_count + 1): new_ids = self._new_ids_d[layer] for id_ in new_ids: From bc6fbd0c4e70e8201e893fdb052d925806a9fbc1 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 21 Aug 2023 17:31:47 +0000 Subject: [PATCH 029/367] wip: edits refactor --- pychunkedgraph/graph/edits.py | 44 ++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 68a8c9b3b..ae7c25b4c 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -7,6 +7,7 @@ from typing import Iterable from collections import defaultdict +import fastremap import numpy as np import fastremap @@ -233,6 +234,8 @@ def add_edges( ) new_roots = create_parents.run() + print("new_roots", new_roots, cg.meta.layer_count) + print(cg.get_children(np.array(new_roots, dtype=np.uint64))) new_entries = create_parents.create_new_entries() return new_roots, new_l2_ids, new_entries @@ -397,21 +400,22 @@ def _get_connected_components(self, node_ids: np.ndarray, layer: int): cross_edges.append(edges_) cross_edges = np.concatenate([*cross_edges, np.vstack([node_ids, node_ids]).T]) + temp_d = {k: next(iter(v)) for k, v in self._old_new_id_d.items()} + cross_edges = fastremap.remap(cross_edges, temp_d, preserve_missing_labels=True) + graph, _, _, graph_ids = flatgraph.build_gt_graph( cross_edges, make_directed=True ) return flatgraph.connected_components(graph), graph_ids - def _get_layer_node_ids(self, new_ids: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + def _get_layer_node_ids( + self, new_ids: np.ndarray, layer: int + ) -> Tuple[np.ndarray, np.ndarray]: # get old identities of new IDs old_ids = self._get_old_ids(new_ids) # get their parents, then children of those parents - node_ids = self.cg.get_children( - np.unique( - self.cg.get_parents(old_ids, time_stamp=self._last_successful_ts) - ), - flatten=True, - ) + parents = self.cg.get_parents(old_ids, time_stamp=self._last_successful_ts) + node_ids = self.cg.get_children(np.unique(parents), flatten=True) # replace old identities with new IDs mask = np.in1d(node_ids, old_ids) node_ids = np.concatenate( @@ -421,7 +425,9 @@ def _get_layer_node_ids(self, new_ids: np.ndarray) -> Tuple[np.ndarray, np.ndarr ] + [node_ids[~mask], new_ids] ) - return np.unique(node_ids) + node_ids = np.unique(node_ids) + layer_mask = self.cg.get_chunk_layers(node_ids) == layer + return node_ids[layer_mask] def _create_new_parents(self, layer: int): """ @@ -434,7 +440,7 @@ def _create_new_parents(self, layer: int): update parent old IDs """ new_ids = self._new_ids_d[layer] - layer_node_ids = self._get_layer_node_ids(new_ids) + layer_node_ids = self._get_layer_node_ids(new_ids, layer) components, graph_ids = self._get_connected_components(layer_node_ids, layer) for cc_indices in components: parent_layer = layer + 1 @@ -458,6 +464,11 @@ def _create_new_parents(self, layer: int): cc_ids, parent_id, ) + + children_cx_edges = [self._cross_edges_d[child] for child in cc_ids] + cx_edges = concatenate_cross_edge_dicts(children_cx_edges) + self.cg.cache.cross_chunk_edges_cache[parent_id] = cx_edges + self._update_id_lineage(parent_id, cc_ids, layer, parent_layer) def run(self) -> Iterable: @@ -513,14 +524,15 @@ def _update_root_id_lineage(self): return rows def _get_cross_edges_val_dict(self): - new_ids = np.array(self._new_ids_d[2], dtype=basetypes.NODE_ID) val_dicts = {} - cross_edges_d = self.cg.get_cross_chunk_edges(new_ids) - for id_ in new_ids: - val_dict = {} - for layer, edges in cross_edges_d[id_].items(): - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges - val_dicts[id_] = val_dict + for layer in range(2, self.cg.meta.layer_count): + new_ids = np.array(self._new_ids_d[layer], dtype=basetypes.NODE_ID) + cross_edges_d = self.cg.get_cross_chunk_edges(new_ids) + for id_ in new_ids: + val_dict = {} + for layer, edges in cross_edges_d[id_].items(): + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + val_dicts[id_] = val_dict return val_dicts def create_new_entries(self) -> List: From ebd35374861d952101afcadf8747dc2256a3897e Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 22 Aug 2023 16:59:25 +0000 Subject: [PATCH 030/367] fix(ingest): cache cross chunk edges from children --- .../ingest/create/abstract_layers.py | 84 ++++++++++++------- pychunkedgraph/ingest/create/cross_edges.py | 2 +- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 63b613ae6..9a339443f 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel +# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel, c-extension-no-member """ Functions for creating parents in level 3 and above @@ -9,8 +9,8 @@ import multiprocessing as mp from typing import Optional from typing import Sequence -from collections import defaultdict +import fastremap import numpy as np from multiwrapper import multiprocessing_utils as mu @@ -21,6 +21,7 @@ from ...graph.utils import basetypes from ...graph.utils import serializers from ...graph.chunkedgraph import ChunkedGraph +from ...graph.edges.utils import concatenate_cross_edge_dicts from ...graph.utils.generic import get_valid_timestamp from ...graph.utils.generic import filter_failed_node_ids from ...graph.chunks.hierarchy import get_children_chunk_coords @@ -60,7 +61,6 @@ def add_layer( layer_id, parent_coords, connected_components, - cx_edges, get_valid_timestamp(time_stamp), n_threads > 1, ) @@ -121,7 +121,7 @@ def _read_chunk(children_ids_shared, cg: ChunkedGraph, layer_id: int, chunk_coor def _write_connected_components( - cg, layer, pcoords, components, cx_edges, time_stamp, use_threads=True + cg, layer, pcoords, components, time_stamp, use_threads=True ): if len(components) == 0: return @@ -131,7 +131,7 @@ def _write_connected_components( node_layer_d = get_chunk_nodes_cross_edge_layer(cg, layer, pcoords, use_threads) if not use_threads: - _write(cg, layer, pcoords, components, cx_edges, node_layer_d, time_stamp, use_threads) + _write(cg, layer, pcoords, components, node_layer_d, time_stamp, use_threads) return task_size = int(math.ceil(len(components) / mp.cpu_count() / 10)) @@ -139,7 +139,7 @@ def _write_connected_components( cg_info = cg.get_serialized_info() multi_args = [] for ccs in chunked_ccs: - args = (cg_info, layer, pcoords, ccs, cx_edges, node_layer_d, time_stamp) + args = (cg_info, layer, pcoords, ccs, node_layer_d, time_stamp) multi_args.append(args) mu.multiprocess_func( _write_components_helper, @@ -149,9 +149,9 @@ def _write_connected_components( def _write_components_helper(args): - cg_info, layer, pcoords, ccs, cx_edges, node_layer_d, time_stamp = args + cg_info, layer, pcoords, ccs, node_layer_d, time_stamp = args cg = ChunkedGraph(**cg_info) - _write(cg, layer, pcoords, ccs, cx_edges, node_layer_d, time_stamp) + _write(cg, layer, pcoords, ccs, node_layer_d, time_stamp) def _write( @@ -159,13 +159,12 @@ def _write( layer_id, parent_coords, components, - cx_edges, node_layer_d, time_stamp, use_threads=True, ): - parent_layer_ids = range(layer_id, cg.meta.layer_count + 1) - cc_connections = {l: [] for l in parent_layer_ids} + parent_layers = range(layer_id, cg.meta.layer_count + 1) + cc_connections = {l: [] for l in parent_layers} for node_ids in components: layer = layer_id if len(node_ids) == 1: @@ -177,40 +176,67 @@ def _write( parent_chunk_id = cg.get_chunk_id(layer=layer_id, x=x, y=y, z=z) parent_chunk_id_dict = cg.get_parent_chunk_id_dict(parent_chunk_id) - cx_edges = np.array(cx_edges, dtype=basetypes.NODE_ID) - for parent_layer_id in parent_layer_ids: - if len(cc_connections[parent_layer_id]) == 0: + for parent_layer in parent_layers: + if len(cc_connections[parent_layer]) == 0: continue - parent_chunk_id = parent_chunk_id_dict[parent_layer_id] + parent_chunk_id = parent_chunk_id_dict[parent_layer] reserved_parent_ids = cg.id_client.create_node_ids( parent_chunk_id, - size=len(cc_connections[parent_layer_id]), - root_chunk=parent_layer_id == cg.meta.layer_count and use_threads, + size=len(cc_connections[parent_layer]), + root_chunk=parent_layer == cg.meta.layer_count and use_threads, ) - for i_cc, node_ids in enumerate(cc_connections[parent_layer_id]): - node_cx_edges_d = defaultdict(lambda: types.empty_2d) - for node in node_ids: - mask0 = cx_edges[:, 0] == node - mask1 = cx_edges[:, 1] == node - node_cx_edges_d[node] = cx_edges[mask0 | mask1] - + for i_cc, node_ids in enumerate(cc_connections[parent_layer]): parent_id = reserved_parent_ids[i_cc] + + if parent_layer == 3: + # children are from atomic chunks + cx_edges_d = cg.get_atomic_cross_edges(node_ids) + else: + # children are from abstract chunks + cx_edges_d = cg.get_cross_chunk_edges(node_ids, raw_only=True) + + children_cx_edges = [] for node in node_ids: + node_layer = cg.get_chunk_layer(node) row_id = serializers.serialize_uint64(node) val_dict = {attributes.Hierarchy.Parent: parent_id} - node_cx_edges = node_cx_edges_d[node] - cx_layers = cg.get_cross_chunk_edges_layer(node_cx_edges) - for layer in set(cx_layers): - layer_mask = cx_layers == layer + node_cx_edges_d = cx_edges_d.get(node, {}) + if not node_cx_edges_d: + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) + continue + + for layer in range(node_layer, cg.meta.layer_count): + if not layer in node_cx_edges_d: + continue + + layer_edges = node_cx_edges_d[layer] + edges_nodes = np.unique(layer_edges) + edges_nodes_parents = cg.get_parents(edges_nodes) + temp_map = dict(zip(edges_nodes, edges_nodes_parents)) + + layer_edges = fastremap.remap( + layer_edges, temp_map, preserve_missing_labels=True + ) + layer_edges = np.unique(layer_edges, axis=0) + col = attributes.Connectivity.CrossChunkEdge[layer] - val_dict[col] = node_cx_edges[layer_mask] + val_dict[col] = layer_edges + node_cx_edges_d[layer] = layer_edges + children_cx_edges.append(node_cx_edges_d) rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) row_id = serializers.serialize_uint64(parent_id) val_dict = {attributes.Hierarchy.Child: node_ids} + parent_cx_edges_d = concatenate_cross_edge_dicts(children_cx_edges, unique=True) + for layer in range(parent_layer, cg.meta.layer_count): + if not layer in parent_cx_edges_d: + continue + col = attributes.Connectivity.CrossChunkEdge[layer] + val_dict[col] = parent_cx_edges_d[layer] + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) if len(rows) > 100000: cg.client.write(rows) diff --git a/pychunkedgraph/ingest/create/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py index 5f0ebf8df..9581838af 100644 --- a/pychunkedgraph/ingest/create/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -63,7 +63,7 @@ def _get_children_chunk_cross_edges_helper(args) -> None: edge_ids_shared.append(_get_children_chunk_cross_edges(cg, atomic_chunks, layer)) -def _get_children_chunk_cross_edges(cg: ChunkedGraph, atomic_chunks, layer) -> None: +def _get_children_chunk_cross_edges(cg: ChunkedGraph, atomic_chunks, layer) -> np.ndarray: """ Non parallelized version Cross edges that connect children chunks. From c19e9184494c99a53c310b4d4af4bf8f9fcce5e3 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 22 Aug 2023 17:21:13 +0000 Subject: [PATCH 031/367] feat: add unique flag --- pychunkedgraph/graph/edges/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/edges/utils.py b/pychunkedgraph/graph/edges/utils.py index 94641343a..cd0e85fe8 100644 --- a/pychunkedgraph/graph/edges/utils.py +++ b/pychunkedgraph/graph/edges/utils.py @@ -46,7 +46,7 @@ def concatenate_chunk_edges(chunk_edge_dicts: Iterable) -> Dict: return edges_dict -def concatenate_cross_edge_dicts(edges_ds: Iterable[Dict]) -> Dict: +def concatenate_cross_edge_dicts(edges_ds: Iterable[Dict], unique: bool = False) -> Dict: """Combines cross chunk edge dicts of form {layer id : edge list}.""" result_d = defaultdict(list) for edges_d in edges_ds: @@ -54,7 +54,10 @@ def concatenate_cross_edge_dicts(edges_ds: Iterable[Dict]) -> Dict: result_d[layer].append(edges) for layer, edge_lists in result_d.items(): - result_d[layer] = np.concatenate(edge_lists) + edges = np.concatenate(edge_lists) + if unique: + edges = np.unique(edges, axis=0) + result_d[layer] = edges return result_d From eaf98fafd73efa470f686f72eda514ab831ed44d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 22 Aug 2023 17:21:53 +0000 Subject: [PATCH 032/367] feat: cross edges column family gcversionrule --- pychunkedgraph/graph/attributes.py | 20 +++++++++---------- .../graph/client/bigtable/client.py | 4 +++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index 958913119..84283161d 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -104,10 +104,12 @@ class Connectivity: serializer=serializers.NumPyArray(dtype=basetypes.EDGE_AREA), ) - FakeEdges = _Attribute( - key=b"fake_edges", - family_id="4", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), + AtomicCrossChunkEdge = _AttributeArray( + pattern=b"atomic_cross_edges_%d", + family_id="3", + serializer=serializers.NumPyArray( + dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 + ), ) CrossChunkEdge = _AttributeArray( @@ -118,12 +120,10 @@ class Connectivity: ), ) - AtomicCrossChunkEdge = _AttributeArray( - pattern=b"atomic_cross_edges_%d", - family_id="3", - serializer=serializers.NumPyArray( - dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 - ), + FakeEdges = _Attribute( + key=b"fake_edges", + family_id="5", + serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), ) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 788c76a8e..1bd027255 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -638,7 +638,9 @@ def _create_column_families(self): f.create() f = self._table.column_family("3", gc_rule=MaxAgeGCRule(timedelta(days=365))) f.create() - f = self._table.column_family("4") + f = self._table.column_family("4", gc_rule=MaxVersionsGCRule(1)) + f.create() + f = self._table.column_family("5") f.create() def _get_ids_range(self, key: bytes, size: int) -> typing.Tuple: From 0595c17fcd86aab24cf0b0144b5615bacf492f49 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 22 Aug 2023 17:22:10 +0000 Subject: [PATCH 033/367] fix: convert input to np arrays --- pychunkedgraph/graph/cache.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index 4e5ed17c1..52fdfd022 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -31,7 +31,9 @@ def __init__(self, cg): self._parent_vec = np.vectorize(self.parent, otypes=[np.uint64]) self._children_vec = np.vectorize(self.children, otypes=[np.ndarray]) - self._cross_chunk_edges_vec = np.vectorize(self.cross_chunk_edges, otypes=[dict]) + self._cross_chunk_edges_vec = np.vectorize( + self.cross_chunk_edges, otypes=[dict] + ) # no limit because we don't want to lose new IDs self.parents_cache = LRUCache(maxsize=maxsize) @@ -77,6 +79,7 @@ def cross_edges_decorated(node_id): return cross_edges_decorated(node_id) def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None): + node_ids = np.array(node_ids, dtype=NODE_ID) if not node_ids.size: return node_ids mask = np.in1d(node_ids, np.fromiter(self.parents_cache.keys(), dtype=NODE_ID)) @@ -90,6 +93,7 @@ def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None) def children_multiple(self, node_ids: np.ndarray, *, flatten=False): result = {} + node_ids = np.array(node_ids, dtype=NODE_ID) if not node_ids.size: return result mask = np.in1d(node_ids, np.fromiter(self.children_cache.keys(), dtype=NODE_ID)) @@ -105,6 +109,7 @@ def children_multiple(self, node_ids: np.ndarray, *, flatten=False): def cross_chunk_edges_multiple(self, node_ids: np.ndarray): result = {} + node_ids = np.array(node_ids, dtype=NODE_ID) if not node_ids.size: return result mask = np.in1d( From 5b1144d0c59a9811f5c519eca3c95ef022e3ef74 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 22 Aug 2023 17:23:47 +0000 Subject: [PATCH 034/367] fix: linting issues --- pychunkedgraph/graph/chunkedgraph.py | 14 ++++---------- pychunkedgraph/graph/operation.py | 6 +++--- pychunkedgraph/graph/subgraph.py | 4 ++-- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 1cdecd77a..f4e87290c 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -24,6 +24,8 @@ from .edges import utils as edge_utils from .chunks import utils as chunk_utils from .chunks import hierarchy as chunk_hierarchy +from .subgraph import get_subgraph_nodes +from .subgraph import get_subgraph_edges_and_leaves class ChunkedGraph: @@ -524,12 +526,10 @@ def get_subgraph( edges_only: bool = False, leaves_only: bool = False, return_flattened: bool = False, - ) -> typing.Tuple[typing.Dict, typing.Dict, Edges]: + ) -> typing.Tuple[typing.Dict, typing.Tuple[Edges]]: """ Generic subgraph method. """ - from .subgraph import get_subgraph_nodes - from .subgraph import get_subgraph_edges_and_leaves if return_layers is None: return_layers = [2] @@ -560,8 +560,6 @@ def get_subgraph_nodes( Get the children of `node_ids` that are at each of return_layers within the specified bounding box. """ - from .subgraph import get_subgraph_nodes - if return_layers is None: return_layers = [2] @@ -584,8 +582,6 @@ def get_subgraph_edges( """ Get the atomic edges of the `node_ids` within the specified bounding box. """ - from .subgraph import get_subgraph_edges_and_leaves - return get_subgraph_edges_and_leaves( self, node_id_or_ids, bbox, bbox_is_coordinate, True, False ) @@ -599,8 +595,6 @@ def get_subgraph_leaves( """ Get the supervoxels of the `node_ids` within the specified bounding box. """ - from .subgraph import get_subgraph_edges_and_leaves - return get_subgraph_edges_and_leaves( self, node_id_or_ids, bbox, bbox_is_coordinate, False, True ) @@ -625,7 +619,7 @@ def get_fake_edges( def get_l2_agglomerations( self, level2_ids: np.ndarray, edges_only: bool = False - ) -> typing.Tuple[typing.Dict[int, types.Agglomeration], np.ndarray]: + ) -> typing.Tuple[typing.Dict[int, types.Agglomeration], typing.Tuple[Edges]]: """ Children of Level 2 Node IDs and edges. Edges are read from cloud storage. diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 98ed651a9..7b88a621e 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-docstring, too-many-lines, protected-access, broad_exception_raised +# pylint: disable=invalid-name, missing-docstring, too-many-lines, protected-access, broad-exception-raised from abc import ABC, abstractmethod from collections import namedtuple @@ -905,11 +905,11 @@ def _apply( self.cg.meta.split_bounding_offset, ) with TimeIt("get_subgraph", self.cg.graph_id, operation_id): - l2id_agglomeration_d, edges = self.cg.get_subgraph( + l2id_agglomeration_d, edges_tuple = self.cg.get_subgraph( root_ids.pop(), bbox=bbox, bbox_is_coordinate=True ) - edges = reduce(lambda x, y: x + y, edges, Edges([], [])) + edges = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) supervoxels = np.concatenate( [agg.supervoxels for agg in l2id_agglomeration_d.values()] ) diff --git a/pychunkedgraph/graph/subgraph.py b/pychunkedgraph/graph/subgraph.py index 5b50b7c43..1538b3cc2 100644 --- a/pychunkedgraph/graph/subgraph.py +++ b/pychunkedgraph/graph/subgraph.py @@ -1,4 +1,4 @@ -# pylint: disable=invalid-name, missing-docstring +# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel from typing import List from typing import Dict @@ -155,7 +155,7 @@ def get_subgraph_edges_and_leaves( bbox_is_coordinate: bool = False, edges_only: bool = False, leaves_only: bool = False, -) -> Tuple[Dict, Dict, Edges]: +) -> Tuple[Dict, Tuple[Edges]]: """Get the edges and/or leaves of the specified node_ids within the specified bounding box.""" from .types import empty_1d From 4545f5e98d52484444611fb6175ec0b664d98b1f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 22 Aug 2023 17:24:05 +0000 Subject: [PATCH 035/367] wip: edits refactor --- pychunkedgraph/graph/edits.py | 169 ++++++++++++++++++---------------- 1 file changed, 92 insertions(+), 77 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index ae7c25b4c..0086f00cd 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -39,7 +39,7 @@ def _analyze_affected_edges( cg, atomic_edges: Iterable[np.ndarray], parent_ts: datetime.datetime = None ) -> Tuple[Iterable, Dict]: """ - Returns l2 edges within chunk and adds self edges for nodes in cross chunk edges. + Returns l2 edges within chunk and self edges for nodes in cross chunk edges. Also returns new cross edges dicts for nodes crossing chunk boundary. """ @@ -208,20 +208,30 @@ def add_edges( for cc_indices in components: l2ids_ = graph_ids[cc_indices] new_id = cg.id_client.create_node_id(cg.get_chunk_id(l2ids_[0])) - cg.cache.children_cache[new_id] = np.concatenate( - [atomic_children_d[l2id] for l2id in l2ids_] - ) - cg.cache.cross_chunk_edges_cache[new_id] = concatenate_cross_edge_dicts( - [cross_edges_d[l2id] for l2id in l2ids_] - ) - cache_utils.update( - cg.cache.parents_cache, cg.cache.children_cache[new_id], new_id - ) new_l2_ids.append(new_id) new_old_id_d[new_id].update(l2ids_) for id_ in l2ids_: old_new_id_d[id_].add(new_id) + # update cache + # map parent to new merged children and vice versa + merged_children = np.concatenate([atomic_children_d[l2id] for l2id in l2ids_]) + cg.cache.children_cache[new_id] = merged_children + cache_utils.update(cg.cache.parents_cache, merged_children, new_id) + + # update cross chunk edges by replacing old_ids with new + # this can be done only after all new IDs have been created + for new_id, cc_indices in zip(new_l2_ids, components): + l2ids_ = graph_ids[cc_indices] + new_cx_edges_d = {} + cx_edges = [cross_edges_d[l2id] for l2id in l2ids_] + cx_edges_d = concatenate_cross_edge_dicts(cx_edges, unique=True) + temp_map = {k: next(iter(v)) for k, v in old_new_id_d.items()} + for layer, edges in cx_edges_d.items(): + edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + new_cx_edges_d[layer] = edges + cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d + create_parents = CreateParentNodes( cg, new_l2_ids=new_l2_ids, @@ -234,50 +244,25 @@ def add_edges( ) new_roots = create_parents.run() - print("new_roots", new_roots, cg.meta.layer_count) - print(cg.get_children(np.array(new_roots, dtype=np.uint64))) + print() + print("layers", cg.meta.layer_count, "new_roots", new_roots) new_entries = create_parents.create_new_entries() return new_roots, new_l2_ids, new_entries -def _process_l2_agglomeration( - agg: types.Agglomeration, - removed_edges: np.ndarray, - atomic_cross_edges_d: Dict[int, np.ndarray], -): +def _process_l2_agglomeration(agg: types.Agglomeration, removed_edges: np.ndarray): """ For a given L2 id, remove given edges; calculate new connected components. """ chunk_edges = agg.in_edges.get_pairs() - cross_edges = np.concatenate([types.empty_2d, *atomic_cross_edges_d.values()]) chunk_edges = chunk_edges[~in2d(chunk_edges, removed_edges)] - cross_edges = cross_edges[~in2d(cross_edges, removed_edges)] isolated_ids = agg.supervoxels[~np.in1d(agg.supervoxels, chunk_edges)] isolated_edges = np.column_stack((isolated_ids, isolated_ids)) graph, _, _, graph_ids = flatgraph.build_gt_graph( np.concatenate([chunk_edges, isolated_edges]), make_directed=True ) - return flatgraph.connected_components(graph), graph_ids, cross_edges - - -def _filter_component_cross_edges( - cc_ids: np.ndarray, cross_edges: np.ndarray, cross_edge_layers: np.ndarray -) -> Dict[int, np.ndarray]: - """ - Filters cross edges for a connected component `cc_ids` - from `cross_edges` of the complete chunk. - """ - mask = np.in1d(cross_edges[:, 0], cc_ids) - cross_edges_ = cross_edges[mask] - cross_edge_layers_ = cross_edge_layers[mask] - edges_d = {} - for layer in np.unique(cross_edge_layers_): - edge_m = cross_edge_layers_ == layer - _cross_edges = cross_edges_[edge_m] - if _cross_edges.size: - edges_d[layer] = _cross_edges - return edges_d + return flatgraph.connected_components(graph), graph_ids def remove_edges( @@ -291,10 +276,9 @@ def remove_edges( ): edges, _ = _analyze_affected_edges(cg, atomic_edges, parent_ts=parent_ts) l2ids = np.unique(edges) - assert ( - np.unique(cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts)).size - == 1 - ), "L2 IDs must belong to same root." + roots = cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts) + assert np.unique(roots).size == 1, "L2 IDs must belong to same root." + new_old_id_d, old_new_id_d, old_hierarchy_d = _init_old_hierarchy( cg, l2ids, parent_ts=parent_ts ) @@ -305,20 +289,14 @@ def remove_edges( new_l2_ids = [] for id_ in l2ids: l2_agg = l2id_agglomeration_d[id_] - ccs, graph_ids, cross_edges = _process_l2_agglomeration( - l2_agg, removed_edges, cross_edges_d[id_] - ) - # done here to avoid repeat computation in loop - cross_edge_layers = cg.get_cross_chunk_edges_layer(cross_edges) + ccs, graph_ids = _process_l2_agglomeration(l2_agg, removed_edges) new_parent_ids = cg.id_client.create_node_ids( l2id_chunk_id_d[l2_agg.node_id], len(ccs) ) for i_cc, cc in enumerate(ccs): new_id = new_parent_ids[i_cc] cg.cache.children_cache[new_id] = graph_ids[cc] - cg.cache.atomic_cx_edges_cache[new_id] = _filter_component_cross_edges( - graph_ids[cc], cross_edges, cross_edge_layers - ) + cg.cache.atomic_cx_edges_cache[new_id] = None cache_utils.update(cg.cache.parents_cache, graph_ids[cc], new_id) new_l2_ids.append(new_id) new_old_id_d[new_id].add(id_) @@ -358,7 +336,6 @@ def __init__( self._new_old_id_d = new_old_id_d self._old_new_id_d = old_new_id_d self._new_ids_d = defaultdict(list) # new IDs in each layer - self._cross_edges_d = {} self._operation_id = operation_id self._time_stamp = time_stamp self._last_successful_ts = parent_ts @@ -385,6 +362,13 @@ def _get_old_ids(self, new_ids): ] return np.concatenate(old_ids) + def _get_new_ids(self, old_ids): + old_ids = [ + np.array(list(self._old_new_id_d[id_]), dtype=basetypes.NODE_ID) + for id_ in old_ids + ] + return np.concatenate(old_ids) + def _get_connected_components(self, node_ids: np.ndarray, layer: int): with TimeIt( f"get_cross_chunk_edges.{layer}", @@ -392,20 +376,16 @@ def _get_connected_components(self, node_ids: np.ndarray, layer: int): self._operation_id, ): cross_edges_d = self.cg.get_cross_chunk_edges(node_ids) - self._cross_edges_d.update(cross_edges_d) - cross_edges = [types.empty_2d] + cx_edges = [types.empty_2d] for id_ in node_ids: - edges_ = self._cross_edges_d[id_].get(layer, types.empty_2d) - cross_edges.append(edges_) - - cross_edges = np.concatenate([*cross_edges, np.vstack([node_ids, node_ids]).T]) - temp_d = {k: next(iter(v)) for k, v in self._old_new_id_d.items()} - cross_edges = fastremap.remap(cross_edges, temp_d, preserve_missing_labels=True) + edges_ = cross_edges_d[id_].get(layer, types.empty_2d) + cx_edges.append(edges_) - graph, _, _, graph_ids = flatgraph.build_gt_graph( - cross_edges, make_directed=True - ) + cx_edges = np.concatenate([*cx_edges, np.vstack([node_ids, node_ids]).T]) + temp_map = {k: next(iter(v)) for k, v in self._old_new_id_d.items()} + cx_edges = fastremap.remap(cx_edges, temp_map, preserve_missing_labels=True) + graph, _, _, graph_ids = flatgraph.build_gt_graph(cx_edges, make_directed=True) return flatgraph.connected_components(graph), graph_ids def _get_layer_node_ids( @@ -419,15 +399,37 @@ def _get_layer_node_ids( # replace old identities with new IDs mask = np.in1d(node_ids, old_ids) node_ids = np.concatenate( - [ - np.array(list(self._old_new_id_d[id_]), dtype=basetypes.NODE_ID) - for id_ in node_ids[mask] - ] - + [node_ids[~mask], new_ids] + [self._get_new_ids(node_ids[mask]), node_ids[~mask], new_ids] ) node_ids = np.unique(node_ids) layer_mask = self.cg.get_chunk_layers(node_ids) == layer return node_ids[layer_mask] + # return node_ids + + def _update_cross_edge_cache(self, parent, children): + """ + updates cross chunk edges in cache; + this can only be done after all new components at a layer have IDs + """ + cx_edges_d = self.cg.get_cross_chunk_edges(children) + cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values(), unique=True) + + parent_layer = self.cg.get_chunk_layer(parent) + edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) + edge_parents = self.cg.get_roots( + edge_nodes, stop_layer=parent_layer, ceil=False + ) + edge_parents_d = dict(zip(edge_nodes, edge_parents)) + + new_cx_edges_d = {} + for layer in range(parent_layer, self.cg.meta.layer_count): + layer_edges = cx_edges_d.get(layer, types.empty_2d) + if len(layer_edges) == 0: + continue + new_cx_edges_d[layer] = fastremap.remap( + layer_edges, edge_parents_d, preserve_missing_labels=True + ) + self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d def _create_new_parents(self, layer: int): """ @@ -439,25 +441,30 @@ def _create_new_parents(self, layer: int): get cross edges of all, find connected components update parent old IDs """ + parent_layer = layer + 1 new_ids = self._new_ids_d[layer] layer_node_ids = self._get_layer_node_ids(new_ids, layer) + print(layer, layer_node_ids) components, graph_ids = self._get_connected_components(layer_node_ids, layer) + new_parent_ids = [] for cc_indices in components: - parent_layer = layer + 1 cc_ids = graph_ids[cc_indices] if len(cc_ids) == 1: # skip connection parent_layer = self.cg.meta.layer_count for l in range(layer + 1, self.cg.meta.layer_count): - if len(self._cross_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: + cx_edges_d = self.cg.get_cross_chunk_edges([cc_ids[0]]) + if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: parent_layer = l break - parent_id = self.cg.id_client.create_node_id( self.cg.get_parent_chunk_id(cc_ids[0], parent_layer), root_chunk=parent_layer == self.cg.meta.layer_count, ) self._new_ids_d[parent_layer].append(parent_id) + self._update_id_lineage(parent_id, cc_ids, layer, parent_layer) + new_parent_ids.append(parent_id) + self.cg.cache.children_cache[parent_id] = cc_ids cache_utils.update( self.cg.cache.parents_cache, @@ -465,11 +472,9 @@ def _create_new_parents(self, layer: int): parent_id, ) - children_cx_edges = [self._cross_edges_d[child] for child in cc_ids] - cx_edges = concatenate_cross_edge_dicts(children_cx_edges) - self.cg.cache.cross_chunk_edges_cache[parent_id] = cx_edges - - self._update_id_lineage(parent_id, cc_ids, layer, parent_layer) + for new_id in new_parent_ids: + children = self.cg.get_children(new_id) + self._update_cross_edge_cache(new_id, children) def run(self) -> Iterable: """ @@ -492,9 +497,14 @@ def _update_root_id_lineage(self): new_root_ids = self._new_ids_d[self.cg.meta.layer_count] former_root_ids = self._get_old_ids(new_root_ids) former_root_ids = np.unique(former_root_ids) + + print() + print(former_root_ids, "->", new_root_ids) + print(self.cg.get_children(former_root_ids)) + print(self.cg.get_children(np.array(new_root_ids, dtype=np.uint64))) assert ( len(former_root_ids) < 2 or len(new_root_ids) < 2 - ), "Something went wrong." + ), "Result inconsistent with either split or merge effects." rows = [] for new_root_id in new_root_ids: val_dict = { @@ -524,10 +534,15 @@ def _update_root_id_lineage(self): return rows def _get_cross_edges_val_dict(self): + print("haha", self.cg.get_cross_chunk_edges([216172782113783809])) val_dicts = {} for layer in range(2, self.cg.meta.layer_count): new_ids = np.array(self._new_ids_d[layer], dtype=basetypes.NODE_ID) cross_edges_d = self.cg.get_cross_chunk_edges(new_ids) + print() + print(layer, new_ids) + print("cx", cross_edges_d) + print("ch", self.cg.get_children(new_ids)) for id_ in new_ids: val_dict = {} for layer, edges in cross_edges_d[id_].items(): From a75a094e9191139e46a6cf731a9db39364920576 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 23 Aug 2023 02:59:22 +0000 Subject: [PATCH 036/367] fix: undo gcrule changes --- pychunkedgraph/graph/attributes.py | 2 +- .../graph/client/bigtable/client.py | 26 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index 84283161d..33f675dc8 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -122,7 +122,7 @@ class Connectivity: FakeEdges = _Attribute( key=b"fake_edges", - family_id="5", + family_id="4", serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), ) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 1bd027255..6601b654e 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -72,6 +72,18 @@ def __init__( self._version = None self._max_row_key_count = config.MAX_ROW_KEY_COUNT + def _create_column_families(self): + f = self._table.column_family("0") + f.create() + f = self._table.column_family("1", gc_rule=MaxVersionsGCRule(1)) + f.create() + f = self._table.column_family("2") + f.create() + f = self._table.column_family("3", gc_rule=MaxAgeGCRule(timedelta(days=365))) + f.create() + f = self._table.column_family("4") + f.create() + @property def graph_meta(self): return self._graph_meta @@ -629,20 +641,6 @@ def get_compatible_timestamp( return utils.get_google_compatible_time_stamp(time_stamp, round_up=round_up) # PRIVATE METHODS - def _create_column_families(self): - f = self._table.column_family("0") - f.create() - f = self._table.column_family("1", gc_rule=MaxVersionsGCRule(1)) - f.create() - f = self._table.column_family("2") - f.create() - f = self._table.column_family("3", gc_rule=MaxAgeGCRule(timedelta(days=365))) - f.create() - f = self._table.column_family("4", gc_rule=MaxVersionsGCRule(1)) - f.create() - f = self._table.column_family("5") - f.create() - def _get_ids_range(self, key: bytes, size: int) -> typing.Tuple: """Returns a range (min, max) of IDs for a given `key`.""" column = attributes.Concurrency.Counter From bd971cc625b2fc497ae12bad99cf2f4a83ecc6ba Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 23 Aug 2023 03:01:18 +0000 Subject: [PATCH 037/367] fix: add mock_edges; linting issues --- pychunkedgraph/debug/utils.py | 4 +++- pychunkedgraph/graph/chunkedgraph.py | 2 ++ pychunkedgraph/graph/types.py | 3 +-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/debug/utils.py b/pychunkedgraph/debug/utils.py index 179f50aef..e194f4ee1 100644 --- a/pychunkedgraph/debug/utils.py +++ b/pychunkedgraph/debug/utils.py @@ -1,3 +1,5 @@ +# pylint: disable=invalid-name, missing-docstring, bare-except, unidiomatic-typecheck + import numpy as np from ..graph import ChunkedGraph @@ -27,7 +29,7 @@ def print_node( if cg.get_chunk_layer(node) <= stop_layer: return for child in children: - print_node(cg, child, indent=indent + 1, stop_layer=stop_layer) + print_node(cg, child, indent=indent + 4, stop_layer=stop_layer) def get_l2children(cg: ChunkedGraph, node: NODE_ID) -> np.ndarray: diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index f4e87290c..a118d4c82 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -642,6 +642,8 @@ def get_l2_agglomerations( chain(edges_d.values(), fake_edges.values()), Edges([], []), ) + if self.mock_edges is not None: + all_chunk_edges += self.mock_edges if edges_only: if self.mock_edges is not None: diff --git a/pychunkedgraph/graph/types.py b/pychunkedgraph/graph/types.py index 9a551f35c..1f35e5f6b 100644 --- a/pychunkedgraph/graph/types.py +++ b/pychunkedgraph/graph/types.py @@ -1,5 +1,4 @@ -from typing import Dict -from typing import Iterable +# pylint: disable=invalid-name, missing-docstring from collections import namedtuple import numpy as np From 99bb3daa289aa866aec89d4143cf930df44945cf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 23 Aug 2023 22:55:50 +0000 Subject: [PATCH 038/367] feat: edits using cached cross edges --- pychunkedgraph/graph/edits.py | 248 +++++++++++++++++++++++----------- 1 file changed, 172 insertions(+), 76 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 0086f00cd..ba9481139 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -221,6 +221,7 @@ def add_edges( # update cross chunk edges by replacing old_ids with new # this can be done only after all new IDs have been created + updated_entries = [] for new_id, cc_indices in zip(new_l2_ids, components): l2ids_ = graph_ids[cc_indices] new_cx_edges_d = {} @@ -230,8 +231,36 @@ def add_edges( for layer, edges in cx_edges_d.items(): edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) new_cx_edges_d[layer] = edges + assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d + # must also update cross chunk edges in reverse (counterparts) + layer_edges = new_cx_edges_d.get(2, types.empty_2d) + counterparts = layer_edges[:, 1] + counterpart_cx_edges_d = cg.get_cross_chunk_edges(counterparts) + temp_map = { + old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) + } + for counterpart, edges_d in counterpart_cx_edges_d.items(): + val_dict = {} + for layer in range(2, cg.meta.layer_count): + edges = edges_d.get(layer, types.empty_2d) + if edges.size == 0: + continue + assert np.all(edges[:, 0] == counterpart) + edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + edges_d[layer] = edges + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + if not val_dict: + continue + cg.cache.cross_chunk_edges_cache[counterpart] = edges_d + row = cg.client.mutate_row( + serialize_uint64(counterpart), + val_dict, + time_stamp=time_stamp, + ) + updated_entries.append(row) + create_parents = CreateParentNodes( cg, new_l2_ids=new_l2_ids, @@ -244,10 +273,8 @@ def add_edges( ) new_roots = create_parents.run() - print() - print("layers", cg.meta.layer_count, "new_roots", new_roots) - new_entries = create_parents.create_new_entries() - return new_roots, new_l2_ids, new_entries + create_parents.create_new_entries() + return new_roots, new_l2_ids, updated_entries + create_parents.new_entries def _process_l2_agglomeration(agg: types.Agglomeration, removed_edges: np.ndarray): @@ -257,12 +284,36 @@ def _process_l2_agglomeration(agg: types.Agglomeration, removed_edges: np.ndarra chunk_edges = agg.in_edges.get_pairs() chunk_edges = chunk_edges[~in2d(chunk_edges, removed_edges)] + # cross during edits refers to all edges crossing chunk boundary + cross_edges = [agg.out_edges.get_pairs(), agg.cross_edges.get_pairs()] + cross_edges = np.concatenate(cross_edges) + cross_edges = cross_edges[~in2d(cross_edges, removed_edges)] + isolated_ids = agg.supervoxels[~np.in1d(agg.supervoxels, chunk_edges)] isolated_edges = np.column_stack((isolated_ids, isolated_ids)) graph, _, _, graph_ids = flatgraph.build_gt_graph( np.concatenate([chunk_edges, isolated_edges]), make_directed=True ) - return flatgraph.connected_components(graph), graph_ids + return flatgraph.connected_components(graph), graph_ids, cross_edges + + +def _filter_component_cross_edges( + component_ids: np.ndarray, cross_edges: np.ndarray, cross_edge_layers: np.ndarray +) -> Dict[int, np.ndarray]: + """ + Filters cross edges for a connected component `cc_ids` + from `cross_edges` of the complete chunk. + """ + mask = np.in1d(cross_edges[:, 0], component_ids) + cross_edges_ = cross_edges[mask] + cross_edge_layers_ = cross_edge_layers[mask] + edges_d = {} + for layer in np.unique(cross_edge_layers_): + edge_m = cross_edge_layers_ == layer + _cross_edges = cross_edges_[edge_m] + if _cross_edges.size: + edges_d[layer] = _cross_edges + return edges_d def remove_edges( @@ -282,25 +333,67 @@ def remove_edges( new_old_id_d, old_new_id_d, old_hierarchy_d = _init_old_hierarchy( cg, l2ids, parent_ts=parent_ts ) - l2id_chunk_id_d = dict(zip(l2ids.tolist(), cg.get_chunk_ids_from_node_ids(l2ids))) - cross_edges_d = cg.get_cross_chunk_edges(l2ids) + chunk_id_map = dict(zip(l2ids.tolist(), cg.get_chunk_ids_from_node_ids(l2ids))) removed_edges = np.concatenate([atomic_edges, atomic_edges[:, ::-1]], axis=0) new_l2_ids = [] for id_ in l2ids: - l2_agg = l2id_agglomeration_d[id_] - ccs, graph_ids = _process_l2_agglomeration(l2_agg, removed_edges) - new_parent_ids = cg.id_client.create_node_ids( - l2id_chunk_id_d[l2_agg.node_id], len(ccs) - ) + agg = l2id_agglomeration_d[id_] + ccs, graph_ids, cross_edges = _process_l2_agglomeration(agg, removed_edges) + new_parents = cg.id_client.create_node_ids(chunk_id_map[agg.node_id], len(ccs)) + + cross_edge_layers = cg.get_cross_chunk_edges_layer(cross_edges) for i_cc, cc in enumerate(ccs): - new_id = new_parent_ids[i_cc] - cg.cache.children_cache[new_id] = graph_ids[cc] - cg.cache.atomic_cx_edges_cache[new_id] = None - cache_utils.update(cg.cache.parents_cache, graph_ids[cc], new_id) + new_id = new_parents[i_cc] new_l2_ids.append(new_id) new_old_id_d[new_id].add(id_) old_new_id_d[id_].add(new_id) + cg.cache.children_cache[new_id] = graph_ids[cc] + cache_utils.update(cg.cache.parents_cache, graph_ids[cc], new_id) + cg.cache.cross_chunk_edges_cache[new_id] = _filter_component_cross_edges( + graph_ids[cc], cross_edges, cross_edge_layers + ) + + updated_entries = [] + new_cx_edges_d = cg.get_cross_chunk_edges(new_l2_ids) + for new_id in new_l2_ids: + cx_edges_d = new_cx_edges_d.get(new_id, {}) + for layer, edges in cx_edges_d.items(): + svs = np.unique(edges) + parents = cg.get_parents(svs) + temp_map = dict(zip(svs, parents)) + + edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + edges = np.unique(edges, axis=0) + cx_edges_d[layer] = edges + assert np.all(edges[:, 0] == new_id) + cg.cache.cross_chunk_edges_cache[new_id] = cx_edges_d + + layer_edges = cx_edges_d.get(2, types.empty_2d) + counterparts = layer_edges[:, 1] + counterpart_cx_edges_d = cg.get_cross_chunk_edges(counterparts) + temp_map = { + old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) + } + for counterpart, edges_d in counterpart_cx_edges_d.items(): + val_dict = {} + for layer in range(2, cg.meta.layer_count): + edges = edges_d.get(layer, types.empty_2d) + if edges.size == 0: + continue + assert np.all(edges[:, 0] == counterpart) + edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + edges_d[layer] = edges + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + if not val_dict: + continue + cg.cache.cross_chunk_edges_cache[counterpart] = edges_d + row = cg.client.mutate_row( + serialize_uint64(counterpart), + val_dict, + time_stamp=time_stamp, + ) + updated_entries.append(row) create_parents = CreateParentNodes( cg, @@ -313,8 +406,16 @@ def remove_edges( parent_ts=parent_ts, ) new_roots = create_parents.run() - new_entries = create_parents.create_new_entries() - return new_roots, new_l2_ids, new_entries + create_parents.create_new_entries() + return new_roots, new_l2_ids, updated_entries + create_parents.new_entries + + +def _get_flipped_ids(id_map, node_ids): + """ + returns old or new ids according to the map + """ + ids = [np.array(list(id_map[id_]), dtype=basetypes.NODE_ID) for id_ in node_ids] + return np.concatenate(ids) class CreateParentNodes: @@ -331,6 +432,7 @@ def __init__( parent_ts: datetime.datetime = None, ): self.cg = cg + self.new_entries = [] self._new_l2_ids = new_l2_ids self._old_hierarchy_d = old_hierarchy_d self._new_old_id_d = new_old_id_d @@ -355,20 +457,6 @@ def _update_id_lineage( self._new_old_id_d[parent].add(old_id) self._old_new_id_d[old_id].add(parent) - def _get_old_ids(self, new_ids): - old_ids = [ - np.array(list(self._new_old_id_d[id_]), dtype=basetypes.NODE_ID) - for id_ in new_ids - ] - return np.concatenate(old_ids) - - def _get_new_ids(self, old_ids): - old_ids = [ - np.array(list(self._old_new_id_d[id_]), dtype=basetypes.NODE_ID) - for id_ in old_ids - ] - return np.concatenate(old_ids) - def _get_connected_components(self, node_ids: np.ndarray, layer: int): with TimeIt( f"get_cross_chunk_edges.{layer}", @@ -381,10 +469,7 @@ def _get_connected_components(self, node_ids: np.ndarray, layer: int): for id_ in node_ids: edges_ = cross_edges_d[id_].get(layer, types.empty_2d) cx_edges.append(edges_) - cx_edges = np.concatenate([*cx_edges, np.vstack([node_ids, node_ids]).T]) - temp_map = {k: next(iter(v)) for k, v in self._old_new_id_d.items()} - cx_edges = fastremap.remap(cx_edges, temp_map, preserve_missing_labels=True) graph, _, _, graph_ids = flatgraph.build_gt_graph(cx_edges, make_directed=True) return flatgraph.connected_components(graph), graph_ids @@ -392,14 +477,14 @@ def _get_layer_node_ids( self, new_ids: np.ndarray, layer: int ) -> Tuple[np.ndarray, np.ndarray]: # get old identities of new IDs - old_ids = self._get_old_ids(new_ids) + old_ids = _get_flipped_ids(self._new_old_id_d, new_ids) # get their parents, then children of those parents - parents = self.cg.get_parents(old_ids, time_stamp=self._last_successful_ts) - node_ids = self.cg.get_children(np.unique(parents), flatten=True) + old_parents = self.cg.get_parents(old_ids, time_stamp=self._last_successful_ts) + siblings = self.cg.get_children(np.unique(old_parents), flatten=True) # replace old identities with new IDs - mask = np.in1d(node_ids, old_ids) + mask = np.in1d(siblings, old_ids) node_ids = np.concatenate( - [self._get_new_ids(node_ids[mask]), node_ids[~mask], new_ids] + [_get_flipped_ids(self._old_new_id_d, old_ids), siblings[~mask], new_ids] ) node_ids = np.unique(node_ids) layer_mask = self.cg.get_chunk_layers(node_ids) == layer @@ -423,14 +508,40 @@ def _update_cross_edge_cache(self, parent, children): new_cx_edges_d = {} for layer in range(parent_layer, self.cg.meta.layer_count): - layer_edges = cx_edges_d.get(layer, types.empty_2d) - if len(layer_edges) == 0: + edges = cx_edges_d.get(layer, types.empty_2d) + if len(edges) == 0: continue - new_cx_edges_d[layer] = fastremap.remap( - layer_edges, edge_parents_d, preserve_missing_labels=True - ) + edges = fastremap.remap(edges, edge_parents_d, preserve_missing_labels=True) + new_cx_edges_d[layer] = np.unique(edges, axis=0) + assert np.all(edges[:, 0] == parent) self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d + layer_edges = new_cx_edges_d.get(parent_layer, types.empty_2d) + counterparts = layer_edges[:, 1] + counterpart_cx_edges_d = self.cg.get_cross_chunk_edges(counterparts) + temp_map = { + old_id: parent for old_id in _get_flipped_ids(self._new_old_id_d, [parent]) + } + for counterpart, edges_d in counterpart_cx_edges_d.items(): + val_dict = {} + for layer in range(parent_layer, self.cg.meta.layer_count): + edges = edges_d.get(layer, types.empty_2d) + if edges.size == 0: + continue + assert np.all(edges[:, 0] == counterpart) + edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + edges_d[layer] = edges + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + if not val_dict: + continue + self.cg.cache.cross_chunk_edges_cache[counterpart] = edges_d + row = self.cg.client.mutate_row( + serialize_uint64(counterpart), + val_dict, + time_stamp=self._time_stamp, + ) + self.new_entries.append(row) + def _create_new_parents(self, layer: int): """ keep track of old IDs @@ -444,7 +555,6 @@ def _create_new_parents(self, layer: int): parent_layer = layer + 1 new_ids = self._new_ids_d[layer] layer_node_ids = self._get_layer_node_ids(new_ids, layer) - print(layer, layer_node_ids) components, graph_ids = self._get_connected_components(layer_node_ids, layer) new_parent_ids = [] for cc_indices in components: @@ -494,24 +604,17 @@ def run(self) -> Iterable: return self._new_ids_d[self.cg.meta.layer_count] def _update_root_id_lineage(self): - new_root_ids = self._new_ids_d[self.cg.meta.layer_count] - former_root_ids = self._get_old_ids(new_root_ids) - former_root_ids = np.unique(former_root_ids) - - print() - print(former_root_ids, "->", new_root_ids) - print(self.cg.get_children(former_root_ids)) - print(self.cg.get_children(np.array(new_root_ids, dtype=np.uint64))) - assert ( - len(former_root_ids) < 2 or len(new_root_ids) < 2 - ), "Result inconsistent with either split or merge effects." - rows = [] - for new_root_id in new_root_ids: + new_roots = self._new_ids_d[self.cg.meta.layer_count] + former_roots = _get_flipped_ids(self._new_old_id_d, new_roots) + former_roots = np.unique(former_roots) + + assert len(former_roots) < 2 or len(new_roots) < 2, "new roots are inconsistent" + for new_root_id in new_roots: val_dict = { - attributes.Hierarchy.FormerParent: np.array(former_root_ids), + attributes.Hierarchy.FormerParent: np.array(former_roots), attributes.OperationLogs.OperationID: self._operation_id, } - rows.append( + self.new_entries.append( self.cg.client.mutate_row( serialize_uint64(new_root_id), val_dict, @@ -519,30 +622,24 @@ def _update_root_id_lineage(self): ) ) - for former_root_id in former_root_ids: + for former_root_id in former_roots: val_dict = { - attributes.Hierarchy.NewParent: np.array(new_root_ids), + attributes.Hierarchy.NewParent: np.array(new_roots), attributes.OperationLogs.OperationID: self._operation_id, } - rows.append( + self.new_entries.append( self.cg.client.mutate_row( serialize_uint64(former_root_id), val_dict, time_stamp=self._time_stamp, ) ) - return rows - def _get_cross_edges_val_dict(self): - print("haha", self.cg.get_cross_chunk_edges([216172782113783809])) + def _get_cross_edges_val_dicts(self): val_dicts = {} for layer in range(2, self.cg.meta.layer_count): new_ids = np.array(self._new_ids_d[layer], dtype=basetypes.NODE_ID) cross_edges_d = self.cg.get_cross_chunk_edges(new_ids) - print() - print(layer, new_ids) - print("cx", cross_edges_d) - print("ch", self.cg.get_children(new_ids)) for id_ in new_ids: val_dict = {} for layer, edges in cross_edges_d[id_].items(): @@ -551,8 +648,7 @@ def _get_cross_edges_val_dict(self): return val_dicts def create_new_entries(self) -> List: - rows = [] - val_dicts = self._get_cross_edges_val_dict() + val_dicts = self._get_cross_edges_val_dicts() for layer in range(2, self.cg.meta.layer_count + 1): new_ids = self._new_ids_d[layer] for id_ in new_ids: @@ -562,7 +658,7 @@ def create_new_entries(self) -> List: self.cg.get_chunk_layers(children) ) < self.cg.get_chunk_layer(id_), "Parent layer less than children." val_dict[attributes.Hierarchy.Child] = children - rows.append( + self.new_entries.append( self.cg.client.mutate_row( serialize_uint64(id_), val_dict, @@ -570,11 +666,11 @@ def create_new_entries(self) -> List: ) ) for child_id in children: - rows.append( + self.new_entries.append( self.cg.client.mutate_row( serialize_uint64(child_id), {attributes.Hierarchy.Parent: id_}, time_stamp=self._time_stamp, ) ) - return rows + self._update_root_id_lineage() + self._update_root_id_lineage() From cc5455bd02fe9832a9af64c30efed5b2cc4451be Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 24 Aug 2023 00:41:22 +0000 Subject: [PATCH 039/367] fix: use function for dry code --- pychunkedgraph/graph/edits.py | 135 +++++++++++++--------------------- 1 file changed, 51 insertions(+), 84 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index ba9481139..7a2a03408 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -178,6 +178,40 @@ def check_fake_edges( return atomic_edges, rows +def _update_neighbor_cross_edges( + cg, new_id: int, cx_edges_d: dict, new_old_id_d: dict, time_stamp +) -> list: + updated_entries = [] + node_layer = cg.get_chunk_layer(new_id) + for cx_layer in range(node_layer, cg.meta.layer_count): + layer_edges = cx_edges_d.get(cx_layer, types.empty_2d) + counterparts = layer_edges[:, 1] + counterpart_cx_edges_d = cg.get_cross_chunk_edges(counterparts) + temp_map = { + old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) + } + for counterpart, edges_d in counterpart_cx_edges_d.items(): + val_dict = {} + for layer in range(2, cg.meta.layer_count): + edges = edges_d.get(layer, types.empty_2d) + if edges.size == 0: + continue + assert np.all(edges[:, 0] == counterpart) + edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + edges_d[layer] = edges + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + if not val_dict: + continue + cg.cache.cross_chunk_edges_cache[counterpart] = edges_d + row = cg.client.mutate_row( + serialize_uint64(counterpart), + val_dict, + time_stamp=time_stamp, + ) + updated_entries.append(row) + return updated_entries + + def add_edges( cg, *, @@ -233,33 +267,10 @@ def add_edges( new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - - # must also update cross chunk edges in reverse (counterparts) - layer_edges = new_cx_edges_d.get(2, types.empty_2d) - counterparts = layer_edges[:, 1] - counterpart_cx_edges_d = cg.get_cross_chunk_edges(counterparts) - temp_map = { - old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) - } - for counterpart, edges_d in counterpart_cx_edges_d.items(): - val_dict = {} - for layer in range(2, cg.meta.layer_count): - edges = edges_d.get(layer, types.empty_2d) - if edges.size == 0: - continue - assert np.all(edges[:, 0] == counterpart) - edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) - edges_d[layer] = edges - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges - if not val_dict: - continue - cg.cache.cross_chunk_edges_cache[counterpart] = edges_d - row = cg.client.mutate_row( - serialize_uint64(counterpart), - val_dict, - time_stamp=time_stamp, - ) - updated_entries.append(row) + entries = _update_neighbor_cross_edges( + cg, new_id, new_cx_edges_d, new_old_id_d, time_stamp + ) + updated_entries.extend(entries) create_parents = CreateParentNodes( cg, @@ -355,45 +366,23 @@ def remove_edges( ) updated_entries = [] - new_cx_edges_d = cg.get_cross_chunk_edges(new_l2_ids) + cx_edges_d = cg.get_cross_chunk_edges(new_l2_ids) for new_id in new_l2_ids: - cx_edges_d = new_cx_edges_d.get(new_id, {}) - for layer, edges in cx_edges_d.items(): + new_cx_edges_d = cx_edges_d.get(new_id, {}) + for layer, edges in new_cx_edges_d.items(): svs = np.unique(edges) parents = cg.get_parents(svs) temp_map = dict(zip(svs, parents)) edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) edges = np.unique(edges, axis=0) - cx_edges_d[layer] = edges + new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) - cg.cache.cross_chunk_edges_cache[new_id] = cx_edges_d - - layer_edges = cx_edges_d.get(2, types.empty_2d) - counterparts = layer_edges[:, 1] - counterpart_cx_edges_d = cg.get_cross_chunk_edges(counterparts) - temp_map = { - old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) - } - for counterpart, edges_d in counterpart_cx_edges_d.items(): - val_dict = {} - for layer in range(2, cg.meta.layer_count): - edges = edges_d.get(layer, types.empty_2d) - if edges.size == 0: - continue - assert np.all(edges[:, 0] == counterpart) - edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) - edges_d[layer] = edges - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges - if not val_dict: - continue - cg.cache.cross_chunk_edges_cache[counterpart] = edges_d - row = cg.client.mutate_row( - serialize_uint64(counterpart), - val_dict, - time_stamp=time_stamp, - ) - updated_entries.append(row) + cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d + entries = _update_neighbor_cross_edges( + cg, new_id, new_cx_edges_d, new_old_id_d, time_stamp + ) + updated_entries.extend(entries) create_parents = CreateParentNodes( cg, @@ -515,32 +504,10 @@ def _update_cross_edge_cache(self, parent, children): new_cx_edges_d[layer] = np.unique(edges, axis=0) assert np.all(edges[:, 0] == parent) self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d - - layer_edges = new_cx_edges_d.get(parent_layer, types.empty_2d) - counterparts = layer_edges[:, 1] - counterpart_cx_edges_d = self.cg.get_cross_chunk_edges(counterparts) - temp_map = { - old_id: parent for old_id in _get_flipped_ids(self._new_old_id_d, [parent]) - } - for counterpart, edges_d in counterpart_cx_edges_d.items(): - val_dict = {} - for layer in range(parent_layer, self.cg.meta.layer_count): - edges = edges_d.get(layer, types.empty_2d) - if edges.size == 0: - continue - assert np.all(edges[:, 0] == counterpart) - edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) - edges_d[layer] = edges - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges - if not val_dict: - continue - self.cg.cache.cross_chunk_edges_cache[counterpart] = edges_d - row = self.cg.client.mutate_row( - serialize_uint64(counterpart), - val_dict, - time_stamp=self._time_stamp, - ) - self.new_entries.append(row) + entries = _update_neighbor_cross_edges( + self.cg, parent, new_cx_edges_d, self._new_old_id_d, self._time_stamp + ) + self.new_entries.extend(entries) def _create_new_parents(self, layer: int): """ From 240ad53cdfbc347cbba575323fe380ccf63e5c9f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 28 Aug 2023 21:02:26 +0000 Subject: [PATCH 040/367] fix: mask skipped nodes --- pychunkedgraph/ingest/create/abstract_layers.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 9a339443f..df6375c5f 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -214,8 +214,10 @@ def _write( layer_edges = node_cx_edges_d[layer] edges_nodes = np.unique(layer_edges) - edges_nodes_parents = cg.get_parents(edges_nodes) - temp_map = dict(zip(edges_nodes, edges_nodes_parents)) + edges_nodes_layers = cg.get_chunk_layers(edges_nodes) + mask = edges_nodes_layers < layer_id - 1 + edges_nodes_parents = cg.get_parents(edges_nodes[mask]) + temp_map = dict(zip(edges_nodes[mask], edges_nodes_parents)) layer_edges = fastremap.remap( layer_edges, temp_map, preserve_missing_labels=True @@ -230,7 +232,9 @@ def _write( row_id = serializers.serialize_uint64(parent_id) val_dict = {attributes.Hierarchy.Child: node_ids} - parent_cx_edges_d = concatenate_cross_edge_dicts(children_cx_edges, unique=True) + parent_cx_edges_d = concatenate_cross_edge_dicts( + children_cx_edges, unique=True + ) for layer in range(parent_layer, cg.meta.layer_count): if not layer in parent_cx_edges_d: continue From cebbadc0b9a9cb126c19fd289bf21357a5e4c0e6 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 28 Aug 2023 21:03:46 +0000 Subject: [PATCH 041/367] fix: use the correct layer variable --- pychunkedgraph/ingest/create/abstract_layers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index df6375c5f..d65e225a3 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -190,8 +190,9 @@ def _write( for i_cc, node_ids in enumerate(cc_connections[parent_layer]): parent_id = reserved_parent_ids[i_cc] - if parent_layer == 3: - # children are from atomic chunks + if layer_id == 3: + # when layer 3 is being processed, children chunks are at layer 2 + # layer 2 chunks at this time will only have atomic cross edges cx_edges_d = cg.get_atomic_cross_edges(node_ids) else: # children are from abstract chunks From ddefd4d09f1933ee5e9a3539016d37c8cd057e4c Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 29 Aug 2023 14:58:10 +0000 Subject: [PATCH 042/367] fix: redis pipeline for lower latency --- pychunkedgraph/ingest/cli.py | 29 ++++++++++++++++++++++++++--- pychunkedgraph/ingest/rq_cli.py | 4 ++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 2ad51ca18..997bf768a 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -121,9 +121,32 @@ def ingest_status(): redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) layers = range(2, imanager.cg_meta.layer_count + 1) - for layer, layer_count in zip(layers, imanager.cg_meta.layer_chunk_counts): - done = redis.scard(f"{layer}c") - print(f"{layer}\t: {done} / {layer_count}") + layer_counts = imanager.cg_meta.layer_chunk_counts + + pipeline = redis.pipeline() + for layer in layers: + pipeline.scard(f"{layer}c") + queue = Queue(f"l{layer}") + pipeline.llen(queue.key) + pipeline.zcard(queue.failed_job_registry.key) + + results = pipeline.execute() + completed = [] + queued = [] + failed = [] + for i in range(0, len(results), 3): + result = results[i : i + 3] + completed.append(result[0]) + queued.append(result[1]) + failed.append(result[2]) + + print("layer status:") + for layer, done, count in zip(layers, completed, layer_counts): + print(f"{layer}\t: {done} / {count}") + + print("\n\nqueue status:") + for layer, q, f in zip(layers, queued, failed): + print(f"l{layer}\t: queued {q}, failed {f}") @ingest_cli.command("chunk") diff --git a/pychunkedgraph/ingest/rq_cli.py b/pychunkedgraph/ingest/rq_cli.py index 27b9c865d..c9b21ae36 100644 --- a/pychunkedgraph/ingest/rq_cli.py +++ b/pychunkedgraph/ingest/rq_cli.py @@ -1,7 +1,8 @@ +# pylint: disable=invalid-name, missing-function-docstring + """ cli for redis jobs """ -import os import sys import click @@ -14,7 +15,6 @@ from rq.exceptions import NoSuchJobError from rq.registry import StartedJobRegistry from rq.registry import FailedJobRegistry -from flask import current_app from flask.cli import AppGroup from ..utils.redis import REDIS_HOST From 787b01fbc95262b75d2780140b3e24f2ab5ba7d5 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 29 Aug 2023 15:15:24 +0000 Subject: [PATCH 043/367] fix: pass redis connection --- pychunkedgraph/ingest/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 997bf768a..93bb328c1 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -126,7 +126,7 @@ def ingest_status(): pipeline = redis.pipeline() for layer in layers: pipeline.scard(f"{layer}c") - queue = Queue(f"l{layer}") + queue = Queue(f"l{layer}", connection=redis) pipeline.llen(queue.key) pipeline.zcard(queue.failed_job_registry.key) @@ -146,7 +146,7 @@ def ingest_status(): print("\n\nqueue status:") for layer, q, f in zip(layers, queued, failed): - print(f"l{layer}\t: queued {q}, failed {f}") + print(f"l{layer}\t: queued\t {q}, failed\t {f}") @ingest_cli.command("chunk") From 36d851414059d9799ca78f0bd3e2ba0cbf2ea11c Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 29 Aug 2023 17:51:17 +0000 Subject: [PATCH 044/367] fix: version update for deployment --- pychunkedgraph/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index e615ea2b7..528787cfc 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "2.21.1" +__version__ = "3.0.0" From 39c585d6da04c9ce4ff8608bc8db2f653e06e101 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 29 Aug 2023 17:52:17 +0000 Subject: [PATCH 045/367] fix: status print padding --- pychunkedgraph/ingest/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 93bb328c1..0fe925d78 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -146,7 +146,7 @@ def ingest_status(): print("\n\nqueue status:") for layer, q, f in zip(layers, queued, failed): - print(f"l{layer}\t: queued\t {q}, failed\t {f}") + print(f"l{layer}\t: queued\t {q}\t, failed\t {f}") @ingest_cli.command("chunk") From 23eae9c3ef3a8ba7074c4a63fd54f4b677f9343b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 30 Aug 2023 02:37:55 +0000 Subject: [PATCH 046/367] fix: filter active edges for split, add timestamp for reading cross chunk edges --- pychunkedgraph/graph/cache.py | 14 ++++-- pychunkedgraph/graph/chunkedgraph.py | 14 +++++- pychunkedgraph/graph/edits.py | 67 ++++++++++++++++++++++------ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index 52fdfd022..d381baa7d 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -68,11 +68,11 @@ def children_decorated(node_id): return children_decorated(node_id) - def cross_chunk_edges(self, node_id): + def cross_chunk_edges(self, node_id, *, time_stamp: datetime = None): @cached(cache=self.cross_chunk_edges_cache, key=lambda node_id: node_id) def cross_edges_decorated(node_id): edges = self._cg.get_cross_chunk_edges( - np.array([node_id], dtype=NODE_ID), raw_only=True + np.array([node_id], dtype=NODE_ID), raw_only=True, time_stamp=time_stamp ) return edges[node_id] @@ -107,7 +107,9 @@ def children_multiple(self, node_ids: np.ndarray, *, flatten=False): return np.concatenate([*result.values()]) return result - def cross_chunk_edges_multiple(self, node_ids: np.ndarray): + def cross_chunk_edges_multiple( + self, node_ids: np.ndarray, *, time_stamp: datetime = None + ): result = {} node_ids = np.array(node_ids, dtype=NODE_ID) if not node_ids.size: @@ -119,7 +121,11 @@ def cross_chunk_edges_multiple(self, node_ids: np.ndarray): result.update( {id_: edges_ for id_, edges_ in zip(node_ids[mask], cached_edges_)} ) - result.update(self._cg.get_cross_chunk_edges(node_ids[~mask], raw_only=True)) + result.update( + self._cg.get_cross_chunk_edges( + node_ids[~mask], raw_only=True, time_stamp=time_stamp + ) + ) update( self.cross_chunk_edges_cache, node_ids[~mask], diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index a118d4c82..049c7f683 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -318,12 +318,17 @@ def get_atomic_cross_edges(self, l2_ids: typing.Iterable) -> typing.Dict: return result def get_cross_chunk_edges( - self, node_ids: typing.Iterable, *, raw_only=False + self, + node_ids: typing.Iterable, + *, + raw_only=False, + time_stamp: typing.Optional[datetime.datetime] = None, ) -> typing.Dict: """ Returns cross edges for `node_ids`. A dict of the form `{node_id: {layer: cross_edges}}`. """ + time_stamp = misc_utils.get_valid_timestamp(time_stamp) if raw_only or not self.cache: result = {} node_ids = np.array(node_ids, dtype=basetypes.NODE_ID) @@ -333,7 +338,12 @@ def get_cross_chunk_edges( attributes.Connectivity.CrossChunkEdge[l] for l in range(2, self.meta.layer_count) ] - node_edges_d_d = self.client.read_nodes(node_ids=node_ids, properties=attrs) + node_edges_d_d = self.client.read_nodes( + node_ids=node_ids, + properties=attrs, + end_time=time_stamp, + end_time_inclusive=True, + ) for id_ in node_ids: try: result[id_] = { diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 7a2a03408..c7485a26e 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -179,14 +179,16 @@ def check_fake_edges( def _update_neighbor_cross_edges( - cg, new_id: int, cx_edges_d: dict, new_old_id_d: dict, time_stamp + cg, new_id: int, cx_edges_d: dict, new_old_id_d: dict, *, time_stamp, parent_ts ) -> list: updated_entries = [] node_layer = cg.get_chunk_layer(new_id) for cx_layer in range(node_layer, cg.meta.layer_count): layer_edges = cx_edges_d.get(cx_layer, types.empty_2d) counterparts = layer_edges[:, 1] - counterpart_cx_edges_d = cg.get_cross_chunk_edges(counterparts) + counterpart_cx_edges_d = cg.get_cross_chunk_edges( + counterparts, time_stamp=parent_ts + ) temp_map = { old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) } @@ -233,7 +235,7 @@ def add_edges( ) atomic_children_d = cg.get_children(l2ids) cross_edges_d = merge_cross_edge_dicts( - cg.get_cross_chunk_edges(l2ids), l2_cross_edges_d + cg.get_cross_chunk_edges(l2ids, time_stamp=parent_ts), l2_cross_edges_d ) graph, _, _, graph_ids = flatgraph.build_gt_graph(edges, make_directed=True) @@ -268,7 +270,12 @@ def add_edges( assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d entries = _update_neighbor_cross_edges( - cg, new_id, new_cx_edges_d, new_old_id_d, time_stamp + cg, + new_id, + new_cx_edges_d, + new_old_id_d, + time_stamp=time_stamp, + parent_ts=parent_ts, ) updated_entries.extend(entries) @@ -288,7 +295,12 @@ def add_edges( return new_roots, new_l2_ids, updated_entries + create_parents.new_entries -def _process_l2_agglomeration(agg: types.Agglomeration, removed_edges: np.ndarray): +def _process_l2_agglomeration( + cg, + agg: types.Agglomeration, + removed_edges: np.ndarray, + parent_ts: datetime.datetime = None, +): """ For a given L2 id, remove given edges; calculate new connected components. """ @@ -298,6 +310,15 @@ def _process_l2_agglomeration(agg: types.Agglomeration, removed_edges: np.ndarra # cross during edits refers to all edges crossing chunk boundary cross_edges = [agg.out_edges.get_pairs(), agg.cross_edges.get_pairs()] cross_edges = np.concatenate(cross_edges) + + parents = cg.get_parents(cross_edges[:, 0], time_stamp=parent_ts) + assert np.unique(parents).size == 1, "got cross edges from more than one l2 node" + root = cg.get_root(parents[0], time_stamp=parent_ts) + + # inactive edges must be filtered out + neighbor_roots = cg.get_roots(cross_edges[:, 1], time_stamp=parent_ts) + active_mask = neighbor_roots == root + cross_edges = cross_edges[active_mask] cross_edges = cross_edges[~in2d(cross_edges, removed_edges)] isolated_ids = agg.supervoxels[~np.in1d(agg.supervoxels, chunk_edges)] @@ -350,7 +371,9 @@ def remove_edges( new_l2_ids = [] for id_ in l2ids: agg = l2id_agglomeration_d[id_] - ccs, graph_ids, cross_edges = _process_l2_agglomeration(agg, removed_edges) + ccs, graph_ids, cross_edges = _process_l2_agglomeration( + cg, agg, removed_edges, parent_ts + ) new_parents = cg.id_client.create_node_ids(chunk_id_map[agg.node_id], len(ccs)) cross_edge_layers = cg.get_cross_chunk_edges_layer(cross_edges) @@ -366,7 +389,7 @@ def remove_edges( ) updated_entries = [] - cx_edges_d = cg.get_cross_chunk_edges(new_l2_ids) + cx_edges_d = cg.get_cross_chunk_edges(new_l2_ids, time_stamp=parent_ts) for new_id in new_l2_ids: new_cx_edges_d = cx_edges_d.get(new_id, {}) for layer, edges in new_cx_edges_d.items(): @@ -380,7 +403,12 @@ def remove_edges( assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d entries = _update_neighbor_cross_edges( - cg, new_id, new_cx_edges_d, new_old_id_d, time_stamp + cg, + new_id, + new_cx_edges_d, + new_old_id_d, + time_stamp=time_stamp, + parent_ts=parent_ts, ) updated_entries.extend(entries) @@ -452,7 +480,9 @@ def _get_connected_components(self, node_ids: np.ndarray, layer: int): self.cg.graph_id, self._operation_id, ): - cross_edges_d = self.cg.get_cross_chunk_edges(node_ids) + cross_edges_d = self.cg.get_cross_chunk_edges( + node_ids, time_stamp=self._last_successful_ts + ) cx_edges = [types.empty_2d] for id_ in node_ids: @@ -485,7 +515,9 @@ def _update_cross_edge_cache(self, parent, children): updates cross chunk edges in cache; this can only be done after all new components at a layer have IDs """ - cx_edges_d = self.cg.get_cross_chunk_edges(children) + cx_edges_d = self.cg.get_cross_chunk_edges( + children, time_stamp=self._last_successful_ts + ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values(), unique=True) parent_layer = self.cg.get_chunk_layer(parent) @@ -505,7 +537,12 @@ def _update_cross_edge_cache(self, parent, children): assert np.all(edges[:, 0] == parent) self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d entries = _update_neighbor_cross_edges( - self.cg, parent, new_cx_edges_d, self._new_old_id_d, self._time_stamp + self.cg, + parent, + new_cx_edges_d, + self._new_old_id_d, + time_stamp=self._time_stamp, + parent_ts=self._last_successful_ts, ) self.new_entries.extend(entries) @@ -530,7 +567,9 @@ def _create_new_parents(self, layer: int): # skip connection parent_layer = self.cg.meta.layer_count for l in range(layer + 1, self.cg.meta.layer_count): - cx_edges_d = self.cg.get_cross_chunk_edges([cc_ids[0]]) + cx_edges_d = self.cg.get_cross_chunk_edges( + [cc_ids[0]], time_stamp=self._last_successful_ts + ) if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: parent_layer = l break @@ -606,7 +645,9 @@ def _get_cross_edges_val_dicts(self): val_dicts = {} for layer in range(2, self.cg.meta.layer_count): new_ids = np.array(self._new_ids_d[layer], dtype=basetypes.NODE_ID) - cross_edges_d = self.cg.get_cross_chunk_edges(new_ids) + cross_edges_d = self.cg.get_cross_chunk_edges( + new_ids, time_stamp=self._last_successful_ts + ) for id_ in new_ids: val_dict = {} for layer, edges in cross_edges_d[id_].items(): From b31e7dba069842a5d5e400027742ffe47931b23d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 30 Aug 2023 14:06:29 +0000 Subject: [PATCH 047/367] fix: get roots no cache flag --- pychunkedgraph/graph/chunkedgraph.py | 11 +++++++++-- pychunkedgraph/graph/edits.py | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 049c7f683..a3c9aafc3 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -364,6 +364,7 @@ def get_roots( stop_layer: int = None, ceil: bool = True, fail_to_zero: bool = False, + raw_only=False, n_tries: int = 1, ) -> typing.Union[np.ndarray, typing.Dict[int, np.ndarray]]: """ @@ -387,7 +388,10 @@ def get_roots( filtered_ids = parent_ids[layer_mask] unique_ids, inverse = np.unique(filtered_ids, return_inverse=True) temp_ids = self.get_parents( - unique_ids, time_stamp=time_stamp, fail_to_zero=fail_to_zero + unique_ids, + time_stamp=time_stamp, + fail_to_zero=fail_to_zero, + raw_only=raw_only, ) if not temp_ids.size: break @@ -442,6 +446,7 @@ def get_root( get_all_parents: bool = False, stop_layer: int = None, ceil: bool = True, + raw_only: bool = False, n_tries: int = 1, ) -> typing.Union[typing.List[np.uint64], np.uint64]: """Takes a node id and returns the associated agglomeration ids.""" @@ -459,7 +464,9 @@ def get_root( for _ in range(n_tries): parent_id = node_id for _ in range(self.get_chunk_layer(node_id), int(stop_layer + 1)): - temp_parent_id = self.get_parent(parent_id, time_stamp=time_stamp) + temp_parent_id = self.get_parent( + parent_id, time_stamp=time_stamp, raw_only=raw_only + ) if temp_parent_id is None: break else: diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index c7485a26e..709c2dadc 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -316,7 +316,10 @@ def _process_l2_agglomeration( root = cg.get_root(parents[0], time_stamp=parent_ts) # inactive edges must be filtered out - neighbor_roots = cg.get_roots(cross_edges[:, 1], time_stamp=parent_ts) + # we must avoid the cache to read roots to get segment state before edit began + neighbor_roots = cg.get_roots( + cross_edges[:, 1], raw_only=True, time_stamp=parent_ts + ) active_mask = neighbor_roots == root cross_edges = cross_edges[active_mask] cross_edges = cross_edges[~in2d(cross_edges, removed_edges)] From 2c2148e1b384f15a6cf8a080d6591952591757b9 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 31 Aug 2023 12:51:51 +0000 Subject: [PATCH 048/367] fix: parent and roots no cache --- pychunkedgraph/graph/edits.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 709c2dadc..dd53354d8 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -311,9 +311,9 @@ def _process_l2_agglomeration( cross_edges = [agg.out_edges.get_pairs(), agg.cross_edges.get_pairs()] cross_edges = np.concatenate(cross_edges) - parents = cg.get_parents(cross_edges[:, 0], time_stamp=parent_ts) + parents = cg.get_parents(cross_edges[:, 0], time_stamp=parent_ts, raw_only=True) assert np.unique(parents).size == 1, "got cross edges from more than one l2 node" - root = cg.get_root(parents[0], time_stamp=parent_ts) + root = cg.get_root(parents[0], time_stamp=parent_ts, raw_only=True) # inactive edges must be filtered out # we must avoid the cache to read roots to get segment state before edit began From 40d0228850c39ecb949373b8aa5650b69c6e6f35 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 31 Aug 2023 15:52:12 +0000 Subject: [PATCH 049/367] fix: out edges here dont refer to edges crossing chunk --- pychunkedgraph/graph/edits.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index dd53354d8..fd397d5a8 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -307,10 +307,7 @@ def _process_l2_agglomeration( chunk_edges = agg.in_edges.get_pairs() chunk_edges = chunk_edges[~in2d(chunk_edges, removed_edges)] - # cross during edits refers to all edges crossing chunk boundary - cross_edges = [agg.out_edges.get_pairs(), agg.cross_edges.get_pairs()] - cross_edges = np.concatenate(cross_edges) - + cross_edges = agg.cross_edges.get_pairs() parents = cg.get_parents(cross_edges[:, 0], time_stamp=parent_ts, raw_only=True) assert np.unique(parents).size == 1, "got cross edges from more than one l2 node" root = cg.get_root(parents[0], time_stamp=parent_ts, raw_only=True) From ecaaa14640ab9d0cfbfd3ca984c12c5d370a402f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 2 Sep 2023 19:42:49 +0000 Subject: [PATCH 050/367] fix: missing timestamps --- pychunkedgraph/graph/edits.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index fd397d5a8..76c708a38 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -394,7 +394,7 @@ def remove_edges( new_cx_edges_d = cx_edges_d.get(new_id, {}) for layer, edges in new_cx_edges_d.items(): svs = np.unique(edges) - parents = cg.get_parents(svs) + parents = cg.get_parents(svs, time_stamp=parent_ts) temp_map = dict(zip(svs, parents)) edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) @@ -523,7 +523,10 @@ def _update_cross_edge_cache(self, parent, children): parent_layer = self.cg.get_chunk_layer(parent) edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) edge_parents = self.cg.get_roots( - edge_nodes, stop_layer=parent_layer, ceil=False + edge_nodes, + stop_layer=parent_layer, + ceil=False, + time_stamp=self._last_successful_ts, ) edge_parents_d = dict(zip(edge_nodes, edge_parents)) From 2e238c8f3acbc1d16f697afc7a51ea1561ace3f2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 8 Sep 2023 15:53:34 +0000 Subject: [PATCH 051/367] fix: consolidate neighbor nodes cx edge updates --- pychunkedgraph/graph/edits.py | 124 +++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 56 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 76c708a38..f08a5310d 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -179,38 +179,53 @@ def check_fake_edges( def _update_neighbor_cross_edges( - cg, new_id: int, cx_edges_d: dict, new_old_id_d: dict, *, time_stamp, parent_ts -) -> list: - updated_entries = [] - node_layer = cg.get_chunk_layer(new_id) - for cx_layer in range(node_layer, cg.meta.layer_count): - layer_edges = cx_edges_d.get(cx_layer, types.empty_2d) - counterparts = layer_edges[:, 1] - counterpart_cx_edges_d = cg.get_cross_chunk_edges( - counterparts, time_stamp=parent_ts - ) - temp_map = { + cg, new_ids: List[int], new_old_id_d: dict, *, time_stamp, parent_ts +) -> List: + temp_map = {} + for new_id in new_ids: + old_new_d = { old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) } - for counterpart, edges_d in counterpart_cx_edges_d.items(): - val_dict = {} - for layer in range(2, cg.meta.layer_count): - edges = edges_d.get(layer, types.empty_2d) - if edges.size == 0: - continue - assert np.all(edges[:, 0] == counterpart) - edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) - edges_d[layer] = edges - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges - if not val_dict: + temp_map.update(old_new_d) + newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids) + + def _get_counterparts(layer) -> set: + result = set() + for new_id in new_ids: + cx_edges_d = newid_cx_edges_d[new_id] + layer_edges = cx_edges_d.get(layer, types.empty_2d) + result.update(layer_edges[:, 1]) + return result + + start_layer = min(cg.get_chunk_layers(new_ids)) + counterparts = set() + for cx_layer in range(start_layer, cg.meta.layer_count): + counterparts.update(_get_counterparts(cx_layer)) + + counterpart_cx_edges_d = cg.get_cross_chunk_edges( + counterparts, time_stamp=parent_ts + ) + + updated_entries = [] + for counterpart, edges_d in counterpart_cx_edges_d.items(): + val_dict = {} + for layer in range(2, cg.meta.layer_count): + edges = edges_d.get(layer, types.empty_2d) + if edges.size == 0: continue - cg.cache.cross_chunk_edges_cache[counterpart] = edges_d - row = cg.client.mutate_row( - serialize_uint64(counterpart), - val_dict, - time_stamp=time_stamp, - ) - updated_entries.append(row) + assert np.all(edges[:, 0] == counterpart) + edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + edges_d[layer] = edges + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + if not val_dict: + continue + cg.cache.cross_chunk_edges_cache[counterpart] = edges_d + row = cg.client.mutate_row( + serialize_uint64(counterpart), + val_dict, + time_stamp=time_stamp, + ) + updated_entries.append(row) return updated_entries @@ -269,15 +284,14 @@ def add_edges( new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - entries = _update_neighbor_cross_edges( - cg, - new_id, - new_cx_edges_d, - new_old_id_d, - time_stamp=time_stamp, - parent_ts=parent_ts, - ) - updated_entries.extend(entries) + entries = _update_neighbor_cross_edges( + cg, + new_l2_ids, + new_old_id_d, + time_stamp=time_stamp, + parent_ts=parent_ts, + ) + updated_entries.extend(entries) create_parents = CreateParentNodes( cg, @@ -402,15 +416,14 @@ def remove_edges( new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - entries = _update_neighbor_cross_edges( - cg, - new_id, - new_cx_edges_d, - new_old_id_d, - time_stamp=time_stamp, - parent_ts=parent_ts, - ) - updated_entries.extend(entries) + entries = _update_neighbor_cross_edges( + cg, + new_l2_ids, + new_old_id_d, + time_stamp=time_stamp, + parent_ts=parent_ts, + ) + updated_entries.extend(entries) create_parents = CreateParentNodes( cg, @@ -539,15 +552,6 @@ def _update_cross_edge_cache(self, parent, children): new_cx_edges_d[layer] = np.unique(edges, axis=0) assert np.all(edges[:, 0] == parent) self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d - entries = _update_neighbor_cross_edges( - self.cg, - parent, - new_cx_edges_d, - self._new_old_id_d, - time_stamp=self._time_stamp, - parent_ts=self._last_successful_ts, - ) - self.new_entries.extend(entries) def _create_new_parents(self, layer: int): """ @@ -594,6 +598,14 @@ def _create_new_parents(self, layer: int): for new_id in new_parent_ids: children = self.cg.get_children(new_id) self._update_cross_edge_cache(new_id, children) + entries = _update_neighbor_cross_edges( + self.cg, + new_parent_ids, + self._new_old_id_d, + time_stamp=self._time_stamp, + parent_ts=self._last_successful_ts, + ) + self.new_entries.extend(entries) def run(self) -> Iterable: """ From 9d66c3c73a193445cd1d6cacd7744e5af67b62d2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 8 Sep 2023 15:57:59 +0000 Subject: [PATCH 052/367] fix: set to list for np.array --- pychunkedgraph/graph/edits.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index f08a5310d..3797e2082 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -202,12 +202,9 @@ def _get_counterparts(layer) -> set: for cx_layer in range(start_layer, cg.meta.layer_count): counterparts.update(_get_counterparts(cx_layer)) - counterpart_cx_edges_d = cg.get_cross_chunk_edges( - counterparts, time_stamp=parent_ts - ) - + cx_edges_d = cg.get_cross_chunk_edges(list(counterparts), time_stamp=parent_ts) updated_entries = [] - for counterpart, edges_d in counterpart_cx_edges_d.items(): + for counterpart, edges_d in cx_edges_d.items(): val_dict = {} for layer in range(2, cg.meta.layer_count): edges = edges_d.get(layer, types.empty_2d) From 18b294fcfa395582f7cadba7849e5ef84dcaacbc Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 8 Sep 2023 16:52:42 +0000 Subject: [PATCH 053/367] fix: use copy=False where possible; some cleanup --- pychunkedgraph/graph/cache.py | 6 +- pychunkedgraph/graph/chunkedgraph.py | 81 +-------------------- pychunkedgraph/graph/chunks/utils.py | 26 ++++--- pychunkedgraph/graph/connectivity/search.py | 47 ------------ pychunkedgraph/graph/edits.py | 6 +- pychunkedgraph/graph/utils/flatgraph.py | 15 +++- 6 files changed, 35 insertions(+), 146 deletions(-) delete mode 100644 pychunkedgraph/graph/connectivity/search.py diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index d381baa7d..13fa962ae 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -79,7 +79,7 @@ def cross_edges_decorated(node_id): return cross_edges_decorated(node_id) def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None): - node_ids = np.array(node_ids, dtype=NODE_ID) + node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) if not node_ids.size: return node_ids mask = np.in1d(node_ids, np.fromiter(self.parents_cache.keys(), dtype=NODE_ID)) @@ -93,7 +93,7 @@ def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None) def children_multiple(self, node_ids: np.ndarray, *, flatten=False): result = {} - node_ids = np.array(node_ids, dtype=NODE_ID) + node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) if not node_ids.size: return result mask = np.in1d(node_ids, np.fromiter(self.children_cache.keys(), dtype=NODE_ID)) @@ -111,7 +111,7 @@ def cross_chunk_edges_multiple( self, node_ids: np.ndarray, *, time_stamp: datetime = None ): result = {} - node_ids = np.array(node_ids, dtype=NODE_ID) + node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) if not node_ids.size: return result mask = np.in1d( diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index a3c9aafc3..472257d1e 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -629,7 +629,7 @@ def get_fake_edges( ) for id_, val in fake_edges_d.items(): edges = np.concatenate( - [np.array(e.value, dtype=basetypes.NODE_ID) for e in val] + [np.array(e.value, dtype=basetypes.NODE_ID, copy=False) for e in val] ) result[id_] = Edges(edges[:, 0], edges[:, 1], fake_edges=True) return result @@ -827,82 +827,7 @@ def redo_operation( multicut_as_split=True, ).execute() - # PRIVATE - - def _get_bounding_chunk_ids( - self, - parent_chunk_ids: typing.Iterable, - unique: bool = False, - ) -> typing.Dict: - """ - Returns bounding chunk IDs at layers < parent_layer for all chunk IDs. - Dict[parent_chunk_id] = np.array(bounding_chunk_ids) - """ - parent_chunk_coords = self.get_chunk_coordinates_multiple(parent_chunk_ids) - parents_layer = self.get_chunk_layer(parent_chunk_ids[0]) - chunk_id_bchunk_ids_d = {} - for i, chunk_id in enumerate(parent_chunk_ids): - if chunk_id in chunk_id_bchunk_ids_d: - # `parent_chunk_ids` can have duplicates - # avoid redundant calculations - continue - parent_coord = parent_chunk_coords[i] - chunk_ids = [types.empty_1d] - for child_layer in range(2, parents_layer): - bcoords = chunk_utils.get_bounding_children_chunks( - self.meta, - parents_layer, - parent_coord, - child_layer, - return_unique=False, - ) - bchunks_ids = chunk_utils.get_chunk_ids_from_coords( - self.meta, child_layer, bcoords - ) - chunk_ids.append(bchunks_ids) - chunk_ids = np.concatenate(chunk_ids) - if unique: - chunk_ids = np.unique(chunk_ids) - chunk_id_bchunk_ids_d[chunk_id] = chunk_ids - return chunk_id_bchunk_ids_d - - def _get_bounding_l2_children(self, parents: typing.Iterable) -> typing.Dict: - parent_chunk_ids = self.get_chunk_ids_from_node_ids(parents) - chunk_id_bchunk_ids_d = self._get_bounding_chunk_ids( - parent_chunk_ids, unique=len(parents) >= 200 - ) - - parent_descendants_d = { - _id: np.array([_id], dtype=basetypes.NODE_ID) for _id in parents - } - descendants_all = np.concatenate(list(parent_descendants_d.values())) - descendants_layers = self.get_chunk_layers(descendants_all) - layer_mask = descendants_layers > 2 - descendants_all = descendants_all[layer_mask] - - while descendants_all.size: - descendant_children_d = self.get_children(descendants_all) - for i, parent_id in enumerate(parents): - _descendants = parent_descendants_d[parent_id] - _layers = self.get_chunk_layers(_descendants) - _l2mask = _layers == 2 - descendants = [_descendants[_l2mask]] - for child in _descendants[~_l2mask]: - descendants.append(descendant_children_d[child]) - descendants = np.concatenate(descendants) - chunk_ids = self.get_chunk_ids_from_node_ids(descendants) - bchunk_ids = chunk_id_bchunk_ids_d[parent_chunk_ids[i]] - bounding_descendants = descendants[np.in1d(chunk_ids, bchunk_ids)] - parent_descendants_d[parent_id] = bounding_descendants - - descendants_all = np.concatenate(list(parent_descendants_d.values())) - descendants_layers = self.get_chunk_layers(descendants_all) - layer_mask = descendants_layers > 2 - descendants_all = descendants_all[layer_mask] - return parent_descendants_d - # HELPERS / WRAPPERS - def is_root(self, node_id: basetypes.NODE_ID) -> bool: return self.get_chunk_layer(node_id) == self.meta.layer_count @@ -940,7 +865,9 @@ def get_chunk_coordinates(self, node_or_chunk_id: basetypes.NODE_ID): return chunk_utils.get_chunk_coordinates(self.meta, node_or_chunk_id) def get_chunk_coordinates_multiple(self, node_or_chunk_ids: typing.Sequence): - node_or_chunk_ids = np.array(node_or_chunk_ids, dtype=basetypes.NODE_ID) + node_or_chunk_ids = np.array( + node_or_chunk_ids, dtype=basetypes.NODE_ID, copy=False + ) layers = self.get_chunk_layers(node_or_chunk_ids) assert np.all(layers == layers[0]), "All IDs must have the same layer." return chunk_utils.get_chunk_coordinates_multiple(self.meta, node_or_chunk_ids) diff --git a/pychunkedgraph/graph/chunks/utils.py b/pychunkedgraph/graph/chunks/utils.py index dc895bde4..4d01258bd 100644 --- a/pychunkedgraph/graph/chunks/utils.py +++ b/pychunkedgraph/graph/chunks/utils.py @@ -8,6 +8,7 @@ import numpy as np + def get_chunks_boundary(voxel_boundary, chunk_size) -> np.ndarray: """returns number of chunks in each dimension""" return np.ceil((voxel_boundary / chunk_size)).astype(int) @@ -43,7 +44,7 @@ def normalize_bounding_box( def get_chunk_layer(meta, node_or_chunk_id: np.uint64) -> int: - """ Extract Layer from Node ID or Chunk ID """ + """Extract Layer from Node ID or Chunk ID""" return int(int(node_or_chunk_id) >> 64 - meta.graph_config.LAYER_ID_BITS) @@ -75,9 +76,9 @@ def get_chunk_coordinates(meta, node_or_chunk_id: np.uint64) -> np.ndarray: y_offset = x_offset - bits_per_dim z_offset = y_offset - bits_per_dim - x = int(node_or_chunk_id) >> x_offset & 2 ** bits_per_dim - 1 - y = int(node_or_chunk_id) >> y_offset & 2 ** bits_per_dim - 1 - z = int(node_or_chunk_id) >> z_offset & 2 ** bits_per_dim - 1 + x = int(node_or_chunk_id) >> x_offset & 2**bits_per_dim - 1 + y = int(node_or_chunk_id) >> y_offset & 2**bits_per_dim - 1 + z = int(node_or_chunk_id) >> z_offset & 2**bits_per_dim - 1 return np.array([x, y, z]) @@ -86,7 +87,7 @@ def get_chunk_coordinates_multiple(meta, ids: np.ndarray) -> np.ndarray: Array version of get_chunk_coordinates. Assumes all given IDs are in same layer. """ - if not len(ids): + if len(ids) == 0: return np.array([]) layer = get_chunk_layer(meta, ids[0]) bits_per_dim = meta.bitmasks[layer] @@ -95,10 +96,10 @@ def get_chunk_coordinates_multiple(meta, ids: np.ndarray) -> np.ndarray: y_offset = x_offset - bits_per_dim z_offset = y_offset - bits_per_dim - ids = np.array(ids, dtype=int) - X = ids >> x_offset & 2 ** bits_per_dim - 1 - Y = ids >> y_offset & 2 ** bits_per_dim - 1 - Z = ids >> z_offset & 2 ** bits_per_dim - 1 + ids = np.array(ids, dtype=int, copy=False) + X = ids >> x_offset & 2**bits_per_dim - 1 + Y = ids >> y_offset & 2**bits_per_dim - 1 + Z = ids >> z_offset & 2**bits_per_dim - 1 return np.column_stack((X, Y, Z)) @@ -142,14 +143,15 @@ def get_chunk_ids_from_coords(meta, layer: int, coords: np.ndarray): def get_chunk_ids_from_node_ids(meta, ids: Iterable[np.uint64]) -> np.ndarray: - """ Extract Chunk IDs from Node IDs""" + """Extract Chunk IDs from Node IDs""" if len(ids) == 0: return np.array([], dtype=np.uint64) bits_per_dims = np.array([meta.bitmasks[l] for l in get_chunk_layers(meta, ids)]) offsets = 64 - meta.graph_config.LAYER_ID_BITS - 3 * bits_per_dims - cids1 = np.array((np.array(ids, dtype=int) >> offsets) << offsets, dtype=np.uint64) + ids = np.array(ids, dtype=int, copy=False) + cids1 = np.array((ids >> offsets) << offsets, dtype=np.uint64) # cids2 = np.vectorize(get_chunk_id)(meta, ids) # assert np.all(cids1 == cids2) return cids1 @@ -164,7 +166,7 @@ def _compute_chunk_id( ) -> np.uint64: s_bits_per_dim = meta.bitmasks[layer] if not ( - x < 2 ** s_bits_per_dim and y < 2 ** s_bits_per_dim and z < 2 ** s_bits_per_dim + x < 2**s_bits_per_dim and y < 2**s_bits_per_dim and z < 2**s_bits_per_dim ): raise ValueError( f"Coordinate is out of range \ diff --git a/pychunkedgraph/graph/connectivity/search.py b/pychunkedgraph/graph/connectivity/search.py deleted file mode 100644 index bd3faf227..000000000 --- a/pychunkedgraph/graph/connectivity/search.py +++ /dev/null @@ -1,47 +0,0 @@ -import random -from typing import List - -import numpy as np -from graph_tool.search import bfs_search -from graph_tool.search import BFSVisitor -from graph_tool.search import StopSearch - -from ..utils.basetypes import NODE_ID - - -class TargetVisitor(BFSVisitor): - def __init__(self, target, reachable): - self.target = target - self.reachable = reachable - - def discover_vertex(self, u): - if u == self.target: - self.reachable[u] = 1 - raise StopSearch - - -def check_reachability(g, sv1s: np.ndarray, sv2s: np.ndarray, original_ids: np.ndarray) -> np.ndarray: - """ - g: graph tool Graph instance with ids 0 to N-1 where N = vertex count - original_ids: sorted ChunkedGraph supervoxel ids - (to identify corresponding ids in graph tool) - for each pair (sv1, sv2) check if a path exists (BFS) - """ - # mapping from original ids to graph tool ids - original_ids_d = { - sv_id: index for sv_id, index in zip(original_ids, range(len(original_ids))) - } - reachable = g.new_vertex_property("int", val=0) - - def _check_reachability(source, target): - bfs_search(g, source, TargetVisitor(target, reachable)) - return reachable[target] - - return np.array( - [ - _check_reachability(original_ids_d[source], original_ids_d[target]) - for source, target in zip(sv1s, sv2s) - ], - dtype=bool, - ) - diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 3797e2082..6792f2f7d 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -441,7 +441,7 @@ def _get_flipped_ids(id_map, node_ids): """ returns old or new ids according to the map """ - ids = [np.array(list(id_map[id_]), dtype=basetypes.NODE_ID) for id_ in node_ids] + ids = [np.array(list(id_map[id_]), dtype=basetypes.NODE_ID, copy=False) for id_ in node_ids] return np.concatenate(ids) @@ -629,7 +629,7 @@ def _update_root_id_lineage(self): assert len(former_roots) < 2 or len(new_roots) < 2, "new roots are inconsistent" for new_root_id in new_roots: val_dict = { - attributes.Hierarchy.FormerParent: np.array(former_roots), + attributes.Hierarchy.FormerParent: former_roots, attributes.OperationLogs.OperationID: self._operation_id, } self.new_entries.append( @@ -642,7 +642,7 @@ def _update_root_id_lineage(self): for former_root_id in former_roots: val_dict = { - attributes.Hierarchy.NewParent: np.array(new_roots), + attributes.Hierarchy.NewParent: new_roots, attributes.OperationLogs.OperationID: self._operation_id, } self.new_entries.append( diff --git a/pychunkedgraph/graph/utils/flatgraph.py b/pychunkedgraph/graph/utils/flatgraph.py index df469d728..03cb6e2d2 100644 --- a/pychunkedgraph/graph/utils/flatgraph.py +++ b/pychunkedgraph/graph/utils/flatgraph.py @@ -1,8 +1,11 @@ +# pylint: disable=invalid-name, missing-docstring, c-extension-no-member + +from itertools import combinations, chain + import fastremap import numpy as np -from itertools import combinations, chain from graph_tool import Graph, GraphView -from graph_tool import topology, search +from graph_tool import topology def build_gt_graph( @@ -88,7 +91,10 @@ def team_paths_all_to_all(graph, capacity, team_vertex_ids): def neighboring_edges(graph, vertex_id): - """Returns vertex and edge lists of a seed vertex, in the same format as team_paths_all_to_all.""" + """ + Returns vertex and edge lists of a seed vertex, + in the same format as team_paths_all_to_all. + """ add_v = [] add_e = [] v0 = graph.vertex(vertex_id) @@ -124,7 +130,8 @@ def compute_filtered_paths( gfilt, capacity, team_vertex_ids ) - # graph-tool will invalidate the vertex and edge properties if I don't rebase them on the main graph + # graph-tool will invalidate the vertex and + # edge properties if I don't rebase them on the main graph # before tearing down the GraphView new_paths_e = [] for pth in paths_e: From 15d8c9a2446abd8d3e09364e6bc9c2d0a0c80aff Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 8 Sep 2023 16:56:09 +0000 Subject: [PATCH 054/367] fix: attribute type must be np.array --- pychunkedgraph/graph/edits.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 6792f2f7d..278cb92db 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -441,7 +441,10 @@ def _get_flipped_ids(id_map, node_ids): """ returns old or new ids according to the map """ - ids = [np.array(list(id_map[id_]), dtype=basetypes.NODE_ID, copy=False) for id_ in node_ids] + ids = [ + np.array(list(id_map[id_]), dtype=basetypes.NODE_ID, copy=False) + for id_ in node_ids + ] return np.concatenate(ids) @@ -642,7 +645,9 @@ def _update_root_id_lineage(self): for former_root_id in former_roots: val_dict = { - attributes.Hierarchy.NewParent: new_roots, + attributes.Hierarchy.NewParent: np.array( + new_roots, dtype=basetypes.NODE_ID + ), attributes.OperationLogs.OperationID: self._operation_id, } self.new_entries.append( From 5af56fe8b54b178514bbb7560416bf529a2b3c31 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 9 Sep 2023 19:00:17 +0000 Subject: [PATCH 055/367] fix(ingest): worker details in status --- pychunkedgraph/ingest/cli.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 0fe925d78..89106a097 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -11,6 +11,8 @@ import yaml from flask.cli import AppGroup from rq import Queue +from rq import Worker +from rq.worker import WorkerStatus from .cluster import create_atomic_chunk from .cluster import create_parent_chunk @@ -124,11 +126,14 @@ def ingest_status(): layer_counts = imanager.cg_meta.layer_chunk_counts pipeline = redis.pipeline() + worker_busy = [] for layer in layers: pipeline.scard(f"{layer}c") queue = Queue(f"l{layer}", connection=redis) pipeline.llen(queue.key) pipeline.zcard(queue.failed_job_registry.key) + workers = Worker.all(queue=queue) + worker_busy.append(sum([w.get_state() == WorkerStatus.BUSY for w in workers])) results = pipeline.execute() completed = [] @@ -140,13 +145,16 @@ def ingest_status(): queued.append(result[1]) failed.append(result[2]) - print("layer status:") + print(f"version: \t{imanager.cg.version}") + print(f"graph_id: \t{imanager.cg.graph_id}") + print(f"chunk_size: \t{imanager.cg.meta.graph_config.CHUNK_SIZE}") + print("\nlayer status:") for layer, done, count in zip(layers, completed, layer_counts): print(f"{layer}\t: {done} / {count}") print("\n\nqueue status:") - for layer, q, f in zip(layers, queued, failed): - print(f"l{layer}\t: queued\t {q}\t, failed\t {f}") + for layer, q, f, wb in zip(layers, queued, failed, worker_busy): + print(f"l{layer}\t: queued: {q}\t\t failed: {f}\t\t busy: {wb}") @ingest_cli.command("chunk") From 85d4f81d98b6da42c8662bd6fc881acc213647b0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 9 Sep 2023 19:44:07 +0000 Subject: [PATCH 056/367] fix: handle empty input --- pychunkedgraph/graph/edits.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 278cb92db..6c7176924 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -441,6 +441,8 @@ def _get_flipped_ids(id_map, node_ids): """ returns old or new ids according to the map """ + if len(node_ids) == 0: + return types.empty_1d ids = [ np.array(list(id_map[id_]), dtype=basetypes.NODE_ID, copy=False) for id_ in node_ids From e5e53e1797e2c2ba35065525395933f02e5a1071 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 9 Sep 2023 20:00:52 +0000 Subject: [PATCH 057/367] fix: use empty array instead --- pychunkedgraph/graph/edits.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 6c7176924..17502ddda 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -441,12 +441,11 @@ def _get_flipped_ids(id_map, node_ids): """ returns old or new ids according to the map """ - if len(node_ids) == 0: - return types.empty_1d ids = [ np.array(list(id_map[id_]), dtype=basetypes.NODE_ID, copy=False) for id_ in node_ids ] + ids.append(types.empty_1d) # concatenate needs at least one array return np.concatenate(ids) From 4bbc6a816b153d158037e7552ef66715470bc9b0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 10 Sep 2023 18:59:55 +0000 Subject: [PATCH 058/367] fix: missed time_stamp --- pychunkedgraph/graph/chunkedgraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 472257d1e..988dd5d89 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -353,7 +353,7 @@ def get_cross_chunk_edges( except KeyError: result[id_] = {} return result - return self.cache.cross_chunk_edges_multiple(node_ids) + return self.cache.cross_chunk_edges_multiple(node_ids, time_stamp=time_stamp) def get_roots( self, From b72aee4bfd891042631acb4d5dc31cf2c32e9148 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 10 Sep 2023 19:15:28 +0000 Subject: [PATCH 059/367] fix: only consolidate cx_edge writes; update per new_id --- pychunkedgraph/graph/edits.py | 76 +++++++++++++++++------------------ 1 file changed, 37 insertions(+), 39 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 17502ddda..f835577e0 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -178,50 +178,53 @@ def check_fake_edges( return atomic_edges, rows -def _update_neighbor_cross_edges( - cg, new_ids: List[int], new_old_id_d: dict, *, time_stamp, parent_ts -) -> List: - temp_map = {} - for new_id in new_ids: - old_new_d = { - old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) - } - temp_map.update(old_new_d) - newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids) - - def _get_counterparts(layer) -> set: - result = set() - for new_id in new_ids: - cx_edges_d = newid_cx_edges_d[new_id] - layer_edges = cx_edges_d.get(layer, types.empty_2d) - result.update(layer_edges[:, 1]) - return result - - start_layer = min(cg.get_chunk_layers(new_ids)) - counterparts = set() - for cx_layer in range(start_layer, cg.meta.layer_count): - counterparts.update(_get_counterparts(cx_layer)) - - cx_edges_d = cg.get_cross_chunk_edges(list(counterparts), time_stamp=parent_ts) - updated_entries = [] - for counterpart, edges_d in cx_edges_d.items(): +def _update_neighbor_cross_edges_single( + cg, new_id: int, cx_edges_d: dict, node_map: dict, *, parent_ts +) -> dict: + node_layer = cg.get_chunk_layer(new_id) + counterparts = [] + for layer in range(node_layer, cg.meta.layer_count): + layer_edges = cx_edges_d.get(layer, types.empty_2d) + counterparts.extend(layer_edges[:, 1]) + + cp_cx_edges_d = cg.get_cross_chunk_edges(counterparts, time_stamp=parent_ts) + updated_counterparts = {} + for counterpart, edges_d in cp_cx_edges_d.items(): val_dict = {} for layer in range(2, cg.meta.layer_count): edges = edges_d.get(layer, types.empty_2d) if edges.size == 0: continue assert np.all(edges[:, 0] == counterpart) - edges = fastremap.remap(edges, temp_map, preserve_missing_labels=True) + edges = fastremap.remap(edges, node_map, preserve_missing_labels=True) edges_d[layer] = edges val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges if not val_dict: continue cg.cache.cross_chunk_edges_cache[counterpart] = edges_d - row = cg.client.mutate_row( - serialize_uint64(counterpart), - val_dict, - time_stamp=time_stamp, + updated_counterparts[counterpart] = val_dict + return updated_counterparts + + +def _update_neighbor_cross_edges( + cg, new_ids: List[int], new_old_id_d: dict, *, time_stamp, parent_ts +) -> List: + newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids, time_stamp=parent_ts) + updated_counterparts = {} + for new_id in new_ids: + cx_edges_d = newid_cx_edges_d[new_id] + temp_map = { + old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) + } + result = _update_neighbor_cross_edges_single( + cg, new_id, cx_edges_d, temp_map, parent_ts=parent_ts ) + updated_counterparts.update(result) + + updated_entries = [] + for node, val_dict in updated_counterparts.items(): + rowkey = serialize_uint64(node) + row = cg.client.mutate_row(rowkey, val_dict, time_stamp=time_stamp) updated_entries.append(row) return updated_entries @@ -269,7 +272,6 @@ def add_edges( # update cross chunk edges by replacing old_ids with new # this can be done only after all new IDs have been created - updated_entries = [] for new_id, cc_indices in zip(new_l2_ids, components): l2ids_ = graph_ids[cc_indices] new_cx_edges_d = {} @@ -281,14 +283,13 @@ def add_edges( new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - entries = _update_neighbor_cross_edges( + updated_entries = _update_neighbor_cross_edges( cg, new_l2_ids, new_old_id_d, time_stamp=time_stamp, parent_ts=parent_ts, ) - updated_entries.extend(entries) create_parents = CreateParentNodes( cg, @@ -399,7 +400,6 @@ def remove_edges( graph_ids[cc], cross_edges, cross_edge_layers ) - updated_entries = [] cx_edges_d = cg.get_cross_chunk_edges(new_l2_ids, time_stamp=parent_ts) for new_id in new_l2_ids: new_cx_edges_d = cx_edges_d.get(new_id, {}) @@ -413,14 +413,13 @@ def remove_edges( new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - entries = _update_neighbor_cross_edges( + updated_entries = _update_neighbor_cross_edges( cg, new_l2_ids, new_old_id_d, time_stamp=time_stamp, parent_ts=parent_ts, ) - updated_entries.extend(entries) create_parents = CreateParentNodes( cg, @@ -595,7 +594,6 @@ def _create_new_parents(self, layer: int): cc_ids, parent_id, ) - for new_id in new_parent_ids: children = self.cg.get_children(new_id) self._update_cross_edge_cache(new_id, children) From 8894149314d2a577564574d558c0cf9b67f9bd8d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 11 Sep 2023 15:05:21 +0000 Subject: [PATCH 060/367] fix: reset parent layer in loop --- pychunkedgraph/graph/edits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index f835577e0..9e186c274 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -563,12 +563,12 @@ def _create_new_parents(self, layer: int): get cross edges of all, find connected components update parent old IDs """ - parent_layer = layer + 1 new_ids = self._new_ids_d[layer] layer_node_ids = self._get_layer_node_ids(new_ids, layer) components, graph_ids = self._get_connected_components(layer_node_ids, layer) new_parent_ids = [] for cc_indices in components: + parent_layer = layer + 1 # must be reset for each connected component cc_ids = graph_ids[cc_indices] if len(cc_ids) == 1: # skip connection From 1838ea57e0caa145e646529bb9a5deed71fa6891 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 11 Sep 2023 15:26:32 +0000 Subject: [PATCH 061/367] fix(ingest): use get_roots with ceil=False instead of get_parents --- pychunkedgraph/ingest/create/abstract_layers.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index d65e225a3..718ec74b7 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -212,16 +212,13 @@ def _write( for layer in range(node_layer, cg.meta.layer_count): if not layer in node_cx_edges_d: continue - layer_edges = node_cx_edges_d[layer] - edges_nodes = np.unique(layer_edges) - edges_nodes_layers = cg.get_chunk_layers(edges_nodes) - mask = edges_nodes_layers < layer_id - 1 - edges_nodes_parents = cg.get_parents(edges_nodes[mask]) - temp_map = dict(zip(edges_nodes[mask], edges_nodes_parents)) + nodes = np.unique(layer_edges) + parents = cg.get_roots(nodes, stop_layer=parent_layer, ceil=False) + edge_parents_d = dict(zip(nodes, parents)) layer_edges = fastremap.remap( - layer_edges, temp_map, preserve_missing_labels=True + layer_edges, edge_parents_d, preserve_missing_labels=True ) layer_edges = np.unique(layer_edges, axis=0) From 59961bcc76d91f58248425f6395b80bd5d76ffd6 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 11 Sep 2023 15:43:44 +0000 Subject: [PATCH 062/367] fix(ingest): incorrect stop_layer --- pychunkedgraph/ingest/create/abstract_layers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/abstract_layers.py index 718ec74b7..adbe4a5ab 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/abstract_layers.py @@ -214,7 +214,7 @@ def _write( continue layer_edges = node_cx_edges_d[layer] nodes = np.unique(layer_edges) - parents = cg.get_roots(nodes, stop_layer=parent_layer, ceil=False) + parents = cg.get_roots(nodes, stop_layer=node_layer, ceil=False) edge_parents_d = dict(zip(nodes, parents)) layer_edges = fastremap.remap( From 37672c3c8256b5ede9ef81e2266df574196682e3 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 12 Sep 2023 14:07:54 +0000 Subject: [PATCH 063/367] fix: add safeguard to against data corruption --- pychunkedgraph/graph/chunkedgraph.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 988dd5d89..8c3e14166 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -676,6 +676,9 @@ def get_l2_agglomerations( sv_parent_d = {} for l2id in l2id_children_d: svs = l2id_children_d[l2id] + for sv in svs: + if sv in sv_parent_d: + raise ValueError("Found conflicting parents.") sv_parent_d.update(dict(zip(svs.tolist(), [l2id] * len(svs)))) in_edges, out_edges, cross_edges = edge_utils.categorize_edges_v2( From 00643327a19d89c71ca111faf3dae1317709d812 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 12 Sep 2023 14:55:09 +0000 Subject: [PATCH 064/367] add another safeguard --- pychunkedgraph/graph/edits.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 9e186c274..5087f503d 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -567,9 +567,18 @@ def _create_new_parents(self, layer: int): layer_node_ids = self._get_layer_node_ids(new_ids, layer) components, graph_ids = self._get_connected_components(layer_node_ids, layer) new_parent_ids = [] + all_old_ids = [] + for v in self._new_old_id_d.values(): + all_old_ids.extend(v) + all_old_ids = np.array(all_old_ids, dtype=basetypes.NODE_ID) + for cc_indices in components: parent_layer = layer + 1 # must be reset for each connected component cc_ids = graph_ids[cc_indices] + mask = np.isin(cc_ids, all_old_ids) + old_ids = cc_ids[mask] + new_ids = _get_flipped_ids(self._old_new_id_d, cc_ids[mask]) + assert np.all(~mask), f"got old ids {old_ids} -> {new_ids}" if len(cc_ids) == 1: # skip connection parent_layer = self.cg.meta.layer_count From 005a51fb6555a40efaa21469409689671b6c1980 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 12 Sep 2023 15:02:05 +0000 Subject: [PATCH 065/367] feat: log operation_id in errors --- pychunkedgraph/graph/edits.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 5087f503d..08792108e 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -309,6 +309,7 @@ def add_edges( def _process_l2_agglomeration( cg, + operation_id: int, agg: types.Agglomeration, removed_edges: np.ndarray, parent_ts: datetime.datetime = None, @@ -321,7 +322,8 @@ def _process_l2_agglomeration( cross_edges = agg.cross_edges.get_pairs() parents = cg.get_parents(cross_edges[:, 0], time_stamp=parent_ts, raw_only=True) - assert np.unique(parents).size == 1, "got cross edges from more than one l2 node" + err = f"got cross edges from more than one l2 node; op {operation_id}" + assert np.unique(parents).size == 1, err root = cg.get_root(parents[0], time_stamp=parent_ts, raw_only=True) # inactive edges must be filtered out @@ -384,7 +386,7 @@ def remove_edges( for id_ in l2ids: agg = l2id_agglomeration_d[id_] ccs, graph_ids, cross_edges = _process_l2_agglomeration( - cg, agg, removed_edges, parent_ts + cg, operation_id, agg, removed_edges, parent_ts ) new_parents = cg.id_client.create_node_ids(chunk_id_map[agg.node_id], len(ccs)) @@ -432,6 +434,7 @@ def remove_edges( parent_ts=parent_ts, ) new_roots = create_parents.run() + raise RuntimeError("haha") create_parents.create_new_entries() return new_roots, new_l2_ids, updated_entries + create_parents.new_entries @@ -578,7 +581,8 @@ def _create_new_parents(self, layer: int): mask = np.isin(cc_ids, all_old_ids) old_ids = cc_ids[mask] new_ids = _get_flipped_ids(self._old_new_id_d, cc_ids[mask]) - assert np.all(~mask), f"got old ids {old_ids} -> {new_ids}" + err = f"got old ids {old_ids} -> {new_ids}; op {self._operation_id}" + assert np.all(~mask), err if len(cc_ids) == 1: # skip connection parent_layer = self.cg.meta.layer_count @@ -637,7 +641,8 @@ def _update_root_id_lineage(self): former_roots = _get_flipped_ids(self._new_old_id_d, new_roots) former_roots = np.unique(former_roots) - assert len(former_roots) < 2 or len(new_roots) < 2, "new roots are inconsistent" + err = f"new roots are inconsistent; op {self._operation_id}" + assert len(former_roots) < 2 or len(new_roots) < 2, err for new_root_id in new_roots: val_dict = { attributes.Hierarchy.FormerParent: former_roots, @@ -687,9 +692,10 @@ def create_new_entries(self) -> List: for id_ in new_ids: val_dict = val_dicts.get(id_, {}) children = self.cg.get_children(id_) + err = f"parent layer less than children; op {self._operation_id}" assert np.max( self.cg.get_chunk_layers(children) - ) < self.cg.get_chunk_layer(id_), "Parent layer less than children." + ) < self.cg.get_chunk_layer(id_), err val_dict[attributes.Hierarchy.Child] = children self.new_entries.append( self.cg.client.mutate_row( From bbd735d1db54c92536a76f1fc5bd1d337c2bd70e Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 12 Sep 2023 16:24:52 +0000 Subject: [PATCH 066/367] fix: remove temp error --- pychunkedgraph/graph/edits.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 08792108e..dd53f8538 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -434,7 +434,6 @@ def remove_edges( parent_ts=parent_ts, ) new_roots = create_parents.run() - raise RuntimeError("haha") create_parents.create_new_entries() return new_roots, new_l2_ids, updated_entries + create_parents.new_entries From 3623a295bb03cf7edb9531755eaf0fb85084d1fb Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 12 Sep 2023 20:08:37 +0000 Subject: [PATCH 067/367] add more safeguards --- pychunkedgraph/graph/edits.py | 40 +++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index dd53f8538..da574db14 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -21,6 +21,7 @@ from .utils.serializers import serialize_uint64 from ..logging.log_db import TimeIt from ..utils.general import in2d +from ..debug.utils import get_l2children def _init_old_hierarchy(cg, l2ids: np.ndarray, parent_ts: datetime.datetime = None): @@ -187,7 +188,9 @@ def _update_neighbor_cross_edges_single( layer_edges = cx_edges_d.get(layer, types.empty_2d) counterparts.extend(layer_edges[:, 1]) - cp_cx_edges_d = cg.get_cross_chunk_edges(counterparts, time_stamp=parent_ts) + cp_cx_edges_d = cg.get_cross_chunk_edges( + counterparts, time_stamp=parent_ts, raw_only=True + ) updated_counterparts = {} for counterpart, edges_d in cp_cx_edges_d.items(): val_dict = {} @@ -207,17 +210,22 @@ def _update_neighbor_cross_edges_single( def _update_neighbor_cross_edges( - cg, new_ids: List[int], new_old_id_d: dict, *, time_stamp, parent_ts + cg, new_ids: List[int], new_old_id_d: dict, old_new_id_d, *, time_stamp, parent_ts ) -> List: - newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids, time_stamp=parent_ts) + node_map = {} + for k, v in old_new_id_d.items(): + node_map[k] = next(iter(v)) + updated_counterparts = {} + newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids, time_stamp=parent_ts) for new_id in new_ids: cx_edges_d = newid_cx_edges_d[new_id] temp_map = { old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) } + node_map.update(temp_map) result = _update_neighbor_cross_edges_single( - cg, new_id, cx_edges_d, temp_map, parent_ts=parent_ts + cg, new_id, cx_edges_d, node_map, parent_ts=parent_ts ) updated_counterparts.update(result) @@ -287,6 +295,7 @@ def add_edges( cg, new_l2_ids, new_old_id_d, + old_new_id_d, time_stamp=time_stamp, parent_ts=parent_ts, ) @@ -303,6 +312,9 @@ def add_edges( ) new_roots = create_parents.run() + for new_root in new_roots: + l2c = get_l2children(cg, new_root) + assert len(l2c) == np.unique(l2c).size, f"inconsistent result op {operation_id}" create_parents.create_new_entries() return new_roots, new_l2_ids, updated_entries + create_parents.new_entries @@ -321,13 +333,13 @@ def _process_l2_agglomeration( chunk_edges = chunk_edges[~in2d(chunk_edges, removed_edges)] cross_edges = agg.cross_edges.get_pairs() + # we must avoid the cache to read roots to get segment state before edit began parents = cg.get_parents(cross_edges[:, 0], time_stamp=parent_ts, raw_only=True) err = f"got cross edges from more than one l2 node; op {operation_id}" assert np.unique(parents).size == 1, err root = cg.get_root(parents[0], time_stamp=parent_ts, raw_only=True) # inactive edges must be filtered out - # we must avoid the cache to read roots to get segment state before edit began neighbor_roots = cg.get_roots( cross_edges[:, 1], raw_only=True, time_stamp=parent_ts ) @@ -419,6 +431,7 @@ def remove_edges( cg, new_l2_ids, new_old_id_d, + old_new_id_d, time_stamp=time_stamp, parent_ts=parent_ts, ) @@ -434,6 +447,9 @@ def remove_edges( parent_ts=parent_ts, ) new_roots = create_parents.run() + for new_root in new_roots: + l2c = get_l2children(cg, new_root) + assert len(l2c) == np.unique(l2c).size, f"inconsistent result op {operation_id}" create_parents.create_new_entries() return new_roots, new_l2_ids, updated_entries + create_parents.new_entries @@ -481,6 +497,7 @@ def _update_id_lineage( layer: int, parent_layer: int, ): + # update newly created children; mask others mask = np.in1d(children, self._new_ids_d[layer]) for child_id in children[mask]: child_old_ids = self._new_old_id_d[child_id] @@ -533,7 +550,7 @@ def _update_cross_edge_cache(self, parent, children): cx_edges_d = self.cg.get_cross_chunk_edges( children, time_stamp=self._last_successful_ts ) - cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values(), unique=True) + cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) parent_layer = self.cg.get_chunk_layer(parent) edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) @@ -569,19 +586,9 @@ def _create_new_parents(self, layer: int): layer_node_ids = self._get_layer_node_ids(new_ids, layer) components, graph_ids = self._get_connected_components(layer_node_ids, layer) new_parent_ids = [] - all_old_ids = [] - for v in self._new_old_id_d.values(): - all_old_ids.extend(v) - all_old_ids = np.array(all_old_ids, dtype=basetypes.NODE_ID) - for cc_indices in components: parent_layer = layer + 1 # must be reset for each connected component cc_ids = graph_ids[cc_indices] - mask = np.isin(cc_ids, all_old_ids) - old_ids = cc_ids[mask] - new_ids = _get_flipped_ids(self._old_new_id_d, cc_ids[mask]) - err = f"got old ids {old_ids} -> {new_ids}; op {self._operation_id}" - assert np.all(~mask), err if len(cc_ids) == 1: # skip connection parent_layer = self.cg.meta.layer_count @@ -613,6 +620,7 @@ def _create_new_parents(self, layer: int): self.cg, new_parent_ids, self._new_old_id_d, + self._old_new_id_d, time_stamp=self._time_stamp, parent_ts=self._last_successful_ts, ) From 3663b6e1b4d25171b210feb715cbf8238d2cbb0b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 12 Sep 2023 20:12:06 +0000 Subject: [PATCH 068/367] fix: circular import --- pychunkedgraph/debug/utils.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/pychunkedgraph/debug/utils.py b/pychunkedgraph/debug/utils.py index e194f4ee1..53152ec6f 100644 --- a/pychunkedgraph/debug/utils.py +++ b/pychunkedgraph/debug/utils.py @@ -2,9 +2,6 @@ import numpy as np -from ..graph import ChunkedGraph -from ..graph.utils.basetypes import NODE_ID - def print_attrs(d): for k, v in d.items(): @@ -18,12 +15,7 @@ def print_attrs(d): print(v) -def print_node( - cg: ChunkedGraph, - node: NODE_ID, - indent: int = 0, - stop_layer: int = 2, -) -> None: +def print_node(cg, node: np.uint64, indent: int = 0, stop_layer: int = 2) -> None: children = cg.get_children(node) print(f"{' ' * indent}{node}[{len(children)}]") if cg.get_chunk_layer(node) <= stop_layer: @@ -32,8 +24,8 @@ def print_node( print_node(cg, child, indent=indent + 4, stop_layer=stop_layer) -def get_l2children(cg: ChunkedGraph, node: NODE_ID) -> np.ndarray: - nodes = np.array([node], dtype=NODE_ID) +def get_l2children(cg, node: np.uint64) -> np.ndarray: + nodes = np.array([node], dtype=np.uint64) layers = cg.get_chunk_layers(nodes) assert np.all(layers > 2), "nodes must be at layers > 2" l2children = [] From d768f8b216302899dd8e6295ec9fc32aeebd07cc Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 12 Sep 2023 20:28:02 +0000 Subject: [PATCH 069/367] fix: consider layer 2 as well --- pychunkedgraph/debug/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/debug/utils.py b/pychunkedgraph/debug/utils.py index 53152ec6f..43562afd2 100644 --- a/pychunkedgraph/debug/utils.py +++ b/pychunkedgraph/debug/utils.py @@ -27,7 +27,7 @@ def print_node(cg, node: np.uint64, indent: int = 0, stop_layer: int = 2) -> Non def get_l2children(cg, node: np.uint64) -> np.ndarray: nodes = np.array([node], dtype=np.uint64) layers = cg.get_chunk_layers(nodes) - assert np.all(layers > 2), "nodes must be at layers > 2" + assert np.all(layers >= 2), "nodes must be at layers >= 2" l2children = [] while nodes.size: children = cg.get_children(nodes, flatten=True) From 44ea8b999db4285939f6f987450c28a30bc85635 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 13 Sep 2023 16:48:49 +0000 Subject: [PATCH 070/367] fix(edits): incorrect order of opeartions; documentation --- pychunkedgraph/graph/edits.py | 210 +++++++++++++++++----------------- 1 file changed, 108 insertions(+), 102 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index da574db14..b9a07493a 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -5,6 +5,7 @@ from typing import List from typing import Tuple from typing import Iterable +from typing import Set from collections import defaultdict import fastremap @@ -25,15 +26,13 @@ def _init_old_hierarchy(cg, l2ids: np.ndarray, parent_ts: datetime.datetime = None): - new_old_id_d = defaultdict(set) - old_new_id_d = defaultdict(set) old_hierarchy_d = {id_: {2: id_} for id_ in l2ids} for id_ in l2ids: layer_parent_d = cg.get_all_parents_dict(id_, time_stamp=parent_ts) old_hierarchy_d[id_].update(layer_parent_d) for parent in layer_parent_d.values(): old_hierarchy_d[parent] = old_hierarchy_d[id_] - return new_old_id_d, old_new_id_d, old_hierarchy_d + return old_hierarchy_d def _analyze_affected_edges( @@ -179,64 +178,6 @@ def check_fake_edges( return atomic_edges, rows -def _update_neighbor_cross_edges_single( - cg, new_id: int, cx_edges_d: dict, node_map: dict, *, parent_ts -) -> dict: - node_layer = cg.get_chunk_layer(new_id) - counterparts = [] - for layer in range(node_layer, cg.meta.layer_count): - layer_edges = cx_edges_d.get(layer, types.empty_2d) - counterparts.extend(layer_edges[:, 1]) - - cp_cx_edges_d = cg.get_cross_chunk_edges( - counterparts, time_stamp=parent_ts, raw_only=True - ) - updated_counterparts = {} - for counterpart, edges_d in cp_cx_edges_d.items(): - val_dict = {} - for layer in range(2, cg.meta.layer_count): - edges = edges_d.get(layer, types.empty_2d) - if edges.size == 0: - continue - assert np.all(edges[:, 0] == counterpart) - edges = fastremap.remap(edges, node_map, preserve_missing_labels=True) - edges_d[layer] = edges - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges - if not val_dict: - continue - cg.cache.cross_chunk_edges_cache[counterpart] = edges_d - updated_counterparts[counterpart] = val_dict - return updated_counterparts - - -def _update_neighbor_cross_edges( - cg, new_ids: List[int], new_old_id_d: dict, old_new_id_d, *, time_stamp, parent_ts -) -> List: - node_map = {} - for k, v in old_new_id_d.items(): - node_map[k] = next(iter(v)) - - updated_counterparts = {} - newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids, time_stamp=parent_ts) - for new_id in new_ids: - cx_edges_d = newid_cx_edges_d[new_id] - temp_map = { - old_id: new_id for old_id in _get_flipped_ids(new_old_id_d, [new_id]) - } - node_map.update(temp_map) - result = _update_neighbor_cross_edges_single( - cg, new_id, cx_edges_d, node_map, parent_ts=parent_ts - ) - updated_counterparts.update(result) - - updated_entries = [] - for node, val_dict in updated_counterparts.items(): - rowkey = serialize_uint64(node) - row = cg.client.mutate_row(rowkey, val_dict, time_stamp=time_stamp) - updated_entries.append(row) - return updated_entries - - def add_edges( cg, *, @@ -253,9 +194,10 @@ def add_edges( if not allow_same_segment_merge: roots = cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts) assert np.unique(roots).size == 2, "L2 IDs must belong to different roots." - new_old_id_d, old_new_id_d, old_hierarchy_d = _init_old_hierarchy( - cg, l2ids, parent_ts=parent_ts - ) + + new_old_id_d = defaultdict(set) + old_new_id_d = defaultdict(set) + old_hierarchy_d = _init_old_hierarchy(cg, l2ids, parent_ts=parent_ts) atomic_children_d = cg.get_children(l2ids) cross_edges_d = merge_cross_edge_dicts( cg.get_cross_chunk_edges(l2ids, time_stamp=parent_ts), l2_cross_edges_d @@ -291,14 +233,6 @@ def add_edges( new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - updated_entries = _update_neighbor_cross_edges( - cg, - new_l2_ids, - new_old_id_d, - old_new_id_d, - time_stamp=time_stamp, - parent_ts=parent_ts, - ) create_parents = CreateParentNodes( cg, @@ -316,7 +250,7 @@ def add_edges( l2c = get_l2children(cg, new_root) assert len(l2c) == np.unique(l2c).size, f"inconsistent result op {operation_id}" create_parents.create_new_entries() - return new_roots, new_l2_ids, updated_entries + create_parents.new_entries + return new_roots, new_l2_ids, create_parents.new_entries def _process_l2_agglomeration( @@ -388,9 +322,9 @@ def remove_edges( roots = cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts) assert np.unique(roots).size == 1, "L2 IDs must belong to same root." - new_old_id_d, old_new_id_d, old_hierarchy_d = _init_old_hierarchy( - cg, l2ids, parent_ts=parent_ts - ) + new_old_id_d = defaultdict(set) + old_new_id_d = defaultdict(set) + old_hierarchy_d = _init_old_hierarchy(cg, l2ids, parent_ts=parent_ts) chunk_id_map = dict(zip(l2ids.tolist(), cg.get_chunk_ids_from_node_ids(l2ids))) removed_edges = np.concatenate([atomic_edges, atomic_edges[:, ::-1]], axis=0) @@ -427,14 +361,6 @@ def remove_edges( new_cx_edges_d[layer] = edges assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - updated_entries = _update_neighbor_cross_edges( - cg, - new_l2_ids, - new_old_id_d, - old_new_id_d, - time_stamp=time_stamp, - parent_ts=parent_ts, - ) create_parents = CreateParentNodes( cg, @@ -451,7 +377,7 @@ def remove_edges( l2c = get_l2children(cg, new_root) assert len(l2c) == np.unique(l2c).size, f"inconsistent result op {operation_id}" create_parents.create_new_entries() - return new_roots, new_l2_ids, updated_entries + create_parents.new_entries + return new_roots, new_l2_ids, create_parents.new_entries def _get_flipped_ids(id_map, node_ids): @@ -466,6 +392,82 @@ def _get_flipped_ids(id_map, node_ids): return np.concatenate(ids) +def _update_neighbor_cross_edges_single( + cg, new_id: int, cx_edges_d: dict, node_map: dict, *, parent_ts +) -> dict: + """ + For each new_id, get counterparts and update its cross chunk edges. + Some of them maybe updated multiple times so we need to collect them first + and then write to storage to consolidate the mutations. + Returns updated counterparts. + """ + node_layer = cg.get_chunk_layer(new_id) + counterparts = [] + for layer in range(node_layer, cg.meta.layer_count): + layer_edges = cx_edges_d.get(layer, types.empty_2d) + counterparts.extend(layer_edges[:, 1]) + + cp_cx_edges_d = cg.get_cross_chunk_edges( + counterparts, time_stamp=parent_ts, raw_only=True + ) + updated_counterparts = {} + for counterpart, edges_d in cp_cx_edges_d.items(): + val_dict = {} + for layer in range(2, cg.meta.layer_count): + edges = edges_d.get(layer, types.empty_2d) + if edges.size == 0: + continue + assert np.all(edges[:, 0] == counterpart) + edges = fastremap.remap(edges, node_map, preserve_missing_labels=True) + edges_d[layer] = edges + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + if not val_dict: + continue + cg.cache.cross_chunk_edges_cache[counterpart] = edges_d + updated_counterparts[counterpart] = val_dict + return updated_counterparts + + +def _update_neighbor_cross_edges( + cg, + new_ids: List[int], + new_old_id: dict, + old_new_id, + *, + time_stamp, + parent_ts, +) -> List: + """ + For each new_id, get counterparts and update its cross chunk edges. + Some of them maybe updated multiple times so we need to collect them first + and then write to storage to consolidate the mutations. + Returns mutations to updated counterparts/partner nodes. + """ + updated_counterparts = {} + newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids, time_stamp=parent_ts) + + node_map = {} + for k, v in old_new_id.items(): + if len(v) == 1: + node_map[k] = next(iter(v)) + + for new_id in new_ids: + cx_edges_d = newid_cx_edges_d[new_id] + m = {old_id: new_id for old_id in _get_flipped_ids(new_old_id, [new_id])} + node_map.update(m) + result = _update_neighbor_cross_edges_single( + cg, new_id, cx_edges_d, node_map, parent_ts=parent_ts + ) + updated_counterparts.update(result) + + updated_entries = [] + for node, val_dict in updated_counterparts.items(): + rowkey = serialize_uint64(node) + row = cg.client.mutate_row(rowkey, val_dict, time_stamp=time_stamp) + updated_entries.append(row) + return updated_entries + + class CreateParentNodes: def __init__( self, @@ -474,8 +476,8 @@ def __init__( new_l2_ids: Iterable, operation_id: basetypes.OPERATION_ID, time_stamp: datetime.datetime, - new_old_id_d: Dict[np.uint64, Iterable[np.uint64]] = None, - old_new_id_d: Dict[np.uint64, Iterable[np.uint64]] = None, + new_old_id_d: Dict[np.uint64, Set[np.uint64]] = None, + old_new_id_d: Dict[np.uint64, Set[np.uint64]] = None, old_hierarchy_d: Dict[np.uint64, Dict[int, np.uint64]] = None, parent_ts: datetime.datetime = None, ): @@ -547,12 +549,15 @@ def _update_cross_edge_cache(self, parent, children): updates cross chunk edges in cache; this can only be done after all new components at a layer have IDs """ + parent_layer = self.cg.get_chunk_layer(parent) + if parent_layer == 2: + # l2 cross edges have already been updated by this point + return cx_edges_d = self.cg.get_cross_chunk_edges( children, time_stamp=self._last_successful_ts ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) - parent_layer = self.cg.get_chunk_layer(parent) edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) edge_parents = self.cg.get_roots( edge_nodes, @@ -603,28 +608,15 @@ def _create_new_parents(self, layer: int): self.cg.get_parent_chunk_id(cc_ids[0], parent_layer), root_chunk=parent_layer == self.cg.meta.layer_count, ) + new_parent_ids.append(parent_id) self._new_ids_d[parent_layer].append(parent_id) self._update_id_lineage(parent_id, cc_ids, layer, parent_layer) - new_parent_ids.append(parent_id) - self.cg.cache.children_cache[parent_id] = cc_ids cache_utils.update( self.cg.cache.parents_cache, cc_ids, parent_id, ) - for new_id in new_parent_ids: - children = self.cg.get_children(new_id) - self._update_cross_edge_cache(new_id, children) - entries = _update_neighbor_cross_edges( - self.cg, - new_parent_ids, - self._new_old_id_d, - self._old_new_id_d, - time_stamp=self._time_stamp, - parent_ts=self._last_successful_ts, - ) - self.new_entries.extend(entries) def run(self) -> Iterable: """ @@ -640,6 +632,20 @@ def run(self) -> Iterable: self.cg.graph_id, self._operation_id, ): + # all new IDs in this layer have been created + # update their cross chunk edges and their neighbors' + for new_id in self._new_ids_d[layer]: + children = self.cg.get_children(new_id) + self._update_cross_edge_cache(new_id, children) + entries = _update_neighbor_cross_edges( + self.cg, + self._new_ids_d[layer], + self._new_old_id_d, + self._old_new_id_d, + time_stamp=self._time_stamp, + parent_ts=self._last_successful_ts, + ) + self.new_entries.extend(entries) self._create_new_parents(layer) return self._new_ids_d[self.cg.meta.layer_count] From dc4b5e17522661d7c229c885208988ddc0fbff23 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 15 Sep 2023 14:51:57 +0000 Subject: [PATCH 071/367] feat(ingest): add tests command --- pychunkedgraph/debug/cross_edge_test.py | 60 -------- pychunkedgraph/debug/existence_test.py | 78 ----------- pychunkedgraph/debug/family_test.py | 54 ------- pychunkedgraph/ingest/cli.py | 9 ++ pychunkedgraph/ingest/simple_tests.py | 178 ++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 192 deletions(-) delete mode 100644 pychunkedgraph/debug/cross_edge_test.py delete mode 100644 pychunkedgraph/debug/existence_test.py delete mode 100644 pychunkedgraph/debug/family_test.py create mode 100644 pychunkedgraph/ingest/simple_tests.py diff --git a/pychunkedgraph/debug/cross_edge_test.py b/pychunkedgraph/debug/cross_edge_test.py deleted file mode 100644 index 25bacfa0b..000000000 --- a/pychunkedgraph/debug/cross_edge_test.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -from datetime import datetime -import numpy as np - -from pychunkedgraph.graph import chunkedgraph -from pychunkedgraph.graph import attributes - -#os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/home/svenmd/.cloudvolume/secrets/google-secret.json" - -layer = 2 -n_chunks = 1000 -n_segments_per_chunk = 200 -# timestamp = datetime.datetime.fromtimestamp(1588875769) -timestamp = datetime.utcnow() - -cg = chunkedgraph.ChunkedGraph(graph_id="pinky_nf_v2") - -np.random.seed(42) - -node_ids = [] -for _ in range(n_chunks): - c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) - c_y = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][1]) - c_z = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][2]) - - chunk_id = cg.get_chunk_id(layer=layer, x=c_x, y=c_y, z=c_z) - - max_segment_id = cg.get_segment_id(cg.id_client.get_max_node_id(chunk_id)) - - if max_segment_id < 10: - continue - - segment_ids = np.random.randint(1, max_segment_id, n_segments_per_chunk) - - for segment_id in segment_ids: - node_ids.append(cg.get_node_id(np.uint64(segment_id), np.uint64(chunk_id))) - -rows = cg.client.read_nodes(node_ids=node_ids, end_time=timestamp, - properties=attributes.Hierarchy.Parent) -valid_node_ids = [] -non_valid_node_ids = [] -for k in rows.keys(): - if len(rows[k]) > 0: - valid_node_ids.append(k) - else: - non_valid_node_ids.append(k) - -cc_edges = cg.get_atomic_cross_edges(valid_node_ids) -cc_ids = np.unique(np.concatenate([np.concatenate(list(v.values())) for v in list(cc_edges.values()) if len(v.values())])) - -roots = cg.get_roots(cc_ids) -root_dict = dict(zip(cc_ids, roots)) -root_dict_vec = np.vectorize(root_dict.get) - -for k in cc_edges: - if len(cc_edges[k]) == 0: - continue - local_ids = np.unique(np.concatenate(list(cc_edges[k].values()))) - - assert len(np.unique(root_dict_vec(local_ids))) \ No newline at end of file diff --git a/pychunkedgraph/debug/existence_test.py b/pychunkedgraph/debug/existence_test.py deleted file mode 100644 index 757d3d542..000000000 --- a/pychunkedgraph/debug/existence_test.py +++ /dev/null @@ -1,78 +0,0 @@ -import os -from datetime import datetime -import numpy as np - -from pychunkedgraph.graph import chunkedgraph -from pychunkedgraph.graph import attributes - -#os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/home/svenmd/.cloudvolume/secrets/google-secret.json" - -layer = 2 -n_chunks = 100 -n_segments_per_chunk = 200 -# timestamp = datetime.datetime.fromtimestamp(1588875769) -timestamp = datetime.utcnow() - -cg = chunkedgraph.ChunkedGraph(graph_id="pinky_nf_v2") - -np.random.seed(42) - -node_ids = [] -for _ in range(n_chunks): - c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) - c_y = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][1]) - c_z = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][2]) - - chunk_id = cg.get_chunk_id(layer=layer, x=c_x, y=c_y, z=c_z) - - max_segment_id = cg.get_segment_id(cg.id_client.get_max_node_id(chunk_id)) - - if max_segment_id < 10: - continue - - segment_ids = np.random.randint(1, max_segment_id, n_segments_per_chunk) - - for segment_id in segment_ids: - node_ids.append(cg.get_node_id(np.uint64(segment_id), np.uint64(chunk_id))) - -rows = cg.client.read_nodes(node_ids=node_ids, end_time=timestamp, - properties=attributes.Hierarchy.Parent) -valid_node_ids = [] -non_valid_node_ids = [] -for k in rows.keys(): - if len(rows[k]) > 0: - valid_node_ids.append(k) - else: - non_valid_node_ids.append(k) - -roots = cg.get_roots(valid_node_ids, time_stamp=timestamp) - -roots = [] -try: - roots = cg.get_roots(valid_node_ids) - assert len(roots) == len(valid_node_ids) - print(f"ALL {len(roots)} have been successful!") -except: - print("At least one node failed. Checking nodes one by one now") - -if len(roots) != len(valid_node_ids): - log_dict = {} - success_dict = {} - for node_id in valid_node_ids: - try: - root = cg.get_root(node_id, time_stamp=timestamp) - print(f"Success: {node_id} from chunk {cg.get_chunk_id(node_id)}") - success_dict[node_id] = True - except Exception as e: - print(f"{node_id} from chunk {cg.get_chunk_id(node_id)} failed with {e}") - success_dict[node_id] = False - - t_id = node_id - - while t_id is not None: - last_working_chunk = cg.get_chunk_id(t_id) - t_id = cg.get_parent(t_id) - - print(f"Failed on layer {cg.get_chunk_layer(last_working_chunk)} in chunk {last_working_chunk}") - log_dict[node_id] = last_working_chunk - diff --git a/pychunkedgraph/debug/family_test.py b/pychunkedgraph/debug/family_test.py deleted file mode 100644 index 198351e74..000000000 --- a/pychunkedgraph/debug/family_test.py +++ /dev/null @@ -1,54 +0,0 @@ -import os -from datetime import datetime -import numpy as np - -from pychunkedgraph.graph import chunkedgraph -from pychunkedgraph.graph import attributes - -# os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/home/svenmd/.cloudvolume/secrets/google-secret.json" - -layers = [2, 3, 4, 5, 6, 7] -n_chunks = 10 -n_segments_per_chunk = 200 -# timestamp = datetime.datetime.fromtimestamp(1588875769) -timestamp = datetime.utcnow() - -cg = chunkedgraph.ChunkedGraph(graph_id="pinky_nf_v2") - -np.random.seed(42) - -node_ids = [] - -for layer in layers: - for _ in range(n_chunks): - c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) - c_y = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][1]) - c_z = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][2]) - - chunk_id = cg.get_chunk_id(layer=layer, x=c_x, y=c_y, z=c_z) - - max_segment_id = cg.get_segment_id(cg.id_client.get_max_node_id(chunk_id)) - - if max_segment_id < 10: - continue - - segment_ids = np.random.randint(1, max_segment_id, n_segments_per_chunk) - - for segment_id in segment_ids: - node_ids.append(cg.get_node_id(np.uint64(segment_id), np.uint64(chunk_id))) - -rows = cg.client.read_nodes(node_ids=node_ids, end_time=timestamp, - properties=attributes.Hierarchy.Parent) -valid_node_ids = [] -non_valid_node_ids = [] -for k in rows.keys(): - if len(rows[k]) > 0: - valid_node_ids.append(k) - else: - non_valid_node_ids.append(k) - -parents = cg.get_parents(valid_node_ids, time_stamp=timestamp) -children_dict = cg.get_children(parents) - -for child, parent in zip(valid_node_ids, parents): - assert child in children_dict[parent] \ No newline at end of file diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 89106a097..67182fc81 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -21,6 +21,7 @@ from .manager import IngestionManager from .utils import bootstrap from .utils import chunk_id_str +from .simple_tests import run_all from .create.abstract_layers import add_layer from ..graph.chunkedgraph import ChunkedGraph from ..utils.redis import get_redis_connection @@ -196,3 +197,11 @@ def ingest_chunk_local(graph_id: str, chunk_info, n_threads: int): else: cg = ChunkedGraph(graph_id=graph_id) add_layer(cg, chunk_info[0], chunk_info[1:], n_threads=n_threads) + cg = ChunkedGraph(graph_id=graph_id) + add_layer(cg, chunk_info[0], chunk_info[1:], n_threads=n_threads) + + +@ingest_cli.command("run_tests") +@click.argument("graph_id", type=str) +def run_tests(graph_id): + run_all(ChunkedGraph(graph_id=graph_id)) diff --git a/pychunkedgraph/ingest/simple_tests.py b/pychunkedgraph/ingest/simple_tests.py new file mode 100644 index 000000000..33946bcec --- /dev/null +++ b/pychunkedgraph/ingest/simple_tests.py @@ -0,0 +1,178 @@ +# pylint: disable=invalid-name, missing-function-docstring, broad-exception-caught + +""" +Some sanity tests to ensure chunkedgraph was created properly. +""" + +from datetime import datetime +import numpy as np + +from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph import attributes + + +def family(cg: ChunkedGraph): + np.random.seed(42) + n_chunks = 100 + n_segments_per_chunk = 200 + timestamp = datetime.utcnow() + + node_ids = [] + for layer in range(2, cg.meta.layer_count - 1): + for _ in range(n_chunks): + c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) + c_y = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][1]) + c_z = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][2]) + chunk_id = cg.get_chunk_id(layer=layer, x=c_x, y=c_y, z=c_z) + max_segment_id = cg.get_segment_id(cg.id_client.get_max_node_id(chunk_id)) + if max_segment_id < 10: + continue + + segment_ids = np.random.randint(1, max_segment_id, n_segments_per_chunk) + for segment_id in segment_ids: + node_ids.append( + cg.get_node_id(np.uint64(segment_id), np.uint64(chunk_id)) + ) + + rows = cg.client.read_nodes( + node_ids=node_ids, end_time=timestamp, properties=attributes.Hierarchy.Parent + ) + valid_node_ids = [] + non_valid_node_ids = [] + for k in rows.keys(): + if len(rows[k]) > 0: + valid_node_ids.append(k) + else: + non_valid_node_ids.append(k) + + parents = cg.get_parents(valid_node_ids, time_stamp=timestamp) + children_dict = cg.get_children(parents) + for child, parent in zip(valid_node_ids, parents): + assert child in children_dict[parent] + print("success") + + +def existence(cg: ChunkedGraph): + np.random.seed(42) + layer = 2 + n_chunks = 100 + n_segments_per_chunk = 200 + timestamp = datetime.utcnow() + node_ids = [] + for _ in range(n_chunks): + c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) + c_y = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][1]) + c_z = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][2]) + chunk_id = cg.get_chunk_id(layer=layer, x=c_x, y=c_y, z=c_z) + max_segment_id = cg.get_segment_id(cg.id_client.get_max_node_id(chunk_id)) + if max_segment_id < 10: + continue + + segment_ids = np.random.randint(1, max_segment_id, n_segments_per_chunk) + for segment_id in segment_ids: + node_ids.append(cg.get_node_id(np.uint64(segment_id), np.uint64(chunk_id))) + + rows = cg.client.read_nodes( + node_ids=node_ids, end_time=timestamp, properties=attributes.Hierarchy.Parent + ) + valid_node_ids = [] + non_valid_node_ids = [] + for k in rows.keys(): + if len(rows[k]) > 0: + valid_node_ids.append(k) + else: + non_valid_node_ids.append(k) + + roots = [] + try: + roots = cg.get_roots(valid_node_ids) + assert len(roots) == len(valid_node_ids) + print("success") + except Exception as e: + print(f"Something went wrong: {e}") + print("At least one node failed. Checking nodes one by one:") + + if len(roots) != len(valid_node_ids): + log_dict = {} + success_dict = {} + for node_id in valid_node_ids: + try: + _ = cg.get_root(node_id, time_stamp=timestamp) + print(f"Success: {node_id} from chunk {cg.get_chunk_id(node_id)}") + success_dict[node_id] = True + except Exception as e: + print(f"{node_id} - chunk {cg.get_chunk_id(node_id)} failed: {e}") + success_dict[node_id] = False + t_id = node_id + while t_id is not None: + last_working_chunk = cg.get_chunk_id(t_id) + t_id = cg.get_parent(t_id) + + layer = cg.get_chunk_layer(last_working_chunk) + print(f"Failed on layer {layer} in chunk {last_working_chunk}") + log_dict[node_id] = last_working_chunk + + +def cross_edges(cg: ChunkedGraph): + np.random.seed(42) + layer = 2 + n_chunks = 10 + n_segments_per_chunk = 200 + timestamp = datetime.utcnow() + node_ids = [] + for _ in range(n_chunks): + c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) + c_y = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][1]) + c_z = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][2]) + chunk_id = cg.get_chunk_id(layer=layer, x=c_x, y=c_y, z=c_z) + max_segment_id = cg.get_segment_id(cg.id_client.get_max_node_id(chunk_id)) + if max_segment_id < 10: + continue + + segment_ids = np.random.randint(1, max_segment_id, n_segments_per_chunk) + for segment_id in segment_ids: + node_ids.append(cg.get_node_id(np.uint64(segment_id), np.uint64(chunk_id))) + + rows = cg.client.read_nodes( + node_ids=node_ids, end_time=timestamp, properties=attributes.Hierarchy.Parent + ) + valid_node_ids = [] + non_valid_node_ids = [] + for k in rows.keys(): + if len(rows[k]) > 0: + valid_node_ids.append(k) + else: + non_valid_node_ids.append(k) + + cc_edges = cg.get_atomic_cross_edges(valid_node_ids) + cc_ids = np.unique( + np.concatenate( + [ + np.concatenate(list(v.values())) + for v in list(cc_edges.values()) + if len(v.values()) + ] + ) + ) + + roots = cg.get_roots(cc_ids) + root_dict = dict(zip(cc_ids, roots)) + root_dict_vec = np.vectorize(root_dict.get) + + for k in cc_edges: + if len(cc_edges[k]) == 0: + continue + local_ids = np.unique(np.concatenate(list(cc_edges[k].values()))) + assert len(np.unique(root_dict_vec(local_ids))) + print("success") + + +def run_all(cg: ChunkedGraph): + print("Running family tests:") + family(cg) + + print("\nRunning existence tests:") + existence(cg) + + print("\nRunning cross_edges tests:") + cross_edges(cg) From 5f69c98d75cceb37d2f09b6aa75f26ca6db03002 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 26 Sep 2023 15:46:54 +0000 Subject: [PATCH 072/367] fix(edits): make sure to add reverse edges --- pychunkedgraph/graph/edits.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index b9a07493a..f4b6fc0ce 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -403,9 +403,12 @@ def _update_neighbor_cross_edges_single( """ node_layer = cg.get_chunk_layer(new_id) counterparts = [] + counterpart_layers = {} for layer in range(node_layer, cg.meta.layer_count): layer_edges = cx_edges_d.get(layer, types.empty_2d) counterparts.extend(layer_edges[:, 1]) + layers_d = dict(zip(layer_edges[:, 1], [layer] * len(layer_edges[:, 1]))) + counterpart_layers.update(layers_d) cp_cx_edges_d = cg.get_cross_chunk_edges( counterparts, time_stamp=parent_ts, raw_only=True @@ -413,12 +416,18 @@ def _update_neighbor_cross_edges_single( updated_counterparts = {} for counterpart, edges_d in cp_cx_edges_d.items(): val_dict = {} + counterpart_layer = counterpart_layers[counterpart] for layer in range(2, cg.meta.layer_count): edges = edges_d.get(layer, types.empty_2d) if edges.size == 0: continue assert np.all(edges[:, 0] == counterpart) edges = fastremap.remap(edges, node_map, preserve_missing_labels=True) + if layer == counterpart_layer: + reverse_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) + edges = np.concatenate([edges, [reverse_edge]]) + edges = np.unique(edges, axis=0) + edges_d[layer] = edges val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges if not val_dict: @@ -445,7 +454,6 @@ def _update_neighbor_cross_edges( """ updated_counterparts = {} newid_cx_edges_d = cg.get_cross_chunk_edges(new_ids, time_stamp=parent_ts) - node_map = {} for k, v in old_new_id.items(): if len(v) == 1: @@ -459,7 +467,6 @@ def _update_neighbor_cross_edges( cg, new_id, cx_edges_d, node_map, parent_ts=parent_ts ) updated_counterparts.update(result) - updated_entries = [] for node, val_dict in updated_counterparts.items(): rowkey = serialize_uint64(node) @@ -557,7 +564,6 @@ def _update_cross_edge_cache(self, parent, children): children, time_stamp=self._last_successful_ts ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) - edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) edge_parents = self.cg.get_roots( edge_nodes, @@ -590,7 +596,6 @@ def _create_new_parents(self, layer: int): new_ids = self._new_ids_d[layer] layer_node_ids = self._get_layer_node_ids(new_ids, layer) components, graph_ids = self._get_connected_components(layer_node_ids, layer) - new_parent_ids = [] for cc_indices in components: parent_layer = layer + 1 # must be reset for each connected component cc_ids = graph_ids[cc_indices] @@ -608,7 +613,6 @@ def _create_new_parents(self, layer: int): self.cg.get_parent_chunk_id(cc_ids[0], parent_layer), root_chunk=parent_layer == self.cg.meta.layer_count, ) - new_parent_ids.append(parent_id) self._new_ids_d[parent_layer].append(parent_id) self._update_id_lineage(parent_id, cc_ids, layer, parent_layer) self.cg.cache.children_cache[parent_id] = cc_ids From 1895e22408af5e5127107fefea7499c7c5a809bc Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 26 Sep 2023 19:47:24 +0000 Subject: [PATCH 073/367] fix(edits): read neighbor cx edges from cache --- pychunkedgraph/graph/edits.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index f4b6fc0ce..36188a03e 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -410,9 +410,7 @@ def _update_neighbor_cross_edges_single( layers_d = dict(zip(layer_edges[:, 1], [layer] * len(layer_edges[:, 1]))) counterpart_layers.update(layers_d) - cp_cx_edges_d = cg.get_cross_chunk_edges( - counterparts, time_stamp=parent_ts, raw_only=True - ) + cp_cx_edges_d = cg.get_cross_chunk_edges(counterparts, time_stamp=parent_ts) updated_counterparts = {} for counterpart, edges_d in cp_cx_edges_d.items(): val_dict = {} From 312148b33f63cfda2c83a201b28af90203ba0d15 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 27 Sep 2023 16:17:45 +0000 Subject: [PATCH 074/367] fix(edits): check for no cx edges; comments --- pychunkedgraph/graph/edits.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 36188a03e..c348b4fcc 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -269,8 +269,11 @@ def _process_l2_agglomeration( cross_edges = agg.cross_edges.get_pairs() # we must avoid the cache to read roots to get segment state before edit began parents = cg.get_parents(cross_edges[:, 0], time_stamp=parent_ts, raw_only=True) + + # if there are cross edges, there must be a single parent. + # if there aren't any, there must be no parents. XOR these 2 conditions. err = f"got cross edges from more than one l2 node; op {operation_id}" - assert np.unique(parents).size == 1, err + assert (np.unique(parents).size == 1) != (cross_edges.size == 0), err root = cg.get_root(parents[0], time_stamp=parent_ts, raw_only=True) # inactive edges must be filtered out From 40ab9d3b24d5f815e86684af9be876f22b388924 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 3 Oct 2023 21:13:11 +0000 Subject: [PATCH 075/367] fix(edits): update neighbor cx edges in a skipped layer --- pychunkedgraph/graph/edits.py | 54 ++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index c348b4fcc..9f96db786 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -623,6 +623,45 @@ def _create_new_parents(self, layer: int): parent_id, ) + def _update_skipped_neighbors(self, current_layer): + """ + Update neighbor nodes in a skipped layer to reflect changes in their descendants. + Get neighbors of new ids at `current_layer - 1`. + Get their parents and update their cx edges. + """ + neighbors = [] + lower_new_ids = self._new_ids_d[current_layer - 1] + newid_cx_edges_d = self.cg.get_cross_chunk_edges( + lower_new_ids, time_stamp=self._last_successful_ts + ) + for cx_edges_d in newid_cx_edges_d.values(): + for edges in cx_edges_d.values(): + neighbors.extend(edges[:, 1]) + + neighbor_parents = self.cg.get_parents( + neighbors, time_stamp=self._last_successful_ts + ) + parents_layers = self.cg.get_chunk_layers(neighbor_parents) + neighbor_parents = neighbor_parents[parents_layers == current_layer] + + updated_entries = [] + children_d = self.cg.get_children(neighbor_parents) + for parent, children in children_d.items(): + self._update_cross_edge_cache(parent, children) + edges_d = self.cg.cache.cross_chunk_edges_cache[parent] + val_dict = {} + for layer in range(2, self.cg.meta.layer_count): + edges = edges_d.get(layer, types.empty_2d) + if edges.size == 0: + continue + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + rowkey = serialize_uint64(parent) + row = self.cg.client.mutate_row( + rowkey, val_dict, time_stamp=self._time_stamp + ) + updated_entries.append(row) + return updated_entries + def run(self) -> Iterable: """ After new level 2 IDs are created, create parents in higher layers. @@ -631,14 +670,15 @@ def run(self) -> Iterable: self._new_ids_d[2] = self._new_l2_ids for layer in range(2, self.cg.meta.layer_count): if len(self._new_ids_d[layer]) == 0: + # if there are no new ids in a layer due to a skipped connection + # ensure updates to cx edges of parents of neighbors from previous layer + entries = self._update_skipped_neighbors(layer) + self.new_entries.extend(entries) continue - with TimeIt( - f"create_new_parents_layer.{layer}", - self.cg.graph_id, - self._operation_id, - ): - # all new IDs in this layer have been created - # update their cross chunk edges and their neighbors' + # all new IDs in this layer have been created + # update their cross chunk edges and their neighbors' + m = f"create_new_parents_layer.{layer}" + with TimeIt(m, self.cg.graph_id, self._operation_id): for new_id in self._new_ids_d[layer]: children = self.cg.get_children(new_id) self._update_cross_edge_cache(new_id, children) From ea797cb8ec16653ea0df13e9fa796770bbf1e137 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 11 Oct 2023 21:03:11 +0000 Subject: [PATCH 076/367] fix(edits): make sure to update all skipped neighbors --- pychunkedgraph/graph/edits.py | 96 ++++++++++++++++------------------- 1 file changed, 43 insertions(+), 53 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 9f96db786..2edfd3137 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -428,7 +428,6 @@ def _update_neighbor_cross_edges_single( reverse_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) edges = np.concatenate([edges, [reverse_edge]]) edges = np.unique(edges, axis=0) - edges_d[layer] = edges val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges if not val_dict: @@ -584,6 +583,39 @@ def _update_cross_edge_cache(self, parent, children): assert np.all(edges[:, 0] == parent) self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d + def _update_neighbor_parents(self, neighbor, ceil_layer, updated) -> list: + updated_parents = [] + while True: + parent = self.cg.get_parent(neighbor, time_stamp=self._last_successful_ts) + parent_layer = self.cg.get_chunk_layer(parent) + if parent_layer >= ceil_layer or parent in updated: + break + children = self.cg.get_children(parent) + self._update_cross_edge_cache(parent, children) + updated_parents.append(parent) + neighbor = parent + return updated_parents + + def _update_skipped_neighbors(self, node, layer, parent_layer): + updated_parents = set() + cx_edges_d = self.cg.cache.cross_chunk_edges_cache[node] + for l in range(layer, parent_layer + 1): + layer_edges = cx_edges_d.get(l, types.empty_2d) + neighbors = layer_edges[:, 1] + for n in neighbors: + res = self._update_neighbor_parents(n, parent_layer, updated_parents) + updated_parents.update(res) + + updated_entries = [] + for parent in updated_parents: + val_dict = {} + for layer, edges in self.cg.cache.cross_chunk_edges_cache[parent].items(): + val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + rkey = serialize_uint64(parent) + row = self.cg.client.mutate_row(rkey, val_dict, time_stamp=self._time_stamp) + updated_entries.append(row) + return updated_entries + def _create_new_parents(self, layer: int): """ keep track of old IDs @@ -598,6 +630,7 @@ def _create_new_parents(self, layer: int): layer_node_ids = self._get_layer_node_ids(new_ids, layer) components, graph_ids = self._get_connected_components(layer_node_ids, layer) for cc_indices in components: + update_skipped_neighbors = False parent_layer = layer + 1 # must be reset for each connected component cc_ids = graph_ids[cc_indices] if len(cc_ids) == 1: @@ -610,57 +643,18 @@ def _create_new_parents(self, layer: int): if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: parent_layer = l break - parent_id = self.cg.id_client.create_node_id( + update_skipped_neighbors = cc_ids[0] in self._new_old_id_d + parent = self.cg.id_client.create_node_id( self.cg.get_parent_chunk_id(cc_ids[0], parent_layer), root_chunk=parent_layer == self.cg.meta.layer_count, ) - self._new_ids_d[parent_layer].append(parent_id) - self._update_id_lineage(parent_id, cc_ids, layer, parent_layer) - self.cg.cache.children_cache[parent_id] = cc_ids - cache_utils.update( - self.cg.cache.parents_cache, - cc_ids, - parent_id, - ) - - def _update_skipped_neighbors(self, current_layer): - """ - Update neighbor nodes in a skipped layer to reflect changes in their descendants. - Get neighbors of new ids at `current_layer - 1`. - Get their parents and update their cx edges. - """ - neighbors = [] - lower_new_ids = self._new_ids_d[current_layer - 1] - newid_cx_edges_d = self.cg.get_cross_chunk_edges( - lower_new_ids, time_stamp=self._last_successful_ts - ) - for cx_edges_d in newid_cx_edges_d.values(): - for edges in cx_edges_d.values(): - neighbors.extend(edges[:, 1]) - - neighbor_parents = self.cg.get_parents( - neighbors, time_stamp=self._last_successful_ts - ) - parents_layers = self.cg.get_chunk_layers(neighbor_parents) - neighbor_parents = neighbor_parents[parents_layers == current_layer] - - updated_entries = [] - children_d = self.cg.get_children(neighbor_parents) - for parent, children in children_d.items(): - self._update_cross_edge_cache(parent, children) - edges_d = self.cg.cache.cross_chunk_edges_cache[parent] - val_dict = {} - for layer in range(2, self.cg.meta.layer_count): - edges = edges_d.get(layer, types.empty_2d) - if edges.size == 0: - continue - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges - rowkey = serialize_uint64(parent) - row = self.cg.client.mutate_row( - rowkey, val_dict, time_stamp=self._time_stamp - ) - updated_entries.append(row) - return updated_entries + self._new_ids_d[parent_layer].append(parent) + self._update_id_lineage(parent, cc_ids, layer, parent_layer) + self.cg.cache.children_cache[parent] = cc_ids + cache_utils.update(self.cg.cache.parents_cache, cc_ids, parent) + if update_skipped_neighbors: + res = self._update_skipped_neighbors(cc_ids[0], layer, parent_layer) + self.new_entries.extend(res) def run(self) -> Iterable: """ @@ -670,10 +664,6 @@ def run(self) -> Iterable: self._new_ids_d[2] = self._new_l2_ids for layer in range(2, self.cg.meta.layer_count): if len(self._new_ids_d[layer]) == 0: - # if there are no new ids in a layer due to a skipped connection - # ensure updates to cx edges of parents of neighbors from previous layer - entries = self._update_skipped_neighbors(layer) - self.new_entries.extend(entries) continue # all new IDs in this layer have been created # update their cross chunk edges and their neighbors' From 5fe4f0aab82a206694b6ddc345c45281961b868f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 11 Oct 2023 23:02:01 +0000 Subject: [PATCH 077/367] fix(edits): ignore new ids in neighbor update --- pychunkedgraph/graph/edits.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 2edfd3137..d2523715b 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -603,6 +603,9 @@ def _update_skipped_neighbors(self, node, layer, parent_layer): layer_edges = cx_edges_d.get(l, types.empty_2d) neighbors = layer_edges[:, 1] for n in neighbors: + if n in self._new_old_id_d: + # ignore new ids + continue res = self._update_neighbor_parents(n, parent_layer, updated_parents) updated_parents.update(res) From 7fb0775bb45e5359df6c3d939c0ec7bf5e209dcf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 12 Oct 2023 17:17:44 +0000 Subject: [PATCH 078/367] add docs --- pychunkedgraph/graph/edits.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index d2523715b..839db48b9 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -494,7 +494,7 @@ def __init__( self._old_hierarchy_d = old_hierarchy_d self._new_old_id_d = new_old_id_d self._old_new_id_d = old_new_id_d - self._new_ids_d = defaultdict(list) # new IDs in each layer + self._new_ids_d = defaultdict(list) self._operation_id = operation_id self._time_stamp = time_stamp self._last_successful_ts = parent_ts @@ -572,7 +572,6 @@ def _update_cross_edge_cache(self, parent, children): time_stamp=self._last_successful_ts, ) edge_parents_d = dict(zip(edge_nodes, edge_parents)) - new_cx_edges_d = {} for layer in range(parent_layer, self.cg.meta.layer_count): edges = cx_edges_d.get(layer, types.empty_2d) @@ -583,8 +582,9 @@ def _update_cross_edge_cache(self, parent, children): assert np.all(edges[:, 0] == parent) self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d - def _update_neighbor_parents(self, neighbor, ceil_layer, updated) -> list: - updated_parents = [] + def _update_neighbor_parents(self, neighbor, ceil_layer: int, updated: set) -> list: + """helper for `_update_skipped_neighbors`""" + parents = [] while True: parent = self.cg.get_parent(neighbor, time_stamp=self._last_successful_ts) parent_layer = self.cg.get_chunk_layer(parent) @@ -592,15 +592,22 @@ def _update_neighbor_parents(self, neighbor, ceil_layer, updated) -> list: break children = self.cg.get_children(parent) self._update_cross_edge_cache(parent, children) - updated_parents.append(parent) + parents.append(parent) neighbor = parent - return updated_parents + return parents def _update_skipped_neighbors(self, node, layer, parent_layer): + """ + Updates cross edges of neighbors of a skip connection node. + Neighbors of such nodes can have parents at contiguous layers. + + This method updates cross edges of all such parents + from `layer` through `parent_layer`. + """ updated_parents = set() cx_edges_d = self.cg.cache.cross_chunk_edges_cache[node] - for l in range(layer, parent_layer + 1): - layer_edges = cx_edges_d.get(l, types.empty_2d) + for _layer in range(layer, parent_layer + 1): + layer_edges = cx_edges_d.get(_layer, types.empty_2d) neighbors = layer_edges[:, 1] for n in neighbors: if n in self._new_old_id_d: @@ -608,12 +615,11 @@ def _update_skipped_neighbors(self, node, layer, parent_layer): continue res = self._update_neighbor_parents(n, parent_layer, updated_parents) updated_parents.update(res) - updated_entries = [] for parent in updated_parents: val_dict = {} - for layer, edges in self.cg.cache.cross_chunk_edges_cache[parent].items(): - val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges + for _layer, edges in self.cg.cache.cross_chunk_edges_cache[parent].items(): + val_dict[attributes.Connectivity.CrossChunkEdge[_layer]] = edges rkey = serialize_uint64(parent) row = self.cg.client.mutate_row(rkey, val_dict, time_stamp=self._time_stamp) updated_entries.append(row) From ea5ca2eda457a0121555ec515506f6a375706582 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 14 Jan 2024 16:53:06 +0000 Subject: [PATCH 079/367] fix: resolve column filter ambiguity --- pychunkedgraph/graph/chunkedgraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 8c3e14166..7b3c5d8f4 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -303,7 +303,7 @@ def get_atomic_cross_edges(self, l2_ids: typing.Iterable) -> typing.Dict: node_ids=l2_ids, properties=[ attributes.Connectivity.AtomicCrossChunkEdge[l] - for l in range(2, self.meta.layer_count) + for l in range(2, max(3, self.meta.layer_count)) ], ) result = {} From 0c4a54a34cdce17a115a2e766bcb823df95681e2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 14 Jan 2024 20:20:18 +0000 Subject: [PATCH 080/367] fix: resolve column filter ambiguity(2) --- pychunkedgraph/graph/chunkedgraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 7b3c5d8f4..7edc538df 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -336,7 +336,7 @@ def get_cross_chunk_edges( return result attrs = [ attributes.Connectivity.CrossChunkEdge[l] - for l in range(2, self.meta.layer_count) + for l in range(2, max(3, self.meta.layer_count)) ] node_edges_d_d = self.client.read_nodes( node_ids=node_ids, From 8bc23c20a7903ebbb8594a73c0ad99943a8e51ce Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 12 May 2024 10:35:48 -0500 Subject: [PATCH 081/367] V3 migration (#484) * feat: convert edges to ocdbt * feat: worker function to convert edges to ocdbt * feat: ocdbt option, consolidate ingest cli * fix(ingest): move fn to utils * fix(ingest): move ocdbt setup to a fn * add tensorstore req, fix build kaniko cache * feat: copy fake_edges to column family 4 * feat: upgrade atomic chunks * fix: rename abstract module to parent * feat: upgrade higher layers, docs * feat: upgrade cli, move common fns to utils * add copy_fake_edges in upgrade fn * handle earliest_timestamp, add test flag to upgrade * fix: fake_edges serialize np.uint64 * add get_operation method, fix timestamp in repair, check for parent * check for l2 ids invalidated by edit retries * remove unnecessary parent assert * remove unused vars * ignore invalid ids, assert parent after earliest_ts * check for ids invalidated by retries in higher layers * parallelize update_cross_edges * overwrite graph version, create col family 4 * improve status print formatting * remove ununsed code, consolidate small common module * efficient check for chunks not done * check for empty chunks, use get_parents * efficient get_edit_ts call by batching all children * reduce earliest_ts calls * combine bigtable calls, use numpy unique * add completion rate command * fix: ignore children without cross edges * add span option to rate calculation * reduce mem usage with global vars * optimize cross edge reading * use existing layer var * limit cx edge reading above given layer * fix: read for earliest_ts check only if true * filter cross edges fn with timestamps * remove git from dockerignore, print stats * shuffle for better distribution of ids * fix: use different var name for layer * increase bigtable read timeout * add message with assert * fix: make span option int * handle skipped connections * fix: read cross edges at layer >= node_layer * handle another case of skipped nodes * check for unique parent count * update graph_id in meta * uncomment line * make repair easier to use * add sanity check for edits * add sanity check for each layer * add layers flag for cx edges * use better names for functions and vars, update types, fix docs --- pychunkedgraph/app/__init__.py | 2 + pychunkedgraph/debug/utils.py | 23 ++ pychunkedgraph/graph/attributes.py | 6 + pychunkedgraph/graph/chunkedgraph.py | 78 ++++-- pychunkedgraph/graph/client/base.py | 2 +- .../graph/client/bigtable/client.py | 37 ++- pychunkedgraph/graph/edges/__init__.py | 94 ++++++- pychunkedgraph/graph/edits.py | 13 +- pychunkedgraph/ingest/__init__.py | 22 +- pychunkedgraph/ingest/cli.py | 131 ++++------ pychunkedgraph/ingest/cli_upgrade.py | 143 +++++++++++ pychunkedgraph/ingest/cluster.py | 243 +++++++++++++----- pychunkedgraph/ingest/common.py | 61 ----- pychunkedgraph/ingest/create/atomic_layer.py | 8 +- .../{abstract_layers.py => parent_layer.py} | 10 +- pychunkedgraph/ingest/ran_agglomeration.py | 8 +- pychunkedgraph/ingest/rq_cli.py | 28 +- pychunkedgraph/ingest/simple_tests.py | 3 +- pychunkedgraph/ingest/upgrade/__init__.py | 0 pychunkedgraph/ingest/upgrade/atomic_layer.py | 119 +++++++++ pychunkedgraph/ingest/upgrade/parent_layer.py | 170 ++++++++++++ pychunkedgraph/ingest/upgrade/utils.py | 13 + pychunkedgraph/ingest/utils.py | 135 +++++++++- pychunkedgraph/repair/edits.py | 6 +- pychunkedgraph/tests/helpers.py | 45 ++-- pychunkedgraph/tests/test_uncategorized.py | 84 +++--- pychunkedgraph/utils/general.py | 9 + requirements.in | 1 + requirements.txt | 6 + 29 files changed, 1100 insertions(+), 400 deletions(-) create mode 100644 pychunkedgraph/ingest/cli_upgrade.py delete mode 100644 pychunkedgraph/ingest/common.py rename pychunkedgraph/ingest/create/{abstract_layers.py => parent_layer.py} (98%) create mode 100644 pychunkedgraph/ingest/upgrade/__init__.py create mode 100644 pychunkedgraph/ingest/upgrade/atomic_layer.py create mode 100644 pychunkedgraph/ingest/upgrade/parent_layer.py create mode 100644 pychunkedgraph/ingest/upgrade/utils.py diff --git a/pychunkedgraph/app/__init__.py b/pychunkedgraph/app/__init__.py index 3e938628b..262849258 100644 --- a/pychunkedgraph/app/__init__.py +++ b/pychunkedgraph/app/__init__.py @@ -105,6 +105,8 @@ def configure_app(app): with app.app_context(): from ..ingest.rq_cli import init_rq_cmds from ..ingest.cli import init_ingest_cmds + from ..ingest.cli_upgrade import init_upgrade_cmds init_rq_cmds(app) init_ingest_cmds(app) + init_upgrade_cmds(app) diff --git a/pychunkedgraph/debug/utils.py b/pychunkedgraph/debug/utils.py index 43562afd2..b1bdbc2be 100644 --- a/pychunkedgraph/debug/utils.py +++ b/pychunkedgraph/debug/utils.py @@ -35,3 +35,26 @@ def get_l2children(cg, node: np.uint64) -> np.ndarray: l2children.append(children[layers == 2]) nodes = children[layers > 2] return np.concatenate(l2children) + + +def sanity_check(cg, new_roots, operation_id): + """ + Check for duplicates in hierarchy, useful for debugging. + """ + print(f"{len(new_roots)} new ids from {operation_id}") + l2c_d = {} + for new_root in new_roots: + l2c_d[new_root] = get_l2children(cg, new_root) + success = True + for k, v in l2c_d.items(): + success = success and (len(v) == np.unique(v).size) + print(f"{k}: {np.unique(v).size}, {len(v)}") + if not success: + raise RuntimeError("Some ids are not valid.") + + +def sanity_check_single(cg, node, operation_id): + v = get_l2children(cg, node) + msg = f"invalid node {node}:" + msg += f" found {len(v)} l2 ids, must be {np.unique(v).size}" + assert np.unique(v).size == len(v), f"{msg}, from {operation_id}." diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index 33f675dc8..b431a159b 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -120,6 +120,12 @@ class Connectivity: ), ) + FakeEdgesCF3 = _Attribute( + key=b"fake_edges", + family_id="3", + serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), + ) + FakeEdges = _Attribute( key=b"fake_edges", family_id="4", diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 7edc538df..7d1a24cc3 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -19,6 +19,7 @@ from .meta import ChunkedGraphMeta from .utils import basetypes from .utils import id_helpers +from .utils import serializers from .utils import generic as misc_utils from .edges import Edges from .edges import utils as edge_utils @@ -76,7 +77,7 @@ def version(self) -> str: return self.client.read_graph_version() @property - def client(self) -> base.SimpleClient: + def client(self) -> BigTableClient: return self._client @property @@ -287,9 +288,11 @@ def _get_children_multiple( node_ids=node_ids, properties=attributes.Hierarchy.Child ) return { - x: node_children_d[x][0].value - if x in node_children_d - else types.empty_1d.copy() + x: ( + node_children_d[x][0].value + if x in node_children_d + else types.empty_1d.copy() + ) for x in node_ids } return self.cache.children_multiple(node_ids) @@ -322,6 +325,7 @@ def get_cross_chunk_edges( node_ids: typing.Iterable, *, raw_only=False, + all_layers=True, time_stamp: typing.Optional[datetime.datetime] = None, ) -> typing.Dict: """ @@ -334,21 +338,24 @@ def get_cross_chunk_edges( node_ids = np.array(node_ids, dtype=basetypes.NODE_ID) if node_ids.size == 0: return result - attrs = [ - attributes.Connectivity.CrossChunkEdge[l] - for l in range(2, max(3, self.meta.layer_count)) - ] + layers = range(2, max(3, self.meta.layer_count)) + attrs = [attributes.Connectivity.CrossChunkEdge[l] for l in layers] node_edges_d_d = self.client.read_nodes( node_ids=node_ids, properties=attrs, end_time=time_stamp, end_time_inclusive=True, ) - for id_ in node_ids: + layers = self.get_chunk_layers(node_ids) + valid_layer = lambda x, y: x >= y + if not all_layers: + valid_layer = lambda x, y: x == y + for layer, id_ in zip(layers, node_ids): try: result[id_] = { prop.index: val[0].value.copy() for prop, val in node_edges_d_d[id_].items() + if valid_layer(prop.index, layer) } except KeyError: result[id_] = {} @@ -631,9 +638,24 @@ def get_fake_edges( edges = np.concatenate( [np.array(e.value, dtype=basetypes.NODE_ID, copy=False) for e in val] ) - result[id_] = Edges(edges[:, 0], edges[:, 1], fake_edges=True) + result[id_] = Edges(edges[:, 0], edges[:, 1]) return result + def copy_fake_edges(self, chunk_id: np.uint64) -> None: + _edges = self.client.read_node( + node_id=chunk_id, + properties=attributes.Connectivity.FakeEdgesCF3, + end_time_inclusive=True, + fake_edges=True, + ) + mutations = [] + _id = serializers.serialize_uint64(chunk_id, fake_edges=True) + for e in _edges: + val_dict = {attributes.Connectivity.FakeEdges: e.value} + row = self.client.mutate_row(_id, val_dict, time_stamp=e.timestamp) + mutations.append(row) + self.client.write(mutations) + def get_l2_agglomerations( self, level2_ids: np.ndarray, edges_only: bool = False ) -> typing.Tuple[typing.Dict[int, types.Agglomeration], typing.Tuple[Edges]]: @@ -690,13 +712,15 @@ def get_l2_agglomerations( ) return ( agglomeration_d, - (self.mock_edges,) - if self.mock_edges is not None - else (in_edges, out_edges, cross_edges), + ( + (self.mock_edges,) + if self.mock_edges is not None + else (in_edges, out_edges, cross_edges) + ), ) def get_node_timestamps( - self, node_ids: typing.Sequence[np.uint64], return_numpy=True + self, node_ids: typing.Sequence[np.uint64], return_numpy=True, normalize=False ) -> typing.Iterable: """ The timestamp of the children column can be assumed @@ -710,17 +734,22 @@ def get_node_timestamps( if return_numpy: return np.array([], dtype=np.datetime64) return [] + result = [] + earliest_ts = self.get_earliest_timestamp() + for n in node_ids: + ts = children[n][0].timestamp + if normalize: + ts = earliest_ts if ts < earliest_ts else ts + result.append(ts) if return_numpy: - return np.array( - [children[x][0].timestamp for x in node_ids], dtype=np.datetime64 - ) - return [children[x][0].timestamp for x in node_ids] + return np.array(result, dtype=np.datetime64) + return result # OPERATIONS def add_edges( self, user_id: str, - atomic_edges: typing.Sequence[np.uint64], + atomic_edges: typing.Sequence[typing.Sequence[np.uint64]], *, affinities: typing.Sequence[np.float32] = None, source_coords: typing.Sequence[int] = None, @@ -935,3 +964,14 @@ def get_earliest_timestamp(self): _, timestamp = self.client.read_log_entry(op_id) if timestamp is not None: return timestamp - timedelta(milliseconds=500) + + def get_operation_ids(self, node_ids: typing.Sequence): + response = self.client.read_nodes(node_ids=node_ids) + result = {} + for node in node_ids: + try: + operations = response[node][attributes.OperationLogs.OperationID] + result[node] = [(x.value, x.timestamp) for x in operations] + except KeyError: + ... + return result diff --git a/pychunkedgraph/graph/client/base.py b/pychunkedgraph/graph/client/base.py index a66602a6a..953734670 100644 --- a/pychunkedgraph/graph/client/base.py +++ b/pychunkedgraph/graph/client/base.py @@ -13,7 +13,7 @@ def create_graph(self) -> None: """Initialize the graph and store associated meta.""" @abstractmethod - def add_graph_version(self, version): + def add_graph_version(self, version: str, overwrite: bool = False): """Add a version to the graph.""" @abstractmethod diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 6601b654e..52ec9a856 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -19,7 +19,7 @@ from google.cloud.bigtable.column_family import MaxVersionsGCRule from google.cloud.bigtable.table import Table from google.cloud.bigtable.row_set import RowSet -from google.cloud.bigtable.row_data import PartialRowData +from google.cloud.bigtable.row_data import DEFAULT_RETRY_READ_ROWS, PartialRowData from google.cloud.bigtable.row_filters import RowFilter from . import utils @@ -97,8 +97,9 @@ def create_graph(self, meta: ChunkedGraphMeta, version: str) -> None: self.add_graph_version(version) self.update_graph_meta(meta) - def add_graph_version(self, version: str): - assert self.read_graph_version() is None, "Graph has already been versioned." + def add_graph_version(self, version: str, overwrite: bool = False): + if not overwrite: + assert self.read_graph_version() is None, self.read_graph_version() self._version = version row = self.mutate_row( attributes.GraphVersion.key, @@ -160,18 +161,25 @@ def read_nodes( # when all IDs in a block are within a range node_ids = np.sort(node_ids) rows = self._read_byte_rows( - start_key=serialize_uint64(start_id, fake_edges=fake_edges) - if start_id is not None - else None, - end_key=serialize_uint64(end_id, fake_edges=fake_edges) - if end_id is not None - else None, + start_key=( + serialize_uint64(start_id, fake_edges=fake_edges) + if start_id is not None + else None + ), + end_key=( + serialize_uint64(end_id, fake_edges=fake_edges) + if end_id is not None + else None + ), end_key_inclusive=end_id_inclusive, row_keys=( - serialize_uint64(node_id, fake_edges=fake_edges) for node_id in node_ids - ) - if node_ids is not None - else None, + ( + serialize_uint64(node_id, fake_edges=fake_edges) + for node_id in node_ids + ) + if node_ids is not None + else None + ), columns=properties, start_time=start_time, end_time=end_time, @@ -819,7 +827,8 @@ def _execute_read_thread(self, args: typing.Tuple[Table, RowSet, RowFilter]): # Check for everything falsy, because Bigtable considers even empty # lists of row_keys as no upper/lower bound! return {} - range_read = table.read_rows(row_set=row_set, filter_=row_filter) + retry = DEFAULT_RETRY_READ_ROWS.with_timeout(180) + range_read = table.read_rows(row_set=row_set, filter_=row_filter, retry=retry) res = {v.row_key: utils.partial_row_data_to_column_dict(v) for v in range_read} return res diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index b0e488d05..430ab9fa7 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -2,10 +2,14 @@ Classes and types for edges """ -from typing import Optional from collections import namedtuple +from os import environ +from typing import Optional import numpy as np +import tensorstore as ts +import zstandard as zstd +from graph_tool import Graph from ..utils import basetypes @@ -18,6 +22,14 @@ DEFAULT_AFFINITY = np.finfo(np.float32).tiny DEFAULT_AREA = np.finfo(np.float32).tiny +ADJACENCY_DTYPE = np.dtype( + [ + ("node", basetypes.NODE_ID), + ("aff", basetypes.EDGE_AFFINITY), + ("area", basetypes.EDGE_AREA), + ] +) +ZSTD_EDGE_COMPRESSION = 17 class Edges: @@ -28,17 +40,17 @@ def __init__( *, affinities: Optional[np.ndarray] = None, areas: Optional[np.ndarray] = None, - fake_edges=False, ): self.node_ids1 = np.array(node_ids1, dtype=basetypes.NODE_ID, copy=False) self.node_ids2 = np.array(node_ids2, dtype=basetypes.NODE_ID, copy=False) assert self.node_ids1.size == self.node_ids2.size self._as_pairs = None - self._fake_edges = fake_edges if affinities is not None and len(affinities) > 0: - self._affinities = np.array(affinities, dtype=basetypes.EDGE_AFFINITY, copy=False) + self._affinities = np.array( + affinities, dtype=basetypes.EDGE_AFFINITY, copy=False + ) assert self.node_ids1.size == self._affinities.size else: self._affinities = np.full(len(self.node_ids1), DEFAULT_AFFINITY) @@ -103,3 +115,77 @@ def get_pairs(self) -> np.ndarray: return self._as_pairs self._as_pairs = np.column_stack((self.node_ids1, self.node_ids2)) return self._as_pairs + + +def put_edges(destination: str, nodes: np.ndarray, edges: Edges) -> None: + graph_ids, _edges = np.unique(edges.get_pairs(), return_inverse=True) + graph_ids_reverse = {n: i for i, n in enumerate(graph_ids)} + _edges = _edges.reshape(-1, 2) + + graph = Graph(directed=False) + graph.add_edge_list(_edges) + e_aff = graph.new_edge_property("double", vals=edges.affinities) + e_area = graph.new_edge_property("int", vals=edges.areas) + cctx = zstd.ZstdCompressor(level=ZSTD_EDGE_COMPRESSION) + ocdbt_host = environ["OCDBT_COORDINATOR_HOST"] + ocdbt_port = environ["OCDBT_COORDINATOR_PORT"] + + spec = { + "driver": "ocdbt", + "base": destination, + "coordinator": {"address": f"{ocdbt_host}:{ocdbt_port}"}, + } + dataset = ts.KvStore.open(spec).result() + with ts.Transaction() as txn: + for _node in nodes: + node = graph_ids_reverse[_node] + neighbors = graph.get_all_neighbors(node) + adjacency_list = np.zeros(neighbors.size, dtype=ADJACENCY_DTYPE) + adjacency_list["node"] = graph_ids[neighbors] + adjacency_list["aff"] = [e_aff[(node, neighbor)] for neighbor in neighbors] + adjacency_list["area"] = [ + e_area[(node, neighbor)] for neighbor in neighbors + ] + dataset.with_transaction(txn)[str(graph_ids[node])] = cctx.compress( + adjacency_list.tobytes() + ) + + +def get_edges(source: str, nodes: np.ndarray) -> Edges: + spec = {"driver": "ocdbt", "base": source} + dataset = ts.KvStore.open(spec).result() + zdc = zstd.ZstdDecompressor() + + read_futures = [dataset.read(str(n)) for n in nodes] + read_results = [rf.result() for rf in read_futures] + compressed = [rr.value for rr in read_results] + + try: + n_threads = int(environ.get("ZSTD_THREADS", 1)) + except ValueError: + n_threads = 1 + + decompressed = [] + try: + decompressed = zdc.multi_decompress_to_buffer(compressed, threads=n_threads) + except ValueError: + for content in compressed: + decompressed.append(zdc.decompressobj().decompress(content)) + + node_ids1 = [np.empty(0, dtype=basetypes.NODE_ID)] + node_ids2 = [np.empty(0, dtype=basetypes.NODE_ID)] + affinities = [np.empty(0, dtype=basetypes.EDGE_AFFINITY)] + areas = [np.empty(0, dtype=basetypes.EDGE_AREA)] + for n, content in zip(nodes, compressed): + adjacency_list = np.frombuffer(content, dtype=ADJACENCY_DTYPE) + node_ids1.append([n] * adjacency_list.size) + node_ids2.append(adjacency_list["node"]) + affinities.append(adjacency_list["aff"]) + areas.append(adjacency_list["area"]) + + return Edges( + np.concatenate(node_ids1), + np.concatenate(node_ids2), + affinities=np.concatenate(affinities), + areas=np.concatenate(areas), + ) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 839db48b9..ee7e643c3 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -22,7 +22,7 @@ from .utils.serializers import serialize_uint64 from ..logging.log_db import TimeIt from ..utils.general import in2d -from ..debug.utils import get_l2children +from ..debug.utils import sanity_check, sanity_check_single def _init_old_hierarchy(cg, l2ids: np.ndarray, parent_ts: datetime.datetime = None): @@ -246,9 +246,7 @@ def add_edges( ) new_roots = create_parents.run() - for new_root in new_roots: - l2c = get_l2children(cg, new_root) - assert len(l2c) == np.unique(l2c).size, f"inconsistent result op {operation_id}" + sanity_check(cg, new_roots, operation_id) create_parents.create_new_entries() return new_roots, new_l2_ids, create_parents.new_entries @@ -376,9 +374,7 @@ def remove_edges( parent_ts=parent_ts, ) new_roots = create_parents.run() - for new_root in new_roots: - l2c = get_l2children(cg, new_root) - assert len(l2c) == np.unique(l2c).size, f"inconsistent result op {operation_id}" + sanity_check(cg, new_roots, operation_id) create_parents.create_new_entries() return new_roots, new_l2_ids, create_parents.new_entries @@ -579,7 +575,7 @@ def _update_cross_edge_cache(self, parent, children): continue edges = fastremap.remap(edges, edge_parents_d, preserve_missing_labels=True) new_cx_edges_d[layer] = np.unique(edges, axis=0) - assert np.all(edges[:, 0] == parent) + assert np.all(edges[:, 0] == parent), f"{parent}, {np.unique(edges[:, 0])}" self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d def _update_neighbor_parents(self, neighbor, ceil_layer: int, updated: set) -> list: @@ -661,6 +657,7 @@ def _create_new_parents(self, layer: int): self._update_id_lineage(parent, cc_ids, layer, parent_layer) self.cg.cache.children_cache[parent] = cc_ids cache_utils.update(self.cg.cache.parents_cache, cc_ids, parent) + sanity_check_single(self.cg, parent, self._operation_id) if update_skipped_neighbors: res = self._update_skipped_neighbors(cc_ids[0], layer, parent_layer) self.new_entries.extend(res) diff --git a/pychunkedgraph/ingest/__init__.py b/pychunkedgraph/ingest/__init__.py index b3d832d5e..55c10ca5f 100644 --- a/pychunkedgraph/ingest/__init__.py +++ b/pychunkedgraph/ingest/__init__.py @@ -1,32 +1,16 @@ +import logging from collections import namedtuple - -_cluster_ingest_config_fields = ( - "ATOMIC_Q_NAME", - "ATOMIC_Q_LIMIT", - "ATOMIC_Q_INTERVAL", -) -_cluster_ingest_defaults = ( - "l2", - 100000, - 120, -) -ClusterIngestConfig = namedtuple( - "ClusterIngestConfig", - _cluster_ingest_config_fields, - defaults=_cluster_ingest_defaults, -) - +logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO) _ingestconfig_fields = ( - "CLUSTER", # cluster config "AGGLOMERATION", "WATERSHED", "USE_RAW_EDGES", "USE_RAW_COMPONENTS", "TEST_RUN", ) -_ingestconfig_defaults = (None, None, None, False, False, False) +_ingestconfig_defaults = (None, None, False, False, False) IngestConfig = namedtuple( "IngestConfig", _ingestconfig_fields, defaults=_ingestconfig_defaults ) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 67182fc81..928e1852f 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -4,29 +4,25 @@ cli for running ingest """ -from os import environ -from time import sleep +import logging import click import yaml from flask.cli import AppGroup -from rq import Queue -from rq import Worker -from rq.worker import WorkerStatus - -from .cluster import create_atomic_chunk -from .cluster import create_parent_chunk -from .cluster import enqueue_atomic_tasks -from .cluster import randomize_grid_points + +from .cluster import create_atomic_chunk, create_parent_chunk, enqueue_l2_tasks from .manager import IngestionManager -from .utils import bootstrap -from .utils import chunk_id_str +from .utils import ( + bootstrap, + chunk_id_str, + print_completion_rate, + print_ingest_status, + queue_layer_helper, +) from .simple_tests import run_all -from .create.abstract_layers import add_layer +from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph -from ..utils.redis import get_redis_connection -from ..utils.redis import keys as r_keys -from ..utils.general import chunked +from ..utils.redis import get_redis_connection, keys as r_keys ingest_cli = AppGroup("ingest") @@ -45,9 +41,9 @@ def flush_redis(): @ingest_cli.command("graph") @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True)) -@click.option("--raw", is_flag=True) -@click.option("--test", is_flag=True) -@click.option("--retry", is_flag=True) +@click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") +@click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") +@click.option("--retry", is_flag=True, help="Rerun without creating a new table.") def ingest_graph( graph_id: str, dataset: click.Path, raw: bool, test: bool, retry: bool ): @@ -58,16 +54,16 @@ def ingest_graph( with open(dataset, "r") as stream: config = yaml.safe_load(stream) - meta, ingest_config, client_info = bootstrap( - graph_id, - config=config, - raw=raw, - test_run=test, - ) + if test: + logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.DEBUG) + + meta, ingest_config, client_info = bootstrap(graph_id, config, raw, test) cg = ChunkedGraph(meta=meta, client_info=client_info) if not retry: cg.create() - enqueue_atomic_tasks(IngestionManager(ingest_config, meta)) + + imanager = IngestionManager(ingest_config, meta) + enqueue_l2_tasks(imanager, create_atomic_chunk) @ingest_cli.command("imanager") @@ -100,22 +96,7 @@ def queue_layer(parent_layer): assert parent_layer > 2, "This command is for layers 3 and above." redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - - if parent_layer == imanager.cg_meta.layer_count: - chunk_coords = [(0, 0, 0)] - else: - bounds = imanager.cg_meta.layer_chunk_bounds[parent_layer] - chunk_coords = randomize_grid_points(*bounds) - - for coords in chunk_coords: - task_q = imanager.get_task_queue(f"l{parent_layer}") - task_q.enqueue( - create_parent_chunk, - job_id=chunk_id_str(parent_layer, coords), - job_timeout=f"{int(parent_layer * parent_layer)}m", - result_ttl=0, - args=(parent_layer, coords), - ) + queue_layer_helper(parent_layer, imanager, create_parent_chunk) @ingest_cli.command("status") @@ -123,39 +104,7 @@ def ingest_status(): """Print ingest status to console by layer.""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - layers = range(2, imanager.cg_meta.layer_count + 1) - layer_counts = imanager.cg_meta.layer_chunk_counts - - pipeline = redis.pipeline() - worker_busy = [] - for layer in layers: - pipeline.scard(f"{layer}c") - queue = Queue(f"l{layer}", connection=redis) - pipeline.llen(queue.key) - pipeline.zcard(queue.failed_job_registry.key) - workers = Worker.all(queue=queue) - worker_busy.append(sum([w.get_state() == WorkerStatus.BUSY for w in workers])) - - results = pipeline.execute() - completed = [] - queued = [] - failed = [] - for i in range(0, len(results), 3): - result = results[i : i + 3] - completed.append(result[0]) - queued.append(result[1]) - failed.append(result[2]) - - print(f"version: \t{imanager.cg.version}") - print(f"graph_id: \t{imanager.cg.graph_id}") - print(f"chunk_size: \t{imanager.cg.meta.graph_config.CHUNK_SIZE}") - print("\nlayer status:") - for layer, done, count in zip(layers, completed, layer_counts): - print(f"{layer}\t: {done} / {count}") - - print("\n\nqueue status:") - for layer, q, f, wb in zip(layers, queued, failed, worker_busy): - print(f"l{layer}\t: queued: {q}\t\t failed: {f}\t\t busy: {wb}") + print_ingest_status(imanager, redis) @ingest_cli.command("chunk") @@ -165,15 +114,14 @@ def ingest_chunk(queue: str, chunk_info): """Manually queue chunk when a job is stuck for whatever reason.""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - layer = chunk_info[0] - coords = chunk_info[1:] - queue = imanager.get_task_queue(queue) + layer, coords = chunk_info[0], chunk_info[1:] + + func = create_parent_chunk + args = (layer, coords) if layer == 2: func = create_atomic_chunk args = (coords,) - else: - func = create_parent_chunk - args = (layer, coords) + queue = imanager.get_task_queue(queue) queue.enqueue( func, job_id=chunk_id_str(layer, coords), @@ -189,16 +137,23 @@ def ingest_chunk(queue: str, chunk_info): @click.option("--n_threads", type=int, default=1) def ingest_chunk_local(graph_id: str, chunk_info, n_threads: int): """Manually ingest a chunk on a local machine.""" - from .create.abstract_layers import add_layer - from .cluster import _create_atomic_chunk - - if chunk_info[0] == 2: - _create_atomic_chunk(chunk_info[1:]) + layer, coords = chunk_info[0], chunk_info[1:] + if layer == 2: + create_atomic_chunk(coords) else: cg = ChunkedGraph(graph_id=graph_id) - add_layer(cg, chunk_info[0], chunk_info[1:], n_threads=n_threads) + add_parent_chunk(cg, layer, coords, n_threads=n_threads) cg = ChunkedGraph(graph_id=graph_id) - add_layer(cg, chunk_info[0], chunk_info[1:], n_threads=n_threads) + add_parent_chunk(cg, layer, coords, n_threads=n_threads) + + +@ingest_cli.command("rate") +@click.argument("layer", type=int) +@click.option("--span", default=10, help="Time span to calculate rate.") +def rate(layer: int, span: int): + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + print_completion_rate(imanager, layer, span=span) @ingest_cli.command("run_tests") diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py new file mode 100644 index 000000000..c77c0be64 --- /dev/null +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -0,0 +1,143 @@ +# pylint: disable=invalid-name, missing-function-docstring, unspecified-encoding + +""" +cli for running upgrade +""" + +import logging +from time import sleep + +import click +import tensorstore as ts +from flask.cli import AppGroup +from pychunkedgraph import __version__ +from pychunkedgraph.graph.meta import GraphConfig + +from . import IngestConfig +from .cluster import ( + convert_to_ocdbt, + enqueue_l2_tasks, + upgrade_atomic_chunk, + upgrade_parent_chunk, +) +from .manager import IngestionManager +from .utils import ( + chunk_id_str, + print_completion_rate, + print_ingest_status, + queue_layer_helper, + start_ocdbt_server, +) +from ..graph.chunkedgraph import ChunkedGraph, ChunkedGraphMeta +from ..utils.redis import get_redis_connection +from ..utils.redis import keys as r_keys + +upgrade_cli = AppGroup("upgrade") + + +def init_upgrade_cmds(app): + app.cli.add_command(upgrade_cli) + + +@upgrade_cli.command("flush_redis") +def flush_redis(): + """FLush redis db.""" + redis = get_redis_connection() + redis.flushdb() + + +@upgrade_cli.command("graph") +@click.argument("graph_id", type=str) +@click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") +@click.option("--ocdbt", is_flag=True, help="Store edges using ts ocdbt kv store.") +def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): + """ + Main upgrade command. + Takes upgrade config from a yaml file and queues atomic tasks. + """ + ingest_config = IngestConfig(TEST_RUN=test) + cg = ChunkedGraph(graph_id=graph_id) + cg.client.add_graph_version(__version__, overwrite=True) + + if graph_id != cg.graph_id: + gc = cg.meta.graph_config._asdict() + gc["ID"] = graph_id + new_meta = ChunkedGraphMeta( + GraphConfig(**gc), cg.meta.data_source, cg.meta.custom_data + ) + cg.update_meta(new_meta, overwrite=True) + cg = ChunkedGraph(graph_id=graph_id) + + try: + # create new column family for cross chunk edges + f = cg.client._table.column_family("4") + f.create() + except Exception: + ... + + imanager = IngestionManager(ingest_config, cg.meta) + server = ts.ocdbt.DistributedCoordinatorServer() + if ocdbt: + start_ocdbt_server(imanager, server) + + fn = convert_to_ocdbt if ocdbt else upgrade_atomic_chunk + enqueue_l2_tasks(imanager, fn) + + if ocdbt: + logging.info("All tasks queued. Keep this alive for ocdbt coordinator server.") + while True: + sleep(60) + + +@upgrade_cli.command("layer") +@click.argument("parent_layer", type=int) +def queue_layer(parent_layer): + """ + Queue all chunk tasks at a given layer. + Must be used when all the chunks at `parent_layer - 1` have completed. + """ + assert parent_layer > 2, "This command is for layers 3 and above." + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + queue_layer_helper(parent_layer, imanager, upgrade_parent_chunk) + + +@upgrade_cli.command("status") +def ingest_status(): + """Print upgrade status to console.""" + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + print_ingest_status(imanager, redis, upgrade=True) + + +@upgrade_cli.command("chunk") +@click.argument("queue", type=str) +@click.argument("chunk_info", nargs=4, type=int) +def ingest_chunk(queue: str, chunk_info): + """Manually queue chunk when a job is stuck for whatever reason.""" + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + layer, coords = chunk_info[0], chunk_info[1:] + + func = upgrade_parent_chunk + args = (layer, coords) + if layer == 2: + func = upgrade_atomic_chunk + args = (coords,) + queue = imanager.get_task_queue(queue) + queue.enqueue( + func, + job_id=chunk_id_str(layer, coords), + job_timeout=f"{int(layer * layer)}m", + result_ttl=0, + args=args, + ) + + +@upgrade_cli.command("rate") +@click.argument("layer", type=int) +@click.option("--span", default=10, help="Time span to calculate rate.") +def rate(layer: int, span: int): + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + print_completion_rate(imanager, layer, span=span) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index a5c6a9861..485251568 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -1,23 +1,37 @@ # pylint: disable=invalid-name, missing-function-docstring, import-outside-toplevel """ -Ingest / create chunkedgraph with workers. +Ingest / create chunkedgraph with workers on a cluster. """ -from typing import Sequence, Tuple +import logging +from os import environ +from time import sleep +from typing import Callable, Dict, Iterable, Tuple, Sequence import numpy as np +from rq import Queue as RQueue -from .utils import chunk_id_str + +from .utils import chunk_id_str, get_chunks_not_done, randomize_grid_points from .manager import IngestionManager -from .common import get_atomic_chunk_data -from .ran_agglomeration import get_active_edges -from .create.atomic_layer import add_atomic_edges -from .create.abstract_layers import add_layer -from ..graph.meta import ChunkedGraphMeta +from .ran_agglomeration import ( + get_active_edges, + read_raw_edge_data, + read_raw_agglomeration_data, +) +from .create.atomic_layer import add_atomic_chunk +from .create.parent_layer import add_parent_chunk +from .upgrade.atomic_layer import update_chunk as update_atomic_chunk +from .upgrade.parent_layer import update_chunk as update_parent_chunk +from ..graph.edges import EDGE_TYPES, Edges, put_edges +from ..graph import ChunkedGraph, ChunkedGraphMeta from ..graph.chunks.hierarchy import get_children_chunk_coords -from ..utils.redis import keys as r_keys -from ..utils.redis import get_redis_connection +from ..graph.utils.basetypes import NODE_ID +from ..io.edges import get_chunk_edges +from ..io.components import get_chunk_components +from ..utils.redis import keys as r_keys, get_redis_connection +from ..utils.general import chunked def _post_task_completion( @@ -36,7 +50,7 @@ def create_parent_chunk( ) -> None: redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - add_layer( + add_parent_chunk( imanager.cg, parent_layer, parent_coords, @@ -49,54 +63,61 @@ def create_parent_chunk( _post_task_completion(imanager, parent_layer, parent_coords) -def randomize_grid_points(X: int, Y: int, Z: int) -> Tuple[int, int, int]: - indices = np.arange(X * Y * Z) - np.random.shuffle(indices) - for index in indices: - yield np.unravel_index(index, (X, Y, Z)) +def upgrade_parent_chunk( + parent_layer: int, + parent_coords: Sequence[int], +) -> None: + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + update_parent_chunk(imanager.cg, parent_coords, layer=parent_layer) + _post_task_completion(imanager, parent_layer, parent_coords) -def enqueue_atomic_tasks(imanager: IngestionManager): - from os import environ - from time import sleep - from rq import Queue as RQueue +def _get_atomic_chunk_data( + imanager: IngestionManager, coord: Sequence[int] +) -> Tuple[Dict, Dict]: + """ + Helper to read either raw data or processed data + If reading from raw data, save it as processed data + """ + chunk_edges = ( + read_raw_edge_data(imanager, coord) + if imanager.config.USE_RAW_EDGES + else get_chunk_edges(imanager.cg_meta.data_source.EDGES, [coord]) + ) - chunk_coords = _get_test_chunks(imanager.cg.meta) - chunk_count = len(chunk_coords) - if not imanager.config.TEST_RUN: - atomic_chunk_bounds = imanager.cg_meta.layer_chunk_bounds[2] - chunk_coords = randomize_grid_points(*atomic_chunk_bounds) - chunk_count = imanager.cg_meta.layer_chunk_counts[0] - print(f"total chunk count: {chunk_count}, queuing...") + _check_edges_direction(chunk_edges, imanager.cg, coord) + + mapping = ( + read_raw_agglomeration_data(imanager, coord) + if imanager.config.USE_RAW_COMPONENTS + else get_chunk_components(imanager.cg_meta.data_source.COMPONENTS, coord) + ) + return chunk_edges, mapping - queue_name = f"{imanager.config.CLUSTER.ATOMIC_Q_NAME}" - q = imanager.get_task_queue(queue_name) - job_datas = [] - batch_size = int(environ.get("L2JOB_BATCH_SIZE", 1000)) - for chunk_coord in chunk_coords: - # buffer for optimal use of redis memory - if len(q) > imanager.config.CLUSTER.ATOMIC_Q_LIMIT: - print(f"Sleeping {imanager.config.CLUSTER.ATOMIC_Q_INTERVAL}s...") - sleep(imanager.config.CLUSTER.ATOMIC_Q_INTERVAL) - - x, y, z = chunk_coord - chunk_str = f"{x}_{y}_{z}" - if imanager.redis.sismember("2c", chunk_str): - # already done, skip - continue - job_datas.append( - RQueue.prepare_data( - create_atomic_chunk, - args=(chunk_coord,), - timeout=environ.get("L2JOB_TIMEOUT", "3m"), - result_ttl=0, - job_id=chunk_id_str(2, chunk_coord), - ) - ) - if len(job_datas) % batch_size == 0: - q.enqueue_many(job_datas) - job_datas = [] - q.enqueue_many(job_datas) + +def _check_edges_direction( + chunk_edges: dict, cg: ChunkedGraph, coord: Sequence[int] +) -> None: + """ + For between and cross chunk edges: + Checks and flips edges such that nodes1 are always within a chunk and nodes2 outside the chunk. + Where nodes1 = edges[:,0] and nodes2 = edges[:,1]. + """ + x, y, z = coord + chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) + for edge_type in [EDGE_TYPES.between_chunk, EDGE_TYPES.cross_chunk]: + edges = chunk_edges[edge_type] + e1 = edges.node_ids1 + e2 = edges.node_ids2 + + e2_chunk_ids = cg.get_chunk_ids_from_node_ids(e2) + mask = e2_chunk_ids == chunk_id + e1[mask], e2[mask] = e2[mask], e1[mask] + + e1_chunk_ids = cg.get_chunk_ids_from_node_ids(e1) + mask = e1_chunk_ids == chunk_id + assert np.all(mask), "all IDs must belong to same chunk" def create_atomic_chunk(coords: Sequence[int]): @@ -105,22 +126,110 @@ def create_atomic_chunk(coords: Sequence[int]): imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) coords = np.array(list(coords), dtype=int) - chunk_edges_all, mapping = get_atomic_chunk_data(imanager, coords) + chunk_edges_all, mapping = _get_atomic_chunk_data(imanager, coords) chunk_edges_active, isolated_ids = get_active_edges(chunk_edges_all, mapping) - add_atomic_edges(imanager.cg, coords, chunk_edges_active, isolated=isolated_ids) - - if imanager.config.TEST_RUN: - # print for debugging - for k, v in chunk_edges_all.items(): - print(k, len(v)) - for k, v in chunk_edges_active.items(): - print(f"active_{k}", len(v)) + add_atomic_chunk(imanager.cg, coords, chunk_edges_active, isolated=isolated_ids) + + for k, v in chunk_edges_all.items(): + logging.debug(f"{k}: {len(v)}") + for k, v in chunk_edges_active.items(): + logging.debug(f"active_{k}: {len(v)}") + _post_task_completion(imanager, 2, coords) + + +def upgrade_atomic_chunk(coords: Sequence[int]): + """Upgrades single atomic chunk""" + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + coords = np.array(list(coords), dtype=int) + update_atomic_chunk(imanager.cg, coords, layer=2) + _post_task_completion(imanager, 2, coords) + + +def convert_to_ocdbt(coords: Sequence[int]): + """ + Convert edges stored per chunk to ajacency list in the tensorstore ocdbt kv store. + """ + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + coords = np.array(list(coords), dtype=int) + chunk_edges_all, mapping = _get_atomic_chunk_data(imanager, coords) + + node_ids1 = [] + node_ids2 = [] + affinities = [] + areas = [] + for edges in chunk_edges_all.values(): + node_ids1.extend(edges.node_ids1) + node_ids2.extend(edges.node_ids2) + affinities.extend(edges.affinities) + areas.extend(edges.areas) + + edges = Edges(node_ids1, node_ids2, affinities=affinities, areas=areas) + nodes = np.concatenate( + [edges.node_ids1, edges.node_ids2, np.fromiter(mapping.keys(), dtype=NODE_ID)] + ) + nodes = np.unique(nodes) + + chunk_id = imanager.cg.get_chunk_id(layer=1, x=coords[0], y=coords[1], z=coords[2]) + chunk_ids = imanager.cg.get_chunk_ids_from_node_ids(nodes) + + host = imanager.redis.get("OCDBT_COORDINATOR_HOST").decode() + port = imanager.redis.get("OCDBT_COORDINATOR_PORT").decode() + environ["OCDBT_COORDINATOR_HOST"] = host + environ["OCDBT_COORDINATOR_PORT"] = port + logging.info(f"OCDBT Coordinator address {host}:{port}") + + put_edges( + f"{imanager.cg.meta.data_source.EDGES}/ocdbt", + nodes[chunk_ids == chunk_id], + edges, + ) _post_task_completion(imanager, 2, coords) def _get_test_chunks(meta: ChunkedGraphMeta): - """Chunks at center of the dataset most likely not to be empty""" + """Chunks at the center most likely not to be empty""" parent_coords = np.array(meta.layer_chunk_bounds[3]) // 2 return get_children_chunk_coords(meta, 3, parent_coords) - # f = lambda r1, r2, r3: np.array(np.meshgrid(r1, r2, r3), dtype=int).T.reshape(-1, 3) - # return f((x, x + 1), (y, y + 1), (z, z + 1)) + + +def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterable): + queue_name = "l2" + q = imanager.get_task_queue(queue_name) + batch_size = int(environ.get("JOB_BATCH_SIZE", 100000)) + batches = chunked(coords, batch_size) + for batch in batches: + _coords = get_chunks_not_done(imanager, 2, batch) + # buffer for optimal use of redis memory + if len(q) > int(environ.get("QUEUE_SIZE", 100000)): + interval = int(environ.get("QUEUE_INTERVAL", 300)) + logging.info(f"Queue full; sleeping {interval}s...") + sleep(interval) + + job_datas = [] + for chunk_coord in _coords: + job_datas.append( + RQueue.prepare_data( + chunk_fn, + args=(chunk_coord,), + timeout=environ.get("L2JOB_TIMEOUT", "3m"), + result_ttl=0, + job_id=chunk_id_str(2, chunk_coord), + ) + ) + q.enqueue_many(job_datas) + + +def enqueue_l2_tasks(imanager: IngestionManager, chunk_fn: Callable): + """ + `chunk_fn`: function to process a given layer 2 chunk. + """ + chunk_coords = _get_test_chunks(imanager.cg.meta) + chunk_count = len(chunk_coords) + if not imanager.config.TEST_RUN: + atomic_chunk_bounds = imanager.cg_meta.layer_chunk_bounds[2] + chunk_coords = randomize_grid_points(*atomic_chunk_bounds) + chunk_count = imanager.cg_meta.layer_chunk_counts[0] + logging.info(f"Chunk count: {chunk_count}, queuing...") + _queue_tasks(imanager, chunk_fn, chunk_coords) diff --git a/pychunkedgraph/ingest/common.py b/pychunkedgraph/ingest/common.py deleted file mode 100644 index dccf58602..000000000 --- a/pychunkedgraph/ingest/common.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Dict -from typing import Tuple -from typing import Sequence - -from .manager import IngestionManager -from .ran_agglomeration import read_raw_edge_data -from .ran_agglomeration import read_raw_agglomeration_data -from ..graph import ChunkedGraph -from ..io.edges import get_chunk_edges -from ..io.components import get_chunk_components - - -def get_atomic_chunk_data( - imanager: IngestionManager, coord: Sequence[int] -) -> Tuple[Dict, Dict]: - """ - Helper to read either raw data or processed data - If reading from raw data, save it as processed data - """ - chunk_edges = ( - read_raw_edge_data(imanager, coord) - if imanager.config.USE_RAW_EDGES - else get_chunk_edges(imanager.cg_meta.data_source.EDGES, [coord]) - ) - - _check_edges_direction(chunk_edges, imanager.cg, coord) - - mapping = ( - read_raw_agglomeration_data(imanager, coord) - if imanager.config.USE_RAW_COMPONENTS - else get_chunk_components(imanager.cg_meta.data_source.COMPONENTS, coord) - ) - return chunk_edges, mapping - - -def _check_edges_direction( - chunk_edges: dict, cg: ChunkedGraph, coord: Sequence[int] -) -> None: - """ - For between and cross chunk edges: - Checks and flips edges such that nodes1 are always within a chunk and nodes2 outside the chunk. - Where nodes1 = edges[:,0] and nodes2 = edges[:,1]. - """ - import numpy as np - from ..graph.edges import Edges - from ..graph.edges import EDGE_TYPES - - x, y, z = coord - chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) - for edge_type in [EDGE_TYPES.between_chunk, EDGE_TYPES.cross_chunk]: - edges = chunk_edges[edge_type] - e1 = edges.node_ids1 - e2 = edges.node_ids2 - - e2_chunk_ids = cg.get_chunk_ids_from_node_ids(e2) - mask = e2_chunk_ids == chunk_id - e1[mask], e2[mask] = e2[mask], e1[mask] - - e1_chunk_ids = cg.get_chunk_ids_from_node_ids(e1) - mask = e1_chunk_ids == chunk_id - assert np.all(mask), "all IDs must belong to same chunk" diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index 054a82840..0a7aae728 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -23,9 +23,9 @@ from ...graph.utils.flatgraph import connected_components -def add_atomic_edges( +def add_atomic_chunk( cg: ChunkedGraph, - chunk_coord: np.ndarray, + coords: Sequence[int], chunk_edges_d: Dict[str, Edges], isolated: Sequence[int], time_stamp: Optional[datetime.datetime] = None, @@ -40,9 +40,7 @@ def add_atomic_edges( graph, _, _, unique_ids = build_gt_graph(chunk_edge_ids, make_directed=True) ccs = connected_components(graph) - parent_chunk_id = cg.get_chunk_id( - layer=2, x=chunk_coord[0], y=chunk_coord[1], z=chunk_coord[2] - ) + parent_chunk_id = cg.get_chunk_id(layer=2, x=coords[0], y=coords[1], z=coords[2]) parent_ids = cg.id_client.create_node_ids(parent_chunk_id, size=len(ccs)) sparse_indices, remapping = _get_remapping(chunk_edges_d) diff --git a/pychunkedgraph/ingest/create/abstract_layers.py b/pychunkedgraph/ingest/create/parent_layer.py similarity index 98% rename from pychunkedgraph/ingest/create/abstract_layers.py rename to pychunkedgraph/ingest/create/parent_layer.py index adbe4a5ab..09be61407 100644 --- a/pychunkedgraph/ingest/create/abstract_layers.py +++ b/pychunkedgraph/ingest/create/parent_layer.py @@ -29,20 +29,20 @@ from .cross_edges import get_chunk_nodes_cross_edge_layer -def add_layer( +def add_parent_chunk( cg: ChunkedGraph, layer_id: int, - parent_coords: Sequence[int], + coords: Sequence[int], children_coords: Sequence[Sequence[int]] = np.array([]), *, time_stamp: Optional[datetime.datetime] = None, n_threads: int = 4, ) -> None: if not children_coords.size: - children_coords = get_children_chunk_coords(cg.meta, layer_id, parent_coords) + children_coords = get_children_chunk_coords(cg.meta, layer_id, coords) children_ids = _read_children_chunks(cg, layer_id, children_coords, n_threads > 1) cx_edges = get_children_chunk_cross_edges( - cg, layer_id, parent_coords, use_threads=n_threads > 1 + cg, layer_id, coords, use_threads=n_threads > 1 ) node_layers = cg.get_chunk_layers(children_ids) @@ -59,7 +59,7 @@ def add_layer( _write_connected_components( cg, layer_id, - parent_coords, + coords, connected_components, get_valid_timestamp(time_stamp), n_threads > 1, diff --git a/pychunkedgraph/ingest/ran_agglomeration.py b/pychunkedgraph/ingest/ran_agglomeration.py index 7c4af51f7..a0ca42d54 100644 --- a/pychunkedgraph/ingest/ran_agglomeration.py +++ b/pychunkedgraph/ingest/ran_agglomeration.py @@ -5,10 +5,7 @@ from collections import defaultdict from itertools import product -from typing import Dict -from typing import Iterable -from typing import Tuple -from typing import Union +from typing import Dict, Iterable, Tuple, Union from binascii import crc32 @@ -23,8 +20,7 @@ from ..io.edges import put_chunk_edges from ..io.components import put_chunk_components from ..graph.utils import basetypes -from ..graph.edges import Edges -from ..graph.edges import EDGE_TYPES +from ..graph.edges import EDGE_TYPES, Edges from ..graph.types import empty_2d from ..graph.chunks.utils import get_chunk_id diff --git a/pychunkedgraph/ingest/rq_cli.py b/pychunkedgraph/ingest/rq_cli.py index c9b21ae36..6a1a4882d 100644 --- a/pychunkedgraph/ingest/rq_cli.py +++ b/pychunkedgraph/ingest/rq_cli.py @@ -8,8 +8,6 @@ import click from redis import Redis from rq import Queue -from rq import Worker -from rq.worker import WorkerStatus from rq.job import Job from rq.exceptions import InvalidJobOperationError from rq.exceptions import NoSuchJobError @@ -27,23 +25,6 @@ connection = Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, password=REDIS_PASSWORD) -@rq_cli.command("status") -@click.argument("queues", nargs=-1, type=str) -@click.option("--show-busy", is_flag=True) -def get_status(queues, show_busy): - print("NOTE: Use --show-busy to display count of non idle workers\n") - for queue in queues: - q = Queue(queue, connection=connection) - print(f"Queue name \t: {queue}") - print(f"Jobs queued \t: {len(q)}") - print(f"Workers total \t: {Worker.count(queue=q)}") - if show_busy: - workers = Worker.all(queue=q) - count = sum([worker.get_state() == WorkerStatus.BUSY for worker in workers]) - print(f"Workers busy \t: {count}") - print(f"Jobs failed \t: {q.failed_job_registry.count}\n") - - @rq_cli.command("failed") @click.argument("queue", type=str) @click.argument("job_ids", nargs=-1) @@ -129,9 +110,14 @@ def clean_start_registry(queue): def clear_failed_registry(queue): failed_job_registry = FailedJobRegistry(queue, connection=connection) job_ids = failed_job_registry.get_job_ids() + count = 0 for job_id in job_ids: - failed_job_registry.remove(job_id, delete_job=True) - print(f"Deleted {len(job_ids)} jobs from the failed job registry.") + try: + failed_job_registry.remove(job_id, delete_job=True) + count += 1 + except Exception: + ... + print(f"Deleted {count} jobs from the failed job registry.") def init_rq_cmds(app): diff --git a/pychunkedgraph/ingest/simple_tests.py b/pychunkedgraph/ingest/simple_tests.py index 33946bcec..07a60f5f3 100644 --- a/pychunkedgraph/ingest/simple_tests.py +++ b/pychunkedgraph/ingest/simple_tests.py @@ -7,8 +7,7 @@ from datetime import datetime import numpy as np -from pychunkedgraph.graph import ChunkedGraph -from pychunkedgraph.graph import attributes +from pychunkedgraph.graph import attributes, ChunkedGraph def family(cg: ChunkedGraph): diff --git a/pychunkedgraph/ingest/upgrade/__init__.py b/pychunkedgraph/ingest/upgrade/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py new file mode 100644 index 000000000..96f7f71bd --- /dev/null +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -0,0 +1,119 @@ +# pylint: disable=invalid-name, missing-docstring, c-extension-no-member +from datetime import timedelta + +import fastremap +import numpy as np +from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph.attributes import Connectivity +from pychunkedgraph.graph.attributes import Hierarchy +from pychunkedgraph.graph.utils import serializers + +from .utils import exists_as_parent + + +def get_parent_timestamps(cg, supervoxels, start_time=None, end_time=None) -> set: + """ + Timestamps of when the given supervoxels were edited, in the given time range. + """ + response = cg.client.read_nodes( + node_ids=supervoxels, + start_time=start_time, + end_time=end_time, + end_time_inclusive=False, + ) + result = set() + for v in response.values(): + for cell in v[Hierarchy.Parent]: + valid = cell.timestamp >= start_time or cell.timestamp < end_time + assert valid, f"{cell.timestamp}, {start_time}" + result.add(cell.timestamp) + return result + + +def get_edit_timestamps(cg: ChunkedGraph, edges_d, start_ts, end_ts) -> list: + """ + Timestamps of when post-side supervoxels were involved in an edit. + Post-side - supervoxels in the neighbor chunk. + This is required because we need to update edges from both sides. + """ + atomic_cx_edges = np.concatenate(list(edges_d.values())) + timestamps = get_parent_timestamps( + cg, atomic_cx_edges[:, 1], start_time=start_ts, end_time=end_ts + ) + timestamps.add(start_ts) + return sorted(timestamps) + + +def update_cross_edges(cg: ChunkedGraph, node, cx_edges_d, node_ts, end_ts) -> list: + """ + Helper function to update a single L2 ID. + Returns a list of mutations with given timestamps. + """ + rows = [] + edges = np.concatenate(list(cx_edges_d.values())) + uparents = np.unique(cg.get_parents(edges[:, 0], time_stamp=node_ts)) + assert uparents.size <= 1, f"{node}, {node_ts}, {uparents}" + if uparents.size == 0 or node != uparents[0]: + # if node is not the parent at this ts, it must be invalid + assert not exists_as_parent(cg, node, edges[:, 0]) + return rows + + timestamps = [node_ts] + if node_ts != end_ts: + timestamps = get_edit_timestamps(cg, cx_edges_d, node_ts, end_ts) + for ts in timestamps: + val_dict = {} + svs = edges[:, 1] + parents = cg.get_parents(svs, time_stamp=ts) + edge_parents_d = dict(zip(svs, parents)) + for layer, layer_edges in cx_edges_d.items(): + layer_edges = fastremap.remap( + layer_edges, edge_parents_d, preserve_missing_labels=True + ) + layer_edges[:, 0] = node + layer_edges = np.unique(layer_edges, axis=0) + col = Connectivity.CrossChunkEdge[layer] + val_dict[col] = layer_edges + row_id = serializers.serialize_uint64(node) + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=ts)) + return rows + + +def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], layer: int = 2): + """ + Iterate over all L2 IDs in a chunk and update their cross chunk edges, + within the periods they were valid/active. + """ + x, y, z = chunk_coords + chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) + cg.copy_fake_edges(chunk_id) + rr = cg.range_read_chunk(chunk_id) + nodes = list(rr.keys()) + + # get start_ts when node becomes valid + nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) + cx_edges_d = cg.get_atomic_cross_edges(nodes) + children_d = cg.get_children(nodes) + + rows = [] + for node, start_ts in zip(nodes, nodes_ts): + if cg.get_parent(node) is None: + # invalid id caused by failed ingest task + continue + node_cx_edges_d = cx_edges_d.get(node, {}) + if not node_cx_edges_d: + continue + + # get end_ts when node becomes invalid (bigtable resolution is in ms) + start = start_ts + timedelta(milliseconds=1) + _timestamps = get_parent_timestamps(cg, children_d[node], start_time=start) + try: + end_ts = sorted(_timestamps)[0] + except IndexError: + # start_ts == end_ts means there has been no edit involving this node + # meaning only one timestamp to update cross edges, start_ts + end_ts = start_ts + # for each timestamp until end_ts, update cross chunk edges of node + _rows = update_cross_edges(cg, node, node_cx_edges_d, start_ts, end_ts) + rows.extend(_rows) + cg.client.write(rows) diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py new file mode 100644 index 000000000..8674e45b7 --- /dev/null +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -0,0 +1,170 @@ +# pylint: disable=invalid-name, missing-docstring, c-extension-no-member + +import math, random, time +import multiprocessing as mp +from collections import defaultdict + +import fastremap +import numpy as np +from multiwrapper import multiprocessing_utils as mu + +from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph.attributes import Connectivity, Hierarchy +from pychunkedgraph.graph.utils import serializers +from pychunkedgraph.graph.types import empty_2d +from pychunkedgraph.utils.general import chunked + +from .utils import exists_as_parent + + +CHILDREN = {} +CX_EDGES = {} + + +def _populate_nodes_and_children( + cg: ChunkedGraph, chunk_id: np.uint64, nodes: list = None +) -> dict: + global CHILDREN + if nodes: + CHILDREN = cg.get_children(nodes) + return + response = cg.range_read_chunk(chunk_id, properties=Hierarchy.Child) + for k, v in response.items(): + CHILDREN[k] = v[0].value + + +def _get_cx_edges_at_timestamp(node, response, ts): + result = defaultdict(list) + for child in CHILDREN[node]: + if child not in response: + continue + for key, cells in response[child].items(): + for cell in cells: + # cells are sorted in descending order of timestamps + if ts >= cell.timestamp: + result[key.index].append(cell.value) + break + for layer, edges in result.items(): + result[layer] = np.concatenate(edges) + return result + + +def _populate_cx_edges_with_timestamps( + cg: ChunkedGraph, layer: int, nodes: list, nodes_ts: list +): + """ + Collect timestamps of edits from children, since we use the same timestamp + for all IDs involved in an edit, we can use the timestamps of + when cross edges of children were updated. + """ + global CX_EDGES + attrs = [Connectivity.CrossChunkEdge[l] for l in range(layer, cg.meta.layer_count)] + all_children = np.concatenate(list(CHILDREN.values())) + response = cg.client.read_nodes(node_ids=all_children, properties=attrs) + for node, node_ts in zip(nodes, nodes_ts): + timestamps = set([node_ts]) + for child in CHILDREN[node]: + if child not in response: + continue + for cells in response[child].values(): + timestamps.update([c.timestamp for c in cells if c.timestamp > node_ts]) + CX_EDGES[node] = {} + for ts in sorted(timestamps): + CX_EDGES[node][ts] = _get_cx_edges_at_timestamp(node, response, ts) + + +def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> list: + """ + Helper function to update a single ID. + Returns a list of mutations with timestamps. + """ + rows = [] + if node_ts > earliest_ts: + try: + cx_edges_d = CX_EDGES[node][node_ts] + except KeyError: + raise KeyError(f"{node}:{node_ts}") + edges = np.concatenate([empty_2d] + list(cx_edges_d.values())) + if edges.size: + parents = cg.get_roots( + edges[:, 0], time_stamp=node_ts, stop_layer=layer, ceil=False + ) + uparents = np.unique(parents) + layers = cg.get_chunk_layers(uparents) + uparents = uparents[layers == layer] + assert uparents.size <= 1, f"{node}, {node_ts}, {uparents}" + if uparents.size == 0 or node != uparents[0]: + # if node is not the parent at this ts, it must be invalid + assert not exists_as_parent(cg, node, edges[:, 0]), f"{node}, {node_ts}" + return rows + + for ts, cx_edges_d in CX_EDGES[node].items(): + edges = np.concatenate([empty_2d] + list(cx_edges_d.values())) + if edges.size == 0: + continue + nodes = np.unique(edges[:, 1]) + parents = cg.get_roots(nodes, time_stamp=ts, stop_layer=layer, ceil=False) + edge_parents_d = dict(zip(nodes, parents)) + val_dict = {} + for _layer, layer_edges in cx_edges_d.items(): + layer_edges = fastremap.remap( + layer_edges, edge_parents_d, preserve_missing_labels=True + ) + layer_edges[:, 0] = node + layer_edges = np.unique(layer_edges, axis=0) + col = Connectivity.CrossChunkEdge[_layer] + val_dict[col] = layer_edges + row_id = serializers.serialize_uint64(node) + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=ts)) + return rows + + +def _update_cross_edges_helper(args): + cg_info, layer, nodes, nodes_ts, earliest_ts = args + rows = [] + cg = ChunkedGraph(**cg_info) + parents = cg.get_parents(nodes, fail_to_zero=True) + for node, parent, node_ts in zip(nodes, parents, nodes_ts): + if parent == 0: + # invalid id caused by failed ingest task + continue + _rows = update_cross_edges(cg, layer, node, node_ts, earliest_ts) + rows.extend(_rows) + cg.client.write(rows) + + +def update_chunk( + cg: ChunkedGraph, chunk_coords: list[int], layer: int, nodes: list = None +): + """ + Iterate over all layer IDs in a chunk and update their cross chunk edges. + """ + start = time.time() + x, y, z = chunk_coords + chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) + _populate_nodes_and_children(cg, chunk_id, nodes=nodes) + if not CHILDREN: + return + nodes = list(CHILDREN.keys()) + random.shuffle(nodes) + nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) + _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts) + + task_size = int(math.ceil(len(nodes) / mp.cpu_count() / 2)) + chunked_nodes = chunked(nodes, task_size) + chunked_nodes_ts = chunked(nodes_ts, task_size) + cg_info = cg.get_serialized_info() + earliest_ts = cg.get_earliest_timestamp() + + multi_args = [] + for chunk, ts_chunk in zip(chunked_nodes, chunked_nodes_ts): + args = (cg_info, layer, chunk, ts_chunk, earliest_ts) + multi_args.append(args) + + print(f"nodes: {len(nodes)}, tasks: {len(multi_args)}, size: {task_size}") + mu.multiprocess_func( + _update_cross_edges_helper, + multi_args, + n_threads=min(len(multi_args), mp.cpu_count()), + ) + print(f"total elaspsed time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py new file mode 100644 index 000000000..43c9a3034 --- /dev/null +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -0,0 +1,13 @@ +from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph.attributes import Hierarchy + + +def exists_as_parent(cg: ChunkedGraph, parent, nodes) -> bool: + """ + Check if a given l2 parent is in the history of given nodes. + """ + response = cg.client.read_nodes(node_ids=nodes, properties=Hierarchy.Parent) + parents = set() + for cells in response.values(): + parents.update([cell.value for cell in cells]) + return parent in parents diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 1c3236561..3d573ce37 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -1,14 +1,21 @@ # pylint: disable=invalid-name, missing-docstring -from typing import Tuple -from . import ClusterIngestConfig -from . import IngestConfig -from ..graph.meta import ChunkedGraphMeta -from ..graph.meta import DataSource -from ..graph.meta import GraphConfig +import logging +from os import environ +from time import sleep +from typing import Any, Generator, Tuple + +import numpy as np +import tensorstore as ts +from rq import Queue, Worker +from rq.worker import WorkerStatus +from . import IngestConfig +from .manager import IngestionManager +from ..graph.meta import ChunkedGraphMeta, DataSource, GraphConfig from ..graph.client import BackendClientInfo from ..graph.client.bigtable import BigTableConfig +from ..utils.general import chunked chunk_id_str = lambda layer, coords: f"{layer}_{'_'.join(map(str, coords))}" @@ -16,14 +23,12 @@ def bootstrap( graph_id: str, config: dict, - overwrite: bool = False, raw: bool = False, test_run: bool = False, ) -> Tuple[ChunkedGraphMeta, IngestConfig, BackendClientInfo]: """Parse config loaded from a yaml file.""" ingest_config = IngestConfig( **config.get("ingest_config", {}), - CLUSTER=ClusterIngestConfig(), USE_RAW_EDGES=raw, USE_RAW_COMPONENTS=raw, TEST_RUN=test_run, @@ -33,7 +38,7 @@ def bootstrap( graph_config = GraphConfig( ID=f"{graph_id}", - OVERWRITE=overwrite, + OVERWRITE=False, **config["graph_config"], ) data_source = DataSource(**config["data_source"]) @@ -73,3 +78,115 @@ def postprocess_edge_data(im, edge_dict): return new_edge_dict else: raise ValueError(f"Unknown data_version: {data_version}") + + +def start_ocdbt_server(imanager: IngestionManager, server: Any): + spec = {"driver": "ocdbt", "base": f"{imanager.cg.meta.data_source.EDGES}/ocdbt"} + spec["coordinator"] = {"address": f"localhost:{server.port}"} + ts.KvStore.open(spec).result() + imanager.redis.set("OCDBT_COORDINATOR_PORT", str(server.port)) + ocdbt_host = environ.get("MY_POD_IP", "localhost") + imanager.redis.set("OCDBT_COORDINATOR_HOST", ocdbt_host) + logging.info(f"OCDBT Coordinator address {ocdbt_host}:{server.port}") + + +def randomize_grid_points(X: int, Y: int, Z: int) -> Generator[int, int, int]: + indices = np.arange(X * Y * Z) + np.random.shuffle(indices) + for index in indices: + yield np.unravel_index(index, (X, Y, Z)) + + +def get_chunks_not_done(imanager: IngestionManager, layer: int, coords: list) -> list: + """check for set membership in redis in batches""" + coords_strs = ["_".join(map(str, coord)) for coord in coords] + try: + completed = imanager.redis.smismember(f"{layer}c", coords_strs) + except Exception: + return coords + return [coord for coord, c in zip(coords, completed) if not c] + + +def print_completion_rate(imanager: IngestionManager, layer: int, span: int = 10): + counts = [] + for _ in range(span + 1): + counts.append(imanager.redis.scard(f"{layer}c")) + sleep(1) + rate = np.diff(counts).sum() / span + print(f"{rate} chunks per second.") + + +def print_ingest_status(imanager: IngestionManager, redis, upgrade: bool = False): + """ + Helper to print status to console. + If `upgrade=True`, status does not include the root layer, + since there is no need to update cross edges for root ids. + """ + layers = range(2, imanager.cg_meta.layer_count + 1) + if upgrade: + layers = range(2, imanager.cg_meta.layer_count) + layer_counts = imanager.cg_meta.layer_chunk_counts + + pipeline = redis.pipeline() + worker_busy = [] + for layer in layers: + pipeline.scard(f"{layer}c") + queue = Queue(f"l{layer}", connection=redis) + pipeline.llen(queue.key) + pipeline.zcard(queue.failed_job_registry.key) + workers = Worker.all(queue=queue) + worker_busy.append(sum([w.get_state() == WorkerStatus.BUSY for w in workers])) + + results = pipeline.execute() + completed = [] + queued = [] + failed = [] + for i in range(0, len(results), 3): + result = results[i : i + 3] + completed.append(result[0]) + queued.append(result[1]) + failed.append(result[2]) + + print(f"version: \t{imanager.cg.version}") + print(f"graph_id: \t{imanager.cg.graph_id}") + print(f"chunk_size: \t{imanager.cg.meta.graph_config.CHUNK_SIZE}") + print("\nlayer status:") + for layer, done, count in zip(layers, completed, layer_counts): + print(f"{layer}\t: {done:<9} / {count}") + + print("\n\nqueue status:") + for layer, q, f, wb in zip(layers, queued, failed, worker_busy): + print(f"l{layer}\t: queued: {q:<10} failed: {f:<10} busy: {wb}") + + +def queue_layer_helper(parent_layer: int, imanager: IngestionManager, fn): + if parent_layer == imanager.cg_meta.layer_count: + chunk_coords = [(0, 0, 0)] + else: + bounds = imanager.cg_meta.layer_chunk_bounds[parent_layer] + chunk_coords = randomize_grid_points(*bounds) + + q = imanager.get_task_queue(f"l{parent_layer}") + batch_size = int(environ.get("JOB_BATCH_SIZE", 10000)) + timeout_scale = int(environ.get("TIMEOUT_SCALE_FACTOR", 1)) + batches = chunked(chunk_coords, batch_size) + for batch in batches: + _coords = get_chunks_not_done(imanager, parent_layer, batch) + # buffer for optimal use of redis memory + if len(q) > int(environ.get("QUEUE_SIZE", 100000)): + interval = int(environ.get("QUEUE_INTERVAL", 300)) + logging.info(f"Queue full; sleeping {interval}s...") + sleep(interval) + + job_datas = [] + for chunk_coord in _coords: + job_datas.append( + Queue.prepare_data( + fn, + args=(parent_layer, chunk_coord), + result_ttl=0, + job_id=chunk_id_str(parent_layer, chunk_coord), + timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", + ) + ) + q.enqueue_many(job_datas) diff --git a/pychunkedgraph/repair/edits.py b/pychunkedgraph/repair/edits.py index cb403a380..849b17e08 100644 --- a/pychunkedgraph/repair/edits.py +++ b/pychunkedgraph/repair/edits.py @@ -56,8 +56,6 @@ def repair_operation( op_ids_to_retry.append(locked_op) print(f"{node_id} indefinitely locked by op {locked_op}") print(f"total to retry: {len(op_ids_to_retry)}") - - logs = cg.client.read_log_entries(op_ids_to_retry) - for op_id, log in logs.items(): + for op_id in op_ids_to_retry: print(f"repairing {op_id}") - repair_operation(cg, log, op_id) + repair_operation(cg, op_id) diff --git a/pychunkedgraph/tests/helpers.py b/pychunkedgraph/tests/helpers.py index de5314422..b9c689ad6 100644 --- a/pychunkedgraph/tests/helpers.py +++ b/pychunkedgraph/tests/helpers.py @@ -14,12 +14,12 @@ from google.cloud import bigtable from ..ingest.utils import bootstrap -from ..ingest.create.atomic_layer import add_atomic_edges +from ..ingest.create.atomic_layer import add_atomic_chunk from ..graph.edges import Edges from ..graph.edges import EDGE_TYPES from ..graph.utils import basetypes from ..graph.chunkedgraph import ChunkedGraph -from ..ingest.create.abstract_layers import add_layer +from ..ingest.create.parent_layer import add_parent_chunk class CloudVolumeBounds(object): @@ -120,7 +120,7 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) "FANOUT": 2, "SPATIAL_BITS": 10, "ID_PREFIX": "", - "ROOT_LOCK_EXPIRY": timedelta(seconds=5) + "ROOT_LOCK_EXPIRY": timedelta(seconds=5), }, "backend_client": { "TYPE": "bigtable", @@ -130,15 +130,14 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", "INSTANCE": "emulated_instance", "CREDENTIALS": credentials.AnonymousCredentials(), - "MAX_ROW_KEY_COUNT": 1000 + "MAX_ROW_KEY_COUNT": 1000, }, }, "ingest_config": {}, } meta, _, client_info = bootstrap("test", config=config) - graph = ChunkedGraph(graph_id="test", meta=meta, - client_info=client_info) + graph = ChunkedGraph(graph_id="test", meta=meta, client_info=client_info) graph.mock_edges = Edges([], []) graph.meta._ws_cv = CloudVolumeMock() graph.meta.layer_count = n_layers @@ -176,8 +175,7 @@ def gen_graph_simplequerytest(request, gen_graph): # Chunk B create_chunk( graph, - vertices=[to_label(graph, 1, 1, 0, 0, 0), - to_label(graph, 1, 1, 0, 0, 1)], + vertices=[to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 1)], edges=[ (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 1), 0.5), (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), @@ -188,13 +186,12 @@ def gen_graph_simplequerytest(request, gen_graph): create_chunk( graph, vertices=[to_label(graph, 1, 2, 0, 0, 0)], - edges=[(to_label(graph, 1, 2, 0, 0, 0), - to_label(graph, 1, 1, 0, 0, 0), inf)], + edges=[(to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf)], ) - add_layer(graph, 3, [0, 0, 0], n_threads=1) - add_layer(graph, 3, [1, 0, 0], n_threads=1) - add_layer(graph, 4, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) return graph @@ -206,8 +203,7 @@ def create_chunk(cg, vertices=None, edges=None, timestamp=None): edges = edges if edges else [] vertices = vertices if vertices else [] vertices = np.unique(np.array(vertices, dtype=np.uint64)) - edges = [(np.uint64(v1), np.uint64(v2), np.float32(aff)) - for v1, v2, aff in edges] + edges = [(np.uint64(v1), np.uint64(v2), np.float32(aff)) for v1, v2, aff in edges] isolated_ids = [ x for x in vertices @@ -230,8 +226,7 @@ def create_chunk(cg, vertices=None, edges=None, timestamp=None): chunk_id = None if len(chunk_edges_active[EDGE_TYPES.in_chunk]): - chunk_id = cg.get_chunk_id( - chunk_edges_active[EDGE_TYPES.in_chunk].node_ids1[0]) + chunk_id = cg.get_chunk_id(chunk_edges_active[EDGE_TYPES.in_chunk].node_ids1[0]) elif len(vertices): chunk_id = cg.get_chunk_id(vertices[0]) @@ -257,7 +252,7 @@ def create_chunk(cg, vertices=None, edges=None, timestamp=None): cg.mock_edges += all_edges isolated_ids = np.array(isolated_ids, dtype=np.uint64) - add_atomic_edges( + add_atomic_chunk( cg, cg.get_chunk_coordinates(chunk_id), chunk_edges_active, @@ -282,21 +277,21 @@ def get_layer_chunk_bounds( return layer_bounds_d -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def sv_data(): - test_data_dir = 'pychunkedgraph/tests/data' - edges_file = f'{test_data_dir}/sv_edges.npy' + test_data_dir = "pychunkedgraph/tests/data" + edges_file = f"{test_data_dir}/sv_edges.npy" sv_edges = np.load(edges_file) - source_file = f'{test_data_dir}/sv_sources.npy' + source_file = f"{test_data_dir}/sv_sources.npy" sv_sources = np.load(source_file) - sinks_file = f'{test_data_dir}/sv_sinks.npy' + sinks_file = f"{test_data_dir}/sv_sinks.npy" sv_sinks = np.load(sinks_file) - affinity_file = f'{test_data_dir}/sv_affinity.npy' + affinity_file = f"{test_data_dir}/sv_affinity.npy" sv_affinity = np.load(affinity_file) - area_file = f'{test_data_dir}/sv_area.npy' + area_file = f"{test_data_dir}/sv_area.npy" sv_area = np.load(area_file) yield (sv_edges, sv_sources, sv_sinks, sv_affinity, sv_area) diff --git a/pychunkedgraph/tests/test_uncategorized.py b/pychunkedgraph/tests/test_uncategorized.py index 93c41158d..8b26f5c5e 100644 --- a/pychunkedgraph/tests/test_uncategorized.py +++ b/pychunkedgraph/tests/test_uncategorized.py @@ -36,7 +36,7 @@ from ..graph.lineage import get_future_root_ids from ..graph.utils.serializers import serialize_uint64 from ..graph.utils.serializers import deserialize_uint64 -from ..ingest.create.abstract_layers import add_layer +from ..ingest.create.parent_layer import add_parent_chunk class TestGraphNodeConversion: @@ -68,9 +68,9 @@ def test_node_id_adjacency(self, gen_graph): ) == cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0) assert cg.get_node_id( - np.uint64(2 ** 53 - 2), layer=10, x=0, y=0, z=0 + np.uint64(2**53 - 2), layer=10, x=0, y=0, z=0 ) + np.uint64(1) == cg.get_node_id( - np.uint64(2 ** 53 - 1), layer=10, x=0, y=0, z=0 + np.uint64(2**53 - 1), layer=10, x=0, y=0, z=0 ) @pytest.mark.timeout(30) @@ -82,9 +82,9 @@ def test_serialize_node_id(self, gen_graph): ) < serialize_uint64(cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0)) assert serialize_uint64( - cg.get_node_id(np.uint64(2 ** 53 - 2), layer=10, x=0, y=0, z=0) + cg.get_node_id(np.uint64(2**53 - 2), layer=10, x=0, y=0, z=0) ) < serialize_uint64( - cg.get_node_id(np.uint64(2 ** 53 - 1), layer=10, x=0, y=0, z=0) + cg.get_node_id(np.uint64(2**53 - 1), layer=10, x=0, y=0, z=0) ) @pytest.mark.timeout(30) @@ -222,7 +222,7 @@ def test_build_single_across_edge(self, gen_graph): edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], ) - add_layer(cg, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) res = cg.client._table.read_rows() res.consume_all() @@ -327,7 +327,7 @@ def test_build_single_edge_and_single_across_edge(self, gen_graph): edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], ) - add_layer(cg, 3, np.array([0, 0, 0]), n_threads=1) + add_parent_chunk(cg, 3, np.array([0, 0, 0]), n_threads=1) res = cg.client._table.read_rows() res.consume_all() @@ -424,10 +424,10 @@ def test_build_big_graph(self, gen_graph): # Preparation: Build Chunk Z create_chunk(cg, vertices=[to_label(cg, 1, 7, 7, 7, 0)], edges=[]) - add_layer(cg, 3, [0, 0, 0], n_threads=1) - add_layer(cg, 3, [3, 3, 3], n_threads=1) - add_layer(cg, 4, [0, 0, 0], n_threads=1) - add_layer(cg, 5, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 3, [3, 3, 3], n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 5, [0, 0, 0], n_threads=1) res = cg.client._table.read_rows() res.consume_all() @@ -468,21 +468,21 @@ def test_double_chunk_creation(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 4, [0, 0, 0], @@ -831,7 +831,7 @@ def test_merge_pair_neighboring_chunks(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -887,28 +887,28 @@ def test_merge_pair_disconnected_chunks(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 3, [3, 3, 3], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 5, [0, 0, 0], @@ -1052,7 +1052,7 @@ def test_merge_triple_chain_to_full_circle_neighboring_chunks(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -1111,35 +1111,35 @@ def test_merge_triple_chain_to_full_circle_disconnected_chunks(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 3, [3, 3, 3], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 4, [1, 1, 1], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 5, [0, 0, 0], @@ -1239,7 +1239,7 @@ def test_merge_pair_abstract_nodes(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -1314,7 +1314,7 @@ def test_diagonal_connections(self, gen_graph): edges=[(to_label(cg, 1, 1, 1, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf)], ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -1405,28 +1405,28 @@ def test_cross_edges(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 3, [1, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, ) - add_layer( + add_parent_chunk( cg, 5, [0, 0, 0], @@ -1591,7 +1591,7 @@ def test_cut_regular_link(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -1662,7 +1662,7 @@ def test_cut_no_link(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -1723,7 +1723,7 @@ def test_cut_old_link(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -1791,7 +1791,7 @@ def test_cut_indivisible_link(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -1922,7 +1922,7 @@ def test_cut_merge_history(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -2063,7 +2063,7 @@ def test_lock_unlock(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -2129,7 +2129,7 @@ def test_lock_expiration(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -2197,7 +2197,7 @@ def test_lock_renew(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -2249,7 +2249,7 @@ def test_lock_merge_lock_old_id(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -2315,7 +2315,7 @@ def test_indefinite_lock(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], @@ -2388,7 +2388,7 @@ def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): timestamp=fake_timestamp, ) - add_layer( + add_parent_chunk( cg, 3, [0, 0, 0], diff --git a/pychunkedgraph/utils/general.py b/pychunkedgraph/utils/general.py index 71e24eab0..c299d3b9b 100644 --- a/pychunkedgraph/utils/general.py +++ b/pychunkedgraph/utils/general.py @@ -1,9 +1,11 @@ """ generic helper funtions """ + from typing import Sequence from itertools import islice + import numpy as np @@ -24,6 +26,10 @@ def reverse_dictionary(dictionary): def chunked(l: Sequence, n: int): + """ + Yield successive n-sized chunks from l. + NOTE: Use itertools.batched from python 3.12 + """ """ Yield successive n-sized chunks from l. NOTE: Use itertools.batched from python 3.12 @@ -33,6 +39,9 @@ def chunked(l: Sequence, n: int): it = iter(l) while batch := tuple(islice(it, n)): yield batch + it = iter(l) + while batch := tuple(islice(it, n)): + yield batch def in2d(arr1: np.ndarray, arr2: np.ndarray) -> np.ndarray: diff --git a/requirements.in b/requirements.in index 63e0b3472..1ec536a5c 100644 --- a/requirements.in +++ b/requirements.in @@ -15,6 +15,7 @@ rq<2 pyyaml cachetools werkzeug +tensorstore # PyPI only: cloud-files>=4.21.1 diff --git a/requirements.txt b/requirements.txt index 5a2f18adc..059b8fd91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -192,6 +192,8 @@ messagingclient==0.1.3 # via -r requirements.in middle-auth-client==3.16.1 # via -r requirements.in +ml-dtypes==0.3.2 + # via tensorstore multiprocess==0.70.15 # via pathos multiwrapper==0.1.1 @@ -210,11 +212,13 @@ numpy==1.26.0 # fastremap # fpzip # messagingclient + # ml-dtypes # multiwrapper # pandas # pyspng-seunglab # simplejpeg # task-queue + # tensorstore # zfpc # zmesh orderedmultidict==1.0.1 @@ -337,6 +341,8 @@ tenacity==8.2.3 # cloud-files # cloud-volume # task-queue +tensorstore==0.1.53 + # via -r requirements.in tqdm==4.66.1 # via # cloud-files From e9f46e4c1a01db07344f82b2c6ad94243b887590 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 12 May 2024 16:10:12 +0000 Subject: [PATCH 082/367] reset version v3 --- .bumpversion.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b6b4de269..5583246c5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.21.1 +current_version = 3.0.0 commit = True tag = True From 956033b06b6350ef6220819aa26b18084c221d3c Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 12 May 2024 18:18:05 +0000 Subject: [PATCH 083/367] breakup long fn --- pychunkedgraph/ingest/create/parent_layer.py | 96 ++++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/pychunkedgraph/ingest/create/parent_layer.py b/pychunkedgraph/ingest/create/parent_layer.py index 09be61407..a777d9efc 100644 --- a/pychunkedgraph/ingest/create/parent_layer.py +++ b/pychunkedgraph/ingest/create/parent_layer.py @@ -154,13 +154,50 @@ def _write_components_helper(args): _write(cg, layer, pcoords, ccs, node_layer_d, time_stamp) +def _children_rows( + cg: ChunkedGraph, parent_id, children: Sequence, cx_edges_d: dict, time_stamp +): + """ + Update children rows to point to the parent_id, collect cached children + cross chunk edges to lift and update parent cross chunk edges. + Returns list of mutations to children and list of children cross edges. + """ + rows = [] + children_cx_edges = [] + for child in children: + node_layer = cg.get_chunk_layer(child) + row_id = serializers.serialize_uint64(child) + val_dict = {attributes.Hierarchy.Parent: parent_id} + node_cx_edges_d = cx_edges_d.get(child, {}) + if not node_cx_edges_d: + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) + continue + for layer in range(node_layer, cg.meta.layer_count): + if not layer in node_cx_edges_d: + continue + layer_edges = node_cx_edges_d[layer] + nodes = np.unique(layer_edges) + parents = cg.get_roots(nodes, stop_layer=node_layer, ceil=False) + edge_parents_d = dict(zip(nodes, parents)) + layer_edges = fastremap.remap( + layer_edges, edge_parents_d, preserve_missing_labels=True + ) + layer_edges = np.unique(layer_edges, axis=0) + col = attributes.Connectivity.CrossChunkEdge[layer] + val_dict[col] = layer_edges + node_cx_edges_d[layer] = layer_edges + children_cx_edges.append(node_cx_edges_d) + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) + return rows, children_cx_edges + + def _write( cg: ChunkedGraph, layer_id, parent_coords, components, node_layer_d, - time_stamp, + ts, use_threads=True, ): parent_layers = range(layer_id, cg.meta.layer_count + 1) @@ -175,71 +212,34 @@ def _write( x, y, z = parent_coords parent_chunk_id = cg.get_chunk_id(layer=layer_id, x=x, y=y, z=z) parent_chunk_id_dict = cg.get_parent_chunk_id_dict(parent_chunk_id) - for parent_layer in parent_layers: if len(cc_connections[parent_layer]) == 0: continue - parent_chunk_id = parent_chunk_id_dict[parent_layer] reserved_parent_ids = cg.id_client.create_node_ids( parent_chunk_id, size=len(cc_connections[parent_layer]), root_chunk=parent_layer == cg.meta.layer_count and use_threads, ) - - for i_cc, node_ids in enumerate(cc_connections[parent_layer]): - parent_id = reserved_parent_ids[i_cc] - + for i_cc, children in enumerate(cc_connections[parent_layer]): + parent = reserved_parent_ids[i_cc] if layer_id == 3: # when layer 3 is being processed, children chunks are at layer 2 # layer 2 chunks at this time will only have atomic cross edges - cx_edges_d = cg.get_atomic_cross_edges(node_ids) + cx_edges_d = cg.get_atomic_cross_edges(children) else: - # children are from abstract chunks - cx_edges_d = cg.get_cross_chunk_edges(node_ids, raw_only=True) - - children_cx_edges = [] - for node in node_ids: - node_layer = cg.get_chunk_layer(node) - row_id = serializers.serialize_uint64(node) - val_dict = {attributes.Hierarchy.Parent: parent_id} - - node_cx_edges_d = cx_edges_d.get(node, {}) - if not node_cx_edges_d: - rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) - continue - - for layer in range(node_layer, cg.meta.layer_count): - if not layer in node_cx_edges_d: - continue - layer_edges = node_cx_edges_d[layer] - nodes = np.unique(layer_edges) - parents = cg.get_roots(nodes, stop_layer=node_layer, ceil=False) - - edge_parents_d = dict(zip(nodes, parents)) - layer_edges = fastremap.remap( - layer_edges, edge_parents_d, preserve_missing_labels=True - ) - layer_edges = np.unique(layer_edges, axis=0) - - col = attributes.Connectivity.CrossChunkEdge[layer] - val_dict[col] = layer_edges - node_cx_edges_d[layer] = layer_edges - children_cx_edges.append(node_cx_edges_d) - rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) - - row_id = serializers.serialize_uint64(parent_id) - val_dict = {attributes.Hierarchy.Child: node_ids} - parent_cx_edges_d = concatenate_cross_edge_dicts( - children_cx_edges, unique=True - ) + cx_edges_d = cg.get_cross_chunk_edges(children, raw_only=True) + _rows, cx_edges = _children_rows(cg, parent, children, cx_edges_d, ts) + rows.extend(_rows) + row_id = serializers.serialize_uint64(parent) + val_dict = {attributes.Hierarchy.Child: children} + parent_cx_edges_d = concatenate_cross_edge_dicts(cx_edges, unique=True) for layer in range(parent_layer, cg.meta.layer_count): if not layer in parent_cx_edges_d: continue col = attributes.Connectivity.CrossChunkEdge[layer] val_dict[col] = parent_cx_edges_d[layer] - - rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp)) + rows.append(cg.client.mutate_row(row_id, val_dict, ts)) if len(rows) > 100000: cg.client.write(rows) rows = [] From a15f6f3e6f402e4217443e7d1f01d8b96eca61c2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 15 May 2024 00:11:42 +0000 Subject: [PATCH 084/367] gh actions for pcgv3 --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 899f0431f..fd20bf4b7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,9 +4,11 @@ on: push: branches: - "main" + - "pcgv3" pull_request: branches: - "main" + - "pcgv3" jobs: unit-tests: From a86e1a739614d1b5a32eb40317d3b9f4f751a1db Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 24 May 2024 21:31:39 -0500 Subject: [PATCH 085/367] update split tests (#497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ingest): use temporarily cached cross chunk edges * fix: switch to using partners vector instead of 2d edges array * fix(edits): l2 - use and store cx edges that become relevant only at l2 * chore: rename counterpart to partner * fix: update partner cx edges * feat(edits): use layer relevant partners * fix tests * persist cross chunk layers with each node * fix: update cross chunk layers in edits * fix: update cross layer from old ids in l2 * update deprecated utcnoww * fix split tests * Bump version: 3.0.0 → 3.0.1 * fix: missed timestamp arg * update docs, remove unnecessary methods * revert structural changes * fix new tests; revert bumpversion.cfg --- pychunkedgraph/graph/edits.py | 22 +- pychunkedgraph/graph/misc.py | 58 +- pychunkedgraph/graph/utils/basetypes.py | 22 +- pychunkedgraph/ingest/create/parent_layer.py | 3 +- pychunkedgraph/tests/helpers.py | 1 + pychunkedgraph/tests/test_uncategorized.py | 2141 ++++++++---------- 6 files changed, 1036 insertions(+), 1211 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index ee7e643c3..807fff257 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -251,7 +251,7 @@ def add_edges( return new_roots, new_l2_ids, create_parents.new_entries -def _process_l2_agglomeration( +def _split_l2_agglomeration( cg, operation_id: int, agg: types.Agglomeration, @@ -272,16 +272,16 @@ def _process_l2_agglomeration( # if there aren't any, there must be no parents. XOR these 2 conditions. err = f"got cross edges from more than one l2 node; op {operation_id}" assert (np.unique(parents).size == 1) != (cross_edges.size == 0), err - root = cg.get_root(parents[0], time_stamp=parent_ts, raw_only=True) - - # inactive edges must be filtered out - neighbor_roots = cg.get_roots( - cross_edges[:, 1], raw_only=True, time_stamp=parent_ts - ) - active_mask = neighbor_roots == root - cross_edges = cross_edges[active_mask] - cross_edges = cross_edges[~in2d(cross_edges, removed_edges)] + if cross_edges.size: + # inactive edges must be filtered out + root = cg.get_root(parents[0], time_stamp=parent_ts, raw_only=True) + neighbor_roots = cg.get_roots( + cross_edges[:, 1], raw_only=True, time_stamp=parent_ts + ) + active_mask = neighbor_roots == root + cross_edges = cross_edges[active_mask] + cross_edges = cross_edges[~in2d(cross_edges, removed_edges)] isolated_ids = agg.supervoxels[~np.in1d(agg.supervoxels, chunk_edges)] isolated_edges = np.column_stack((isolated_ids, isolated_ids)) graph, _, _, graph_ids = flatgraph.build_gt_graph( @@ -332,7 +332,7 @@ def remove_edges( new_l2_ids = [] for id_ in l2ids: agg = l2id_agglomeration_d[id_] - ccs, graph_ids, cross_edges = _process_l2_agglomeration( + ccs, graph_ids, cross_edges = _split_l2_agglomeration( cg, operation_id, agg, removed_edges, parent_ts ) new_parents = cg.id_client.create_node_ids(chunk_id_map[agg.node_id], len(ccs)) diff --git a/pychunkedgraph/graph/misc.py b/pychunkedgraph/graph/misc.py index 873422db1..0f53c71c3 100644 --- a/pychunkedgraph/graph/misc.py +++ b/pychunkedgraph/graph/misc.py @@ -8,7 +8,6 @@ import fastremap import numpy as np -from multiwrapper import multiprocessing_utils as mu from . import ChunkedGraph from . import attributes @@ -51,22 +50,6 @@ def _read_delta_root_rows( return new_root_ids, expired_root_ids -def _read_root_rows_thread(args) -> list: - start_seg_id, end_seg_id, serialized_cg_info, time_stamp = args - cg = ChunkedGraph(**serialized_cg_info) - start_id = cg.get_node_id(segment_id=start_seg_id, chunk_id=cg.root_chunk_id) - end_id = cg.get_node_id(segment_id=end_seg_id, chunk_id=cg.root_chunk_id) - rows = cg.client.read_nodes( - start_id=start_id, - end_id=end_id, - end_id_inclusive=False, - end_time=time_stamp, - end_time_inclusive=True, - ) - root_ids = [k for (k, v) in rows.items() if attributes.Hierarchy.NewParent not in v] - return root_ids - - def get_proofread_root_ids( cg: ChunkedGraph, start_time: Optional[datetime.datetime] = None, @@ -94,43 +77,12 @@ def get_proofread_root_ids( def get_latest_roots( - cg, time_stamp: Optional[datetime.datetime] = None, n_threads: int = 1 + cg: ChunkedGraph, time_stamp: Optional[datetime.datetime] = None, n_threads: int = 1 ) -> Sequence[np.uint64]: - # Create filters: time and id range - max_seg_id = cg.get_max_seg_id(cg.root_chunk_id) + 1 - n_blocks = 1 if n_threads == 1 else int(np.min([n_threads * 3 + 1, max_seg_id])) - seg_id_blocks = np.linspace(1, max_seg_id, n_blocks + 1, dtype=np.uint64) - cg_serialized_info = cg.get_serialized_info() - if n_threads > 1: - del cg_serialized_info["credentials"] - - multi_args = [] - for i_id_block in range(0, len(seg_id_blocks) - 1): - multi_args.append( - [ - seg_id_blocks[i_id_block], - seg_id_blocks[i_id_block + 1], - cg_serialized_info, - time_stamp, - ] - ) - - if n_threads == 1: - results = mu.multiprocess_func( - _read_root_rows_thread, - multi_args, - n_threads=n_threads, - verbose=False, - debug=n_threads == 1, - ) - else: - results = mu.multisubprocess_func( - _read_root_rows_thread, multi_args, n_threads=n_threads - ) - root_ids = [] - for result in results: - root_ids.extend(result) - return np.array(root_ids, dtype=np.uint64) + root_chunk = cg.get_chunk_id(layer=cg.meta.layer_count, x=0, y=0, z=0) + rr = cg.range_read_chunk(root_chunk, time_stamp=time_stamp) + roots = [k for k, v in rr.items() if attributes.Hierarchy.NewParent not in v] + return np.array(roots, dtype=np.uint64) def get_delta_roots( diff --git a/pychunkedgraph/graph/utils/basetypes.py b/pychunkedgraph/graph/utils/basetypes.py index e55324e6a..c6b0b1974 100644 --- a/pychunkedgraph/graph/utils/basetypes.py +++ b/pychunkedgraph/graph/utils/basetypes.py @@ -1,16 +1,16 @@ import numpy as np -CHUNK_ID = SEGMENT_ID = NODE_ID = OPERATION_ID = np.dtype('uint64').newbyteorder('L') -EDGE_AFFINITY = np.dtype('float32').newbyteorder('L') -EDGE_AREA = np.dtype('uint64').newbyteorder('L') +CHUNK_ID = SEGMENT_ID = NODE_ID = OPERATION_ID = np.dtype("uint64").newbyteorder("L") +EDGE_AFFINITY = np.dtype("float32").newbyteorder("L") +EDGE_AREA = np.dtype("uint64").newbyteorder("L") -COUNTER = np.dtype('int64').newbyteorder('B') +COUNTER = np.dtype("int64").newbyteorder("B") -COORDINATES = np.dtype('int64').newbyteorder('L') -CHUNKSIZE = np.dtype('uint64').newbyteorder('L') -FANOUT = np.dtype('uint64').newbyteorder('L') -LAYERCOUNT = np.dtype('uint64').newbyteorder('L') -SPATIALBITS = np.dtype('uint64').newbyteorder('L') -ROOTCOUNTERBITS = np.dtype('uint64').newbyteorder('L') -SKIPCONNECTIONS = np.dtype('uint64').newbyteorder('L') \ No newline at end of file +COORDINATES = np.dtype("int64").newbyteorder("L") +CHUNKSIZE = np.dtype("uint64").newbyteorder("L") +FANOUT = np.dtype("uint64").newbyteorder("L") +LAYERCOUNT = np.dtype("uint64").newbyteorder("L") +SPATIALBITS = np.dtype("uint64").newbyteorder("L") +ROOTCOUNTERBITS = np.dtype("uint64").newbyteorder("L") +SKIPCONNECTIONS = np.dtype("uint64").newbyteorder("L") diff --git a/pychunkedgraph/ingest/create/parent_layer.py b/pychunkedgraph/ingest/create/parent_layer.py index a777d9efc..90b24d26a 100644 --- a/pychunkedgraph/ingest/create/parent_layer.py +++ b/pychunkedgraph/ingest/create/parent_layer.py @@ -164,7 +164,8 @@ def _children_rows( """ rows = [] children_cx_edges = [] - for child in children: + children_layers = cg.get_chunk_layers(children) + for child, node_layer in zip(children, children_layers): node_layer = cg.get_chunk_layer(child) row_id = serializers.serialize_uint64(child) val_dict = {attributes.Hierarchy.Parent: parent_id} diff --git a/pychunkedgraph/tests/helpers.py b/pychunkedgraph/tests/helpers.py index b9c689ad6..551c596bf 100644 --- a/pychunkedgraph/tests/helpers.py +++ b/pychunkedgraph/tests/helpers.py @@ -257,6 +257,7 @@ def create_chunk(cg, vertices=None, edges=None, timestamp=None): cg.get_chunk_coordinates(chunk_id), chunk_edges_active, isolated=isolated_ids, + time_stamp=timestamp, ) diff --git a/pychunkedgraph/tests/test_uncategorized.py b/pychunkedgraph/tests/test_uncategorized.py index 8b26f5c5e..5c2de29d4 100644 --- a/pychunkedgraph/tests/test_uncategorized.py +++ b/pychunkedgraph/tests/test_uncategorized.py @@ -1,20 +1,10 @@ -import collections -import os -import subprocess -import sys from time import sleep -from datetime import datetime, timedelta -from functools import partial +from datetime import datetime, timedelta, UTC from math import inf -from signal import SIGTERM -from unittest import mock from warnings import warn import numpy as np import pytest -from google.auth import credentials -from google.cloud import bigtable -from grpc._channel import _Rendezvous from .helpers import ( bigtable_emulator, @@ -24,13 +14,14 @@ to_label, sv_data, ) +from ..graph import ChunkedGraph from ..graph import types from ..graph import attributes from ..graph import exceptions -from ..graph import chunkedgraph from ..graph.edges import Edges from ..graph.utils import basetypes -from ..graph.misc import get_delta_roots +from ..graph.lineage import lineage_graph +from ..graph.misc import get_delta_roots, get_latest_roots from ..graph.cutting import run_multicut from ..graph.lineage import get_root_id_history from ..graph.lineage import get_future_root_ids @@ -452,7 +443,7 @@ def test_double_chunk_creation(self, gen_graph): cg = gen_graph(n_layers=4, atomic_chunk_bounds=atomic_chunk_bounds) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -775,7 +766,7 @@ def test_merge_pair_same_chunk(self, gen_graph): cg = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], @@ -815,7 +806,7 @@ def test_merge_pair_neighboring_chunks(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -871,7 +862,7 @@ def test_merge_pair_disconnected_chunks(self, gen_graph): cg = gen_graph(n_layers=5) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -955,7 +946,7 @@ def test_merge_pair_already_connected(self, gen_graph): cg = gen_graph(n_layers=2) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], @@ -996,7 +987,7 @@ def test_merge_triple_chain_to_full_circle_same_chunk(self, gen_graph): cg = gen_graph(n_layers=2) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[ @@ -1033,7 +1024,7 @@ def test_merge_triple_chain_to_full_circle_neighboring_chunks(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], @@ -1082,7 +1073,7 @@ def test_merge_triple_chain_to_full_circle_disconnected_chunks(self, gen_graph): cg = gen_graph(n_layers=5) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], @@ -1181,7 +1172,7 @@ def test_merge_same_node(self, gen_graph): cg = gen_graph(n_layers=2) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -1223,7 +1214,7 @@ def test_merge_pair_abstract_nodes(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -1352,7 +1343,7 @@ def test_cross_edges(self, gen_graph): cg = gen_graph(n_layers=5) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[ @@ -1466,81 +1457,72 @@ def test_multiple_cuts_and_splits(self, gen_graph_simplequerytest): child_ids = np.concatenate(child_ids) for i in range(10): - - print(f"\n\nITERATION {i}/10") - print("\n\nMERGE 1 & 3\n\n") + print(f"\n\nITERATION {i}/10 - MERGE 1 & 3") new_roots = cg.add_edges( "Jane Doe", [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], affinities=0.9, ).new_root_ids - assert len(new_roots) == 1 + assert len(new_roots) == 1, new_roots assert len(cg.get_subgraph([new_roots[0]], leaves_only=True)) == 4 - root_ids = [] - for child_id in child_ids: - root_ids.append(cg.get_root(child_id)) - + root_ids = cg.get_roots(child_ids, assert_roots=True) + print(child_ids) + print(list(root_ids)) u_root_ids = np.unique(root_ids) - assert len(u_root_ids) == 1 + assert len(u_root_ids) == 1, u_root_ids # ------------------------------------------------------------------ + print(f"\n\nITERATION {i}/10 - SPLIT 2 & 3") new_roots = cg.remove_edges( "John Doe", source_ids=to_label(cg, 1, 1, 0, 0, 0), sink_ids=to_label(cg, 1, 1, 0, 0, 1), mincut=False, ).new_root_ids + assert len(new_roots) == 2, new_roots - assert len(np.unique(new_roots)) == 2 - - root_ids = [] - for child_id in child_ids: - root_ids.append(cg.get_root(child_id)) - + root_ids = cg.get_roots(child_ids, assert_roots=True) + print(child_ids) + print(list(root_ids)) u_root_ids = np.unique(root_ids) these_child_ids = [] for root_id in u_root_ids: these_child_ids.extend(cg.get_subgraph([root_id], leaves_only=True)) assert len(these_child_ids) == 4 - assert len(u_root_ids) == 2 + assert len(u_root_ids) == 2, u_root_ids # ------------------------------------------------------------------ - + print(f"\n\nITERATION {i}/10 - SPLIT 1 & 3") new_roots = cg.remove_edges( "Jane Doe", source_ids=to_label(cg, 1, 0, 0, 0, 0), sink_ids=to_label(cg, 1, 1, 0, 0, 1), mincut=False, ).new_root_ids - assert len(new_roots) == 2 - - root_ids = [] - for child_id in child_ids: - root_ids.append(cg.get_root(child_id)) + assert len(new_roots) == 2, new_roots + root_ids = cg.get_roots(child_ids, assert_roots=True) + print(child_ids) + print(list(root_ids)) u_root_ids = np.unique(root_ids) - assert len(u_root_ids) == 3 + assert len(u_root_ids) == 3, u_root_ids # ------------------------------------------------------------------ - - print(f"\n\nITERATION {i}/10") - print("\n\nMERGE 2 & 3\n\n") - + print(f"\n\nITERATION {i}/10 - MERGE 2 & 3") new_roots = cg.add_edges( "Jane Doe", [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], affinities=0.9, ).new_root_ids - assert len(new_roots) == 1 - - root_ids = [] - for child_id in child_ids: - root_ids.append(cg.get_root(child_id)) + assert len(new_roots) == 1, new_roots + root_ids = cg.get_roots(child_ids, assert_roots=True) + print(child_ids) + print(list(root_ids)) u_root_ids = np.unique(root_ids) - assert len(u_root_ids) == 2 + assert len(u_root_ids) == 2, u_root_ids # for root_id in root_ids: # cross_edge_dict_layers = graph_tests.root_cross_edge_test( @@ -1575,7 +1557,7 @@ def test_cut_regular_link(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -1614,7 +1596,7 @@ def test_cut_regular_link(self, gen_graph): disallow_isolating_cut=True, ).new_root_ids - # Check New State + # verify new state assert len(new_root_ids) == 2 assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( to_label(cg, 1, 1, 0, 0, 0) @@ -1646,7 +1628,7 @@ def test_cut_no_link(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -1707,7 +1689,7 @@ def test_cut_old_link(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -1775,7 +1757,7 @@ def test_cut_indivisible_link(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], @@ -1837,7 +1819,7 @@ def test_mincut_disrespects_sources_or_sinks(self, gen_graph): """ cg = gen_graph(n_layers=2) - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[ @@ -1877,13 +1859,11 @@ def test_path_augmented_multicut(self, sv_data): edges = Edges( sv_edges[:, 0], sv_edges[:, 1], affinities=sv_affinity, areas=sv_area ) - cut_edges_aug = run_multicut(edges, sv_sources, sv_sinks, path_augment=True) assert cut_edges_aug.shape[0] == 350 with pytest.raises(exceptions.PreconditionError): run_multicut(edges, sv_sources, sv_sinks, path_augment=False) - pass class TestGraphHistory: @@ -1901,20 +1881,14 @@ def test_cut_merge_history(self, gen_graph): (1) Split 1 and 2 (2) Merge 1 and 2 """ - from ..graph.lineage import lineage_graph - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], timestamp=fake_timestamp, ) - - # Preparation: Build Chunk B create_chunk( cg, vertices=[to_label(cg, 1, 1, 0, 0, 0)], @@ -1932,7 +1906,7 @@ def test_cut_merge_history(self, gen_graph): first_root = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) assert first_root == cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) - timestamp_before_split = datetime.utcnow() + timestamp_before_split = datetime.now(UTC) split_roots = cg.remove_edges( "Jane Doe", source_ids=to_label(cg, 1, 0, 0, 0, 0), @@ -1945,7 +1919,7 @@ def test_cut_merge_history(self, gen_graph): g = lineage_graph(cg, split_roots) assert g.size() == 2 - timestamp_after_split = datetime.utcnow() + timestamp_after_split = datetime.now(UTC) merge_roots = cg.add_edges( "Jane Doe", [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], @@ -1953,7 +1927,7 @@ def test_cut_merge_history(self, gen_graph): ).new_root_ids assert len(merge_roots) == 1 merge_root = merge_roots[0] - timestamp_after_merge = datetime.utcnow() + timestamp_after_merge = datetime.now(UTC) g = lineage_graph(cg, merge_roots) assert g.size() == 4 @@ -2047,7 +2021,7 @@ def test_lock_unlock(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -2113,7 +2087,7 @@ def test_lock_expiration(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -2181,7 +2155,7 @@ def test_lock_renew(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -2233,7 +2207,7 @@ def test_lock_merge_lock_old_id(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -2299,7 +2273,7 @@ def test_indefinite_lock(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -2372,7 +2346,7 @@ def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): cg = gen_graph(n_layers=3) # Preparation: Build Chunk A - fake_timestamp = datetime.utcnow() - timedelta(days=10) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( cg, vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -2451,7 +2425,7 @@ def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): # cg = gen_graph(n_layers=3) # # Preparation: Build Chunk A - # fake_timestamp = datetime.utcnow() - timedelta(days=10) + # fake_timestamp = datetime.now(UTC) - timedelta(days=10) # create_chunk( # cg, # vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], @@ -2467,7 +2441,7 @@ def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): # timestamp=fake_timestamp, # ) - # add_layer( + # add_parent_chunk( # cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, # ) @@ -2491,1054 +2465,951 @@ def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): # )[0] -# class MockChunkedGraph: -# """ -# Dummy class to mock partial functionality of the ChunkedGraph for use in unit tests. -# Feel free to add more functions as need be. Can pass in alternative member functions into constructor. -# """ - -# def __init__( -# self, get_chunk_coordinates=None, get_chunk_layer=None, get_chunk_id=None -# ): -# if get_chunk_coordinates is not None: -# self.get_chunk_coordinates = get_chunk_coordinates -# if get_chunk_layer is not None: -# self.get_chunk_layer = get_chunk_layer -# if get_chunk_id is not None: -# self.get_chunk_id = get_chunk_id - -# def get_chunk_coordinates(self, chunk_id): # pylint: disable=method-hidden -# return np.array([0, 0, 0]) - -# def get_chunk_layer(self, chunk_id): # pylint: disable=method-hidden -# return 2 - -# def get_chunk_id(self, *args): # pylint: disable=method-hidden -# return 0 - - -# class TestGraphSplit: -# @pytest.mark.timeout(30) -# def test_split_pair_same_chunk(self, gen_graph): -# """ -# Remove edge between existing RG supervoxels 1 and 2 (same chunk) -# Expected: Different (new) parents for RG 1 and 2 on Layer two -# ┌─────┐ ┌─────┐ -# │ A¹ │ │ A¹ │ -# │ 1━2 │ => │ 1 2 │ -# │ │ │ │ -# └─────┘ └─────┘ -# """ - -# cg = gen_graph(n_layers=2) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], -# edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], -# timestamp=fake_timestamp, -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 0, 0, 0, 1), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ).new_root_ids - -# # Check New State -# assert len(new_root_ids) == 2 -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( -# to_label(cg, 1, 0, 0, 0, 1) -# ) -# leaves = np.unique( -# cg.get_subgraph([cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True) -# ) -# assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves -# leaves = np.unique( -# cg.get_subgraph([cg.get_root(to_label(cg, 1, 0, 0, 0, 1))], leaves_only=True) -# ) -# assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 1) in leaves - -# # Check Old State still accessible -# assert cg.get_root( -# to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp -# ) == cg.get_root(to_label(cg, 1, 0, 0, 0, 1), time_stamp=fake_timestamp) -# leaves = np.unique( -# cg.get_subgraph( -# [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], -# leaves_only=True, -# ) -# ) -# assert len(leaves) == 2 -# assert to_label(cg, 1, 0, 0, 0, 0) in leaves -# assert to_label(cg, 1, 0, 0, 0, 1) in leaves - -# # assert len(cg.get_latest_roots()) == 2 -# # assert len(cg.get_latest_roots(fake_timestamp)) == 1 - -# def test_split_nonexisting_edge(self, gen_graph): -# """ -# Remove edge between existing RG supervoxels 1 and 2 (same chunk) -# Expected: Different (new) parents for RG 1 and 2 on Layer two -# ┌─────┐ ┌─────┐ -# │ A¹ │ │ A¹ │ -# │ 1━2 │ => │ 1━2 │ -# │ | │ │ | │ -# │ 3 │ │ 3 │ -# └─────┘ └─────┘ -# """ - -# cg = gen_graph(n_layers=2) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], -# edges=[ -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), -# (to_label(cg, 1, 0, 0, 0, 2), to_label(cg, 1, 0, 0, 0, 1), 0.5), -# ], -# timestamp=fake_timestamp, -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 0, 0, 0, 0), -# sink_ids=to_label(cg, 1, 0, 0, 0, 2), -# mincut=False, -# ).new_root_ids - -# assert len(new_root_ids) == 1 - -# @pytest.mark.timeout(30) -# def test_split_pair_neighboring_chunks(self, gen_graph): -# """ -# Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) -# ┌─────┬─────┐ ┌─────┬─────┐ -# │ A¹ │ B¹ │ │ A¹ │ B¹ │ -# │ 1━━┿━━2 │ => │ 1 │ 2 │ -# │ │ │ │ │ │ -# └─────┴─────┘ └─────┴─────┘ -# """ - -# cg = gen_graph(n_layers=3) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0)], -# edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 1.0)], -# timestamp=fake_timestamp, -# ) - -# # Preparation: Build Chunk B -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 1, 0, 0, 0)], -# edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 1.0)], -# timestamp=fake_timestamp, -# ) - -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 1, 0, 0, 0), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ).new_root_ids - -# # Check New State -# assert len(new_root_ids) == 2 -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( -# to_label(cg, 1, 1, 0, 0, 0) -# ) -# leaves = np.unique( -# cg.get_subgraph([cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True) -# ) -# assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves -# leaves = np.unique( -# cg.get_subgraph([cg.get_root(to_label(cg, 1, 1, 0, 0, 0))], leaves_only=True) -# ) -# assert len(leaves) == 1 and to_label(cg, 1, 1, 0, 0, 0) in leaves - -# # Check Old State still accessible -# assert cg.get_root( -# to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp -# ) == cg.get_root(to_label(cg, 1, 1, 0, 0, 0), time_stamp=fake_timestamp) -# leaves = np.unique( -# cg.get_subgraph( -# [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], -# leaves_only=True, -# ) -# ) -# assert len(leaves) == 2 -# assert to_label(cg, 1, 0, 0, 0, 0) in leaves -# assert to_label(cg, 1, 1, 0, 0, 0) in leaves - -# assert len(cg.get_latest_roots()) == 2 -# assert len(cg.get_latest_roots(fake_timestamp)) == 1 - -# @pytest.mark.timeout(30) -# def test_split_verify_cross_chunk_edges(self, gen_graph): -# """ -# Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) -# ┌─────┬─────┬─────┐ ┌─────┬─────┬─────┐ -# | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ -# | │ 1━━┿━━3 │ => | │ 1━━┿━━3 │ -# | │ | │ │ | │ │ │ -# | │ 2 │ │ | │ 2 │ │ -# └─────┴─────┴─────┘ └─────┴─────┴─────┘ -# """ - -# cg = gen_graph(n_layers=4) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], -# edges=[ -# (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), -# (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1), 0.5), -# ], -# timestamp=fake_timestamp, -# ) - -# # Preparation: Build Chunk B -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 2, 0, 0, 0)], -# edges=[(to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], -# timestamp=fake_timestamp, -# ) - -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 4, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) - -# assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( -# to_label(cg, 1, 1, 0, 0, 1) -# ) -# assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( -# to_label(cg, 1, 2, 0, 0, 0) -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 1, 0, 0, 0), -# sink_ids=to_label(cg, 1, 1, 0, 0, 1), -# mincut=False, -# ).new_root_ids - -# assert len(new_root_ids) == 2 - -# svs2 = cg.get_subgraph([new_root_ids[0]], leaves_only=True) -# svs1 = cg.get_subgraph([new_root_ids[1]], leaves_only=True) -# len_set = {1, 2} -# assert len(svs1) in len_set -# len_set.remove(len(svs1)) -# assert len(svs2) in len_set - -# # Check New State -# assert len(new_root_ids) == 2 -# assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) != cg.get_root( -# to_label(cg, 1, 1, 0, 0, 1) -# ) -# assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( -# to_label(cg, 1, 2, 0, 0, 0) -# ) - -# cc_dict = cg.get_atomic_cross_edges( -# cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) -# ) -# assert len(cc_dict[3]) == 1 -# assert cc_dict[3][0][0] == to_label(cg, 1, 1, 0, 0, 0) -# assert cc_dict[3][0][1] == to_label(cg, 1, 2, 0, 0, 0) - -# assert len(cg.get_latest_roots()) == 2 -# assert len(cg.get_latest_roots(fake_timestamp)) == 1 - -# @pytest.mark.timeout(30) -# def test_split_verify_loop(self, gen_graph): -# """ -# Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) -# ┌─────┬────────┬─────┐ ┌─────┬────────┬─────┐ -# | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ -# | │ 4━━1━━┿━━5 │ => | │ 4 1━━┿━━5 │ -# | │ / │ | │ | │ │ | │ -# | │ 3 2━━┿━━6 │ | │ 3 2━━┿━━6 │ -# └─────┴────────┴─────┘ └─────┴────────┴─────┘ -# """ - -# cg = gen_graph(n_layers=4) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[ -# to_label(cg, 1, 1, 0, 0, 0), -# to_label(cg, 1, 1, 0, 0, 1), -# to_label(cg, 1, 1, 0, 0, 2), -# to_label(cg, 1, 1, 0, 0, 3), -# ], -# edges=[ -# (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), -# (to_label(cg, 1, 1, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 1), inf), -# (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 2), 0.5), -# (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 3), 0.5), -# ], -# timestamp=fake_timestamp, -# ) - -# # Preparation: Build Chunk B -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 1)], -# edges=[ -# (to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), -# (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 1), inf), -# (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 0), 0.5), -# ], -# timestamp=fake_timestamp, -# ) - -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 4, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) - -# assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( -# to_label(cg, 1, 1, 0, 0, 1) -# ) -# assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( -# to_label(cg, 1, 2, 0, 0, 0) -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 1, 0, 0, 0), -# sink_ids=to_label(cg, 1, 1, 0, 0, 2), -# mincut=False, -# ).new_root_ids - -# assert len(new_root_ids) == 2 - -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 1, 0, 0, 0), -# sink_ids=to_label(cg, 1, 1, 0, 0, 3), -# mincut=False, -# ).new_root_ids - -# assert len(new_root_ids) == 2 - -# cc_dict = cg.get_atomic_cross_edges( -# cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) -# ) -# assert len(cc_dict[3]) == 1 -# cc_dict = cg.get_atomic_cross_edges( -# cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) -# ) -# assert len(cc_dict[3]) == 1 - -# assert len(cg.get_latest_roots()) == 3 -# assert len(cg.get_latest_roots(fake_timestamp)) == 1 - -# @pytest.mark.timeout(30) -# def test_split_pair_disconnected_chunks(self, gen_graph): -# """ -# Remove edge between existing RG supervoxels 1 and 2 (disconnected chunks) -# ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ -# │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ -# │ 1━━┿━━━━━┿━━2 │ => │ 1 │ │ 2 │ -# │ │ │ │ │ │ │ │ -# └─────┘ └─────┘ └─────┘ └─────┘ -# """ - -# cg = gen_graph(n_layers=9) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0)], -# edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 7, 7, 7, 0), 1.0,)], -# timestamp=fake_timestamp, -# ) - -# # Preparation: Build Chunk Z -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 7, 7, 7, 0)], -# edges=[(to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 0), 1.0,)], -# timestamp=fake_timestamp, -# ) - -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 4, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 4, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 5, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 5, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 6, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 6, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 7, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 7, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 8, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 8, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# 9, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) - -# # Split -# new_roots = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 7, 7, 7, 0), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ).new_root_ids - -# # Check New State -# assert len(new_roots) == 2 -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( -# to_label(cg, 1, 7, 7, 7, 0) -# ) -# leaves = np.unique( -# cg.get_subgraph([cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True) -# ) -# assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves -# leaves = np.unique( -# cg.get_subgraph([cg.get_root(to_label(cg, 1, 7, 7, 7, 0))], leaves_only=True) -# ) -# assert len(leaves) == 1 and to_label(cg, 1, 7, 7, 7, 0) in leaves - -# # Check Old State still accessible -# assert cg.get_root( -# to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp -# ) == cg.get_root(to_label(cg, 1, 7, 7, 7, 0), time_stamp=fake_timestamp) -# leaves = np.unique( -# cg.get_subgraph( -# [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], -# leaves_only=True, -# ) -# ) -# assert len(leaves) == 2 -# assert to_label(cg, 1, 0, 0, 0, 0) in leaves -# assert to_label(cg, 1, 7, 7, 7, 0) in leaves - -# @pytest.mark.timeout(30) -# def test_split_pair_already_disconnected(self, gen_graph): -# """ -# Try to remove edge between already disconnected RG supervoxels 1 and 2 (same chunk). -# Expected: No change, no error -# ┌─────┐ ┌─────┐ -# │ A¹ │ │ A¹ │ -# │ 1 2 │ => │ 1 2 │ -# │ │ │ │ -# └─────┘ └─────┘ -# """ - -# cg = gen_graph(n_layers=2) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], -# edges=[], -# timestamp=fake_timestamp, -# ) - -# res_old = cg.client._table.read_rows() -# res_old.consume_all() - -# # Split -# with pytest.raises(exceptions.PreconditionError): -# cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 0, 0, 0, 1), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ) - -# res_new = cg.client._table.read_rows() -# res_new.consume_all() - -# # Check -# if res_old.rows != res_new.rows: -# warn( -# "Rows were modified when splitting a pair of already disconnected supervoxels. " -# "While probably not an error, it is an unnecessary operation." -# ) - -# @pytest.mark.timeout(30) -# def test_split_full_circle_to_triple_chain_same_chunk(self, gen_graph): -# """ -# Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (same chunk) -# ┌─────┐ ┌─────┐ -# │ A¹ │ │ A¹ │ -# │ 1━2 │ => │ 1 2 │ -# │ ┗3┛ │ │ ┗3┛ │ -# └─────┘ └─────┘ -# """ - -# cg = gen_graph(n_layers=2) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[ -# to_label(cg, 1, 0, 0, 0, 0), -# to_label(cg, 1, 0, 0, 0, 1), -# to_label(cg, 1, 0, 0, 0, 2), -# ], -# edges=[ -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 0.5), -# (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 0.5), -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.3), -# ], -# timestamp=fake_timestamp, -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 0, 0, 0, 1), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ).new_root_ids - -# # Check New State -# assert len(new_root_ids) == 1 -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 2)) == new_root_ids[0] -# leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) -# assert len(leaves) == 3 -# assert to_label(cg, 1, 0, 0, 0, 0) in leaves -# assert to_label(cg, 1, 0, 0, 0, 1) in leaves -# assert to_label(cg, 1, 0, 0, 0, 2) in leaves - -# # Check Old State still accessible -# old_root_id = cg.get_root( -# to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp -# ) -# assert new_root_ids[0] != old_root_id - -# # assert len(cg.get_latest_roots()) == 1 -# # assert len(cg.get_latest_roots(fake_timestamp)) == 1 - -# @pytest.mark.timeout(30) -# def test_split_full_circle_to_triple_chain_neighboring_chunks(self, gen_graph): -# """ -# Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (neighboring chunks) -# ┌─────┬─────┐ ┌─────┬─────┐ -# │ A¹ │ B¹ │ │ A¹ │ B¹ │ -# │ 1━━┿━━2 │ => │ 1 │ 2 │ -# │ ┗3━┿━━┛ │ │ ┗3━┿━━┛ │ -# └─────┴─────┘ └─────┴─────┘ -# """ - -# cg = gen_graph(n_layers=3) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], -# edges=[ -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), -# (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 0), 0.5), -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.3), -# ], -# timestamp=fake_timestamp, -# ) - -# # Preparation: Build Chunk B -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 1, 0, 0, 0)], -# edges=[ -# (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), -# (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.3), -# ], -# timestamp=fake_timestamp, -# ) - -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 1, 0, 0, 0), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ).new_root_ids - -# # Check New State -# assert len(new_root_ids) == 1 -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] -# assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == new_root_ids[0] -# leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) -# assert len(leaves) == 3 -# assert to_label(cg, 1, 0, 0, 0, 0) in leaves -# assert to_label(cg, 1, 0, 0, 0, 1) in leaves -# assert to_label(cg, 1, 1, 0, 0, 0) in leaves - -# # Check Old State still accessible -# old_root_id = cg.get_root( -# to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp -# ) -# assert new_root_ids[0] != old_root_id - -# assert len(cg.get_latest_roots()) == 1 -# assert len(cg.get_latest_roots(fake_timestamp)) == 1 - -# @pytest.mark.timeout(30) -# def test_split_full_circle_to_triple_chain_disconnected_chunks(self, gen_graph): -# """ -# Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (disconnected chunks) -# ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ -# │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ -# │ 1━━┿━━━━━┿━━2 │ => │ 1 │ │ 2 │ -# │ ┗3━┿━━━━━┿━━┛ │ │ ┗3━┿━━━━━┿━━┛ │ -# └─────┘ └─────┘ └─────┘ └─────┘ -# """ - -# cg = gen_graph(n_layers=9) - -# loc = 2 - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], -# edges=[ -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), -# (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, loc, loc, loc, 0), 0.5,), -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, loc, loc, loc, 0), 0.3,), -# ], -# timestamp=fake_timestamp, -# ) - -# # Preparation: Build Chunk Z -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, loc, loc, loc, 0)], -# edges=[ -# (to_label(cg, 1, loc, loc, loc, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5,), -# (to_label(cg, 1, loc, loc, loc, 0), to_label(cg, 1, 0, 0, 0, 0), 0.3,), -# ], -# timestamp=fake_timestamp, -# ) - -# for i_layer in range(3, 10): -# if loc // 2 ** (i_layer - 3) == 1: -# add_layer( -# cg, -# i_layer, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# elif loc // 2 ** (i_layer - 3) == 0: -# add_layer( -# cg, -# i_layer, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# else: -# add_layer( -# cg, -# i_layer, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) -# add_layer( -# cg, -# i_layer, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) - -# assert ( -# cg.get_root(to_label(cg, 1, loc, loc, loc, 0)) -# == cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) -# == cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) -# ) - -# # Split -# new_root_ids = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, loc, loc, loc, 0), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ).new_root_ids - -# # Check New State -# assert len(new_root_ids) == 1 -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] -# assert cg.get_root(to_label(cg, 1, loc, loc, loc, 0)) == new_root_ids[0] -# leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) -# assert len(leaves) == 3 -# assert to_label(cg, 1, 0, 0, 0, 0) in leaves -# assert to_label(cg, 1, 0, 0, 0, 1) in leaves -# assert to_label(cg, 1, loc, loc, loc, 0) in leaves - -# # Check Old State still accessible -# old_root_id = cg.get_root( -# to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp -# ) -# assert new_root_ids[0] != old_root_id - -# assert len(cg.get_latest_roots()) == 1 -# assert len(cg.get_latest_roots(fake_timestamp)) == 1 - -# @pytest.mark.timeout(30) -# def test_split_same_node(self, gen_graph): -# """ -# Try to remove (non-existing) edge between RG supervoxel 1 and itself -# ┌─────┐ -# │ A¹ │ -# │ 1 │ => Reject -# │ │ -# └─────┘ -# """ - -# cg = gen_graph(n_layers=2) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0)], -# edges=[], -# timestamp=fake_timestamp, -# ) - -# res_old = cg.client._table.read_rows() -# res_old.consume_all() - -# # Split -# with pytest.raises(exceptions.PreconditionError): -# cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 0, 0, 0, 0), -# sink_ids=to_label(cg, 1, 0, 0, 0, 0), -# mincut=False, -# ) - -# res_new = cg.client._table.read_rows() -# res_new.consume_all() - -# assert res_new.rows == res_old.rows - -# @pytest.mark.timeout(30) -# def test_split_pair_abstract_nodes(self, gen_graph): -# """ -# Try to remove (non-existing) edge between RG supervoxel 1 and abstract node "2" -# ┌─────┐ -# │ B² │ -# │ "2" │ -# │ │ -# └─────┘ -# ┌─────┐ => Reject -# │ A¹ │ -# │ 1 │ -# │ │ -# └─────┘ -# """ - -# cg = gen_graph(n_layers=3) - -# # Preparation: Build Chunk A -# fake_timestamp = datetime.utcnow() - timedelta(days=10) -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0)], -# edges=[], -# timestamp=fake_timestamp, -# ) - -# # Preparation: Build Chunk B -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 1, 0, 0, 0)], -# edges=[], -# timestamp=fake_timestamp, -# ) - -# add_layer( -# cg, -# 3, -# [0, 0, 0], -# -# time_stamp=fake_timestamp, -# n_threads=1, -# ) - -# res_old = cg.client._table.read_rows() -# res_old.consume_all() - -# # Split -# with pytest.raises(exceptions.PreconditionError): -# cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 0, 0, 0, 0), -# sink_ids=to_label(cg, 2, 1, 0, 0, 1), -# mincut=False, -# ) - -# res_new = cg.client._table.read_rows() -# res_new.consume_all() - -# assert res_new.rows == res_old.rows - -# @pytest.mark.timeout(30) -# def test_diagonal_connections(self, gen_graph): -# """ -# Create graph with edge between RG supervoxels 1 and 2 (same chunk) -# and edge between RG supervoxels 1 and 3 (neighboring chunks) -# ┌─────┬─────┐ -# │ A¹ │ B¹ │ -# │ 2━1━┿━━3 │ -# │ / │ │ -# ┌─────┬─────┐ -# │ | │ │ -# │ 4━━┿━━5 │ -# │ C¹ │ D¹ │ -# └─────┴─────┘ -# """ - -# cg = gen_graph(n_layers=3) - -# # Chunk A -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], -# edges=[ -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), -# (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf), -# ], -# ) - -# # Chunk B -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 1, 0, 0, 0)], -# edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], -# ) - -# # Chunk C -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 0, 1, 0, 0)], -# edges=[ -# (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 1, 1, 0, 0), inf), -# (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), -# ], -# ) - -# # Chunk D -# create_chunk( -# cg, -# vertices=[to_label(cg, 1, 1, 1, 0, 0)], -# edges=[(to_label(cg, 1, 1, 1, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf)], -# ) - -# add_layer( -# cg, 3, [0, 0, 0], n_threads=1, -# ) - -# rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=3, x=0, y=0, z=0)) -# root_ids_t0 = list(rr.keys()) - -# assert len(root_ids_t0) == 1 - -# child_ids = [] -# for root_id in root_ids_t0: -# child_ids.extend([cg.get_subgraph([root_id])], leaves_only=True) - -# new_roots = cg.remove_edges( -# "Jane Doe", -# source_ids=to_label(cg, 1, 0, 0, 0, 0), -# sink_ids=to_label(cg, 1, 0, 0, 0, 1), -# mincut=False, -# ).new_root_ids - -# assert len(new_roots) == 2 -# assert cg.get_root(to_label(cg, 1, 1, 1, 0, 0)) == cg.get_root( -# to_label(cg, 1, 0, 1, 0, 0) -# ) -# assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == cg.get_root( -# to_label(cg, 1, 0, 0, 0, 0) -# ) +class TestGraphSplit: + @pytest.mark.timeout(30) + def test_split_pair_same_chunk(self, gen_graph): + """ + Remove edge between existing RG supervoxels 1 and 2 (same chunk) + Expected: Different (new) parents for RG 1 and 2 on Layer two + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1━2 │ => │ 1 2 │ + │ │ │ │ + └─────┘ └─────┘ + """ + + cg: ChunkedGraph = gen_graph(n_layers=2) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], + timestamp=fake_timestamp, + ) + + # Split + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 1), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 2 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( + to_label(cg, 1, 0, 0, 0, 1) + ) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 1))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 1) in leaves + + # verify old state + cg.cache = None + assert cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) == cg.get_root(to_label(cg, 1, 0, 0, 0, 1), time_stamp=fake_timestamp) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], + leaves_only=True, + ) + ) + assert len(leaves) == 2 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 0, 0, 0, 1) in leaves + + assert len(get_latest_roots(cg)) == 2 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + def test_split_nonexisting_edge(self, gen_graph): + """ + Remove edge between existing RG supervoxels 1 and 2 (same chunk) + Expected: Different (new) parents for RG 1 and 2 on Layer two + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1━2 │ => │ 1━2 │ + │ | │ │ | │ + │ 3 │ │ 3 │ + └─────┘ └─────┘ + """ + cg = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 2), to_label(cg, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_timestamp, + ) + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 2), + mincut=False, + ).new_root_ids + assert len(new_root_ids) == 1 + + @pytest.mark.timeout(30) + def test_split_pair_neighboring_chunks(self, gen_graph): + """ + Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) + ┌─────┬─────┐ ┌─────┬─────┐ + │ A¹ │ B¹ │ │ A¹ │ B¹ │ + │ 1━━┿━━2 │ => │ 1 │ 2 │ + │ │ │ │ │ │ + └─────┴─────┘ └─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 1.0)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 1.0)], + timestamp=fake_timestamp, + ) + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 2 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( + to_label(cg, 1, 1, 0, 0, 0) + ) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 1, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 1, 0, 0, 0) in leaves + + # verify old state + assert cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) == cg.get_root(to_label(cg, 1, 1, 0, 0, 0), time_stamp=fake_timestamp) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], + leaves_only=True, + ) + ) + assert len(leaves) == 2 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 1, 0, 0, 0) in leaves + assert len(get_latest_roots(cg)) == 2 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_verify_cross_chunk_edges(self, gen_graph): + """ + Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) + ┌─────┬─────┬─────┐ ┌─────┬─────┬─────┐ + | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ + | │ 1━━┿━━3 │ => | │ 1━━┿━━3 │ + | │ | │ │ | │ │ │ + | │ 2 │ │ | │ 2 │ │ + └─────┴─────┴─────┘ └─────┴─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1), 0.5), + ], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0)], + edges=[(to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + add_parent_chunk( + cg, + 3, + [1, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + add_parent_chunk( + cg, + 4, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 1, 0, 0, 1) + ) + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 2, 0, 0, 0) + ) + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 1), + mincut=False, + ).new_root_ids + + assert len(new_root_ids) == 2 + + svs2 = cg.get_subgraph([new_root_ids[0]], leaves_only=True) + svs1 = cg.get_subgraph([new_root_ids[1]], leaves_only=True) + len_set = {1, 2} + assert len(svs1) in len_set + len_set.remove(len(svs1)) + assert len(svs2) in len_set + + # verify new state + assert len(new_root_ids) == 2 + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) != cg.get_root( + to_label(cg, 1, 1, 0, 0, 1) + ) + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 2, 0, 0, 0) + ) + + # l2id = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + # cce = cg.get_atomic_cross_edges([l2id])[l2id] + # assert len(cce[3]) == 1 + # assert cce[3][0][0] == to_label(cg, 1, 1, 0, 0, 0) + # assert cce[3][0][1] == to_label(cg, 1, 2, 0, 0, 0) + + assert len(get_latest_roots(cg)) == 2 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_verify_loop(self, gen_graph): + """ + Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) + ┌─────┬────────┬─────┐ ┌─────┬────────┬─────┐ + | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ + | │ 4━━1━━┿━━5 │ => | │ 4 1━━┿━━5 │ + | │ / │ | │ | │ │ | │ + | │ 3 2━━┿━━6 │ | │ 3 2━━┿━━6 │ + └─────┴────────┴─────┘ └─────┴────────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[ + to_label(cg, 1, 1, 0, 0, 0), + to_label(cg, 1, 1, 0, 0, 1), + to_label(cg, 1, 1, 0, 0, 2), + to_label(cg, 1, 1, 0, 0, 3), + ], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), + (to_label(cg, 1, 1, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 1), inf), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 2), 0.5), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 3), 0.5), + ], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 1), inf), + (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 0), 0.5), + ], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + add_parent_chunk( + cg, + 3, + [1, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + add_parent_chunk( + cg, + 4, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 1, 0, 0, 1) + ) + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 2, 0, 0, 0) + ) + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 2), + mincut=False, + ).new_root_ids + assert len(new_root_ids) == 2 + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 3), + mincut=False, + ).new_root_ids + assert len(new_root_ids) == 2 + + # l2id = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + # cce = cg.get_atomic_cross_edges([l2id]) + # assert len(cce[3]) == 1 + + assert len(get_latest_roots(cg)) == 3 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + # @pytest.mark.timeout(30) + # def test_split_pair_disconnected_chunks(self, gen_graph): + # """ + # Remove edge between existing RG supervoxels 1 and 2 (disconnected chunks) + # ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + # │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ + # │ 1━━┿━━━━━┿━━2 │ => │ 1 │ │ 2 │ + # │ │ │ │ │ │ │ │ + # └─────┘ └─────┘ └─────┘ └─────┘ + # """ + # cg: ChunkedGraph = gen_graph(n_layers=9) + # fake_timestamp = datetime.now(UTC) - timedelta(days=10) + # create_chunk( + # cg, + # vertices=[to_label(cg, 1, 0, 0, 0, 0)], + # edges=[ + # ( + # to_label(cg, 1, 0, 0, 0, 0), + # to_label(cg, 1, 7, 7, 7, 0), + # 1.0, + # ) + # ], + # timestamp=fake_timestamp, + # ) + # create_chunk( + # cg, + # vertices=[to_label(cg, 1, 7, 7, 7, 0)], + # edges=[ + # ( + # to_label(cg, 1, 7, 7, 7, 0), + # to_label(cg, 1, 0, 0, 0, 0), + # 1.0, + # ) + # ], + # timestamp=fake_timestamp, + # ) + + # add_parent_chunk( + # cg, + # 3, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 3, + # [1, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 4, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 4, + # [1, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 5, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 5, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 6, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 6, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 7, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 7, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 8, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 8, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # 9, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + + # new_roots = cg.remove_edges( + # "Jane Doe", + # source_ids=to_label(cg, 1, 7, 7, 7, 0), + # sink_ids=to_label(cg, 1, 0, 0, 0, 0), + # mincut=False, + # ).new_root_ids + + # # verify new state + # assert len(new_roots) == 2 + # assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( + # to_label(cg, 1, 7, 7, 7, 0) + # ) + # leaves = np.unique( + # cg.get_subgraph( + # [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True + # ) + # ) + # assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves + # leaves = np.unique( + # cg.get_subgraph( + # [cg.get_root(to_label(cg, 1, 7, 7, 7, 0))], leaves_only=True + # ) + # ) + # assert len(leaves) == 1 and to_label(cg, 1, 7, 7, 7, 0) in leaves + + # # verify old state + # assert cg.get_root( + # to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + # ) == cg.get_root(to_label(cg, 1, 7, 7, 7, 0), time_stamp=fake_timestamp) + # leaves = np.unique( + # cg.get_subgraph( + # [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], + # leaves_only=True, + # ) + # ) + # assert len(leaves) == 2 + # assert to_label(cg, 1, 0, 0, 0, 0) in leaves + # assert to_label(cg, 1, 7, 7, 7, 0) in leaves + + @pytest.mark.timeout(30) + def test_split_pair_already_disconnected(self, gen_graph): + """ + Try to remove edge between already disconnected RG supervoxels 1 and 2 (same chunk). + Expected: No change, no error + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1 2 │ => │ 1 2 │ + │ │ │ │ + └─────┘ └─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + res_old = cg.client._table.read_rows() + res_old.consume_all() + + with pytest.raises(exceptions.PreconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 1), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + + if res_old.rows != res_new.rows: + warn( + "Rows were modified when splitting a pair of already disconnected supervoxels." + "While probably not an error, it is an unnecessary operation." + ) + + @pytest.mark.timeout(30) + def test_split_full_circle_to_triple_chain_same_chunk(self, gen_graph): + """ + Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (same chunk) + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1━2 │ => │ 1 2 │ + │ ┗3┛ │ │ ┗3┛ │ + └─────┘ └─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[ + to_label(cg, 1, 0, 0, 0, 0), + to_label(cg, 1, 0, 0, 0, 1), + to_label(cg, 1, 0, 0, 0, 2), + ], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 0.5), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.3), + ], + timestamp=fake_timestamp, + ) + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 1), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 1 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 2)) == new_root_ids[0] + leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) + assert len(leaves) == 3 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 0, 0, 0, 1) in leaves + assert to_label(cg, 1, 0, 0, 0, 2) in leaves + + # verify old state + old_root_id = cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) + assert new_root_ids[0] != old_root_id + assert len(get_latest_roots(cg)) == 1 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_full_circle_to_triple_chain_neighboring_chunks(self, gen_graph): + """ + Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (neighboring chunks) + ┌─────┬─────┐ ┌─────┬─────┐ + │ A¹ │ B¹ │ │ A¹ │ B¹ │ + │ 1━━┿━━2 │ => │ 1 │ 2 │ + │ ┗3━┿━━┛ │ │ ┗3━┿━━┛ │ + └─────┴─────┘ └─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 0), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.3), + ], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.3), + ], + timestamp=fake_timestamp, + ) + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 1 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == new_root_ids[0] + leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) + assert len(leaves) == 3 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 0, 0, 0, 1) in leaves + assert to_label(cg, 1, 1, 0, 0, 0) in leaves + + # verify old state + old_root_id = cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) + assert new_root_ids[0] != old_root_id + assert len(get_latest_roots(cg)) == 1 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + # @pytest.mark.timeout(30) + # def test_split_full_circle_to_triple_chain_disconnected_chunks(self, gen_graph): + # """ + # Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (disconnected chunks) + # ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + # │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ + # │ 1━━┿━━━━━┿━━2 │ => │ 1 │ │ 2 │ + # │ ┗3━┿━━━━━┿━━┛ │ │ ┗3━┿━━━━━┿━━┛ │ + # └─────┘ └─────┘ └─────┘ └─────┘ + # """ + # cg: ChunkedGraph = gen_graph(n_layers=9) + # loc = 2 + # fake_timestamp = datetime.now(UTC) - timedelta(days=10) + # create_chunk( + # cg, + # vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + # edges=[ + # (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + # ( + # to_label(cg, 1, 0, 0, 0, 1), + # to_label(cg, 1, loc, loc, loc, 0), + # 0.5, + # ), + # ( + # to_label(cg, 1, 0, 0, 0, 0), + # to_label(cg, 1, loc, loc, loc, 0), + # 0.3, + # ), + # ], + # timestamp=fake_timestamp, + # ) + # create_chunk( + # cg, + # vertices=[to_label(cg, 1, loc, loc, loc, 0)], + # edges=[ + # ( + # to_label(cg, 1, loc, loc, loc, 0), + # to_label(cg, 1, 0, 0, 0, 1), + # 0.5, + # ), + # ( + # to_label(cg, 1, loc, loc, loc, 0), + # to_label(cg, 1, 0, 0, 0, 0), + # 0.3, + # ), + # ], + # timestamp=fake_timestamp, + # ) + # for i_layer in range(3, 10): + # if loc // 2 ** (i_layer - 3) == 1: + # add_parent_chunk( + # cg, + # i_layer, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # elif loc // 2 ** (i_layer - 3) == 0: + # add_parent_chunk( + # cg, + # i_layer, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # else: + # add_parent_chunk( + # cg, + # i_layer, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + # add_parent_chunk( + # cg, + # i_layer, + # [0, 0, 0], + # time_stamp=fake_timestamp, + # n_threads=1, + # ) + + # assert ( + # cg.get_root(to_label(cg, 1, loc, loc, loc, 0)) + # == cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + # == cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) + # ) + # new_root_ids = cg.remove_edges( + # "Jane Doe", + # source_ids=to_label(cg, 1, loc, loc, loc, 0), + # sink_ids=to_label(cg, 1, 0, 0, 0, 0), + # mincut=False, + # ).new_root_ids + + # # verify new state + # assert len(new_root_ids) == 1 + # assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] + # assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] + # assert cg.get_root(to_label(cg, 1, loc, loc, loc, 0)) == new_root_ids[0] + # leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) + # assert len(leaves) == 3 + # assert to_label(cg, 1, 0, 0, 0, 0) in leaves + # assert to_label(cg, 1, 0, 0, 0, 1) in leaves + # assert to_label(cg, 1, loc, loc, loc, 0) in leaves + + # # verify old state + # old_root_id = cg.get_root( + # to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + # ) + # assert new_root_ids[0] != old_root_id + + # assert len(get_latest_roots(cg)) == 1 + # assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_same_node(self, gen_graph): + """ + Try to remove (non-existing) edge between RG supervoxel 1 and itself + ┌─────┐ + │ A¹ │ + │ 1 │ => Reject + │ │ + └─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + res_old = cg.client._table.read_rows() + res_old.consume_all() + with pytest.raises(exceptions.PreconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_split_pair_abstract_nodes(self, gen_graph): + """ + Try to remove (non-existing) edge between RG supervoxel 1 and abstract node "2" + ┌─────┐ + │ B² │ + │ "2" │ + │ │ + └─────┘ + ┌─────┐ => Reject + │ A¹ │ + │ 1 │ + │ │ + └─────┘ + """ + + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + res_old = cg.client._table.read_rows() + res_old.consume_all() + with pytest.raises((exceptions.PreconditionError, AssertionError)): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 2, 1, 0, 0, 1), + mincut=False, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_diagonal_connections(self, gen_graph): + """ + Create graph with edge between RG supervoxels 1 and 2 (same chunk) + and edge between RG supervoxels 1 and 3 (neighboring chunks) + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 2━1━┿━━3 │ + │ / │ │ + ┌─────┬─────┐ + │ | │ │ + │ 4━━┿━━5 │ + │ C¹ │ D¹ │ + └─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=3) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf), + ], + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 1, 0, 0)], + edges=[ + (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 1, 1, 0, 0), inf), + (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), + ], + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 1, 0, 0)], + edges=[(to_label(cg, 1, 1, 1, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf)], + ) + add_parent_chunk( + cg, + 3, + [0, 0, 0], + n_threads=1, + ) + + rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=3, x=0, y=0, z=0)) + root_ids_t0 = list(rr.keys()) + assert len(root_ids_t0) == 1 + + child_ids = [] + for root_id in root_ids_t0: + child_ids.extend([cg.get_subgraph([root_id], leaves_only=True)]) + + new_roots = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 1), + mincut=False, + ).new_root_ids + + assert len(new_roots) == 2 + assert cg.get_root(to_label(cg, 1, 1, 1, 0, 0)) == cg.get_root( + to_label(cg, 1, 0, 1, 0, 0) + ) + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 0, 0, 0, 0) + ) From 2d2096b363f2092f704d594e1b3b99bf35b6118c Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 10 Jun 2024 15:13:15 +0000 Subject: [PATCH 086/367] segregate update nodes logic --- .../graph/client/bigtable/client.py | 8 +++++- pychunkedgraph/ingest/upgrade/atomic_layer.py | 26 +++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 52ec9a856..9195fb397 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -151,6 +151,7 @@ def read_nodes( end_time=None, end_time_inclusive: bool = False, fake_edges: bool = False, + attr_keys: bool = True, ): """ Read nodes and their properties. @@ -186,8 +187,13 @@ def read_nodes( end_time_inclusive=end_time_inclusive, user_id=user_id, ) + if attr_keys: + return { + deserialize_uint64(row_key, fake_edges=fake_edges): data + for (row_key, data) in rows.items() + } return { - deserialize_uint64(row_key, fake_edges=fake_edges): data + deserialize_uint64(row_key, fake_edges=fake_edges): {k.key:v for k,v in data.items()} for (row_key, data) in rows.items() } diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index 96f7f71bd..6c4244968 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -79,17 +79,7 @@ def update_cross_edges(cg: ChunkedGraph, node, cx_edges_d, node_ts, end_ts) -> l return rows -def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], layer: int = 2): - """ - Iterate over all L2 IDs in a chunk and update their cross chunk edges, - within the periods they were valid/active. - """ - x, y, z = chunk_coords - chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) - cg.copy_fake_edges(chunk_id) - rr = cg.range_read_chunk(chunk_id) - nodes = list(rr.keys()) - +def update_nodes(cg: ChunkedGraph, nodes) -> list: # get start_ts when node becomes valid nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) cx_edges_d = cg.get_atomic_cross_edges(nodes) @@ -116,4 +106,18 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], layer: int = 2): # for each timestamp until end_ts, update cross chunk edges of node _rows = update_cross_edges(cg, node, node_cx_edges_d, start_ts, end_ts) rows.extend(_rows) + return rows + + +def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], layer: int = 2): + """ + Iterate over all L2 IDs in a chunk and update their cross chunk edges, + within the periods they were valid/active. + """ + x, y, z = chunk_coords + chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) + cg.copy_fake_edges(chunk_id) + rr = cg.range_read_chunk(chunk_id) + nodes = list(rr.keys()) + rows = update_nodes(cg, nodes) cg.client.write(rows) From 55e8897c89ef7b8e59681452619c96362bcab029 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 28 Jun 2024 16:43:45 +0000 Subject: [PATCH 087/367] fix(edits): overwrite children partners when superseded by parents --- pychunkedgraph/debug/utils.py | 15 +++++++-- pychunkedgraph/graph/edits.py | 56 ++++--------------------------- pychunkedgraph/graph/operation.py | 3 ++ 3 files changed, 23 insertions(+), 51 deletions(-) diff --git a/pychunkedgraph/debug/utils.py b/pychunkedgraph/debug/utils.py index b1bdbc2be..130d85500 100644 --- a/pychunkedgraph/debug/utils.py +++ b/pychunkedgraph/debug/utils.py @@ -2,6 +2,8 @@ import numpy as np +from pychunkedgraph.graph.meta import ChunkedGraphMeta, GraphConfig + def print_attrs(d): for k, v in d.items(): @@ -41,14 +43,14 @@ def sanity_check(cg, new_roots, operation_id): """ Check for duplicates in hierarchy, useful for debugging. """ - print(f"{len(new_roots)} new ids from {operation_id}") + # print(f"{len(new_roots)} new ids from {operation_id}") l2c_d = {} for new_root in new_roots: l2c_d[new_root] = get_l2children(cg, new_root) success = True for k, v in l2c_d.items(): success = success and (len(v) == np.unique(v).size) - print(f"{k}: {np.unique(v).size}, {len(v)}") + # print(f"{k}: {np.unique(v).size}, {len(v)}") if not success: raise RuntimeError("Some ids are not valid.") @@ -58,3 +60,12 @@ def sanity_check_single(cg, node, operation_id): msg = f"invalid node {node}:" msg += f" found {len(v)} l2 ids, must be {np.unique(v).size}" assert np.unique(v).size == len(v), f"{msg}, from {operation_id}." + return v + + +def update_graph_id(cg, new_graph_id:str): + old_gc = cg.meta.graph_config._asdict() + old_gc["ID"] = new_graph_id + new_gc = GraphConfig(**old_gc) + new_meta = ChunkedGraphMeta(new_gc, cg.meta.data_source, cg.meta.custom_data) + cg.update_meta(new_meta, overwrite=True) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 807fff257..0778a1f82 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -420,9 +420,15 @@ def _update_neighbor_cross_edges_single( continue assert np.all(edges[:, 0] == counterpart) edges = fastremap.remap(edges, node_map, preserve_missing_labels=True) - if layer == counterpart_layer: + if layer == counterpart_layer and layer >= node_layer: reverse_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) edges = np.concatenate([edges, [reverse_edge]]) + children = cg.get_children(new_id) + mask = np.isin(edges[:, 1], children) + if np.any(mask): + masked_edges = edges[mask] + masked_edges[:, 1] = new_id + edges[mask] = masked_edges edges = np.unique(edges, axis=0) edges_d[layer] = edges val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges @@ -578,49 +584,6 @@ def _update_cross_edge_cache(self, parent, children): assert np.all(edges[:, 0] == parent), f"{parent}, {np.unique(edges[:, 0])}" self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d - def _update_neighbor_parents(self, neighbor, ceil_layer: int, updated: set) -> list: - """helper for `_update_skipped_neighbors`""" - parents = [] - while True: - parent = self.cg.get_parent(neighbor, time_stamp=self._last_successful_ts) - parent_layer = self.cg.get_chunk_layer(parent) - if parent_layer >= ceil_layer or parent in updated: - break - children = self.cg.get_children(parent) - self._update_cross_edge_cache(parent, children) - parents.append(parent) - neighbor = parent - return parents - - def _update_skipped_neighbors(self, node, layer, parent_layer): - """ - Updates cross edges of neighbors of a skip connection node. - Neighbors of such nodes can have parents at contiguous layers. - - This method updates cross edges of all such parents - from `layer` through `parent_layer`. - """ - updated_parents = set() - cx_edges_d = self.cg.cache.cross_chunk_edges_cache[node] - for _layer in range(layer, parent_layer + 1): - layer_edges = cx_edges_d.get(_layer, types.empty_2d) - neighbors = layer_edges[:, 1] - for n in neighbors: - if n in self._new_old_id_d: - # ignore new ids - continue - res = self._update_neighbor_parents(n, parent_layer, updated_parents) - updated_parents.update(res) - updated_entries = [] - for parent in updated_parents: - val_dict = {} - for _layer, edges in self.cg.cache.cross_chunk_edges_cache[parent].items(): - val_dict[attributes.Connectivity.CrossChunkEdge[_layer]] = edges - rkey = serialize_uint64(parent) - row = self.cg.client.mutate_row(rkey, val_dict, time_stamp=self._time_stamp) - updated_entries.append(row) - return updated_entries - def _create_new_parents(self, layer: int): """ keep track of old IDs @@ -635,7 +598,6 @@ def _create_new_parents(self, layer: int): layer_node_ids = self._get_layer_node_ids(new_ids, layer) components, graph_ids = self._get_connected_components(layer_node_ids, layer) for cc_indices in components: - update_skipped_neighbors = False parent_layer = layer + 1 # must be reset for each connected component cc_ids = graph_ids[cc_indices] if len(cc_ids) == 1: @@ -648,7 +610,6 @@ def _create_new_parents(self, layer: int): if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: parent_layer = l break - update_skipped_neighbors = cc_ids[0] in self._new_old_id_d parent = self.cg.id_client.create_node_id( self.cg.get_parent_chunk_id(cc_ids[0], parent_layer), root_chunk=parent_layer == self.cg.meta.layer_count, @@ -658,9 +619,6 @@ def _create_new_parents(self, layer: int): self.cg.cache.children_cache[parent] = cc_ids cache_utils.update(self.cg.cache.parents_cache, cc_ids, parent) sanity_check_single(self.cg, parent, self._operation_id) - if update_skipped_neighbors: - res = self._update_skipped_neighbors(cc_ids[0], layer, parent_layer) - self.new_entries.extend(res) def run(self) -> Iterable: """ diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 7b88a621e..5d5e14b8e 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -460,6 +460,9 @@ def execute( except PostconditionError as err: self.cg.cache = None raise PostconditionError(err) from err + except (AssertionError, RuntimeError) as err: + self.cg.cache = None + raise RuntimeError(err) from err except Exception as err: # unknown exception, update log record with error self.cg.cache = None From 8fc4c4be3d7f3e928be4c8fe279a923e0c9ac96d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 4 Jul 2024 15:54:48 +0000 Subject: [PATCH 088/367] fix: unique edges always, predecing edit ts, allow same segment merge --- pychunkedgraph/graph/edits.py | 6 +++--- pychunkedgraph/graph/operation.py | 18 +++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 0778a1f82..735ae65f8 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -414,13 +414,13 @@ def _update_neighbor_cross_edges_single( for counterpart, edges_d in cp_cx_edges_d.items(): val_dict = {} counterpart_layer = counterpart_layers[counterpart] - for layer in range(2, cg.meta.layer_count): + for layer in range(node_layer, cg.meta.layer_count): edges = edges_d.get(layer, types.empty_2d) if edges.size == 0: continue assert np.all(edges[:, 0] == counterpart) edges = fastremap.remap(edges, node_map, preserve_missing_labels=True) - if layer == counterpart_layer and layer >= node_layer: + if layer == counterpart_layer: reverse_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) edges = np.concatenate([edges, [reverse_edge]]) children = cg.get_children(new_id) @@ -429,7 +429,7 @@ def _update_neighbor_cross_edges_single( masked_edges = edges[mask] masked_edges[:, 1] = new_id edges[mask] = masked_edges - edges = np.unique(edges, axis=0) + edges = np.unique(edges, axis=0) edges_d[layer] = edges val_dict[attributes.Connectivity.CrossChunkEdge[layer]] = edges if not val_dict: diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 5d5e14b8e..84cc923dc 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -628,13 +628,16 @@ def _apply( edges_only=True, ) - with TimeIt("preprocess", self.cg.graph_id, operation_id): - inactive_edges = edits.merge_preprocess( - self.cg, - subgraph_edges=edges, - supervoxels=self.added_edges.ravel(), - parent_ts=self.parent_ts, - ) + if self.allow_same_segment_merge: + inactive_edges = types.empty_2d + else: + with TimeIt("preprocess", self.cg.graph_id, operation_id): + inactive_edges = edits.merge_preprocess( + self.cg, + subgraph_edges=edges, + supervoxels=self.added_edges.ravel(), + parent_ts=self.parent_ts, + ) atomic_edges, fake_edge_rows = edits.check_fake_edges( self.cg, @@ -650,6 +653,7 @@ def _apply( operation_id=operation_id, time_stamp=timestamp, parent_ts=self.parent_ts, + allow_same_segment_merge=self.allow_same_segment_merge ) return new_roots, new_l2_ids, fake_edge_rows + new_entries From 4b62dc2b19bd51603e543456ace9a0429b307b56 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 4 Jul 2024 16:22:25 +0000 Subject: [PATCH 089/367] =?UTF-8?q?Bump=20version:=203.0.0=20=E2=86=92=203?= =?UTF-8?q?.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5583246c5..6526fbc66 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.0 +current_version = 3.0.1 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 528787cfc..055276878 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.0" +__version__ = "3.0.1" From 72c6cf22a65aa58fda7c747d07bfe1ee49bacd1b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 6 Jul 2024 17:38:44 +0000 Subject: [PATCH 090/367] fix(edits): mask all descendants when updating cx edges --- pychunkedgraph/graph/edits.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 735ae65f8..add0c9d0c 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -391,6 +391,25 @@ def _get_flipped_ids(id_map, node_ids): return np.concatenate(ids) +def _get_descendants(cg, new_id): + """get all descendants at layers >= 2""" + result = [] + children = cg.get_children(new_id) + while True: + mask = cg.get_chunk_layers(children) >= 2 + children = children[mask] + result.extend(children) + + mask = cg.get_chunk_layers(children) > 2 + children = children[mask] + if children.size == 0: + break + + children = cg.get_children(children, flatten=True) + return result + + + def _update_neighbor_cross_edges_single( cg, new_id: int, cx_edges_d: dict, node_map: dict, *, parent_ts ) -> dict: @@ -423,8 +442,8 @@ def _update_neighbor_cross_edges_single( if layer == counterpart_layer: reverse_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) edges = np.concatenate([edges, [reverse_edge]]) - children = cg.get_children(new_id) - mask = np.isin(edges[:, 1], children) + descendants = _get_descendants(cg, new_id) + mask = np.isin(edges[:, 1], descendants) if np.any(mask): masked_edges = edges[mask] masked_edges[:, 1] = new_id From 5a57b6132372df82ae3f2b3999b1bbd4bb0ed258 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 6 Jul 2024 17:39:16 +0000 Subject: [PATCH 091/367] =?UTF-8?q?Bump=20version:=203.0.1=20=E2=86=92=203?= =?UTF-8?q?.0.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6526fbc66..62209053d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.1 +current_version = 3.0.2 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 055276878..131942e76 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.1" +__version__ = "3.0.2" From 74a6e69c969314f3212986613667132ed34e45b4 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 7 Jul 2024 01:25:34 +0000 Subject: [PATCH 092/367] fix(edits): use supervoxels to get the correct cross edge parents --- pychunkedgraph/graph/edits.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index add0c9d0c..4efead0c9 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -496,6 +496,25 @@ def _update_neighbor_cross_edges( return updated_entries +def _get_supervoxels(cg, node_ids): + """Returns the first supervoxel found for each node_id.""" + result = {} + node_ids_copy = np.copy(node_ids) + children = np.copy(node_ids) + children_d = cg.get_children(node_ids) + while True: + children = [children_d[k][0] for k in children] + children = np.array(children, dtype=basetypes.NODE_ID) + mask = cg.get_chunk_layers(children) == 1 + result.update([(node, sv) for node, sv in zip(node_ids[mask], children[mask])]) + node_ids = node_ids[~mask] + children = children[~mask] + if children.size == 0: + break + children_d = cg.get_children(children) + return np.array([result[k] for k in node_ids_copy], dtype=basetypes.NODE_ID) + + class CreateParentNodes: def __init__( self, @@ -586,8 +605,9 @@ def _update_cross_edge_cache(self, parent, children): ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) + edge_supervoxels = _get_supervoxels(self.cg, edge_nodes) edge_parents = self.cg.get_roots( - edge_nodes, + edge_supervoxels, stop_layer=parent_layer, ceil=False, time_stamp=self._last_successful_ts, From 751869bc155fde8faf02fa99e684142c96ae59c0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 7 Jul 2024 01:25:52 +0000 Subject: [PATCH 093/367] =?UTF-8?q?Bump=20version:=203.0.2=20=E2=86=92=203?= =?UTF-8?q?.0.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 62209053d..f98e5ee64 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.2 +current_version = 3.0.3 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 131942e76..8d1c8625f 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.2" +__version__ = "3.0.3" From d63328a248b8bcf3b0c189d7416c3fdd652e0bec Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 16 Jul 2024 19:24:15 +0000 Subject: [PATCH 094/367] fix(edits/split): filter out inactive cross edges --- pychunkedgraph/graph/chunkedgraph.py | 15 ++++++++++++++- pychunkedgraph/graph/edits.py | 7 ++++--- pychunkedgraph/graph/operation.py | 10 +--------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 7d1a24cc3..1836094f0 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -657,7 +657,11 @@ def copy_fake_edges(self, chunk_id: np.uint64) -> None: self.client.write(mutations) def get_l2_agglomerations( - self, level2_ids: np.ndarray, edges_only: bool = False + self, + level2_ids: np.ndarray, + edges_only: bool = False, + active: bool = False, + time_stamp: typing.Optional[datetime.datetime] = None, ) -> typing.Tuple[typing.Dict[int, types.Agglomeration], typing.Tuple[Edges]]: """ Children of Level 2 Node IDs and edges. @@ -703,6 +707,15 @@ def get_l2_agglomerations( raise ValueError("Found conflicting parents.") sv_parent_d.update(dict(zip(svs.tolist(), [l2id] * len(svs)))) + if active: + n1, n2 = all_chunk_edges.node_ids1, all_chunk_edges.node_ids2 + layers = self.get_cross_chunk_edges_layer(all_chunk_edges.get_pairs()) + max_layer = np.max(layers) + 1 + parents1 = self.get_roots(n1, stop_layer=max_layer, time_stamp=time_stamp) + parents2 = self.get_roots(n2, stop_layer=max_layer, time_stamp=time_stamp) + mask = parents1 == parents2 + all_chunk_edges = all_chunk_edges[mask] + in_edges, out_edges, cross_edges = edge_utils.categorize_edges_v2( self.meta, all_chunk_edges, sv_parent_d ) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 4efead0c9..30e86951a 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -313,7 +313,6 @@ def remove_edges( cg, *, atomic_edges: Iterable[np.ndarray], - l2id_agglomeration_d: Dict, operation_id: basetypes.OPERATION_ID = None, time_stamp: datetime.datetime = None, parent_ts: datetime.datetime = None, @@ -323,6 +322,9 @@ def remove_edges( roots = cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts) assert np.unique(roots).size == 1, "L2 IDs must belong to same root." + l2id_agglomeration_d, _ = cg.get_l2_agglomerations( + l2ids, active=True, time_stamp=parent_ts + ) new_old_id_d = defaultdict(set) old_new_id_d = defaultdict(set) old_hierarchy_d = _init_old_hierarchy(cg, l2ids, parent_ts=parent_ts) @@ -409,7 +411,6 @@ def _get_descendants(cg, new_id): return result - def _update_neighbor_cross_edges_single( cg, new_id: int, cx_edges_d: dict, node_map: dict, *, parent_ts ) -> dict: @@ -498,7 +499,7 @@ def _update_neighbor_cross_edges( def _get_supervoxels(cg, node_ids): """Returns the first supervoxel found for each node_id.""" - result = {} + result = {} node_ids_copy = np.copy(node_ids) children = np.copy(node_ids) children_d = cg.get_children(node_ids) diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 84cc923dc..0e865566e 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -653,7 +653,7 @@ def _apply( operation_id=operation_id, time_stamp=timestamp, parent_ts=self.parent_ts, - allow_same_segment_merge=self.allow_same_segment_merge + allow_same_segment_merge=self.allow_same_segment_merge, ) return new_roots, new_l2_ids, fake_edge_rows + new_entries @@ -764,18 +764,11 @@ def _apply( ): raise PreconditionError("Supervoxels must belong to the same object.") - with TimeIt("subgraph", self.cg.graph_id, operation_id): - l2id_agglomeration_d, _ = self.cg.get_l2_agglomerations( - self.cg.get_parents( - self.removed_edges.ravel(), time_stamp=self.parent_ts - ), - ) with TimeIt("remove_edges", self.cg.graph_id, operation_id): return edits.remove_edges( self.cg, operation_id=operation_id, atomic_edges=self.removed_edges, - l2id_agglomeration_d=l2id_agglomeration_d, time_stamp=timestamp, parent_ts=self.parent_ts, ) @@ -942,7 +935,6 @@ def _apply( self.cg, operation_id=operation_id, atomic_edges=self.removed_edges, - l2id_agglomeration_d=l2id_agglomeration_d, time_stamp=timestamp, parent_ts=self.parent_ts, ) From f4b07a40d62728cf1017d200d769ded1c0067366 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 17 Jul 2024 16:16:21 +0000 Subject: [PATCH 095/367] fix(edits/split): filter out inactive cross edges AT EACH LAYER --- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/graph/chunkedgraph.py | 14 +++++--------- pychunkedgraph/graph/edges/utils.py | 22 +++++++++++++++++++++- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 8d1c8625f..528787cfc 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.3" +__version__ = "3.0.0" diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 1836094f0..7823695db 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -3,6 +3,8 @@ import time import typing import datetime +from itertools import chain +from functools import reduce import numpy as np from pychunkedgraph import __version__ @@ -667,8 +669,6 @@ def get_l2_agglomerations( Children of Level 2 Node IDs and edges. Edges are read from cloud storage. """ - from itertools import chain - from functools import reduce from .misc import get_agglomerations chunk_ids = np.unique(self.get_chunk_ids_from_node_ids(level2_ids)) @@ -708,13 +708,9 @@ def get_l2_agglomerations( sv_parent_d.update(dict(zip(svs.tolist(), [l2id] * len(svs)))) if active: - n1, n2 = all_chunk_edges.node_ids1, all_chunk_edges.node_ids2 - layers = self.get_cross_chunk_edges_layer(all_chunk_edges.get_pairs()) - max_layer = np.max(layers) + 1 - parents1 = self.get_roots(n1, stop_layer=max_layer, time_stamp=time_stamp) - parents2 = self.get_roots(n2, stop_layer=max_layer, time_stamp=time_stamp) - mask = parents1 == parents2 - all_chunk_edges = all_chunk_edges[mask] + all_chunk_edges = edge_utils.filter_inactive_cross_edges( + self, all_chunk_edges, time_stamp=time_stamp + ) in_edges, out_edges, cross_edges = edge_utils.categorize_edges_v2( self.meta, all_chunk_edges, sv_parent_d diff --git a/pychunkedgraph/graph/edges/utils.py b/pychunkedgraph/graph/edges/utils.py index cd0e85fe8..76f8ea1d8 100644 --- a/pychunkedgraph/graph/edges/utils.py +++ b/pychunkedgraph/graph/edges/utils.py @@ -9,6 +9,7 @@ from typing import Iterable from typing import Optional from collections import defaultdict +from functools import reduce import fastremap import numpy as np @@ -46,7 +47,9 @@ def concatenate_chunk_edges(chunk_edge_dicts: Iterable) -> Dict: return edges_dict -def concatenate_cross_edge_dicts(edges_ds: Iterable[Dict], unique: bool = False) -> Dict: +def concatenate_cross_edge_dicts( + edges_ds: Iterable[Dict], unique: bool = False +) -> Dict: """Combines cross chunk edge dicts of form {layer id : edge list}.""" result_d = defaultdict(list) for edges_d in edges_ds: @@ -182,3 +185,20 @@ def get_edges_status(cg, edges: Iterable, time_stamp: Optional[float] = None): active_status.extend(mask) active_status = np.array(active_status, dtype=bool) return existence_status, active_status + + +def filter_inactive_cross_edges( + cg, all_chunk_edges: Edges, time_stamp: Optional[float] = None +): + result = [] + layers = cg.get_cross_chunk_edges_layer(all_chunk_edges.get_pairs()) + for layer in np.unique(layers): + layer_mask = layers == layer + parent_layer = layer + 1 + layer_edges = all_chunk_edges[layer_mask] + n1, n2 = layer_edges.node_ids1, layer_edges.node_ids2 + parents1 = cg.get_roots(n1, stop_layer=parent_layer, time_stamp=time_stamp) + parents2 = cg.get_roots(n2, stop_layer=parent_layer, time_stamp=time_stamp) + mask = parents1 == parents2 + result.append(layer_edges[mask]) + return reduce(lambda x, y: x + y, result, Edges([], [])) From aaef2441d33bd803e6b6a936c504f38b45d23fe5 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 30 Aug 2024 15:38:53 +0000 Subject: [PATCH 096/367] migration debug code --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/graph/edits.py | 22 ++++++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f98e5ee64..6526fbc66 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.3 +current_version = 3.0.1 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 528787cfc..055276878 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.0" +__version__ = "3.0.1" diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 30e86951a..340cefadd 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -313,7 +313,7 @@ def remove_edges( cg, *, atomic_edges: Iterable[np.ndarray], - operation_id: basetypes.OPERATION_ID = None, + operation_id: basetypes.OPERATION_ID = None, # type: ignore time_stamp: datetime.datetime = None, parent_ts: datetime.datetime = None, ): @@ -522,7 +522,7 @@ def __init__( cg, *, new_l2_ids: Iterable, - operation_id: basetypes.OPERATION_ID, + operation_id: basetypes.OPERATION_ID, # type: ignore time_stamp: datetime.datetime, new_old_id_d: Dict[np.uint64, Set[np.uint64]] = None, old_new_id_d: Dict[np.uint64, Set[np.uint64]] = None, @@ -542,7 +542,7 @@ def __init__( def _update_id_lineage( self, - parent: basetypes.NODE_ID, + parent: basetypes.NODE_ID, # type: ignore children: np.ndarray, layer: int, parent_layer: int, @@ -658,7 +658,21 @@ def _create_new_parents(self, layer: int): self._update_id_lineage(parent, cc_ids, layer, parent_layer) self.cg.cache.children_cache[parent] = cc_ids cache_utils.update(self.cg.cache.parents_cache, cc_ids, parent) - sanity_check_single(self.cg, parent, self._operation_id) + + try: + sanity_check_single(self.cg, parent, self._operation_id) + except AssertionError: + from pychunkedgraph.debug.utils import get_l2children + + pairs = [ + (a, b) for idx, a in enumerate(cc_ids) for b in cc_ids[idx + 1 :] + ] + for c1, c2 in pairs: + l2c1 = get_l2children(self.cg, c1) + l2c2 = get_l2children(self.cg, c2) + if np.intersect1d(l2c1, l2c2).size: + msg = f"{self._operation_id}:{c1} {c2} have common children." + raise ValueError(msg) def run(self) -> Iterable: """ From 50360da43ad71351b9d85f535776434d118b7fd9 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 22 Sep 2024 15:30:45 +0000 Subject: [PATCH 097/367] use parent timestamps to lift cx edges --- pychunkedgraph/ingest/upgrade/atomic_layer.py | 72 ++++--------------- pychunkedgraph/ingest/upgrade/parent_layer.py | 20 +++--- pychunkedgraph/ingest/upgrade/utils.py | 50 +++++++++++++ 3 files changed, 74 insertions(+), 68 deletions(-) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index 6c4244968..a975146de 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -1,50 +1,19 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member + from datetime import timedelta import fastremap import numpy as np from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.attributes import Connectivity -from pychunkedgraph.graph.attributes import Hierarchy from pychunkedgraph.graph.utils import serializers -from .utils import exists_as_parent - - -def get_parent_timestamps(cg, supervoxels, start_time=None, end_time=None) -> set: - """ - Timestamps of when the given supervoxels were edited, in the given time range. - """ - response = cg.client.read_nodes( - node_ids=supervoxels, - start_time=start_time, - end_time=end_time, - end_time_inclusive=False, - ) - result = set() - for v in response.values(): - for cell in v[Hierarchy.Parent]: - valid = cell.timestamp >= start_time or cell.timestamp < end_time - assert valid, f"{cell.timestamp}, {start_time}" - result.add(cell.timestamp) - return result +from .utils import exists_as_parent, get_parent_timestamps -def get_edit_timestamps(cg: ChunkedGraph, edges_d, start_ts, end_ts) -> list: - """ - Timestamps of when post-side supervoxels were involved in an edit. - Post-side - supervoxels in the neighbor chunk. - This is required because we need to update edges from both sides. - """ - atomic_cx_edges = np.concatenate(list(edges_d.values())) - timestamps = get_parent_timestamps( - cg, atomic_cx_edges[:, 1], start_time=start_ts, end_time=end_ts - ) - timestamps.add(start_ts) - return sorted(timestamps) - - -def update_cross_edges(cg: ChunkedGraph, node, cx_edges_d, node_ts, end_ts) -> list: +def update_cross_edges( + cg: ChunkedGraph, node, cx_edges_d, node_ts, timestamps, earliest_ts +) -> list: """ Helper function to update a single L2 ID. Returns a list of mutations with given timestamps. @@ -58,10 +27,9 @@ def update_cross_edges(cg: ChunkedGraph, node, cx_edges_d, node_ts, end_ts) -> l assert not exists_as_parent(cg, node, edges[:, 0]) return rows - timestamps = [node_ts] - if node_ts != end_ts: - timestamps = get_edit_timestamps(cg, cx_edges_d, node_ts, end_ts) for ts in timestamps: + if ts < earliest_ts: + ts = earliest_ts val_dict = {} svs = edges[:, 1] parents = cg.get_parents(svs, time_stamp=ts) @@ -80,31 +48,21 @@ def update_cross_edges(cg: ChunkedGraph, node, cx_edges_d, node_ts, end_ts) -> l def update_nodes(cg: ChunkedGraph, nodes) -> list: - # get start_ts when node becomes valid nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) + earliest_ts = cg.get_earliest_timestamp() + timestamps_d = get_parent_timestamps(cg, nodes) cx_edges_d = cg.get_atomic_cross_edges(nodes) - children_d = cg.get_children(nodes) - rows = [] - for node, start_ts in zip(nodes, nodes_ts): + for node, node_ts in zip(nodes, nodes_ts): if cg.get_parent(node) is None: # invalid id caused by failed ingest task continue - node_cx_edges_d = cx_edges_d.get(node, {}) - if not node_cx_edges_d: + _cx_edges_d = cx_edges_d.get(node, {}) + if not _cx_edges_d: continue - - # get end_ts when node becomes invalid (bigtable resolution is in ms) - start = start_ts + timedelta(milliseconds=1) - _timestamps = get_parent_timestamps(cg, children_d[node], start_time=start) - try: - end_ts = sorted(_timestamps)[0] - except IndexError: - # start_ts == end_ts means there has been no edit involving this node - # meaning only one timestamp to update cross edges, start_ts - end_ts = start_ts - # for each timestamp until end_ts, update cross chunk edges of node - _rows = update_cross_edges(cg, node, node_cx_edges_d, start_ts, end_ts) + _rows = update_cross_edges( + cg, node, _cx_edges_d, node_ts, timestamps_d[node], earliest_ts + ) rows.extend(_rows) return rows diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 8674e45b7..0606ff674 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -14,7 +14,7 @@ from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked -from .utils import exists_as_parent +from .utils import exists_as_parent, get_parent_timestamps CHILDREN = {} @@ -50,7 +50,7 @@ def _get_cx_edges_at_timestamp(node, response, ts): def _populate_cx_edges_with_timestamps( - cg: ChunkedGraph, layer: int, nodes: list, nodes_ts: list + cg: ChunkedGraph, layer: int, nodes: list, earliest_ts ): """ Collect timestamps of edits from children, since we use the same timestamp @@ -61,15 +61,13 @@ def _populate_cx_edges_with_timestamps( attrs = [Connectivity.CrossChunkEdge[l] for l in range(layer, cg.meta.layer_count)] all_children = np.concatenate(list(CHILDREN.values())) response = cg.client.read_nodes(node_ids=all_children, properties=attrs) - for node, node_ts in zip(nodes, nodes_ts): - timestamps = set([node_ts]) - for child in CHILDREN[node]: - if child not in response: - continue - for cells in response[child].values(): - timestamps.update([c.timestamp for c in cells if c.timestamp > node_ts]) + timestamps_d = get_parent_timestamps(cg, nodes) + for node in nodes: CX_EDGES[node] = {} + timestamps = timestamps_d[node] for ts in sorted(timestamps): + if ts < earliest_ts: + ts = earliest_ts CX_EDGES[node][ts] = _get_cx_edges_at_timestamp(node, response, ts) @@ -142,19 +140,19 @@ def update_chunk( start = time.time() x, y, z = chunk_coords chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) + earliest_ts = cg.get_earliest_timestamp() _populate_nodes_and_children(cg, chunk_id, nodes=nodes) if not CHILDREN: return nodes = list(CHILDREN.keys()) random.shuffle(nodes) nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) - _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts) + _populate_cx_edges_with_timestamps(cg, layer, nodes, earliest_ts) task_size = int(math.ceil(len(nodes) / mp.cpu_count() / 2)) chunked_nodes = chunked(nodes, task_size) chunked_nodes_ts = chunked(nodes_ts, task_size) cg_info = cg.get_serialized_info() - earliest_ts = cg.get_earliest_timestamp() multi_args = [] for chunk, ts_chunk in zip(chunked_nodes, chunked_nodes_ts): diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py index 43c9a3034..cc43b561a 100644 --- a/pychunkedgraph/ingest/upgrade/utils.py +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -1,3 +1,9 @@ +# pylint: disable=invalid-name, missing-docstring + +from collections import defaultdict +from datetime import timedelta + +import numpy as np from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.attributes import Hierarchy @@ -11,3 +17,47 @@ def exists_as_parent(cg: ChunkedGraph, parent, nodes) -> bool: for cells in response.values(): parents.update([cell.value for cell in cells]) return parent in parents + + +def get_edit_timestamps(cg: ChunkedGraph, edges_d, start_ts, end_ts) -> list: + """ + Timestamps of when post-side nodes were involved in an edit. + Post-side - nodes in the neighbor chunk. + This is required because we need to update edges from both sides. + """ + cx_edges = np.concatenate(list(edges_d.values())) + timestamps = get_parent_timestamps( + cg, cx_edges[:, 1], start_time=start_ts, end_time=end_ts + ) + timestamps.add(start_ts) + return sorted(timestamps) + + +def get_end_ts(cg: ChunkedGraph, children, start_ts): + # get end_ts when node becomes invalid (bigtable resolution is in ms) + start = start_ts + timedelta(milliseconds=1) + _timestamps = get_parent_timestamps(cg, children, start_time=start) + try: + end_ts = sorted(_timestamps)[0] + except IndexError: + # start_ts == end_ts means there has been no edit involving this node + # meaning only one timestamp to update cross edges, start_ts + end_ts = start_ts + return end_ts + + +def get_parent_timestamps(cg: ChunkedGraph, nodes) -> dict[int, set]: + """ + Timestamps of when the given nodes were edited. + """ + response = cg.client.read_nodes( + node_ids=nodes, + properties=[Hierarchy.Parent], + end_time_inclusive=False, + ) + + result = defaultdict(set) + for k, v in response.items(): + for cell in v[Hierarchy.Parent]: + result[k].add(cell.timestamp) + return result From 0240011e9e19e8e737a0079dcd0ac272560d9eb3 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 23 Sep 2024 14:48:14 +0000 Subject: [PATCH 098/367] make dynamic mesh dir graph specific --- pychunkedgraph/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 055276878..8e10cb462 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.1" +__version__ = "3.0.4" From 1e0ab1b77d355a93a20504a5b1788ee31c1717c0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 26 Sep 2024 14:45:14 +0000 Subject: [PATCH 099/367] fix(upgrade): use hierarchy from supervoxels --- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/graph/edits.py | 7 ++++--- pychunkedgraph/ingest/upgrade/parent_layer.py | 4 +++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 8e10cb462..e94f36fe8 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.4" +__version__ = "3.0.5" diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 340cefadd..afe1b3abf 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -497,7 +497,7 @@ def _update_neighbor_cross_edges( return updated_entries -def _get_supervoxels(cg, node_ids): +def get_supervoxels(cg, node_ids): """Returns the first supervoxel found for each node_id.""" result = {} node_ids_copy = np.copy(node_ids) @@ -606,7 +606,7 @@ def _update_cross_edge_cache(self, parent, children): ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) - edge_supervoxels = _get_supervoxels(self.cg, edge_nodes) + edge_supervoxels = get_supervoxels(self.cg, edge_nodes) edge_parents = self.cg.get_roots( edge_supervoxels, stop_layer=parent_layer, @@ -671,7 +671,8 @@ def _create_new_parents(self, layer: int): l2c1 = get_l2children(self.cg, c1) l2c2 = get_l2children(self.cg, c2) if np.intersect1d(l2c1, l2c2).size: - msg = f"{self._operation_id}:{c1} {c2} have common children." + c = np.intersect1d(l2c1, l2c2) + msg = f"{self._operation_id}: {layer} {c1} {c2} have common children {c}" raise ValueError(msg) def run(self) -> Iterable: diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 0606ff674..2869fcf85 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -10,6 +10,7 @@ from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.attributes import Connectivity, Hierarchy +from pychunkedgraph.graph.edits import get_supervoxels from pychunkedgraph.graph.utils import serializers from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked @@ -101,7 +102,8 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l if edges.size == 0: continue nodes = np.unique(edges[:, 1]) - parents = cg.get_roots(nodes, time_stamp=ts, stop_layer=layer, ceil=False) + svs = get_supervoxels(cg, nodes) + parents = cg.get_roots(svs, time_stamp=ts, stop_layer=layer, ceil=False) edge_parents_d = dict(zip(nodes, parents)) val_dict = {} for _layer, layer_edges in cx_edges_d.items(): From 205224d3548511eb1c540e1ec3716697340a42cf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 26 Sep 2024 16:59:39 +0000 Subject: [PATCH 100/367] fix(upgrade): include cx edges at node_ts explicitly --- pychunkedgraph/ingest/upgrade/parent_layer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 2869fcf85..a7e79b8f0 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -51,7 +51,7 @@ def _get_cx_edges_at_timestamp(node, response, ts): def _populate_cx_edges_with_timestamps( - cg: ChunkedGraph, layer: int, nodes: list, earliest_ts + cg: ChunkedGraph, layer: int, nodes: list, nodes_ts:list, earliest_ts ): """ Collect timestamps of edits from children, since we use the same timestamp @@ -63,9 +63,10 @@ def _populate_cx_edges_with_timestamps( all_children = np.concatenate(list(CHILDREN.values())) response = cg.client.read_nodes(node_ids=all_children, properties=attrs) timestamps_d = get_parent_timestamps(cg, nodes) - for node in nodes: + for node, node_ts in zip(nodes, nodes_ts): CX_EDGES[node] = {} timestamps = timestamps_d[node] + timestamps.add(node_ts) for ts in sorted(timestamps): if ts < earliest_ts: ts = earliest_ts @@ -82,6 +83,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l try: cx_edges_d = CX_EDGES[node][node_ts] except KeyError: + print(CX_EDGES) raise KeyError(f"{node}:{node_ts}") edges = np.concatenate([empty_2d] + list(cx_edges_d.values())) if edges.size: @@ -149,7 +151,7 @@ def update_chunk( nodes = list(CHILDREN.keys()) random.shuffle(nodes) nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) - _populate_cx_edges_with_timestamps(cg, layer, nodes, earliest_ts) + _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts, earliest_ts) task_size = int(math.ceil(len(nodes) / mp.cpu_count() / 2)) chunked_nodes = chunked(nodes, task_size) From 9519b4cdb34c764b28c34c58090d32469f200e83 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 29 Sep 2024 19:42:14 +0000 Subject: [PATCH 101/367] adds job type guard, flush_redis prompts, improved status output --- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/ingest/cli.py | 27 ++++++++-- pychunkedgraph/ingest/cli_upgrade.py | 29 ++++++++--- pychunkedgraph/ingest/upgrade/parent_layer.py | 22 ++++----- pychunkedgraph/ingest/utils.py | 49 ++++++++++++++++--- pychunkedgraph/utils/redis.py | 4 +- 6 files changed, 97 insertions(+), 36 deletions(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index e94f36fe8..6ed01825f 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.5" +__version__ = "3.0.6" diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 928e1852f..c50525ec6 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -16,15 +16,17 @@ bootstrap, chunk_id_str, print_completion_rate, - print_ingest_status, + print_status, queue_layer_helper, + job_type_guard, ) from .simple_tests import run_all from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph from ..utils.redis import get_redis_connection, keys as r_keys -ingest_cli = AppGroup("ingest") +group_name = "ingest" +ingest_cli = AppGroup(group_name) def init_ingest_cmds(app): @@ -32,6 +34,8 @@ def init_ingest_cmds(app): @ingest_cli.command("flush_redis") +@click.confirmation_option(prompt="Are you sure you want to flush redis?") +@job_type_guard(group_name) def flush_redis(): """FLush redis db.""" redis = get_redis_connection() @@ -44,6 +48,7 @@ def flush_redis(): @click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @click.option("--retry", is_flag=True, help="Rerun without creating a new table.") +@job_type_guard(group_name) def ingest_graph( graph_id: str, dataset: click.Path, raw: bool, test: bool, retry: bool ): @@ -51,6 +56,8 @@ def ingest_graph( Main ingest command. Takes ingest config from a yaml file and queues atomic tasks. """ + redis = get_redis_connection() + redis.set(r_keys.JOB_TYPE, group_name) with open(dataset, "r") as stream: config = yaml.safe_load(stream) @@ -70,6 +77,7 @@ def ingest_graph( @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True)) @click.option("--raw", is_flag=True) +@job_type_guard(group_name) def pickle_imanager(graph_id: str, dataset: click.Path, raw: bool): """ Load ingest config into redis server. @@ -83,11 +91,12 @@ def pickle_imanager(graph_id: str, dataset: click.Path, raw: bool): meta, ingest_config, _ = bootstrap(graph_id, config=config, raw=raw) imanager = IngestionManager(ingest_config, meta) - imanager.redis # pylint: disable=pointless-statement + imanager.redis.set(r_keys.JOB_TYPE, group_name) @ingest_cli.command("layer") @click.argument("parent_layer", type=int) +@job_type_guard(group_name) def queue_layer(parent_layer): """ Queue all chunk tasks at a given layer. @@ -100,16 +109,21 @@ def queue_layer(parent_layer): @ingest_cli.command("status") +@job_type_guard(group_name) def ingest_status(): """Print ingest status to console by layer.""" redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - print_ingest_status(imanager, redis) + try: + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + print_status(imanager, redis) + except TypeError as err: + print(f"\nNo current `{group_name}` job found in redis: {err}") @ingest_cli.command("chunk") @click.argument("queue", type=str) @click.argument("chunk_info", nargs=4, type=int) +@job_type_guard(group_name) def ingest_chunk(queue: str, chunk_info): """Manually queue chunk when a job is stuck for whatever reason.""" redis = get_redis_connection() @@ -135,6 +149,7 @@ def ingest_chunk(queue: str, chunk_info): @click.argument("graph_id", type=str) @click.argument("chunk_info", nargs=4, type=int) @click.option("--n_threads", type=int, default=1) +@job_type_guard(group_name) def ingest_chunk_local(graph_id: str, chunk_info, n_threads: int): """Manually ingest a chunk on a local machine.""" layer, coords = chunk_info[0], chunk_info[1:] @@ -150,6 +165,7 @@ def ingest_chunk_local(graph_id: str, chunk_info, n_threads: int): @ingest_cli.command("rate") @click.argument("layer", type=int) @click.option("--span", default=10, help="Time span to calculate rate.") +@job_type_guard(group_name) def rate(layer: int, span: int): redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) @@ -158,5 +174,6 @@ def rate(layer: int, span: int): @ingest_cli.command("run_tests") @click.argument("graph_id", type=str) +@job_type_guard(group_name) def run_tests(graph_id): run_all(ChunkedGraph(graph_id=graph_id)) diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index c77c0be64..84939544b 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -24,15 +24,17 @@ from .utils import ( chunk_id_str, print_completion_rate, - print_ingest_status, + print_status, queue_layer_helper, start_ocdbt_server, + job_type_guard, ) from ..graph.chunkedgraph import ChunkedGraph, ChunkedGraphMeta from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys -upgrade_cli = AppGroup("upgrade") +group_name = "upgrade" +upgrade_cli = AppGroup(group_name) def init_upgrade_cmds(app): @@ -40,6 +42,8 @@ def init_upgrade_cmds(app): @upgrade_cli.command("flush_redis") +@click.confirmation_option(prompt="Are you sure you want to flush redis?") +@job_type_guard(group_name) def flush_redis(): """FLush redis db.""" redis = get_redis_connection() @@ -50,11 +54,13 @@ def flush_redis(): @click.argument("graph_id", type=str) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @click.option("--ocdbt", is_flag=True, help="Store edges using ts ocdbt kv store.") +@job_type_guard(group_name) def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): """ - Main upgrade command. - Takes upgrade config from a yaml file and queues atomic tasks. + Main upgrade command. Queues atomic tasks. """ + redis = get_redis_connection() + redis.set(r_keys.JOB_TYPE, group_name) ingest_config = IngestConfig(TEST_RUN=test) cg = ChunkedGraph(graph_id=graph_id) cg.client.add_graph_version(__version__, overwrite=True) @@ -91,6 +97,7 @@ def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): @upgrade_cli.command("layer") @click.argument("parent_layer", type=int) +@job_type_guard(group_name) def queue_layer(parent_layer): """ Queue all chunk tasks at a given layer. @@ -103,17 +110,22 @@ def queue_layer(parent_layer): @upgrade_cli.command("status") -def ingest_status(): +@job_type_guard(group_name) +def upgrade_status(): """Print upgrade status to console.""" redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - print_ingest_status(imanager, redis, upgrade=True) + try: + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + print_status(imanager, redis, upgrade=True) + except TypeError as err: + print(f"\nNo current `{group_name}` job found in redis: {err}") @upgrade_cli.command("chunk") @click.argument("queue", type=str) @click.argument("chunk_info", nargs=4, type=int) -def ingest_chunk(queue: str, chunk_info): +@job_type_guard(group_name) +def upgrade_chunk(queue: str, chunk_info): """Manually queue chunk when a job is stuck for whatever reason.""" redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) @@ -137,6 +149,7 @@ def ingest_chunk(queue: str, chunk_info): @upgrade_cli.command("rate") @click.argument("layer", type=int) @click.option("--span", default=10, help="Time span to calculate rate.") +@job_type_guard(group_name) def rate(layer: int, span: int): redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index a7e79b8f0..7c95cc1b6 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -6,7 +6,7 @@ import fastremap import numpy as np -from multiwrapper import multiprocessing_utils as mu +from tqdm import tqdm from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.attributes import Connectivity, Hierarchy @@ -51,7 +51,7 @@ def _get_cx_edges_at_timestamp(node, response, ts): def _populate_cx_edges_with_timestamps( - cg: ChunkedGraph, layer: int, nodes: list, nodes_ts:list, earliest_ts + cg: ChunkedGraph, layer: int, nodes: list, nodes_ts: list, earliest_ts ): """ Collect timestamps of edits from children, since we use the same timestamp @@ -83,7 +83,6 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l try: cx_edges_d = CX_EDGES[node][node_ts] except KeyError: - print(CX_EDGES) raise KeyError(f"{node}:{node_ts}") edges = np.concatenate([empty_2d] + list(cx_edges_d.values())) if edges.size: @@ -158,15 +157,14 @@ def update_chunk( chunked_nodes_ts = chunked(nodes_ts, task_size) cg_info = cg.get_serialized_info() - multi_args = [] + tasks = [] for chunk, ts_chunk in zip(chunked_nodes, chunked_nodes_ts): args = (cg_info, layer, chunk, ts_chunk, earliest_ts) - multi_args.append(args) - - print(f"nodes: {len(nodes)}, tasks: {len(multi_args)}, size: {task_size}") - mu.multiprocess_func( - _update_cross_edges_helper, - multi_args, - n_threads=min(len(multi_args), mp.cpu_count()), - ) + tasks.append(args) + + with mp.Pool(min(mp.cpu_count(), len(tasks))) as pool: + tqdm( + pool.imap_unordered(_update_cross_edges_helper, tasks), + total=len(tasks), + ) print(f"total elaspsed time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 3d573ce37..1692db43b 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -1,6 +1,7 @@ # pylint: disable=invalid-name, missing-docstring import logging +import functools from os import environ from time import sleep from typing import Any, Generator, Tuple @@ -16,6 +17,8 @@ from ..graph.client import BackendClientInfo from ..graph.client.bigtable import BigTableConfig from ..utils.general import chunked +from ..utils.redis import get_redis_connection +from ..utils.redis import keys as r_keys chunk_id_str = lambda layer, coords: f"{layer}_{'_'.join(map(str, coords))}" @@ -116,7 +119,7 @@ def print_completion_rate(imanager: IngestionManager, layer: int, span: int = 10 print(f"{rate} chunks per second.") -def print_ingest_status(imanager: IngestionManager, redis, upgrade: bool = False): +def print_status(imanager: IngestionManager, redis, upgrade: bool = False): """ Helper to print status to console. If `upgrade=True`, status does not include the root layer, @@ -128,6 +131,7 @@ def print_ingest_status(imanager: IngestionManager, redis, upgrade: bool = False layer_counts = imanager.cg_meta.layer_chunk_counts pipeline = redis.pipeline() + pipeline.get(r_keys.JOB_TYPE) worker_busy = [] for layer in layers: pipeline.scard(f"{layer}c") @@ -138,25 +142,32 @@ def print_ingest_status(imanager: IngestionManager, redis, upgrade: bool = False worker_busy.append(sum([w.get_state() == WorkerStatus.BUSY for w in workers])) results = pipeline.execute() + job_type = "not_available" + if results[0] is not None: + job_type = results[0].decode() completed = [] queued = [] failed = [] - for i in range(0, len(results), 3): + for i in range(1, len(results), 3): result = results[i : i + 3] completed.append(result[0]) queued.append(result[1]) failed.append(result[2]) - print(f"version: \t{imanager.cg.version}") - print(f"graph_id: \t{imanager.cg.graph_id}") - print(f"chunk_size: \t{imanager.cg.meta.graph_config.CHUNK_SIZE}") - print("\nlayer status:") + header = ( + f"\njob_type: \t{job_type}" + f"\nversion: \t{imanager.cg.version}" + f"\ngraph_id: \t{imanager.cg.graph_id}" + f"\nchunk_size: \t{imanager.cg.meta.graph_config.CHUNK_SIZE}" + "\n\nlayer status:" + ) + print(header) for layer, done, count in zip(layers, completed, layer_counts): - print(f"{layer}\t: {done:<9} / {count}") + print(f"{layer}\t| {done:9} / {count} \t| {done/count:6.1%}") print("\n\nqueue status:") for layer, q, f, wb in zip(layers, queued, failed, worker_busy): - print(f"l{layer}\t: queued: {q:<10} failed: {f:<10} busy: {wb}") + print(f"l{layer}\t| queued: {q:<10} failed: {f:<10} busy: {wb}") def queue_layer_helper(parent_layer: int, imanager: IngestionManager, fn): @@ -190,3 +201,25 @@ def queue_layer_helper(parent_layer: int, imanager: IngestionManager, fn): ) ) q.enqueue_many(job_datas) + + +def job_type_guard(job_type: str): + def decorator_job_type_guard(func): + @functools.wraps(func) + def wrapper_job_type_guard(*args, **kwargs): + redis = get_redis_connection() + current_type = redis.get(r_keys.JOB_TYPE) + if current_type is not None: + current_type = current_type.decode() + msg = ( + f"Currently running `{current_type}`. You're attempting to run `{job_type}`." + f"\nRun `[flask] {current_type} flush_redis` to clear the current job and restart." + ) + if current_type != job_type: + print(f"\n*WARNING*\n{msg}") + exit(1) + return func(*args, **kwargs) + + return wrapper_job_type_guard + + return decorator_job_type_guard diff --git a/pychunkedgraph/utils/redis.py b/pychunkedgraph/utils/redis.py index 420a849f1..fa43c867a 100644 --- a/pychunkedgraph/utils/redis.py +++ b/pychunkedgraph/utils/redis.py @@ -19,8 +19,8 @@ REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0" -keys_fields = ("INGESTION_MANAGER",) -keys_defaults = ("pcg:imanager",) +keys_fields = ("INGESTION_MANAGER", "JOB_TYPE") +keys_defaults = ("pcg:imanager", "pcg:job_type") Keys = namedtuple("keys", keys_fields, defaults=keys_defaults) keys = Keys() From 528b60fa1f9b4ed31211877aa91923da7705dcaf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 10 Nov 2024 19:47:48 +0000 Subject: [PATCH 102/367] fix(upgrade): include timestamps for partner supervoxel parents --- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/ingest/upgrade/atomic_layer.py | 8 ++++++-- pychunkedgraph/ingest/upgrade/parent_layer.py | 8 +++++--- pychunkedgraph/ingest/utils.py | 3 ++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 6ed01825f..c11769ec9 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.6" +__version__ = "3.0.7" diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index a975146de..c9c8bdb11 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -12,7 +12,7 @@ def update_cross_edges( - cg: ChunkedGraph, node, cx_edges_d, node_ts, timestamps, earliest_ts + cg: ChunkedGraph, node, cx_edges_d: dict, node_ts, timestamps: set, earliest_ts ) -> list: """ Helper function to update a single L2 ID. @@ -27,7 +27,11 @@ def update_cross_edges( assert not exists_as_parent(cg, node, edges[:, 0]) return rows - for ts in timestamps: + partner_parent_ts_d = get_parent_timestamps(cg, edges[:, 1]) + for v in partner_parent_ts_d.values(): + timestamps.update(v) + + for ts in sorted(timestamps): if ts < earliest_ts: ts = earliest_ts val_dict = {} diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 7c95cc1b6..dace88b43 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -163,8 +163,10 @@ def update_chunk( tasks.append(args) with mp.Pool(min(mp.cpu_count(), len(tasks))) as pool: - tqdm( - pool.imap_unordered(_update_cross_edges_helper, tasks), - total=len(tasks), + _ = list( + tqdm( + pool.imap_unordered(_update_cross_edges_helper, tasks), + total=len(tasks), + ) ) print(f"total elaspsed time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 1692db43b..45b6e728f 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -2,6 +2,7 @@ import logging import functools +import math from os import environ from time import sleep from typing import Any, Generator, Tuple @@ -163,7 +164,7 @@ def print_status(imanager: IngestionManager, redis, upgrade: bool = False): ) print(header) for layer, done, count in zip(layers, completed, layer_counts): - print(f"{layer}\t| {done:9} / {count} \t| {done/count:6.1%}") + print(f"{layer}\t| {done:9} / {count} \t| {math.floor((done/count)*100):6}%") print("\n\nqueue status:") for layer, q, f, wb in zip(layers, queued, failed, worker_busy): From 8ec81a6b09bb039c71d799d54fa941e7931eb838 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 Nov 2024 18:49:27 +0000 Subject: [PATCH 103/367] fix(upgrade): use timestamps of partners at layers > 2 --- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/ingest/upgrade/parent_layer.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index c11769ec9..35c154a9d 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.7" +__version__ = "3.0.8" diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index dace88b43..6f0b08711 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -66,7 +66,14 @@ def _populate_cx_edges_with_timestamps( for node, node_ts in zip(nodes, nodes_ts): CX_EDGES[node] = {} timestamps = timestamps_d[node] - timestamps.add(node_ts) + cx_edges_d_node_ts = _get_cx_edges_at_timestamp(node, response, node_ts) + + edges = np.concatenate([empty_2d] + list(cx_edges_d_node_ts.values())) + partner_parent_ts_d = get_parent_timestamps(cg, edges[:, 1]) + for v in partner_parent_ts_d.values(): + timestamps.update(v) + CX_EDGES[node][node_ts] = cx_edges_d_node_ts + for ts in sorted(timestamps): if ts < earliest_ts: ts = earliest_ts From fcd152c0ab37c2ec2ff75dea10bc2e662a732817 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 5 Dec 2024 20:33:53 +0000 Subject: [PATCH 104/367] version 3.0.9 --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6526fbc66..250e55eff 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.1 +current_version = 3.0.9 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 35c154a9d..67ae584d7 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.8" +__version__ = "3.0.9" From 7f13deb4fb86341c882394bab988f3c90330036e Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 9 Dec 2024 22:33:47 +0000 Subject: [PATCH 105/367] feat: use mesh dir and dynamic dir from metadata --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 250e55eff..2a9dad726 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.9 +current_version = 3.0.10 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 67ae584d7..84994dc59 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.9" +__version__ = "3.0.10" From 3c963ee5e097ae0c20bdb29bcd8bf2c5111035f2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 15 Jul 2025 21:44:19 +0000 Subject: [PATCH 106/367] ingest: change job batch size, more logging --- pychunkedgraph/ingest/cluster.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 485251568..f557ac45a 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -197,12 +197,12 @@ def _get_test_chunks(meta: ChunkedGraphMeta): def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterable): queue_name = "l2" q = imanager.get_task_queue(queue_name) - batch_size = int(environ.get("JOB_BATCH_SIZE", 100000)) + batch_size = int(environ.get("JOB_BATCH_SIZE", 10000)) batches = chunked(coords, batch_size) for batch in batches: _coords = get_chunks_not_done(imanager, 2, batch) # buffer for optimal use of redis memory - if len(q) > int(environ.get("QUEUE_SIZE", 100000)): + if len(q) > int(environ.get("QUEUE_SIZE", 1000000)): interval = int(environ.get("QUEUE_INTERVAL", 300)) logging.info(f"Queue full; sleeping {interval}s...") sleep(interval) @@ -219,6 +219,7 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl ) ) q.enqueue_many(job_datas) + logging.info(f"Queued {len(job_datas)} chunks.") def enqueue_l2_tasks(imanager: IngestionManager, chunk_fn: Callable): From d5986db3249c5dec34798bd3c65e5a261022c45f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 15 Jul 2025 21:44:25 +0000 Subject: [PATCH 107/367] =?UTF-8?q?Bump=20version:=203.0.10=20=E2=86=92=20?= =?UTF-8?q?3.0.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2a9dad726..5f550ff4a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.10 +current_version = 3.0.11 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 84994dc59..6c5152b5f 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.10" +__version__ = "3.0.11" From d2b80f8a60aebb69eeef252095248f4e9cfdb869 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 15 Jul 2025 22:20:26 +0000 Subject: [PATCH 108/367] ingest: add socket_timeout for redis connections --- pychunkedgraph/utils/redis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/utils/redis.py b/pychunkedgraph/utils/redis.py index fa43c867a..45ccfbdcc 100644 --- a/pychunkedgraph/utils/redis.py +++ b/pychunkedgraph/utils/redis.py @@ -27,9 +27,9 @@ def get_redis_connection(redis_url=REDIS_URL): - return redis.Redis.from_url(redis_url) + return redis.Redis.from_url(redis_url, socket_timeout=60) def get_rq_queue(queue): - connection = redis.Redis.from_url(REDIS_URL) + connection = redis.Redis.from_url(REDIS_URL, socket_timeout=60) return Queue(queue, connection=connection) From 56629f8968cc5bc93a66cdc71f325071d7089839 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 15 Jul 2025 22:20:34 +0000 Subject: [PATCH 109/367] =?UTF-8?q?Bump=20version:=203.0.11=20=E2=86=92=20?= =?UTF-8?q?3.0.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5f550ff4a..7592461a3 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.11 +current_version = 3.0.12 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 6c5152b5f..730a6c4a9 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.11" +__version__ = "3.0.12" From 14580c786ae236feee7da3c7c9e837cd2ef95e92 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 17 Jul 2025 21:01:45 +0000 Subject: [PATCH 110/367] fix(edits): descriptive error message --- pychunkedgraph/graph/edits.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index afe1b3abf..899d1ce42 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -621,7 +621,9 @@ def _update_cross_edge_cache(self, parent, children): continue edges = fastremap.remap(edges, edge_parents_d, preserve_missing_labels=True) new_cx_edges_d[layer] = np.unique(edges, axis=0) - assert np.all(edges[:, 0] == parent), f"{parent}, {np.unique(edges[:, 0])}" + assert np.all( + edges[:, 0] == parent + ), f"OP {self._operation_id}: parent mismatch {parent} != {np.unique(edges[:, 0])}" self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d def _create_new_parents(self, layer: int): From 964f6ee3e6e44d90efae750f80d140f988acb122 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 17 Jul 2025 21:01:57 +0000 Subject: [PATCH 111/367] =?UTF-8?q?Bump=20version:=203.0.12=20=E2=86=92=20?= =?UTF-8?q?3.0.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 7592461a3..1e9b72ac5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.12 +current_version = 3.0.13 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 730a6c4a9..1adf1ce9e 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.12" +__version__ = "3.0.13" From b1f715f6c3ef36414dbe1189841875f059c4ef9f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 29 Jul 2025 23:51:48 +0000 Subject: [PATCH 112/367] fix(edits): find stale edges and their latest nodes --- pychunkedgraph/graph/chunkedgraph.py | 40 +++++ pychunkedgraph/graph/chunks/hierarchy.py | 15 +- pychunkedgraph/graph/edges/__init__.py | 168 +++++++++++++++++- pychunkedgraph/graph/edges/utils.py | 2 +- pychunkedgraph/graph/edits.py | 54 +++--- pychunkedgraph/ingest/upgrade/parent_layer.py | 3 +- 6 files changed, 255 insertions(+), 27 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 7823695db..143d1ba9e 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -940,6 +940,11 @@ def get_parent_chunk_id( self.meta, node_or_chunk_id, parent_layer ) + def get_parent_chunk_id_multiple(self, node_or_chunk_ids: typing.Sequence): + return chunk_hierarchy.get_parent_chunk_id_multiple( + self.meta, node_or_chunk_ids + ) + def get_parent_chunk_ids(self, node_or_chunk_id: basetypes.NODE_ID): return chunk_hierarchy.get_parent_chunk_ids(self.meta, node_or_chunk_id) @@ -984,3 +989,38 @@ def get_operation_ids(self, node_ids: typing.Sequence): except KeyError: ... return result + + def get_single_leaf_multiple(self, node_ids): + """Returns the first supervoxel found for each node_id.""" + result = {} + node_ids_copy = np.copy(node_ids) + children = np.copy(node_ids) + children_d = self.get_children(node_ids) + while True: + children = [children_d[k][0] for k in children] + children = np.array(children, dtype=basetypes.NODE_ID) + mask = self.get_chunk_layers(children) == 1 + result.update( + [(node, sv) for node, sv in zip(node_ids[mask], children[mask])] + ) + node_ids = node_ids[~mask] + children = children[~mask] + if children.size == 0: + break + children_d = self.get_children(children) + return np.array([result[k] for k in node_ids_copy], dtype=basetypes.NODE_ID) + + def get_chunk_layers_and_coordinates(self, node_or_chunk_ids: typing.Sequence): + """ + Helper function that wraps get chunk layer and coordinates for nodes at any layer. + """ + node_or_chunk_ids = np.array(node_or_chunk_ids, dtype=basetypes.NODE_ID) + layers = self.get_chunk_layers(node_or_chunk_ids) + chunk_coords = np.zeros(shape=(len(node_or_chunk_ids), 3)) + for _layer in np.unique(layers): + mask = layers == _layer + _nodes = node_or_chunk_ids[mask] + chunk_coords[mask] = chunk_utils.get_chunk_coordinates_multiple( + self.meta, _nodes + ) + return layers, chunk_coords diff --git a/pychunkedgraph/graph/chunks/hierarchy.py b/pychunkedgraph/graph/chunks/hierarchy.py index 32d6029ee..6128d5914 100644 --- a/pychunkedgraph/graph/chunks/hierarchy.py +++ b/pychunkedgraph/graph/chunks/hierarchy.py @@ -43,7 +43,7 @@ def get_children_chunk_ids( else: children_coords = get_children_chunk_coords(meta, layer, (x, y, z)) children_chunk_ids = [] - for (x, y, z) in children_coords: + for x, y, z in children_coords: children_chunk_ids.append( utils.get_chunk_id(meta, layer=layer - 1, x=x, y=y, z=z) ) @@ -62,6 +62,19 @@ def get_parent_chunk_id( return utils.get_chunk_id(meta, layer=parent_layer, x=x, y=y, z=z) +def get_parent_chunk_id_multiple( + meta: ChunkedGraphMeta, node_or_chunk_ids: np.ndarray +) -> np.ndarray: + """Parent chunk IDs for multiple nodes. Assumes nodes at same layer.""" + + node_layers = utils.get_chunk_layers(meta, node_or_chunk_ids) + assert np.unique(node_layers).size == 1, np.unique(node_layers) + parent_layer = node_layers[0] + 1 + coords = utils.get_chunk_coordinates_multiple(meta, node_or_chunk_ids) + coords = coords // meta.graph_config.FANOUT + return utils.get_chunk_ids_from_coords(meta, layer=parent_layer, coords=coords) + + def get_parent_chunk_ids( meta: ChunkedGraphMeta, node_or_chunk_id: np.uint64 ) -> np.ndarray: diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 430ab9fa7..2bc523313 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -3,14 +3,20 @@ """ from collections import namedtuple +import datetime from os import environ -from typing import Optional +from copy import copy +from typing import Iterable, Optional import numpy as np import tensorstore as ts import zstandard as zstd from graph_tool import Graph +from pychunkedgraph.graph import types +from pychunkedgraph.graph.chunks import utils as chunk_utils +from pychunkedgraph.graph.utils import basetypes + from ..utils import basetypes @@ -189,3 +195,163 @@ def get_edges(source: str, nodes: np.ndarray) -> Edges: affinities=np.concatenate(affinities), areas=np.concatenate(areas), ) + + +def get_stale_nodes( + cg, edge_nodes: Iterable[basetypes.NODE_ID], parent_ts: datetime.datetime = None +): + """ + Checks to see if partner nodes in edges (edges[:,1]) are stale. + This is done by getting a supervoxel of the node and check + if it has a new parent at the same layer as the node. + """ + edge_supervoxels = cg.get_single_leaf_multiple(edge_nodes) + # nodes can be at different layers due to skip connections + edge_nodes_layers = cg.get_chunk_layers(edge_nodes) + stale_nodes = [types.empty_1d] + for layer in np.unique(edge_nodes_layers): + _mask = edge_nodes_layers == layer + layer_nodes = edge_nodes[_mask] + _nodes = cg.get_roots( + edge_supervoxels[_mask], + stop_layer=layer, + ceil=False, + time_stamp=parent_ts, + ) + stale_mask = layer_nodes != _nodes + stale_nodes.append(layer_nodes[stale_mask]) + return np.concatenate(stale_nodes), edge_supervoxels + + +def get_latest_edges( + cg, + stale_edges: Iterable, + edge_layers: Iterable, + parent_ts: datetime.datetime = None, +) -> dict: + """ + For each of stale_edges [[`node`, `partner`]], get their L2 edge equivalent. + Then get supervoxels of those L2 IDs and get parent(s) at `node` level. + These parents would be the new identities for the stale `partner`. + """ + _nodes = np.unique(stale_edges[:, 1]) + nodes_ts_map = dict(zip(_nodes, cg.get_node_timestamps(_nodes, return_numpy=False))) + _nodes = np.unique(stale_edges) + layers, coords = cg.get_chunk_layers_and_coordinates(_nodes) + layers_d = dict(zip(_nodes, layers)) + coords_d = dict(zip(_nodes, coords)) + + def _get_normalized_coords(node_a, node_b) -> tuple: + max_layer = layers_d[node_a] + coord_a, coord_b = coords_d[node_a], coords_d[node_b] + if layers_d[node_a] != layers_d[node_b]: + # normalize if nodes are not from the same layer + max_layer = max(layers_d[node_a], layers_d[node_b]) + chunk_a = cg.get_parent_chunk_id(node_a, parent_layer=max_layer) + chunk_b = cg.get_parent_chunk_id(node_b, parent_layer=max_layer) + coord_a, coord_b = cg.get_chunk_coordinates_multiple([chunk_a, chunk_b]) + return max_layer, coord_a, coord_b + + def _get_l2chunkids_along_boundary(max_layer, coord_a, coord_b): + direction = coord_a - coord_b + axis = np.flatnonzero(direction) + assert len(axis) == 1, f"{direction}, {coord_a}, {coord_b}" + axis = axis[0] + children_a = chunk_utils.get_bounding_children_chunks( + cg.meta, max_layer, coord_a, children_layer=2 + ) + children_b = chunk_utils.get_bounding_children_chunks( + cg.meta, max_layer, coord_b, children_layer=2 + ) + if direction[axis] > 0: + mid = coord_a[axis] * 2 ** (max_layer - 2) + l2chunks_a = children_a[children_a[:, axis] == mid] + l2chunks_b = children_b[children_b[:, axis] == mid - 1] + else: + mid = coord_b[axis] * 2 ** (max_layer - 2) + l2chunks_a = children_a[children_a[:, axis] == mid - 1] + l2chunks_b = children_b[children_b[:, axis] == mid] + + l2chunk_ids_a = chunk_utils.get_chunk_ids_from_coords(cg.meta, 2, l2chunks_a) + l2chunk_ids_b = chunk_utils.get_chunk_ids_from_coords(cg.meta, 2, l2chunks_b) + return l2chunk_ids_a, l2chunk_ids_b + + def _get_filtered_l2ids(node_a, node_b, chunks_map): + def _filter(node): + result = [] + children = cg.get_children(node) + while True: + chunk_ids = cg.get_chunk_ids_from_node_ids(children) + mask = np.isin(chunk_ids, chunks_map[node]) + children = children[mask] + + mask = cg.get_chunk_layers(children) == 2 + result.append(children[mask]) + + mask = cg.get_chunk_layers(children) > 2 + if children[mask].size == 0: + break + children = cg.get_children(children[mask], flatten=True) + return np.concatenate(result) + + return _filter(node_a), _filter(node_b) + + result = [] + chunks_map = {} + for edge_layer, _edge in zip(edge_layers, stale_edges): + node_a, node_b = _edge + mlayer, coord_a, coord_b = _get_normalized_coords(node_a, node_b) + chunks_a, chunks_b = _get_l2chunkids_along_boundary(mlayer, coord_a, coord_b) + + chunks_map[node_a] = [] + chunks_map[node_b] = [] + _layer = 2 + while _layer < mlayer: + chunks_map[node_a].append(chunks_a) + chunks_map[node_b].append(chunks_b) + chunks_a = np.unique(cg.get_parent_chunk_id_multiple(chunks_a)) + chunks_b = np.unique(cg.get_parent_chunk_id_multiple(chunks_b)) + _layer += 1 + chunks_map[node_a] = np.concatenate(chunks_map[node_a]) + chunks_map[node_b] = np.concatenate(chunks_map[node_b]) + + l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, chunks_map) + edges_d = cg.get_cross_chunk_edges( + node_ids=l2ids_a, time_stamp=nodes_ts_map[node_b], raw_only=True + ) + + _edges = [] + for v in edges_d.values(): + _edges.append(v.get(edge_layer, types.empty_2d)) + _edges = np.concatenate(_edges) + mask = np.isin(_edges[:, 1], l2ids_b) + + children_a = cg.get_children(_edges[mask][:, 0], flatten=True) + children_b = cg.get_children(_edges[mask][:, 1], flatten=True) + if 85431849467249595 in children_a and 85502218144317440 in children_b: + print("woohoo0") + continue + + if 85502218144317440 in children_a and 85431849467249595 in children_b: + print("woohoo1") + continue + parents_a = np.unique( + cg.get_roots( + children_a, stop_layer=mlayer, ceil=False, time_stamp=parent_ts + ) + ) + assert parents_a.size == 1 and parents_a[0] == node_a, ( + node_a, + parents_a, + children_a, + ) + + parents_b = np.unique( + cg.get_roots( + children_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts + ) + ) + + parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) + result.append(np.column_stack((parents_a, parents_b))) + return np.concatenate(result) diff --git a/pychunkedgraph/graph/edges/utils.py b/pychunkedgraph/graph/edges/utils.py index 76f8ea1d8..b49a9a547 100644 --- a/pychunkedgraph/graph/edges/utils.py +++ b/pychunkedgraph/graph/edges/utils.py @@ -135,7 +135,7 @@ def categorize_edges_v2( def get_cross_chunk_edges_layer(meta: ChunkedGraphMeta, cross_edges: Iterable): - """Computes the layer in which a cross chunk edge becomes relevant. + """Computes the layer in which an atomic cross chunk edge becomes relevant. I.e. if a cross chunk edge links two nodes in layer 4 this function returns 3. :param cross_edges: n x 2 array diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 899d1ce42..af96ebb93 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -15,6 +15,7 @@ from . import types from . import attributes from . import cache as cache_utils +from .edges import get_latest_edges, get_stale_nodes from .edges.utils import concatenate_cross_edge_dicts from .edges.utils import merge_cross_edge_dicts from .utils import basetypes @@ -497,25 +498,6 @@ def _update_neighbor_cross_edges( return updated_entries -def get_supervoxels(cg, node_ids): - """Returns the first supervoxel found for each node_id.""" - result = {} - node_ids_copy = np.copy(node_ids) - children = np.copy(node_ids) - children_d = cg.get_children(node_ids) - while True: - children = [children_d[k][0] for k in children] - children = np.array(children, dtype=basetypes.NODE_ID) - mask = cg.get_chunk_layers(children) == 1 - result.update([(node, sv) for node, sv in zip(node_ids[mask], children[mask])]) - node_ids = node_ids[~mask] - children = children[~mask] - if children.size == 0: - break - children_d = cg.get_children(children) - return np.array([result[k] for k in node_ids_copy], dtype=basetypes.NODE_ID) - - class CreateParentNodes: def __init__( self, @@ -605,10 +587,38 @@ def _update_cross_edge_cache(self, parent, children): children, time_stamp=self._last_successful_ts ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) - edge_nodes = np.unique(np.concatenate([*cx_edges_d.values(), types.empty_2d])) - edge_supervoxels = get_supervoxels(self.cg, edge_nodes) + + _cx_edges = [types.empty_2d] + _edge_layers = [types.empty_1d] + for k, v in cx_edges_d.items(): + _cx_edges.append(v) + _edge_layers.append([k] * len(v)) + _cx_edges = np.concatenate(_cx_edges) + _edge_layers = np.concatenate(_edge_layers, dtype=int) + + edge_nodes = np.unique(_cx_edges) + stale_nodes, edge_supervoxels = get_stale_nodes( + self.cg, edge_nodes, parent_ts=self._last_successful_ts + ) + stale_nodes_mask = np.isin(edge_nodes, stale_nodes) + + latest_edges = types.empty_2d.copy() + if np.any(stale_nodes_mask): + stalte_edges_mask = _cx_edges[:, 1] == stale_nodes + stale_edges = _cx_edges[stalte_edges_mask] + stale_edge_layers = _edge_layers[stalte_edges_mask] + latest_edges = get_latest_edges( + self.cg, + stale_edges, + stale_edge_layers, + parent_ts=self._last_successful_ts, + ) + + _cx_edges = np.concatenate([_cx_edges, latest_edges]) + edge_nodes = np.unique(_cx_edges) + edge_parents = self.cg.get_roots( - edge_supervoxels, + edge_nodes, stop_layer=parent_layer, ceil=False, time_stamp=self._last_successful_ts, diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 6f0b08711..80558a362 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -10,7 +10,6 @@ from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.attributes import Connectivity, Hierarchy -from pychunkedgraph.graph.edits import get_supervoxels from pychunkedgraph.graph.utils import serializers from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked @@ -110,7 +109,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l if edges.size == 0: continue nodes = np.unique(edges[:, 1]) - svs = get_supervoxels(cg, nodes) + svs = cg.get_single_leaf_multiple(nodes) parents = cg.get_roots(svs, time_stamp=ts, stop_layer=layer, ceil=False) edge_parents_d = dict(zip(nodes, parents)) val_dict = {} From 10fc1ac304a7c61bcfe584f3aa501afa9f6faadf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 3 Aug 2025 21:26:08 +0000 Subject: [PATCH 113/367] fix(edits): more precise filter for latest edges; error on chunk_id mismatch (ingest); bump version --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/graph/chunkedgraph.py | 3 +- pychunkedgraph/graph/edges/__init__.py | 77 ++++++++++++------- pychunkedgraph/graph/edits.py | 34 +------- pychunkedgraph/ingest/cluster.py | 11 +-- pychunkedgraph/ingest/upgrade/parent_layer.py | 15 ++-- pychunkedgraph/ingest/utils.py | 1 + 8 files changed, 71 insertions(+), 74 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1e9b72ac5..59a83e91b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.13 +current_version = 3.1.0 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 1adf1ce9e..f5f41e567 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.0.13" +__version__ = "3.1.0" diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 143d1ba9e..1754315d8 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -216,6 +216,7 @@ def get_parents( if fail_to_zero: parents.append(0) else: + exc.add_note(f"timestamp: {time_stamp}") raise KeyError from exc parents = np.array(parents, dtype=basetypes.NODE_ID) else: @@ -1016,7 +1017,7 @@ def get_chunk_layers_and_coordinates(self, node_or_chunk_ids: typing.Sequence): """ node_or_chunk_ids = np.array(node_or_chunk_ids, dtype=basetypes.NODE_ID) layers = self.get_chunk_layers(node_or_chunk_ids) - chunk_coords = np.zeros(shape=(len(node_or_chunk_ids), 3)) + chunk_coords = np.zeros(shape=(len(node_or_chunk_ids), 3), dtype=int) for _layer in np.unique(layers): mask = layers == _layer _nodes = node_or_chunk_ids[mask] diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 2bc523313..1d54248c2 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -220,7 +220,7 @@ def get_stale_nodes( ) stale_mask = layer_nodes != _nodes stale_nodes.append(layer_nodes[stale_mask]) - return np.concatenate(stale_nodes), edge_supervoxels + return np.concatenate(stale_nodes) def get_latest_edges( @@ -279,7 +279,7 @@ def _get_l2chunkids_along_boundary(max_layer, coord_a, coord_b): def _get_filtered_l2ids(node_a, node_b, chunks_map): def _filter(node): result = [] - children = cg.get_children(node) + children = np.array([node], dtype=basetypes.NODE_ID) while True: chunk_ids = cg.get_chunk_ids_from_node_ids(children) mask = np.isin(chunk_ids, chunks_map[node]) @@ -296,15 +296,15 @@ def _filter(node): return _filter(node_a), _filter(node_b) - result = [] + result = [types.empty_2d] chunks_map = {} for edge_layer, _edge in zip(edge_layers, stale_edges): node_a, node_b = _edge mlayer, coord_a, coord_b = _get_normalized_coords(node_a, node_b) chunks_a, chunks_b = _get_l2chunkids_along_boundary(mlayer, coord_a, coord_b) - chunks_map[node_a] = [] - chunks_map[node_b] = [] + chunks_map[node_a] = [np.array([cg.get_chunk_id(node_a)])] + chunks_map[node_b] = [np.array([cg.get_chunk_id(node_b)])] _layer = 2 while _layer < mlayer: chunks_map[node_a].append(chunks_a) @@ -312,8 +312,8 @@ def _filter(node): chunks_a = np.unique(cg.get_parent_chunk_id_multiple(chunks_a)) chunks_b = np.unique(cg.get_parent_chunk_id_multiple(chunks_b)) _layer += 1 - chunks_map[node_a] = np.concatenate(chunks_map[node_a]) - chunks_map[node_b] = np.concatenate(chunks_map[node_b]) + chunks_map[node_a] = np.concatenate(chunks_map[node_a]).astype(basetypes.NODE_ID) + chunks_map[node_b] = np.concatenate(chunks_map[node_b]).astype(basetypes.NODE_ID) l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, chunks_map) edges_d = cg.get_cross_chunk_edges( @@ -326,32 +326,57 @@ def _filter(node): _edges = np.concatenate(_edges) mask = np.isin(_edges[:, 1], l2ids_b) - children_a = cg.get_children(_edges[mask][:, 0], flatten=True) children_b = cg.get_children(_edges[mask][:, 1], flatten=True) - if 85431849467249595 in children_a and 85502218144317440 in children_b: - print("woohoo0") - continue - - if 85502218144317440 in children_a and 85431849467249595 in children_b: - print("woohoo1") - continue - parents_a = np.unique( - cg.get_roots( - children_a, stop_layer=mlayer, ceil=False, time_stamp=parent_ts - ) - ) - assert parents_a.size == 1 and parents_a[0] == node_a, ( - node_a, - parents_a, - children_a, - ) + parents_a = _edges[mask][:, 0] + parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) + _cx_edges_d = cg.get_cross_chunk_edges(parents_b) + parents_b = [] + for _node, _edges_d in _cx_edges_d.items(): + for _edges in _edges_d.values(): + _mask = np.isin(_edges[:,1], parents_a) + if np.any(_mask): + parents_b.append(_node) + + parents_b = np.array(parents_b, dtype=basetypes.NODE_ID) parents_b = np.unique( cg.get_roots( - children_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts + parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts ) ) parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) result.append(np.column_stack((parents_a, parents_b))) return np.concatenate(result) + + +def get_latest_edges_wrapper( + cg, + cx_edges_d: dict, + parent_ts: datetime.datetime = None, +) -> np.ndarray: + """Helper function to filter stale edges and replace with latest edges.""" + _cx_edges = [types.empty_2d] + _edge_layers = [types.empty_1d] + for k, v in cx_edges_d.items(): + _cx_edges.append(v) + _edge_layers.append([k] * len(v)) + _cx_edges = np.concatenate(_cx_edges) + _edge_layers = np.concatenate(_edge_layers, dtype=int) + + edge_nodes = np.unique(_cx_edges) + stale_nodes = get_stale_nodes(cg, edge_nodes, parent_ts=parent_ts) + stale_nodes_mask = np.isin(edge_nodes, stale_nodes) + + latest_edges = types.empty_2d.copy() + if np.any(stale_nodes_mask): + stalte_edges_mask = np.isin(_cx_edges[:, 1], stale_nodes) + stale_edges = _cx_edges[stalte_edges_mask] + stale_edge_layers = _edge_layers[stalte_edges_mask] + latest_edges = get_latest_edges( + cg, + stale_edges, + stale_edge_layers, + parent_ts=parent_ts, + ) + return np.concatenate([_cx_edges, latest_edges]) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index af96ebb93..4ac9352a8 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -15,7 +15,7 @@ from . import types from . import attributes from . import cache as cache_utils -from .edges import get_latest_edges, get_stale_nodes +from .edges import get_latest_edges, get_latest_edges_wrapper, get_stale_nodes from .edges.utils import concatenate_cross_edge_dicts from .edges.utils import merge_cross_edge_dicts from .utils import basetypes @@ -587,36 +587,10 @@ def _update_cross_edge_cache(self, parent, children): children, time_stamp=self._last_successful_ts ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) - - _cx_edges = [types.empty_2d] - _edge_layers = [types.empty_1d] - for k, v in cx_edges_d.items(): - _cx_edges.append(v) - _edge_layers.append([k] * len(v)) - _cx_edges = np.concatenate(_cx_edges) - _edge_layers = np.concatenate(_edge_layers, dtype=int) - - edge_nodes = np.unique(_cx_edges) - stale_nodes, edge_supervoxels = get_stale_nodes( - self.cg, edge_nodes, parent_ts=self._last_successful_ts + _cx_edges = get_latest_edges_wrapper( + self.cg, cx_edges_d, parent_ts=self._last_successful_ts ) - stale_nodes_mask = np.isin(edge_nodes, stale_nodes) - - latest_edges = types.empty_2d.copy() - if np.any(stale_nodes_mask): - stalte_edges_mask = _cx_edges[:, 1] == stale_nodes - stale_edges = _cx_edges[stalte_edges_mask] - stale_edge_layers = _edge_layers[stalte_edges_mask] - latest_edges = get_latest_edges( - self.cg, - stale_edges, - stale_edge_layers, - parent_ts=self._last_successful_ts, - ) - - _cx_edges = np.concatenate([_cx_edges, latest_edges]) - edge_nodes = np.unique(_cx_edges) - + edge_nodes = np.unique(_cx_edges) edge_parents = self.cg.get_roots( edge_nodes, stop_layer=parent_layer, diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index f557ac45a..1ae13a353 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -108,15 +108,8 @@ def _check_edges_direction( chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) for edge_type in [EDGE_TYPES.between_chunk, EDGE_TYPES.cross_chunk]: edges = chunk_edges[edge_type] - e1 = edges.node_ids1 - e2 = edges.node_ids2 - - e2_chunk_ids = cg.get_chunk_ids_from_node_ids(e2) - mask = e2_chunk_ids == chunk_id - e1[mask], e2[mask] = e2[mask], e1[mask] - - e1_chunk_ids = cg.get_chunk_ids_from_node_ids(e1) - mask = e1_chunk_ids == chunk_id + chunk_ids = cg.get_chunk_ids_from_node_ids(edges.node_ids1) + mask = chunk_ids == chunk_id assert np.all(mask), "all IDs must belong to same chunk" diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 80558a362..b8503f1d9 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -10,6 +10,7 @@ from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.attributes import Connectivity, Hierarchy +from pychunkedgraph.graph.edges import get_latest_edges_wrapper from pychunkedgraph.graph.utils import serializers from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked @@ -104,14 +105,17 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l assert not exists_as_parent(cg, node, edges[:, 0]), f"{node}, {node_ts}" return rows + row_id = serializers.serialize_uint64(node) for ts, cx_edges_d in CX_EDGES[node].items(): - edges = np.concatenate([empty_2d] + list(cx_edges_d.values())) + if node_ts > ts: + continue + edges = get_latest_edges_wrapper(cg, cx_edges_d, parent_ts=ts) if edges.size == 0: continue - nodes = np.unique(edges[:, 1]) - svs = cg.get_single_leaf_multiple(nodes) - parents = cg.get_roots(svs, time_stamp=ts, stop_layer=layer, ceil=False) - edge_parents_d = dict(zip(nodes, parents)) + + edge_nodes = np.unique(edges) + parents = cg.get_roots(edge_nodes, time_stamp=ts, stop_layer=layer, ceil=False) + edge_parents_d = dict(zip(edge_nodes, parents)) val_dict = {} for _layer, layer_edges in cx_edges_d.items(): layer_edges = fastremap.remap( @@ -121,7 +125,6 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l layer_edges = np.unique(layer_edges, axis=0) col = Connectivity.CrossChunkEdge[_layer] val_dict[col] = layer_edges - row_id = serializers.serialize_uint64(node) rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=ts)) return rows diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 45b6e728f..5c51242ac 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -202,6 +202,7 @@ def queue_layer_helper(parent_layer: int, imanager: IngestionManager, fn): ) ) q.enqueue_many(job_datas) + logging.info(f"Queued {len(job_datas)} chunks.") def job_type_guard(job_type: str): From c1812aa4c1a6f73933131fbc3dc6979ba1a148ad Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 5 Aug 2025 19:39:37 +0000 Subject: [PATCH 114/367] fix(edits): account for fake edges when finding latest edges --- pychunkedgraph/graph/chunks/atomic.py | 4 +- pychunkedgraph/graph/chunks/utils.py | 7 +- pychunkedgraph/graph/edges/__init__.py | 131 +++++++++++------- pychunkedgraph/ingest/upgrade/parent_layer.py | 3 +- 4 files changed, 94 insertions(+), 51 deletions(-) diff --git a/pychunkedgraph/graph/chunks/atomic.py b/pychunkedgraph/graph/chunks/atomic.py index b609f4cfb..ec0109c69 100644 --- a/pychunkedgraph/graph/chunks/atomic.py +++ b/pychunkedgraph/graph/chunks/atomic.py @@ -62,4 +62,6 @@ def get_bounding_atomic_chunks( chunkedgraph_meta: ChunkedGraphMeta, layer: int, chunk_coords: Sequence[int] ) -> List: """Atomic chunk coordinates along the boundary of a chunk""" - return get_bounding_children_chunks(chunkedgraph_meta, layer, chunk_coords, 2) + return get_bounding_children_chunks( + chunkedgraph_meta, layer, tuple(chunk_coords), 2 + ) diff --git a/pychunkedgraph/graph/chunks/utils.py b/pychunkedgraph/graph/chunks/utils.py index 4d01258bd..f22a4d84a 100644 --- a/pychunkedgraph/graph/chunks/utils.py +++ b/pychunkedgraph/graph/chunks/utils.py @@ -1,11 +1,13 @@ # pylint: disable=invalid-name, missing-docstring -from typing import List from typing import Union from typing import Optional from typing import Sequence +from typing import Tuple from typing import Iterable +from functools import lru_cache + import numpy as np @@ -210,8 +212,9 @@ def _get_chunk_coordinates_from_vol_coordinates( return coords.astype(int) +@lru_cache() def get_bounding_children_chunks( - cg_meta, layer: int, chunk_coords: Sequence[int], children_layer, return_unique=True + cg_meta, layer: int, chunk_coords: Tuple[int], children_layer, return_unique=True ) -> np.ndarray: """Children chunk coordinates at given layer, along the boundary of a chunk""" chunk_coords = np.array(chunk_coords, dtype=int) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 1d54248c2..ad039f4b4 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -3,7 +3,7 @@ """ from collections import namedtuple -import datetime +import datetime, logging from os import environ from copy import copy from typing import Iterable, Optional @@ -14,7 +14,10 @@ from graph_tool import Graph from pychunkedgraph.graph import types -from pychunkedgraph.graph.chunks import utils as chunk_utils +from pychunkedgraph.graph.chunks.utils import ( + get_bounding_children_chunks, + get_chunk_ids_from_coords, +) from pychunkedgraph.graph.utils import basetypes from ..utils import basetypes @@ -235,7 +238,9 @@ def get_latest_edges( These parents would be the new identities for the stale `partner`. """ _nodes = np.unique(stale_edges[:, 1]) - nodes_ts_map = dict(zip(_nodes, cg.get_node_timestamps(_nodes, return_numpy=False))) + nodes_ts_map = dict( + zip(_nodes, cg.get_node_timestamps(_nodes, return_numpy=False, normalize=True)) + ) _nodes = np.unique(stale_edges) layers, coords = cg.get_chunk_layers_and_coordinates(_nodes) layers_d = dict(zip(_nodes, layers)) @@ -252,31 +257,55 @@ def _get_normalized_coords(node_a, node_b) -> tuple: coord_a, coord_b = cg.get_chunk_coordinates_multiple([chunk_a, chunk_b]) return max_layer, coord_a, coord_b - def _get_l2chunkids_along_boundary(max_layer, coord_a, coord_b): + def _get_l2chunkids_along_boundary(mlayer: int, coord_a, coord_b, padding: int = 0): + """ + Gets L2 Chunk IDs along opposing faces for larger chunks. + If padding is enabled, more faces of L2 chunks are padded on both sides. + This is necessary to find fake edges that can span more than 2 L2 chunks. + """ direction = coord_a - coord_b - axis = np.flatnonzero(direction) - assert len(axis) == 1, f"{direction}, {coord_a}, {coord_b}" - axis = axis[0] - children_a = chunk_utils.get_bounding_children_chunks( - cg.meta, max_layer, coord_a, children_layer=2 - ) - children_b = chunk_utils.get_bounding_children_chunks( - cg.meta, max_layer, coord_b, children_layer=2 - ) - if direction[axis] > 0: - mid = coord_a[axis] * 2 ** (max_layer - 2) - l2chunks_a = children_a[children_a[:, axis] == mid] - l2chunks_b = children_b[children_b[:, axis] == mid - 1] - else: - mid = coord_b[axis] * 2 ** (max_layer - 2) - l2chunks_a = children_a[children_a[:, axis] == mid - 1] - l2chunks_b = children_b[children_b[:, axis] == mid] + major_axis = np.argmax(np.abs(direction)) + bounds_a = get_bounding_children_chunks(cg.meta, mlayer, tuple(coord_a), 2) + bounds_b = get_bounding_children_chunks(cg.meta, mlayer, tuple(coord_b), 2) + + l2chunk_count = 2 ** (mlayer - 2) + max_coord = coord_a if direction[major_axis] > 0 else coord_b + + skip = abs(direction[major_axis]) - 1 + l2_skip = skip * l2chunk_count - l2chunk_ids_a = chunk_utils.get_chunk_ids_from_coords(cg.meta, 2, l2chunks_a) - l2chunk_ids_b = chunk_utils.get_chunk_ids_from_coords(cg.meta, 2, l2chunks_b) + mid = max_coord[major_axis] * l2chunk_count + face_a = mid if direction[major_axis] > 0 else (mid - l2_skip - 1) + face_b = mid if direction[major_axis] < 0 else (mid - l2_skip - 1) + + l2chunks_a = [bounds_a[bounds_a[:, major_axis] == face_a]] + l2chunks_b = [bounds_b[bounds_b[:, major_axis] == face_b]] + + step_a, step_b = (1, -1) if direction[major_axis] > 0 else (-1, 1) + for _ in range(padding): + _l2_chunks_a = copy(l2chunks_a[-1]) + _l2_chunks_b = copy(l2chunks_b[-1]) + _l2_chunks_a[:, major_axis] += step_a + _l2_chunks_b[:, major_axis] += step_b + l2chunks_a.append(_l2_chunks_a) + l2chunks_b.append(_l2_chunks_b) + + l2chunks_a = np.concatenate(l2chunks_a) + l2chunks_b = np.concatenate(l2chunks_b) + + l2chunk_ids_a = get_chunk_ids_from_coords(cg.meta, 2, l2chunks_a) + l2chunk_ids_b = get_chunk_ids_from_coords(cg.meta, 2, l2chunks_b) return l2chunk_ids_a, l2chunk_ids_b - def _get_filtered_l2ids(node_a, node_b, chunks_map): + def _get_filtered_l2ids(node_a, node_b, padding: int): + """ + Finds L2 IDs along opposing faces for given nodes. + Filterting is done by first finding L2 chunks along these faces. + Then get their parent chunks iteratively. + Then filter children iteratively using these chunks. + """ + chunks_map = {} + def _filter(node): result = [] children = np.array([node], dtype=basetypes.NODE_ID) @@ -294,17 +323,13 @@ def _filter(node): children = cg.get_children(children[mask], flatten=True) return np.concatenate(result) - return _filter(node_a), _filter(node_b) - - result = [types.empty_2d] - chunks_map = {} - for edge_layer, _edge in zip(edge_layers, stale_edges): - node_a, node_b = _edge mlayer, coord_a, coord_b = _get_normalized_coords(node_a, node_b) - chunks_a, chunks_b = _get_l2chunkids_along_boundary(mlayer, coord_a, coord_b) + chunks_a, chunks_b = _get_l2chunkids_along_boundary( + mlayer, coord_a, coord_b, padding + ) - chunks_map[node_a] = [np.array([cg.get_chunk_id(node_a)])] - chunks_map[node_b] = [np.array([cg.get_chunk_id(node_b)])] + chunks_map[node_a] = [[cg.get_chunk_id(node_a)]] + chunks_map[node_b] = [[cg.get_chunk_id(node_b)]] _layer = 2 while _layer < mlayer: chunks_map[node_a].append(chunks_a) @@ -312,41 +337,53 @@ def _filter(node): chunks_a = np.unique(cg.get_parent_chunk_id_multiple(chunks_a)) chunks_b = np.unique(cg.get_parent_chunk_id_multiple(chunks_b)) _layer += 1 - chunks_map[node_a] = np.concatenate(chunks_map[node_a]).astype(basetypes.NODE_ID) - chunks_map[node_b] = np.concatenate(chunks_map[node_b]).astype(basetypes.NODE_ID) + chunks_map[node_a] = np.concatenate(chunks_map[node_a]) + chunks_map[node_b] = np.concatenate(chunks_map[node_b]) + return int(mlayer), _filter(node_a), _filter(node_b) - l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, chunks_map) + result = [types.empty_2d] + for edge_layer, _edge in zip(edge_layers, stale_edges): + node_a, node_b = _edge + mlayer, l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, padding=0) + if l2ids_a.size == 0 or l2ids_b.size == 0: + logging.info(f"{node_a}, {node_b}, expanding search with padding.") + mlayer, l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, padding=2) + logging.info(f"Found {l2ids_a} and {l2ids_b}") + + _edges = [] edges_d = cg.get_cross_chunk_edges( node_ids=l2ids_a, time_stamp=nodes_ts_map[node_b], raw_only=True ) - - _edges = [] for v in edges_d.values(): _edges.append(v.get(edge_layer, types.empty_2d)) - _edges = np.concatenate(_edges) - mask = np.isin(_edges[:, 1], l2ids_b) - children_b = cg.get_children(_edges[mask][:, 1], flatten=True) + try: + _edges = np.concatenate(_edges) + except ValueError as exc: + logging.warning(f"No edges found for {node_a}, {node_b}") + raise ValueError from exc + mask = np.isin(_edges[:, 1], l2ids_b) parents_a = _edges[mask][:, 0] + children_b = cg.get_children(_edges[mask][:, 1], flatten=True) parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) - _cx_edges_d = cg.get_cross_chunk_edges(parents_b) + _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) parents_b = [] for _node, _edges_d in _cx_edges_d.items(): for _edges in _edges_d.values(): - _mask = np.isin(_edges[:,1], parents_a) + _mask = np.isin(_edges[:, 1], parents_a) if np.any(_mask): parents_b.append(_node) parents_b = np.array(parents_b, dtype=basetypes.NODE_ID) parents_b = np.unique( - cg.get_roots( - parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts - ) + cg.get_roots(parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts) ) parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) - result.append(np.column_stack((parents_a, parents_b))) + _new_edges = np.column_stack((parents_a, parents_b)) + assert _new_edges.size, f"No edge found for {node_a}, {node_b} at {parent_ts}" + result.append(_new_edges) return np.concatenate(result) diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index b8503f1d9..8c92e9e77 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -1,6 +1,6 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member -import math, random, time +import logging, math, random, time import multiprocessing as mp from collections import defaultdict @@ -171,6 +171,7 @@ def update_chunk( args = (cg_info, layer, chunk, ts_chunk, earliest_ts) tasks.append(args) + logging.info(f"Processing {len(nodes)} nodes.") with mp.Pool(min(mp.cpu_count(), len(tasks))) as pool: _ = list( tqdm( From 5022ec612b6e14e927ff0ad45e7ee7c2a6311f46 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 21 Oct 2025 17:17:26 +0000 Subject: [PATCH 115/367] fix(upgrade): use start and end timestamps to filter out irreleveant timestamps --- pychunkedgraph/app/meshing/common.py | 40 ++------- pychunkedgraph/graph/edges/__init__.py | 10 ++- pychunkedgraph/ingest/upgrade/atomic_layer.py | 82 +++++++++++++++---- pychunkedgraph/ingest/upgrade/parent_layer.py | 39 ++++++--- pychunkedgraph/ingest/upgrade/utils.py | 47 +++++++---- pychunkedgraph/utils/redis.py | 6 +- requirements.in | 4 +- requirements.txt | 5 +- 8 files changed, 148 insertions(+), 85 deletions(-) diff --git a/pychunkedgraph/app/meshing/common.py b/pychunkedgraph/app/meshing/common.py index 8f1a0c20a..10306543a 100644 --- a/pychunkedgraph/app/meshing/common.py +++ b/pychunkedgraph/app/meshing/common.py @@ -4,8 +4,6 @@ import threading import numpy as np -import redis -from rq import Queue, Connection, Retry from flask import Response, current_app, jsonify, make_response, request from pychunkedgraph import __version__ @@ -145,37 +143,15 @@ def _check_post_options(cg, resp, data, seg_ids): def handle_remesh(table_id): current_app.request_type = "remesh_enque" current_app.table_id = table_id - is_priority = request.args.get("priority", True, type=str2bool) - is_redisjob = request.args.get("use_redis", False, type=str2bool) - new_lvl2_ids = json.loads(request.data)["new_lvl2_ids"] - - if is_redisjob: - with Connection(redis.from_url(current_app.config["REDIS_URL"])): - - if is_priority: - retry = Retry(max=3, interval=[1, 10, 60]) - queue_name = "mesh-chunks" - else: - retry = Retry(max=3, interval=[60, 60, 60]) - queue_name = "mesh-chunks-low-priority" - q = Queue(queue_name, retry=retry, default_timeout=1200) - task = q.enqueue(meshing_tasks.remeshing, table_id, new_lvl2_ids) - - response_object = {"status": "success", "data": {"task_id": task.get_id()}} - - return jsonify(response_object), 202 - else: - new_lvl2_ids = np.array(new_lvl2_ids, dtype=np.uint64) - cg = app_utils.get_cg(table_id) - - if len(new_lvl2_ids) > 0: - t = threading.Thread( - target=_remeshing, args=(cg.get_serialized_info(), new_lvl2_ids) - ) - t.start() - - return Response(status=202) + new_lvl2_ids = np.array(new_lvl2_ids, dtype=np.uint64) + cg = app_utils.get_cg(table_id) + if len(new_lvl2_ids) > 0: + t = threading.Thread( + target=_remeshing, args=(cg.get_serialized_info(), new_lvl2_ids) + ) + t.start() + return Response(status=202) def _remeshing(serialized_cg_info, lvl2_nodes): diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index ad039f4b4..3359cefdd 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -237,11 +237,10 @@ def get_latest_edges( Then get supervoxels of those L2 IDs and get parent(s) at `node` level. These parents would be the new identities for the stale `partner`. """ - _nodes = np.unique(stale_edges[:, 1]) + _nodes = np.unique(stale_edges) nodes_ts_map = dict( zip(_nodes, cg.get_node_timestamps(_nodes, return_numpy=False, normalize=True)) ) - _nodes = np.unique(stale_edges) layers, coords = cg.get_chunk_layers_and_coordinates(_nodes) layers_d = dict(zip(_nodes, layers)) coords_d = dict(zip(_nodes, coords)) @@ -352,7 +351,9 @@ def _filter(node): _edges = [] edges_d = cg.get_cross_chunk_edges( - node_ids=l2ids_a, time_stamp=nodes_ts_map[node_b], raw_only=True + node_ids=l2ids_a, + time_stamp=max(nodes_ts_map[node_a], nodes_ts_map[node_b]), + raw_only=True, ) for v in edges_d.values(): _edges.append(v.get(edge_layer, types.empty_2d)) @@ -382,7 +383,8 @@ def _filter(node): parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) _new_edges = np.column_stack((parents_a, parents_b)) - assert _new_edges.size, f"No edge found for {node_a}, {node_b} at {parent_ts}" + err = f"No edge found for {node_a}, {node_b} at {edge_layer}; {parent_ts}" + assert _new_edges.size, err result.append(_new_edges) return np.concatenate(result) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index c9c8bdb11..99a67b1de 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -1,18 +1,23 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member -from datetime import timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed +import logging, math, time import fastremap import numpy as np +from tqdm import tqdm from pychunkedgraph.graph import ChunkedGraph -from pychunkedgraph.graph.attributes import Connectivity +from pychunkedgraph.graph.attributes import Connectivity, Hierarchy from pychunkedgraph.graph.utils import serializers +from pychunkedgraph.utils.general import chunked -from .utils import exists_as_parent, get_parent_timestamps +from .utils import exists_as_parent, get_end_timestamps, get_parent_timestamps + +CHILDREN = {} def update_cross_edges( - cg: ChunkedGraph, node, cx_edges_d: dict, node_ts, timestamps: set, earliest_ts + cg: ChunkedGraph, node, cx_edges_d: dict, node_ts, node_end_ts, timestamps: set ) -> list: """ Helper function to update a single L2 ID. @@ -27,13 +32,15 @@ def update_cross_edges( assert not exists_as_parent(cg, node, edges[:, 0]) return rows - partner_parent_ts_d = get_parent_timestamps(cg, edges[:, 1]) + partner_parent_ts_d = get_parent_timestamps(cg, np.unique(edges[:, 1])) for v in partner_parent_ts_d.values(): timestamps.update(v) for ts in sorted(timestamps): - if ts < earliest_ts: - ts = earliest_ts + if ts < node_ts: + continue + if ts > node_end_ts: + break val_dict = {} svs = edges[:, 1] parents = cg.get_parents(svs, time_stamp=ts) @@ -51,35 +58,78 @@ def update_cross_edges( return rows -def update_nodes(cg: ChunkedGraph, nodes) -> list: - nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) - earliest_ts = cg.get_earliest_timestamp() +def update_nodes(cg: ChunkedGraph, nodes, nodes_ts, children_map=None) -> list: + if children_map is None: + children_map = CHILDREN + end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, children_map) timestamps_d = get_parent_timestamps(cg, nodes) cx_edges_d = cg.get_atomic_cross_edges(nodes) rows = [] - for node, node_ts in zip(nodes, nodes_ts): + for node, node_ts, end_ts in zip(nodes, nodes_ts, end_timestamps): if cg.get_parent(node) is None: - # invalid id caused by failed ingest task + # invalid id caused by failed ingest task / edits continue _cx_edges_d = cx_edges_d.get(node, {}) if not _cx_edges_d: continue _rows = update_cross_edges( - cg, node, _cx_edges_d, node_ts, timestamps_d[node], earliest_ts + cg, node, _cx_edges_d, node_ts, end_ts, timestamps_d[node] ) rows.extend(_rows) return rows -def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], layer: int = 2): +def _update_nodes_helper(args): + cg, nodes, nodes_ts = args + return update_nodes(cg, nodes, nodes_ts) + + +def update_chunk( + cg: ChunkedGraph, chunk_coords: list[int], layer: int = 2, debug: bool = False +): """ Iterate over all L2 IDs in a chunk and update their cross chunk edges, within the periods they were valid/active. """ + global CHILDREN + + start = time.time() x, y, z = chunk_coords chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) cg.copy_fake_edges(chunk_id) rr = cg.range_read_chunk(chunk_id) - nodes = list(rr.keys()) - rows = update_nodes(cg, nodes) + + nodes = [] + nodes_ts = [] + earliest_ts = cg.get_earliest_timestamp() + for k, v in rr.items(): + nodes.append(k) + CHILDREN[k] = v[Hierarchy.Child][0].value + ts = v[Hierarchy.Child][0].timestamp + nodes_ts.append(earliest_ts if ts < earliest_ts else ts) + + if len(nodes) > 0: + logging.info(f"Processing {len(nodes)} nodes.") + assert len(CHILDREN) > 0, (nodes, CHILDREN) + else: + return + + if debug: + rows = update_nodes(cg, nodes, nodes_ts) + else: + task_size = int(math.ceil(len(nodes) / 64)) + chunked_nodes = chunked(nodes, task_size) + chunked_nodes_ts = chunked(nodes_ts, task_size) + tasks = [] + for chunk, ts_chunk in zip(chunked_nodes, chunked_nodes_ts): + args = (cg, chunk, ts_chunk) + tasks.append(args) + + rows = [] + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [executor.submit(_update_nodes_helper, task) for task in tasks] + for future in tqdm(as_completed(futures), total=len(futures)): + rows.extend(future.result()) + cg.client.write(rows) + print(f"total elaspsed time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 8c92e9e77..79d97b9fe 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -3,6 +3,7 @@ import logging, math, random, time import multiprocessing as mp from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed import fastremap import numpy as np @@ -15,7 +16,7 @@ from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked -from .utils import exists_as_parent, get_parent_timestamps +from .utils import exists_as_parent, get_end_timestamps, get_parent_timestamps CHILDREN = {} @@ -51,7 +52,7 @@ def _get_cx_edges_at_timestamp(node, response, ts): def _populate_cx_edges_with_timestamps( - cg: ChunkedGraph, layer: int, nodes: list, nodes_ts: list, earliest_ts + cg: ChunkedGraph, layer: int, nodes: list, nodes_ts: list ): """ Collect timestamps of edits from children, since we use the same timestamp @@ -63,7 +64,8 @@ def _populate_cx_edges_with_timestamps( all_children = np.concatenate(list(CHILDREN.values())) response = cg.client.read_nodes(node_ids=all_children, properties=attrs) timestamps_d = get_parent_timestamps(cg, nodes) - for node, node_ts in zip(nodes, nodes_ts): + end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, CHILDREN) + for node, node_ts, node_end_ts in zip(nodes, nodes_ts, end_timestamps): CX_EDGES[node] = {} timestamps = timestamps_d[node] cx_edges_d_node_ts = _get_cx_edges_at_timestamp(node, response, node_ts) @@ -75,8 +77,8 @@ def _populate_cx_edges_with_timestamps( CX_EDGES[node][node_ts] = cx_edges_d_node_ts for ts in sorted(timestamps): - if ts < earliest_ts: - ts = earliest_ts + if ts > node_end_ts: + break CX_EDGES[node][ts] = _get_cx_edges_at_timestamp(node, response, ts) @@ -107,7 +109,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l row_id = serializers.serialize_uint64(node) for ts, cx_edges_d in CX_EDGES[node].items(): - if node_ts > ts: + if ts < node_ts: continue edges = get_latest_edges_wrapper(cg, cx_edges_d, parent_ts=ts) if edges.size == 0: @@ -129,17 +131,29 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l return rows +def _update_cross_edges_helper_thread(args): + cg, layer, node, node_ts, earliest_ts = args + return update_cross_edges(cg, layer, node, node_ts, earliest_ts) + + def _update_cross_edges_helper(args): cg_info, layer, nodes, nodes_ts, earliest_ts = args rows = [] cg = ChunkedGraph(**cg_info) parents = cg.get_parents(nodes, fail_to_zero=True) + + tasks = [] for node, parent, node_ts in zip(nodes, parents, nodes_ts): if parent == 0: - # invalid id caused by failed ingest task + # invalid id caused by failed ingest task / edits continue - _rows = update_cross_edges(cg, layer, node, node_ts, earliest_ts) - rows.extend(_rows) + tasks.append((cg, layer, node, node_ts, earliest_ts)) + + with ThreadPoolExecutor(max_workers=4) as executor: + futures = [executor.submit(_update_cross_edges_helper_thread, task) for task in tasks] + for future in tqdm(as_completed(futures), total=len(futures)): + rows.extend(future.result()) + cg.client.write(rows) @@ -159,7 +173,7 @@ def update_chunk( nodes = list(CHILDREN.keys()) random.shuffle(nodes) nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) - _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts, earliest_ts) + _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts) task_size = int(math.ceil(len(nodes) / mp.cpu_count() / 2)) chunked_nodes = chunked(nodes, task_size) @@ -171,8 +185,9 @@ def update_chunk( args = (cg_info, layer, chunk, ts_chunk, earliest_ts) tasks.append(args) - logging.info(f"Processing {len(nodes)} nodes.") - with mp.Pool(min(mp.cpu_count(), len(tasks))) as pool: + processes = min(mp.cpu_count() * 2, len(tasks)) + logging.info(f"Processing {len(nodes)} nodes with {processes} workers.") + with mp.Pool(processes) as pool: _ = list( tqdm( pool.imap_unordered(_update_cross_edges_helper, tasks), diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py index cc43b561a..3407ea7b5 100644 --- a/pychunkedgraph/ingest/upgrade/utils.py +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -1,7 +1,7 @@ # pylint: disable=invalid-name, missing-docstring from collections import defaultdict -from datetime import timedelta +from datetime import datetime, timedelta, timezone import numpy as np from pychunkedgraph.graph import ChunkedGraph @@ -33,31 +33,50 @@ def get_edit_timestamps(cg: ChunkedGraph, edges_d, start_ts, end_ts) -> list: return sorted(timestamps) -def get_end_ts(cg: ChunkedGraph, children, start_ts): - # get end_ts when node becomes invalid (bigtable resolution is in ms) - start = start_ts + timedelta(milliseconds=1) - _timestamps = get_parent_timestamps(cg, children, start_time=start) - try: - end_ts = sorted(_timestamps)[0] - except IndexError: - # start_ts == end_ts means there has been no edit involving this node - # meaning only one timestamp to update cross edges, start_ts - end_ts = start_ts - return end_ts +def get_end_timestamps(cg: ChunkedGraph, nodes, nodes_ts, children_map): + """ + Gets the last timestamp for each node at which to update its cross edges. + For this, we get parent timestamps for all children of a node. + The first timestamp > node_timestamp among these is the last timestamp. + This is the timestamp at which one of node's children + got a new parent that superseded the current node. + """ + result = [] + children = np.concatenate([*children_map.values()]) + timestamps_d = get_parent_timestamps(cg, children) + for node, node_ts in zip(nodes, nodes_ts): + node_children = children_map[node] + _timestamps = set().union(*[timestamps_d[k] for k in node_children]) + try: + _timestamps = sorted(_timestamps) + _index = np.searchsorted(_timestamps, node_ts) + assert _timestamps[_index] == node_ts, (_index, node_ts, _timestamps) + end_ts = _timestamps[_index + 1] - timedelta(milliseconds=1) + except IndexError: + # this node has not been edited, but might have it edges updated + end_ts = datetime.now(timezone.utc) + result.append(end_ts) + return result -def get_parent_timestamps(cg: ChunkedGraph, nodes) -> dict[int, set]: +def get_parent_timestamps( + cg: ChunkedGraph, nodes, start_time=None, end_time=None +) -> dict[int, set]: """ Timestamps of when the given nodes were edited. """ + earliest_ts = cg.get_earliest_timestamp() response = cg.client.read_nodes( node_ids=nodes, properties=[Hierarchy.Parent], + start_time=start_time, + end_time=end_time, end_time_inclusive=False, ) result = defaultdict(set) for k, v in response.items(): for cell in v[Hierarchy.Parent]: - result[k].add(cell.timestamp) + ts = cell.timestamp + result[k].add(earliest_ts if ts < earliest_ts else ts) return result diff --git a/pychunkedgraph/utils/redis.py b/pychunkedgraph/utils/redis.py index 45ccfbdcc..82921f030 100644 --- a/pychunkedgraph/utils/redis.py +++ b/pychunkedgraph/utils/redis.py @@ -18,6 +18,7 @@ ) REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0" +CONNECTION = redis.Redis.from_url(REDIS_URL, socket_timeout=60) keys_fields = ("INGESTION_MANAGER", "JOB_TYPE") keys_defaults = ("pcg:imanager", "pcg:job_type") @@ -27,9 +28,10 @@ def get_redis_connection(redis_url=REDIS_URL): + if redis_url == REDIS_URL: + return CONNECTION return redis.Redis.from_url(redis_url, socket_timeout=60) def get_rq_queue(queue): - connection = redis.Redis.from_url(REDIS_URL, socket_timeout=60) - return Queue(queue, connection=connection) + return Queue(queue, connection=CONNECTION) diff --git a/requirements.in b/requirements.in index 1ec536a5c..4fcd353ed 100644 --- a/requirements.in +++ b/requirements.in @@ -10,8 +10,8 @@ google-cloud-datastore>=1.8 flask flask_cors python-json-logger -redis -rq<2 +redis>5 +rq>2 pyyaml cachetools werkzeug diff --git a/requirements.txt b/requirements.txt index 059b8fd91..0eedacb31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -97,7 +97,6 @@ gevent==23.9.1 # task-queue google-api-core[grpc]==2.11.1 # via - # google-api-core # google-cloud-bigtable # google-cloud-core # google-cloud-datastore @@ -295,7 +294,7 @@ pytz==2023.3.post1 # via pandas pyyaml==6.0.1 # via -r requirements.in -redis==5.0.0 +redis==6.4.0 # via # -r requirements.in # rq @@ -316,7 +315,7 @@ rpds-py==0.10.3 # via # jsonschema # referencing -rq==1.15.1 +rq==2.4.1 # via -r requirements.in rsa==4.9 # via From 8829d9400dd41e83e91fd7df8a38837c677625e9 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 24 Oct 2025 16:13:28 +0000 Subject: [PATCH 116/367] feat(upgrade): cache stale timestamp info; remove unnecessary checks to reduce latency --- pychunkedgraph/graph/attributes.py | 6 ++ pychunkedgraph/graph/edges/__init__.py | 19 ++--- pychunkedgraph/ingest/cluster.py | 2 +- pychunkedgraph/ingest/upgrade/atomic_layer.py | 81 +++++++++++-------- pychunkedgraph/ingest/upgrade/parent_layer.py | 56 ++++++------- pychunkedgraph/ingest/upgrade/utils.py | 37 ++++++--- pychunkedgraph/utils/general.py | 7 -- 7 files changed, 118 insertions(+), 90 deletions(-) diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py index b431a159b..6b7a277f0 100644 --- a/pychunkedgraph/graph/attributes.py +++ b/pychunkedgraph/graph/attributes.py @@ -160,6 +160,12 @@ class Hierarchy: serializer=serializers.NumPyValue(dtype=basetypes.NODE_ID), ) + # track when nodes became stale, required for migration + # will be eventually deleted by GC rule for column family_id 3. + StaleTimeStamp = _Attribute( + key=b"stale_ts", family_id="3", serializer=serializers.Pickle() + ) + class GraphMeta: key = b"meta" diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 3359cefdd..1a8baf225 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -201,22 +201,23 @@ def get_edges(source: str, nodes: np.ndarray) -> Edges: def get_stale_nodes( - cg, edge_nodes: Iterable[basetypes.NODE_ID], parent_ts: datetime.datetime = None + cg, nodes: Iterable[basetypes.NODE_ID], parent_ts: datetime.datetime = None ): """ - Checks to see if partner nodes in edges (edges[:,1]) are stale. - This is done by getting a supervoxel of the node and check + Checks to see if given nodes are stale. + This is done by getting a supervoxel of a node and checking if it has a new parent at the same layer as the node. """ - edge_supervoxels = cg.get_single_leaf_multiple(edge_nodes) + nodes = np.array(nodes, dtype=basetypes.NODE_ID) + supervoxels = cg.get_single_leaf_multiple(nodes) # nodes can be at different layers due to skip connections - edge_nodes_layers = cg.get_chunk_layers(edge_nodes) + node_layers = cg.get_chunk_layers(nodes) stale_nodes = [types.empty_1d] - for layer in np.unique(edge_nodes_layers): - _mask = edge_nodes_layers == layer - layer_nodes = edge_nodes[_mask] + for layer in np.unique(node_layers): + _mask = node_layers == layer + layer_nodes = nodes[_mask] _nodes = cg.get_roots( - edge_supervoxels[_mask], + supervoxels[_mask], stop_layer=layer, ceil=False, time_stamp=parent_ts, diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 1ae13a353..d87576ca0 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -135,7 +135,7 @@ def upgrade_atomic_chunk(coords: Sequence[int]): redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) coords = np.array(list(coords), dtype=int) - update_atomic_chunk(imanager.cg, coords, layer=2) + update_atomic_chunk(imanager.cg, coords) _post_task_completion(imanager, 2, coords) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index 99a67b1de..e4bd18b62 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -1,23 +1,31 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member +from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import timedelta import logging, math, time +from copy import copy import fastremap import numpy as np from tqdm import tqdm -from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph import ChunkedGraph, types from pychunkedgraph.graph.attributes import Connectivity, Hierarchy from pychunkedgraph.graph.utils import serializers from pychunkedgraph.utils.general import chunked -from .utils import exists_as_parent, get_end_timestamps, get_parent_timestamps +from .utils import get_end_timestamps, get_parent_timestamps CHILDREN = {} def update_cross_edges( - cg: ChunkedGraph, node, cx_edges_d: dict, node_ts, node_end_ts, timestamps: set + cg: ChunkedGraph, + node, + cx_edges_d: dict, + node_ts, + node_end_ts, + timestamps_d: defaultdict[int, set], ) -> list: """ Helper function to update a single L2 ID. @@ -25,26 +33,21 @@ def update_cross_edges( """ rows = [] edges = np.concatenate(list(cx_edges_d.values())) - uparents = np.unique(cg.get_parents(edges[:, 0], time_stamp=node_ts)) - assert uparents.size <= 1, f"{node}, {node_ts}, {uparents}" - if uparents.size == 0 or node != uparents[0]: - # if node is not the parent at this ts, it must be invalid - assert not exists_as_parent(cg, node, edges[:, 0]) - return rows + partners = np.unique(edges[:, 1]) - partner_parent_ts_d = get_parent_timestamps(cg, np.unique(edges[:, 1])) - for v in partner_parent_ts_d.values(): - timestamps.update(v) + timestamps = copy(timestamps_d[node]) + for partner in partners: + timestamps.update(timestamps_d[partner]) for ts in sorted(timestamps): if ts < node_ts: continue if ts > node_end_ts: break + val_dict = {} - svs = edges[:, 1] - parents = cg.get_parents(svs, time_stamp=ts) - edge_parents_d = dict(zip(svs, parents)) + parents = cg.get_parents(partners, time_stamp=ts) + edge_parents_d = dict(zip(partners, parents)) for layer, layer_edges in cx_edges_d.items(): layer_edges = fastremap.remap( layer_edges, edge_parents_d, preserve_missing_labels=True @@ -61,20 +64,26 @@ def update_cross_edges( def update_nodes(cg: ChunkedGraph, nodes, nodes_ts, children_map=None) -> list: if children_map is None: children_map = CHILDREN - end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, children_map) - timestamps_d = get_parent_timestamps(cg, nodes) + end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, children_map, layer=2) + cx_edges_d = cg.get_atomic_cross_edges(nodes) + all_cx_edges = [types.empty_2d] + for _cx_edges_d in cx_edges_d.values(): + if _cx_edges_d: + all_cx_edges.append(np.concatenate(list(_cx_edges_d.values()))) + all_partners = np.unique(np.concatenate(all_cx_edges)[:, 1]) + timestamps_d = get_parent_timestamps(cg, np.concatenate([nodes, all_partners])) + rows = [] for node, node_ts, end_ts in zip(nodes, nodes_ts, end_timestamps): - if cg.get_parent(node) is None: - # invalid id caused by failed ingest task / edits - continue + end_ts -= timedelta(milliseconds=1) _cx_edges_d = cx_edges_d.get(node, {}) if not _cx_edges_d: continue - _rows = update_cross_edges( - cg, node, _cx_edges_d, node_ts, end_ts, timestamps_d[node] - ) + _rows = update_cross_edges(cg, node, _cx_edges_d, node_ts, end_ts, timestamps_d) + row_id = serializers.serialize_uint64(node) + val_dict = {Hierarchy.StaleTimeStamp: 0} + _rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=end_ts)) rows.extend(_rows) return rows @@ -84,9 +93,7 @@ def _update_nodes_helper(args): return update_nodes(cg, nodes, nodes_ts) -def update_chunk( - cg: ChunkedGraph, chunk_coords: list[int], layer: int = 2, debug: bool = False -): +def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], debug: bool = False): """ Iterate over all L2 IDs in a chunk and update their cross chunk edges, within the periods they were valid/active. @@ -95,7 +102,7 @@ def update_chunk( start = time.time() x, y, z = chunk_coords - chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) + chunk_id = cg.get_chunk_id(layer=2, x=x, y=y, z=z) cg.copy_fake_edges(chunk_id) rr = cg.range_read_chunk(chunk_id) @@ -103,13 +110,18 @@ def update_chunk( nodes_ts = [] earliest_ts = cg.get_earliest_timestamp() for k, v in rr.items(): - nodes.append(k) - CHILDREN[k] = v[Hierarchy.Child][0].value - ts = v[Hierarchy.Child][0].timestamp - nodes_ts.append(earliest_ts if ts < earliest_ts else ts) + try: + _ = v[Hierarchy.Parent] + nodes.append(k) + CHILDREN[k] = v[Hierarchy.Child][0].value + ts = v[Hierarchy.Child][0].timestamp + nodes_ts.append(earliest_ts if ts < earliest_ts else ts) + except KeyError: + # invalid nodes from failed tasks w/o parent column entry + continue if len(nodes) > 0: - logging.info(f"Processing {len(nodes)} nodes.") + logging.info(f"processing {len(nodes)} nodes.") assert len(CHILDREN) > 0, (nodes, CHILDREN) else: return @@ -117,13 +129,14 @@ def update_chunk( if debug: rows = update_nodes(cg, nodes, nodes_ts) else: - task_size = int(math.ceil(len(nodes) / 64)) + task_size = int(math.ceil(len(nodes) / 16)) chunked_nodes = chunked(nodes, task_size) chunked_nodes_ts = chunked(nodes_ts, task_size) tasks = [] for chunk, ts_chunk in zip(chunked_nodes, chunked_nodes_ts): args = (cg, chunk, ts_chunk) tasks.append(args) + logging.info(f"task size {task_size}, count {len(tasks)}.") rows = [] with ThreadPoolExecutor(max_workers=8) as executor: @@ -132,4 +145,4 @@ def update_chunk( rows.extend(future.result()) cg.client.write(rows) - print(f"total elaspsed time: {time.time() - start}") + logging.info(f"total elaspsed time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 79d97b9fe..b205f1753 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -16,7 +16,7 @@ from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked -from .utils import exists_as_parent, get_end_timestamps, get_parent_timestamps +from .utils import get_end_timestamps, get_parent_timestamps CHILDREN = {} @@ -64,7 +64,9 @@ def _populate_cx_edges_with_timestamps( all_children = np.concatenate(list(CHILDREN.values())) response = cg.client.read_nodes(node_ids=all_children, properties=attrs) timestamps_d = get_parent_timestamps(cg, nodes) - end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, CHILDREN) + end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, CHILDREN, layer=layer) + + rows = [] for node, node_ts, node_end_ts in zip(nodes, nodes_ts, end_timestamps): CX_EDGES[node] = {} timestamps = timestamps_d[node] @@ -81,32 +83,18 @@ def _populate_cx_edges_with_timestamps( break CX_EDGES[node][ts] = _get_cx_edges_at_timestamp(node, response, ts) + row_id = serializers.serialize_uint64(node) + val_dict = {Hierarchy.StaleTimeStamp: 0} + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=node_end_ts)) + cg.client.write(rows) + -def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> list: +def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: """ Helper function to update a single ID. Returns a list of mutations with timestamps. """ rows = [] - if node_ts > earliest_ts: - try: - cx_edges_d = CX_EDGES[node][node_ts] - except KeyError: - raise KeyError(f"{node}:{node_ts}") - edges = np.concatenate([empty_2d] + list(cx_edges_d.values())) - if edges.size: - parents = cg.get_roots( - edges[:, 0], time_stamp=node_ts, stop_layer=layer, ceil=False - ) - uparents = np.unique(parents) - layers = cg.get_chunk_layers(uparents) - uparents = uparents[layers == layer] - assert uparents.size <= 1, f"{node}, {node_ts}, {uparents}" - if uparents.size == 0 or node != uparents[0]: - # if node is not the parent at this ts, it must be invalid - assert not exists_as_parent(cg, node, edges[:, 0]), f"{node}, {node_ts}" - return rows - row_id = serializers.serialize_uint64(node) for ts, cx_edges_d in CX_EDGES[node].items(): if ts < node_ts: @@ -132,12 +120,12 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts, earliest_ts) -> l def _update_cross_edges_helper_thread(args): - cg, layer, node, node_ts, earliest_ts = args - return update_cross_edges(cg, layer, node, node_ts, earliest_ts) + cg, layer, node, node_ts = args + return update_cross_edges(cg, layer, node, node_ts) def _update_cross_edges_helper(args): - cg_info, layer, nodes, nodes_ts, earliest_ts = args + cg_info, layer, nodes, nodes_ts = args rows = [] cg = ChunkedGraph(**cg_info) parents = cg.get_parents(nodes, fail_to_zero=True) @@ -147,7 +135,7 @@ def _update_cross_edges_helper(args): if parent == 0: # invalid id caused by failed ingest task / edits continue - tasks.append((cg, layer, node, node_ts, earliest_ts)) + tasks.append((cg, layer, node, node_ts)) with ThreadPoolExecutor(max_workers=4) as executor: futures = [executor.submit(_update_cross_edges_helper_thread, task) for task in tasks] @@ -163,10 +151,10 @@ def update_chunk( """ Iterate over all layer IDs in a chunk and update their cross chunk edges. """ + debug = nodes is not None start = time.time() x, y, z = chunk_coords chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) - earliest_ts = cg.get_earliest_timestamp() _populate_nodes_and_children(cg, chunk_id, nodes=nodes) if not CHILDREN: return @@ -175,6 +163,14 @@ def update_chunk( nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts) + if debug: + rows = [] + for node, node_ts in zip(nodes, nodes_ts): + rows.extend(update_cross_edges(cg, layer, node, node_ts)) + cg.client.write(rows) + logging.info(f"total elaspsed time: {time.time() - start}") + return + task_size = int(math.ceil(len(nodes) / mp.cpu_count() / 2)) chunked_nodes = chunked(nodes, task_size) chunked_nodes_ts = chunked(nodes_ts, task_size) @@ -182,11 +178,11 @@ def update_chunk( tasks = [] for chunk, ts_chunk in zip(chunked_nodes, chunked_nodes_ts): - args = (cg_info, layer, chunk, ts_chunk, earliest_ts) + args = (cg_info, layer, chunk, ts_chunk) tasks.append(args) processes = min(mp.cpu_count() * 2, len(tasks)) - logging.info(f"Processing {len(nodes)} nodes with {processes} workers.") + logging.info(f"processing {len(nodes)} nodes with {processes} workers.") with mp.Pool(processes) as pool: _ = list( tqdm( @@ -194,4 +190,4 @@ def update_chunk( total=len(tasks), ) ) - print(f"total elaspsed time: {time.time() - start}") + logging.info(f"total elaspsed time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py index 3407ea7b5..17f5db84e 100644 --- a/pychunkedgraph/ingest/upgrade/utils.py +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -1,7 +1,7 @@ # pylint: disable=invalid-name, missing-docstring from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import numpy as np from pychunkedgraph.graph import ChunkedGraph @@ -33,25 +33,44 @@ def get_edit_timestamps(cg: ChunkedGraph, edges_d, start_ts, end_ts) -> list: return sorted(timestamps) -def get_end_timestamps(cg: ChunkedGraph, nodes, nodes_ts, children_map): +def _get_end_timestamps_helper(cg: ChunkedGraph, nodes: list) -> defaultdict[int, set]: + result = defaultdict(set) + response = cg.client.read_nodes(node_ids=nodes, properties=Hierarchy.StaleTimeStamp) + for k, v in response.items(): + result[k].add(v[0].timestamp) + return result + + +def get_end_timestamps( + cg: ChunkedGraph, nodes: list, nodes_ts: datetime, children_map: dict, layer: int +): """ Gets the last timestamp for each node at which to update its cross edges. - For this, we get parent timestamps for all children of a node. - The first timestamp > node_timestamp among these is the last timestamp. - This is the timestamp at which one of node's children - got a new parent that superseded the current node. + For layer 2: + Get parent timestamps for all children of a node. + The first timestamp > node_timestamp among these is the last timestamp. + This is the timestamp at which one of node's children + got a new parent that superseded the current node. + These are cached in database. + For all nodes in each layer > 2: + Pick the earliest child node_end_ts > node_ts and cache in database. """ result = [] children = np.concatenate([*children_map.values()]) - timestamps_d = get_parent_timestamps(cg, children) + if layer == 2: + timestamps_d = get_parent_timestamps(cg, children) + else: + timestamps_d = _get_end_timestamps_helper(cg, children) + for node, node_ts in zip(nodes, nodes_ts): node_children = children_map[node] _timestamps = set().union(*[timestamps_d[k] for k in node_children]) + _timestamps.add(node_ts) try: _timestamps = sorted(_timestamps) _index = np.searchsorted(_timestamps, node_ts) assert _timestamps[_index] == node_ts, (_index, node_ts, _timestamps) - end_ts = _timestamps[_index + 1] - timedelta(milliseconds=1) + end_ts = _timestamps[_index + 1] except IndexError: # this node has not been edited, but might have it edges updated end_ts = datetime.now(timezone.utc) @@ -61,7 +80,7 @@ def get_end_timestamps(cg: ChunkedGraph, nodes, nodes_ts, children_map): def get_parent_timestamps( cg: ChunkedGraph, nodes, start_time=None, end_time=None -) -> dict[int, set]: +) -> defaultdict[int, set]: """ Timestamps of when the given nodes were edited. """ diff --git a/pychunkedgraph/utils/general.py b/pychunkedgraph/utils/general.py index c299d3b9b..ac4929660 100644 --- a/pychunkedgraph/utils/general.py +++ b/pychunkedgraph/utils/general.py @@ -26,10 +26,6 @@ def reverse_dictionary(dictionary): def chunked(l: Sequence, n: int): - """ - Yield successive n-sized chunks from l. - NOTE: Use itertools.batched from python 3.12 - """ """ Yield successive n-sized chunks from l. NOTE: Use itertools.batched from python 3.12 @@ -39,9 +35,6 @@ def chunked(l: Sequence, n: int): it = iter(l) while batch := tuple(islice(it, n)): yield batch - it = iter(l) - while batch := tuple(islice(it, n)): - yield batch def in2d(arr1: np.ndarray, arr2: np.ndarray) -> np.ndarray: From 2f72d1a030fa3448d7bf345cef725a007552fb40 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 27 Oct 2025 22:31:31 +0000 Subject: [PATCH 117/367] fix(edits): include padded region to search for edges; not just L2 IDs --- pychunkedgraph/graph/edges/__init__.py | 44 ++++++++++++++++---------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 1a8baf225..a2734522c 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -341,29 +341,32 @@ def _filter(node): chunks_map[node_b] = np.concatenate(chunks_map[node_b]) return int(mlayer), _filter(node_a), _filter(node_b) - result = [types.empty_2d] - for edge_layer, _edge in zip(edge_layers, stale_edges): - node_a, node_b = _edge - mlayer, l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, padding=0) + def _get_new_edge(edge, parent_ts, padding): + """ + Attempts to find new edge(s) for the stale `edge`. + * Find L2 IDs on opposite sides of the face in L2 chunks along the face. + * Find new edges between them (before the given timestamp). + * If none found, expand search by adding another layer of L2 chunks. + """ + node_a, node_b = edge + mlayer, l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, padding=padding) if l2ids_a.size == 0 or l2ids_b.size == 0: - logging.info(f"{node_a}, {node_b}, expanding search with padding.") - mlayer, l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, padding=2) - logging.info(f"Found {l2ids_a} and {l2ids_b}") + return types.empty_2d.copy() _edges = [] - edges_d = cg.get_cross_chunk_edges( + _edges_d = cg.get_cross_chunk_edges( node_ids=l2ids_a, time_stamp=max(nodes_ts_map[node_a], nodes_ts_map[node_b]), raw_only=True, ) - for v in edges_d.values(): - _edges.append(v.get(edge_layer, types.empty_2d)) + for v in _edges_d.values(): + if edge_layer in v: + _edges.append(v[edge_layer]) try: _edges = np.concatenate(_edges) - except ValueError as exc: - logging.warning(f"No edges found for {node_a}, {node_b}") - raise ValueError from exc + except ValueError: + return types.empty_2d.copy() mask = np.isin(_edges[:, 1], l2ids_b) parents_a = _edges[mask][:, 0] @@ -383,9 +386,17 @@ def _filter(node): ) parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) - _new_edges = np.column_stack((parents_a, parents_b)) - err = f"No edge found for {node_a}, {node_b} at {edge_layer}; {parent_ts}" - assert _new_edges.size, err + return np.column_stack((parents_a, parents_b)) + + result = [types.empty_2d] + for edge_layer, _edge in zip(edge_layers, stale_edges): + max_chebyshev_distance = int(environ.get("MAX_CHEBYSHEV_DISTANCE", 3)) + for pad in range(0, max_chebyshev_distance): + _new_edges = _get_new_edge(_edge, parent_ts, padding=pad) + if _new_edges.size: + break + logging.info(f"{_edge}, expanding search with padding {pad+1}.") + assert _new_edges.size, f"No new edge found {_edge}; {edge_layer}, {parent_ts}" result.append(_new_edges) return np.concatenate(result) @@ -419,4 +430,5 @@ def get_latest_edges_wrapper( stale_edge_layers, parent_ts=parent_ts, ) + logging.info(f"{stale_edges} -> {latest_edges}; {parent_ts}") return np.concatenate([_cx_edges, latest_edges]) From 01536523ec1f8b84e8f2d0265e894aeb744950ec Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 27 Oct 2025 22:33:51 +0000 Subject: [PATCH 118/367] =?UTF-8?q?Bump=20version:=203.1.0=20=E2=86=92=203?= =?UTF-8?q?.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 59a83e91b..dbe7088b3 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.0 +current_version = 3.1.1 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index f5f41e567..d539d50ce 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.1.0" +__version__ = "3.1.1" From 139d59defd42d55808fe804cf400902a9c8c4ebf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 28 Oct 2025 17:16:34 +0000 Subject: [PATCH 119/367] fix(migration): filter out stale source nodes for latest edges --- pychunkedgraph/graph/edges/__init__.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index a2734522c..2322ec03f 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -406,7 +406,10 @@ def get_latest_edges_wrapper( cx_edges_d: dict, parent_ts: datetime.datetime = None, ) -> np.ndarray: - """Helper function to filter stale edges and replace with latest edges.""" + """ + Helper function to filter stale edges and replace with latest edges. + Filters out edges with nodes stale in source, edges[:,0], at given timestamp. + """ _cx_edges = [types.empty_2d] _edge_layers = [types.empty_1d] for k, v in cx_edges_d.items(): @@ -417,13 +420,16 @@ def get_latest_edges_wrapper( edge_nodes = np.unique(_cx_edges) stale_nodes = get_stale_nodes(cg, edge_nodes, parent_ts=parent_ts) - stale_nodes_mask = np.isin(edge_nodes, stale_nodes) + + stale_source_mask = np.isin(_cx_edges[:, 0], stale_nodes) + _cx_edges = _cx_edges[~stale_source_mask] + _edge_layers = _edge_layers[~stale_source_mask] + stale_destination_mask = np.isin(_cx_edges[:, 1], stale_nodes) latest_edges = types.empty_2d.copy() - if np.any(stale_nodes_mask): - stalte_edges_mask = np.isin(_cx_edges[:, 1], stale_nodes) - stale_edges = _cx_edges[stalte_edges_mask] - stale_edge_layers = _edge_layers[stalte_edges_mask] + if np.any(stale_destination_mask): + stale_edges = _cx_edges[stale_destination_mask] + stale_edge_layers = _edge_layers[stale_destination_mask] latest_edges = get_latest_edges( cg, stale_edges, From 393d536fe4cc5f48d8f0ec0f1c946fe15b9543f9 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 30 Oct 2025 15:29:24 +0000 Subject: [PATCH 120/367] fix(migration): handle cases where children IDs have already had their edges migrated --- pychunkedgraph/graph/edges/__init__.py | 42 ++++++++++++++++---------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 2322ec03f..23cb093fe 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -354,10 +354,9 @@ def _get_new_edge(edge, parent_ts, padding): return types.empty_2d.copy() _edges = [] + max_node_ts = max(nodes_ts_map[node_a], nodes_ts_map[node_b]) _edges_d = cg.get_cross_chunk_edges( - node_ids=l2ids_a, - time_stamp=max(nodes_ts_map[node_a], nodes_ts_map[node_b]), - raw_only=True, + node_ids=l2ids_a, time_stamp=max_node_ts, raw_only=True ) for v in _edges_d.values(): if edge_layer in v: @@ -369,18 +368,29 @@ def _get_new_edge(edge, parent_ts, padding): return types.empty_2d.copy() mask = np.isin(_edges[:, 1], l2ids_b) - parents_a = _edges[mask][:, 0] - children_b = cg.get_children(_edges[mask][:, 1], flatten=True) - parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) - _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) - parents_b = [] - for _node, _edges_d in _cx_edges_d.items(): - for _edges in _edges_d.values(): - _mask = np.isin(_edges[:, 1], parents_a) - if np.any(_mask): - parents_b.append(_node) - - parents_b = np.array(parents_b, dtype=basetypes.NODE_ID) + if np.any(mask): + parents_a = _edges[mask][:, 0] + children_b = cg.get_children(_edges[mask][:, 1], flatten=True) + parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) + _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) + parents_b = [] + for _node, _edges_d in _cx_edges_d.items(): + for _edges in _edges_d.values(): + _mask = np.isin(_edges[:, 1], parents_a) + if np.any(_mask): + parents_b.append(_node) + parents_b = np.array(parents_b, dtype=basetypes.NODE_ID) + else: + # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges + # so get the new identities of `l2ids_b` by using chunk mask + parents_b = _edges[:, 1] + chunks_old = cg.get_chunk_ids_from_node_ids(l2ids_b) + chunks_new = cg.get_chunk_ids_from_node_ids(parents_b) + chunk_mask = np.isin(chunks_new, chunks_old) + parents_b = parents_b[chunk_mask] + _stale_nodes = get_stale_nodes(cg, parents_b, parent_ts=max_node_ts) + assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {parent_ts}" + parents_b = np.unique( cg.get_roots(parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts) ) @@ -436,5 +446,5 @@ def get_latest_edges_wrapper( stale_edge_layers, parent_ts=parent_ts, ) - logging.info(f"{stale_edges} -> {latest_edges}; {parent_ts}") + logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") return np.concatenate([_cx_edges, latest_edges]) From 1f8b096cb201cd76c0b62a3e0bbd2af299ef7259 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 31 Oct 2025 20:11:31 +0000 Subject: [PATCH 121/367] fix(migration, edits): replace stale edges with new edges before persisting --- pychunkedgraph/graph/edges/__init__.py | 67 ++++++++++--------- pychunkedgraph/graph/edits.py | 3 +- pychunkedgraph/ingest/upgrade/parent_layer.py | 6 +- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 23cb093fe..a6a438668 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -412,39 +412,42 @@ def _get_new_edge(edge, parent_ts, padding): def get_latest_edges_wrapper( - cg, - cx_edges_d: dict, - parent_ts: datetime.datetime = None, -) -> np.ndarray: + cg, cx_edges_d: dict, parent_ts: datetime.datetime = None +) -> tuple[dict, np.ndarray]: """ Helper function to filter stale edges and replace with latest edges. Filters out edges with nodes stale in source, edges[:,0], at given timestamp. """ - _cx_edges = [types.empty_2d] - _edge_layers = [types.empty_1d] - for k, v in cx_edges_d.items(): - _cx_edges.append(v) - _edge_layers.append([k] * len(v)) - _cx_edges = np.concatenate(_cx_edges) - _edge_layers = np.concatenate(_edge_layers, dtype=int) - - edge_nodes = np.unique(_cx_edges) - stale_nodes = get_stale_nodes(cg, edge_nodes, parent_ts=parent_ts) - - stale_source_mask = np.isin(_cx_edges[:, 0], stale_nodes) - _cx_edges = _cx_edges[~stale_source_mask] - _edge_layers = _edge_layers[~stale_source_mask] - stale_destination_mask = np.isin(_cx_edges[:, 1], stale_nodes) - - latest_edges = types.empty_2d.copy() - if np.any(stale_destination_mask): - stale_edges = _cx_edges[stale_destination_mask] - stale_edge_layers = _edge_layers[stale_destination_mask] - latest_edges = get_latest_edges( - cg, - stale_edges, - stale_edge_layers, - parent_ts=parent_ts, - ) - logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") - return np.concatenate([_cx_edges, latest_edges]) + nodes = [types.empty_1d] + new_cx_edges_d = {0: types.empty_2d} + for layer, _cx_edges in cx_edges_d.items(): + if _cx_edges.size == 0: + continue + + _new_cx_edges = [types.empty_2d] + _edge_layers = np.array([layer] * len(_cx_edges), dtype=int) + edge_nodes = np.unique(_cx_edges) + stale_nodes = get_stale_nodes(cg, edge_nodes, parent_ts=parent_ts) + + stale_source_mask = np.isin(_cx_edges[:, 0], stale_nodes) + _new_cx_edges.append(_cx_edges[stale_source_mask]) + + _cx_edges = _cx_edges[~stale_source_mask] + _edge_layers = _edge_layers[~stale_source_mask] + stale_destination_mask = np.isin(_cx_edges[:, 1], stale_nodes) + _new_cx_edges.append(_cx_edges[~stale_destination_mask]) + + if np.any(stale_destination_mask): + stale_edges = _cx_edges[stale_destination_mask] + stale_edge_layers = _edge_layers[stale_destination_mask] + latest_edges = get_latest_edges( + cg, + stale_edges, + stale_edge_layers, + parent_ts=parent_ts, + ) + logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") + _new_cx_edges.append(latest_edges) + new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) + nodes.append(np.unique(new_cx_edges_d[layer])) + return new_cx_edges_d, np.concatenate(nodes) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 4ac9352a8..4e66a12d3 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -587,10 +587,9 @@ def _update_cross_edge_cache(self, parent, children): children, time_stamp=self._last_successful_ts ) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) - _cx_edges = get_latest_edges_wrapper( + cx_edges_d, edge_nodes = get_latest_edges_wrapper( self.cg, cx_edges_d, parent_ts=self._last_successful_ts ) - edge_nodes = np.unique(_cx_edges) edge_parents = self.cg.get_roots( edge_nodes, stop_layer=parent_layer, diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index b205f1753..80613825e 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -99,11 +99,10 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: for ts, cx_edges_d in CX_EDGES[node].items(): if ts < node_ts: continue - edges = get_latest_edges_wrapper(cg, cx_edges_d, parent_ts=ts) - if edges.size == 0: + cx_edges_d, edge_nodes = get_latest_edges_wrapper(cg, cx_edges_d, parent_ts=ts) + if edge_nodes.size == 0: continue - edge_nodes = np.unique(edges) parents = cg.get_roots(edge_nodes, time_stamp=ts, stop_layer=layer, ceil=False) edge_parents_d = dict(zip(edge_nodes, parents)) val_dict = {} @@ -167,7 +166,6 @@ def update_chunk( rows = [] for node, node_ts in zip(nodes, nodes_ts): rows.extend(update_cross_edges(cg, layer, node, node_ts)) - cg.client.write(rows) logging.info(f"total elaspsed time: {time.time() - start}") return From e477077d0e32a3a8b1bb7e004a6a37654ffb3d2b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 31 Oct 2025 20:11:45 +0000 Subject: [PATCH 122/367] =?UTF-8?q?Bump=20version:=203.1.1=20=E2=86=92=203?= =?UTF-8?q?.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index dbe7088b3..e71cbe7fc 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.1 +current_version = 3.1.2 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index d539d50ce..911557b86 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.1.1" +__version__ = "3.1.2" From 444d192ed223cdaa14928c7e119a8da31f4ce9db Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 6 Nov 2025 14:20:04 +0000 Subject: [PATCH 123/367] fix(migration): use None for non stale node end ts, performance improvements --- .../graph/client/bigtable/client.py | 2 +- pychunkedgraph/ingest/upgrade/atomic_layer.py | 16 ++++--- pychunkedgraph/ingest/upgrade/parent_layer.py | 44 ++++++++++++++----- pychunkedgraph/ingest/upgrade/utils.py | 9 ++-- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 9195fb397..8a1d596ff 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -833,7 +833,7 @@ def _execute_read_thread(self, args: typing.Tuple[Table, RowSet, RowFilter]): # Check for everything falsy, because Bigtable considers even empty # lists of row_keys as no upper/lower bound! return {} - retry = DEFAULT_RETRY_READ_ROWS.with_timeout(180) + retry = DEFAULT_RETRY_READ_ROWS.with_timeout(600) range_read = table.read_rows(row_set=row_set, filter_=row_filter, retry=retry) res = {v.row_key: utils.partial_row_data_to_column_dict(v) for v in range_read} return res diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index e4bd18b62..a52b4da9a 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -2,7 +2,7 @@ from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import timedelta +from datetime import datetime, timedelta, timezone import logging, math, time from copy import copy @@ -39,6 +39,7 @@ def update_cross_edges( for partner in partners: timestamps.update(timestamps_d[partner]) + node_end_ts = node_end_ts or datetime.now(timezone.utc) for ts in sorted(timestamps): if ts < node_ts: continue @@ -76,15 +77,20 @@ def update_nodes(cg: ChunkedGraph, nodes, nodes_ts, children_map=None) -> list: rows = [] for node, node_ts, end_ts in zip(nodes, nodes_ts, end_timestamps): - end_ts -= timedelta(milliseconds=1) + is_stale = end_ts is not None _cx_edges_d = cx_edges_d.get(node, {}) if not _cx_edges_d: continue + if is_stale: + end_ts -= timedelta(milliseconds=1) + _rows = update_cross_edges(cg, node, _cx_edges_d, node_ts, end_ts, timestamps_d) - row_id = serializers.serialize_uint64(node) - val_dict = {Hierarchy.StaleTimeStamp: 0} - _rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=end_ts)) + if is_stale: + row_id = serializers.serialize_uint64(node) + val_dict = {Hierarchy.StaleTimeStamp: 0} + _rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=end_ts)) rows.extend(_rows) + return rows diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 80613825e..e6103a99a 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -4,6 +4,7 @@ import multiprocessing as mp from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone import fastremap import numpy as np @@ -59,33 +60,47 @@ def _populate_cx_edges_with_timestamps( for all IDs involved in an edit, we can use the timestamps of when cross edges of children were updated. """ + + start = time.time() global CX_EDGES attrs = [Connectivity.CrossChunkEdge[l] for l in range(layer, cg.meta.layer_count)] all_children = np.concatenate(list(CHILDREN.values())) response = cg.client.read_nodes(node_ids=all_children, properties=attrs) timestamps_d = get_parent_timestamps(cg, nodes) end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, CHILDREN, layer=layer) + logging.info(f"_populate_nodes_and_children init: {time.time() - start}") - rows = [] - for node, node_ts, node_end_ts in zip(nodes, nodes_ts, end_timestamps): + start = time.time() + partners_map = {} + for node, node_ts in zip(nodes, nodes_ts): CX_EDGES[node] = {} - timestamps = timestamps_d[node] cx_edges_d_node_ts = _get_cx_edges_at_timestamp(node, response, node_ts) - edges = np.concatenate([empty_2d] + list(cx_edges_d_node_ts.values())) - partner_parent_ts_d = get_parent_timestamps(cg, edges[:, 1]) - for v in partner_parent_ts_d.values(): - timestamps.update(v) + partners_map[node] = edges[:, 1] CX_EDGES[node][node_ts] = cx_edges_d_node_ts + partners = np.unique(np.concatenate([*partners_map.values()])) + partner_parent_ts_d = get_parent_timestamps(cg, partners) + logging.info(f"get partners timestamps init: {time.time() - start}") + + rows = [] + for node, node_ts, node_end_ts in zip(nodes, nodes_ts, end_timestamps): + timestamps = timestamps_d[node] + for partner in partners_map[node]: + timestamps.update(partner_parent_ts_d[partner]) + + is_stale = node_end_ts is not None + node_end_ts = node_end_ts or datetime.now(timezone.utc) for ts in sorted(timestamps): if ts > node_end_ts: break CX_EDGES[node][ts] = _get_cx_edges_at_timestamp(node, response, ts) - row_id = serializers.serialize_uint64(node) - val_dict = {Hierarchy.StaleTimeStamp: 0} - rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=node_end_ts)) + if is_stale: + row_id = serializers.serialize_uint64(node) + val_dict = {Hierarchy.StaleTimeStamp: 0} + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=node_end_ts)) + cg.client.write(rows) @@ -140,7 +155,6 @@ def _update_cross_edges_helper(args): futures = [executor.submit(_update_cross_edges_helper_thread, task) for task in tasks] for future in tqdm(as_completed(futures), total=len(futures)): rows.extend(future.result()) - cg.client.write(rows) @@ -154,13 +168,21 @@ def update_chunk( start = time.time() x, y, z = chunk_coords chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) + _populate_nodes_and_children(cg, chunk_id, nodes=nodes) + logging.info(f"_populate_nodes_and_children: {time.time() - start}") if not CHILDREN: return nodes = list(CHILDREN.keys()) random.shuffle(nodes) + + start = time.time() nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) + logging.info(f"get_node_timestamps: {time.time() - start}") + + start = time.time() _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts) + logging.info(f"_populate_cx_edges_with_timestamps: {time.time() - start}") if debug: rows = [] diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py index 17f5db84e..2703058c4 100644 --- a/pychunkedgraph/ingest/upgrade/utils.py +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -64,16 +64,19 @@ def get_end_timestamps( for node, node_ts in zip(nodes, nodes_ts): node_children = children_map[node] - _timestamps = set().union(*[timestamps_d[k] for k in node_children]) + _children_timestamps = [] + for k in node_children: + if k in timestamps_d: + _children_timestamps.append(timestamps_d[k]) + _timestamps = set().union(*_children_timestamps) _timestamps.add(node_ts) try: _timestamps = sorted(_timestamps) _index = np.searchsorted(_timestamps, node_ts) - assert _timestamps[_index] == node_ts, (_index, node_ts, _timestamps) end_ts = _timestamps[_index + 1] except IndexError: # this node has not been edited, but might have it edges updated - end_ts = datetime.now(timezone.utc) + end_ts = None result.append(end_ts) return result From 0cd87d442b884f45ee5e7d689096ba401d987213 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 6 Nov 2025 20:59:53 +0000 Subject: [PATCH 124/367] perf(upgrade): reduce latency for atomic layer chunks --- pychunkedgraph/ingest/upgrade/atomic_layer.py | 84 ++++++++++--------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index a52b4da9a..32bb169c3 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -1,31 +1,43 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone -import logging, math, time +import logging, time from copy import copy import fastremap import numpy as np -from tqdm import tqdm from pychunkedgraph.graph import ChunkedGraph, types from pychunkedgraph.graph.attributes import Connectivity, Hierarchy from pychunkedgraph.graph.utils import serializers -from pychunkedgraph.utils.general import chunked from .utils import get_end_timestamps, get_parent_timestamps CHILDREN = {} +def _get_parents_at_timestamp(nodes, parents_ts_map, time_stamp): + """ + Search for the first parent with ts <= `time_stamp`. + `parents_ts_map[node]` is a map of ts:parent with sorted timestamps (desc). + """ + parents = [] + for node in nodes: + for ts, parent in parents_ts_map[node].items(): + if time_stamp >= ts: + parents.append(parent) + break + return parents + + def update_cross_edges( cg: ChunkedGraph, node, cx_edges_d: dict, node_ts, node_end_ts, - timestamps_d: defaultdict[int, set], + timestamps_map: defaultdict[int, set], + parents_ts_map: defaultdict[int, dict], ) -> list: """ Helper function to update a single L2 ID. @@ -35,9 +47,9 @@ def update_cross_edges( edges = np.concatenate(list(cx_edges_d.values())) partners = np.unique(edges[:, 1]) - timestamps = copy(timestamps_d[node]) + timestamps = copy(timestamps_map[node]) for partner in partners: - timestamps.update(timestamps_d[partner]) + timestamps.update(timestamps_map[partner]) node_end_ts = node_end_ts or datetime.now(timezone.utc) for ts in sorted(timestamps): @@ -47,7 +59,7 @@ def update_cross_edges( break val_dict = {} - parents = cg.get_parents(partners, time_stamp=ts) + parents = _get_parents_at_timestamp(partners, parents_ts_map, ts) edge_parents_d = dict(zip(partners, parents)) for layer, layer_edges in cx_edges_d.items(): layer_edges = fastremap.remap( @@ -63,6 +75,7 @@ def update_cross_edges( def update_nodes(cg: ChunkedGraph, nodes, nodes_ts, children_map=None) -> list: + start = time.time() if children_map is None: children_map = CHILDREN end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, children_map, layer=2) @@ -75,31 +88,39 @@ def update_nodes(cg: ChunkedGraph, nodes, nodes_ts, children_map=None) -> list: all_partners = np.unique(np.concatenate(all_cx_edges)[:, 1]) timestamps_d = get_parent_timestamps(cg, np.concatenate([nodes, all_partners])) + parents_ts_map = defaultdict(dict) + all_parents = cg.get_parents(all_partners, current=False) + for partner, parents in zip(all_partners, all_parents): + for parent, ts in parents: + parents_ts_map[partner][ts] = parent + logging.info(f"update_nodes init {len(nodes)}: {time.time() - start}") + rows = [] + skipped = [] for node, node_ts, end_ts in zip(nodes, nodes_ts, end_timestamps): is_stale = end_ts is not None _cx_edges_d = cx_edges_d.get(node, {}) - if not _cx_edges_d: - continue if is_stale: end_ts -= timedelta(milliseconds=1) - - _rows = update_cross_edges(cg, node, _cx_edges_d, node_ts, end_ts, timestamps_d) - if is_stale: row_id = serializers.serialize_uint64(node) val_dict = {Hierarchy.StaleTimeStamp: 0} - _rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=end_ts)) - rows.extend(_rows) - - return rows + rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=end_ts)) + if not _cx_edges_d: + skipped.append(node) + continue -def _update_nodes_helper(args): - cg, nodes, nodes_ts = args - return update_nodes(cg, nodes, nodes_ts) + _rows = update_cross_edges( + cg, node, _cx_edges_d, node_ts, end_ts, timestamps_d, parents_ts_map + ) + rows.extend(_rows) + parents = cg.get_roots(skipped) + layers = cg.get_chunk_layers(parents) + assert np.all(layers == cg.meta.layer_count) + return rows -def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], debug: bool = False): +def update_chunk(cg: ChunkedGraph, chunk_coords: list[int]): """ Iterate over all L2 IDs in a chunk and update their cross chunk edges, within the periods they were valid/active. @@ -132,23 +153,6 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int], debug: bool = False) else: return - if debug: - rows = update_nodes(cg, nodes, nodes_ts) - else: - task_size = int(math.ceil(len(nodes) / 16)) - chunked_nodes = chunked(nodes, task_size) - chunked_nodes_ts = chunked(nodes_ts, task_size) - tasks = [] - for chunk, ts_chunk in zip(chunked_nodes, chunked_nodes_ts): - args = (cg, chunk, ts_chunk) - tasks.append(args) - logging.info(f"task size {task_size}, count {len(tasks)}.") - - rows = [] - with ThreadPoolExecutor(max_workers=8) as executor: - futures = [executor.submit(_update_nodes_helper, task) for task in tasks] - for future in tqdm(as_completed(futures), total=len(futures)): - rows.extend(future.result()) - + rows = update_nodes(cg, nodes, nodes_ts) cg.client.write(rows) - logging.info(f"total elaspsed time: {time.time() - start}") + logging.info(f"mutations: {len(rows)}, time: {time.time() - start}") From c9ee0c3cd62070fc5e92e17b1643fcce401a165e Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 8 Nov 2025 19:50:18 +0000 Subject: [PATCH 125/367] fix(migration): erase corrupt ids from failed edits --- pychunkedgraph/ingest/upgrade/atomic_layer.py | 30 ++++++++++------ pychunkedgraph/ingest/upgrade/parent_layer.py | 34 +++++++++++++----- pychunkedgraph/ingest/upgrade/utils.py | 35 ++++++++++++++++++- 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index 32bb169c3..8aa39929d 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -2,7 +2,7 @@ from collections import defaultdict from datetime import datetime, timedelta, timezone -import logging, time +import logging, time, os from copy import copy import fastremap @@ -11,7 +11,7 @@ from pychunkedgraph.graph.attributes import Connectivity, Hierarchy from pychunkedgraph.graph.utils import serializers -from .utils import get_end_timestamps, get_parent_timestamps +from .utils import fix_corrupt_nodes, get_end_timestamps, get_parent_timestamps CHILDREN = {} @@ -130,29 +130,37 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int]): start = time.time() x, y, z = chunk_coords chunk_id = cg.get_chunk_id(layer=2, x=x, y=y, z=z) - cg.copy_fake_edges(chunk_id) rr = cg.range_read_chunk(chunk_id) nodes = [] nodes_ts = [] earliest_ts = cg.get_earliest_timestamp() + corrupt_nodes = [] for k, v in rr.items(): try: - _ = v[Hierarchy.Parent] - nodes.append(k) CHILDREN[k] = v[Hierarchy.Child][0].value ts = v[Hierarchy.Child][0].timestamp + _ = v[Hierarchy.Parent] + nodes.append(k) nodes_ts.append(earliest_ts if ts < earliest_ts else ts) except KeyError: - # invalid nodes from failed tasks w/o parent column entry - continue + # ignore invalid nodes from failed ingest tasks, w/o parent column entry + # retain invalid nodes from edits to fix the hierarchy + if ts > earliest_ts: + corrupt_nodes.append(k) + + clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" + if clean_task: + logging.info(f"found {len(corrupt_nodes)} corrupt nodes {corrupt_nodes[:3]}...") + fix_corrupt_nodes(cg, corrupt_nodes, CHILDREN) + return - if len(nodes) > 0: - logging.info(f"processing {len(nodes)} nodes.") - assert len(CHILDREN) > 0, (nodes, CHILDREN) - else: + cg.copy_fake_edges(chunk_id) + if len(nodes) == 0: return + logging.info(f"processing {len(nodes)} nodes.") + assert len(CHILDREN) > 0, (nodes, CHILDREN) rows = update_nodes(cg, nodes, nodes_ts) cg.client.write(rows) logging.info(f"mutations: {len(rows)}, time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index e6103a99a..ef8336e74 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -1,6 +1,6 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member -import logging, math, random, time +import logging, math, random, time, os import multiprocessing as mp from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed @@ -17,7 +17,7 @@ from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked -from .utils import get_end_timestamps, get_parent_timestamps +from .utils import fix_corrupt_nodes, get_end_timestamps, get_parent_timestamps CHILDREN = {} @@ -61,6 +61,11 @@ def _populate_cx_edges_with_timestamps( when cross edges of children were updated. """ + clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" + # this data is not needed for clean tasks + if clean_task: + return + start = time.time() global CX_EDGES attrs = [Connectivity.CrossChunkEdge[l] for l in range(layer, cg.meta.layer_count)] @@ -139,20 +144,33 @@ def _update_cross_edges_helper_thread(args): def _update_cross_edges_helper(args): - cg_info, layer, nodes, nodes_ts = args rows = [] + clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" + cg_info, layer, nodes, nodes_ts = args cg = ChunkedGraph(**cg_info) parents = cg.get_parents(nodes, fail_to_zero=True) tasks = [] + corrupt_nodes = [] + earliest_ts = cg.get_earliest_timestamp() for node, parent, node_ts in zip(nodes, parents, nodes_ts): if parent == 0: - # invalid id caused by failed ingest task / edits - continue - tasks.append((cg, layer, node, node_ts)) + # ignore invalid nodes from failed ingest tasks, w/o parent column entry + # retain invalid nodes from edits to fix the hierarchy + if node_ts > earliest_ts: + corrupt_nodes.append(node) + else: + tasks.append((cg, layer, node, node_ts)) + + if clean_task: + logging.info(f"found {len(corrupt_nodes)} corrupt nodes {corrupt_nodes[:3]}...") + fix_corrupt_nodes(cg, corrupt_nodes, CHILDREN) + return with ThreadPoolExecutor(max_workers=4) as executor: - futures = [executor.submit(_update_cross_edges_helper_thread, task) for task in tasks] + futures = [ + executor.submit(_update_cross_edges_helper_thread, task) for task in tasks + ] for future in tqdm(as_completed(futures), total=len(futures)): rows.extend(future.result()) cg.client.write(rows) @@ -164,7 +182,7 @@ def update_chunk( """ Iterate over all layer IDs in a chunk and update their cross chunk edges. """ - debug = nodes is not None + debug = nodes is not None start = time.time() x, y, z = chunk_coords chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py index 2703058c4..d17fbc002 100644 --- a/pychunkedgraph/ingest/upgrade/utils.py +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -1,11 +1,13 @@ # pylint: disable=invalid-name, missing-docstring from collections import defaultdict -from datetime import datetime, timezone +from datetime import datetime, timedelta import numpy as np from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.attributes import Hierarchy +from pychunkedgraph.graph.utils import serializers +from google.cloud.bigtable.row_filters import TimestampRange def exists_as_parent(cg: ChunkedGraph, parent, nodes) -> bool: @@ -102,3 +104,34 @@ def get_parent_timestamps( ts = cell.timestamp result[k].add(earliest_ts if ts < earliest_ts else ts) return result + + +def fix_corrupt_nodes(cg: ChunkedGraph, nodes: list, children_d: dict): + """ + Iteratively removes a node from parent column of its children. + Then removes the node iteself, effectively erasing it. + """ + table = cg.client._table + batcher = table.mutations_batcher(flush_count=500) + for node in nodes: + children = children_d[node] + _map = cg.client.read_nodes(node_ids=children, properties=Hierarchy.Parent) + + for child, parent_cells in _map.items(): + row = table.direct_row(serializers.serialize_uint64(child)) + for cell in parent_cells: + if cell.value == node: + start = cell.timestamp + end = start + timedelta(microseconds=1) + row.delete_cell( + column_family_id=Hierarchy.Parent.family_id, + column=Hierarchy.Parent.key, + time_range=TimestampRange(start=start, end=end), + ) + batcher.mutate(row) + + row = table.direct_row(serializers.serialize_uint64(node)) + row.delete() + batcher.mutate(row) + + batcher.flush() From 9bd35a43c2ebc7f915bfbf10fc12f3116a7f166f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 12 Nov 2025 21:38:40 +0000 Subject: [PATCH 126/367] fix(edits/migration): pass edge layer to helper function; handle stale source nodes using atomic edges; improve latency with lru cache --- pychunkedgraph/graph/edges/__init__.py | 98 ++++++++++++++----- pychunkedgraph/graph/utils/generic.py | 25 ++++- pychunkedgraph/ingest/cluster.py | 3 +- pychunkedgraph/ingest/upgrade/atomic_layer.py | 17 +--- pychunkedgraph/ingest/upgrade/parent_layer.py | 24 ++--- pychunkedgraph/ingest/upgrade/utils.py | 4 +- pychunkedgraph/ingest/utils.py | 3 +- 7 files changed, 111 insertions(+), 63 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index a6a438668..2e478af4c 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -12,6 +12,7 @@ import tensorstore as ts import zstandard as zstd from graph_tool import Graph +from cachetools import LRUCache from pychunkedgraph.graph import types from pychunkedgraph.graph.chunks.utils import ( @@ -21,6 +22,7 @@ from pychunkedgraph.graph.utils import basetypes from ..utils import basetypes +from ..utils.generic import get_parents_at_timestamp _edge_type_fileds = ("in_chunk", "between_chunk", "cross_chunk") @@ -39,6 +41,7 @@ ] ) ZSTD_EDGE_COMPRESSION = 17 +PARENTS_CACHE = LRUCache(256 * 1024) class Edges: @@ -341,7 +344,72 @@ def _filter(node): chunks_map[node_b] = np.concatenate(chunks_map[node_b]) return int(mlayer), _filter(node_a), _filter(node_b) - def _get_new_edge(edge, parent_ts, padding): + def _populate_parents_cache(children: np.ndarray): + global PARENTS_CACHE + + not_cached = [] + for child in children: + try: + # reset lru index, these will be needed soon + _ = PARENTS_CACHE[child] + except KeyError: + not_cached.append(child) + + all_parents = cg.get_parents(not_cached, current=False) + for child, parents in zip(not_cached, all_parents): + PARENTS_CACHE[child] = {} + for parent, ts in parents: + PARENTS_CACHE[child][ts] = parent + + def _get_parents_b(edges, parent_ts, layer): + """ + Attempts to find new partner side nodes. + Gets new partners at parent_ts using supervoxels, at `parent_ts`. + Searches for new partners that may have any edges to `edges[:,0]`. + """ + children_b = cg.get_children(edges[:, 1], flatten=True) + _populate_parents_cache(children_b) + _parents_b, missing = get_parents_at_timestamp( + children_b, PARENTS_CACHE, time_stamp=parent_ts, unique=True + ) + # handle cache miss cases + _parents_b_missing = np.unique(cg.get_parents(missing, time_stamp=parent_ts)) + parents_b = np.concatenate([_parents_b, _parents_b_missing]) + + parents_a = edges[:, 0] + stale_a = get_stale_nodes(cg, parents_a, parent_ts=parent_ts) + if stale_a.size == parents_a.size: + # this is applicable only for v2 to v3 migration + # handle cases when source nodes in `edges[:,0]` are stale + atomic_edges_d = cg.get_atomic_cross_edges(stale_a) + partners = [types.empty_1d] + for _edges_d in atomic_edges_d.values(): + _edges = _edges_d.get(layer, types.empty_2d) + partners.append(_edges[:, 1]) + partners = np.concatenate(partners) + return np.unique(cg.get_parents(partners, time_stamp=parent_ts)) + + _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) + _parents_b = [] + for _node, _edges_d in _cx_edges_d.items(): + for _edges in _edges_d.values(): + _mask = np.isin(_edges[:, 1], parents_a) + if np.any(_mask): + _parents_b.append(_node) + return np.array(_parents_b, dtype=basetypes.NODE_ID) + + def _get_parents_b_with_chunk_mask( + l2ids_b: np.ndarray, parents_b: np.ndarray, max_ts: datetime.datetime, edge + ): + chunks_old = cg.get_chunk_ids_from_node_ids(l2ids_b) + chunks_new = cg.get_chunk_ids_from_node_ids(parents_b) + chunk_mask = np.isin(chunks_new, chunks_old) + parents_b = parents_b[chunk_mask] + _stale_nodes = get_stale_nodes(cg, parents_b, parent_ts=max_ts) + assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {parent_ts}" + return parents_b + + def _get_new_edge(edge, edge_layer, parent_ts, padding): """ Attempts to find new edge(s) for the stale `edge`. * Find L2 IDs on opposite sides of the face in L2 chunks along the face. @@ -353,11 +421,11 @@ def _get_new_edge(edge, parent_ts, padding): if l2ids_a.size == 0 or l2ids_b.size == 0: return types.empty_2d.copy() - _edges = [] max_node_ts = max(nodes_ts_map[node_a], nodes_ts_map[node_b]) _edges_d = cg.get_cross_chunk_edges( node_ids=l2ids_a, time_stamp=max_node_ts, raw_only=True ) + _edges = [] for v in _edges_d.values(): if edge_layer in v: _edges.append(v[edge_layer]) @@ -369,27 +437,13 @@ def _get_new_edge(edge, parent_ts, padding): mask = np.isin(_edges[:, 1], l2ids_b) if np.any(mask): - parents_a = _edges[mask][:, 0] - children_b = cg.get_children(_edges[mask][:, 1], flatten=True) - parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) - _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) - parents_b = [] - for _node, _edges_d in _cx_edges_d.items(): - for _edges in _edges_d.values(): - _mask = np.isin(_edges[:, 1], parents_a) - if np.any(_mask): - parents_b.append(_node) - parents_b = np.array(parents_b, dtype=basetypes.NODE_ID) + parents_b = _get_parents_b(_edges[mask], parent_ts, edge_layer) else: # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges # so get the new identities of `l2ids_b` by using chunk mask - parents_b = _edges[:, 1] - chunks_old = cg.get_chunk_ids_from_node_ids(l2ids_b) - chunks_new = cg.get_chunk_ids_from_node_ids(parents_b) - chunk_mask = np.isin(chunks_new, chunks_old) - parents_b = parents_b[chunk_mask] - _stale_nodes = get_stale_nodes(cg, parents_b, parent_ts=max_node_ts) - assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {parent_ts}" + parents_b = _get_parents_b_with_chunk_mask( + l2ids_b, _edges[:, 1], max_node_ts, edge + ) parents_b = np.unique( cg.get_roots(parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts) @@ -402,7 +456,7 @@ def _get_new_edge(edge, parent_ts, padding): for edge_layer, _edge in zip(edge_layers, stale_edges): max_chebyshev_distance = int(environ.get("MAX_CHEBYSHEV_DISTANCE", 3)) for pad in range(0, max_chebyshev_distance): - _new_edges = _get_new_edge(_edge, parent_ts, padding=pad) + _new_edges = _get_new_edge(_edge, edge_layer, parent_ts, padding=pad) if _new_edges.size: break logging.info(f"{_edge}, expanding search with padding {pad+1}.") @@ -446,7 +500,7 @@ def get_latest_edges_wrapper( stale_edge_layers, parent_ts=parent_ts, ) - logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") + logging.debug(f"{stale_edges} -> {latest_edges[:,1].tolist()}; {parent_ts}") _new_cx_edges.append(latest_edges) new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) nodes.append(np.unique(new_cx_edges_d[layer])) diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 9a2b6f979..799fc5332 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -3,7 +3,6 @@ TODO categorize properly """ - import datetime from typing import Dict from typing import Iterable @@ -173,9 +172,7 @@ def mask_nodes_by_bounding_box( adapt_layers = layers - 2 adapt_layers[adapt_layers < 0] = 0 fanout = meta.graph_config.FANOUT - bounding_box_layer = ( - bounding_box[None] / (fanout ** adapt_layers)[:, None, None] - ) + bounding_box_layer = bounding_box[None] / (fanout**adapt_layers)[:, None, None] bound_check = np.array( [ np.all(chunk_coordinates < bounding_box_layer[:, 1], axis=1), @@ -183,4 +180,22 @@ def mask_nodes_by_bounding_box( ] ).T - return np.all(bound_check, axis=1) \ No newline at end of file + return np.all(bound_check, axis=1) + + +def get_parents_at_timestamp(nodes, parents_ts_map, time_stamp, unique: bool = False): + """ + Search for the first parent with ts <= `time_stamp`. + `parents_ts_map[node]` is a map of ts:parent with sorted timestamps (desc). + """ + skipped_nodes = [] + parents = set() if unique else [] + for node in nodes: + try: + for ts, parent in parents_ts_map[node].items(): + if time_stamp >= ts: + parents.add(parent) if unique else parents.append(parent) + break + except KeyError: + skipped_nodes.append(node) + return list(parents), skipped_nodes diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index d87576ca0..608d7b332 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -10,7 +10,7 @@ from typing import Callable, Dict, Iterable, Tuple, Sequence import numpy as np -from rq import Queue as RQueue +from rq import Queue as RQueue, Retry from .utils import chunk_id_str, get_chunks_not_done, randomize_grid_points @@ -209,6 +209,7 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl timeout=environ.get("L2JOB_TIMEOUT", "3m"), result_ttl=0, job_id=chunk_id_str(2, chunk_coord), + retry=Retry(int(environ.get("RETRY_COUNT", 1))), ) ) q.enqueue_many(job_datas) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index 8aa39929d..a1d44a8d9 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -10,26 +10,13 @@ from pychunkedgraph.graph import ChunkedGraph, types from pychunkedgraph.graph.attributes import Connectivity, Hierarchy from pychunkedgraph.graph.utils import serializers +from pychunkedgraph.graph.utils.generic import get_parents_at_timestamp from .utils import fix_corrupt_nodes, get_end_timestamps, get_parent_timestamps CHILDREN = {} -def _get_parents_at_timestamp(nodes, parents_ts_map, time_stamp): - """ - Search for the first parent with ts <= `time_stamp`. - `parents_ts_map[node]` is a map of ts:parent with sorted timestamps (desc). - """ - parents = [] - for node in nodes: - for ts, parent in parents_ts_map[node].items(): - if time_stamp >= ts: - parents.append(parent) - break - return parents - - def update_cross_edges( cg: ChunkedGraph, node, @@ -59,7 +46,7 @@ def update_cross_edges( break val_dict = {} - parents = _get_parents_at_timestamp(partners, parents_ts_map, ts) + parents, _ = get_parents_at_timestamp(partners, parents_ts_map, ts) edge_parents_d = dict(zip(partners, parents)) for layer, layer_edges in cx_edges_d.items(): layer_edges = fastremap.remap( diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index ef8336e74..06962c38f 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -10,9 +10,8 @@ import numpy as np from tqdm import tqdm -from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph import ChunkedGraph, edges from pychunkedgraph.graph.attributes import Connectivity, Hierarchy -from pychunkedgraph.graph.edges import get_latest_edges_wrapper from pychunkedgraph.graph.utils import serializers from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked @@ -105,7 +104,6 @@ def _populate_cx_edges_with_timestamps( row_id = serializers.serialize_uint64(node) val_dict = {Hierarchy.StaleTimeStamp: 0} rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=node_end_ts)) - cg.client.write(rows) @@ -119,7 +117,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: for ts, cx_edges_d in CX_EDGES[node].items(): if ts < node_ts: continue - cx_edges_d, edge_nodes = get_latest_edges_wrapper(cg, cx_edges_d, parent_ts=ts) + cx_edges_d, edge_nodes = edges.get_latest_edges_wrapper(cg, cx_edges_d, parent_ts=ts) if edge_nodes.size == 0: continue @@ -138,13 +136,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: return rows -def _update_cross_edges_helper_thread(args): - cg, layer, node, node_ts = args - return update_cross_edges(cg, layer, node, node_ts) - - def _update_cross_edges_helper(args): - rows = [] clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" cg_info, layer, nodes, nodes_ts = args cg = ChunkedGraph(**cg_info) @@ -167,12 +159,9 @@ def _update_cross_edges_helper(args): fix_corrupt_nodes(cg, corrupt_nodes, CHILDREN) return - with ThreadPoolExecutor(max_workers=4) as executor: - futures = [ - executor.submit(_update_cross_edges_helper_thread, task) for task in tasks - ] - for future in tqdm(as_completed(futures), total=len(futures)): - rows.extend(future.result()) + rows = [] + for task in tasks: + rows.extend(update_cross_edges(*task)) cg.client.write(rows) @@ -204,12 +193,13 @@ def update_chunk( if debug: rows = [] + logging.info(f"processing {len(nodes)} nodes with 1 worker.") for node, node_ts in zip(nodes, nodes_ts): rows.extend(update_cross_edges(cg, layer, node, node_ts)) logging.info(f"total elaspsed time: {time.time() - start}") return - task_size = int(math.ceil(len(nodes) / mp.cpu_count() / 2)) + task_size = int(math.ceil(len(nodes) / mp.cpu_count())) chunked_nodes = chunked(nodes, task_size) chunked_nodes_ts = chunked(nodes_ts, task_size) cg_info = cg.get_serialized_info() diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py index d17fbc002..0410245c3 100644 --- a/pychunkedgraph/ingest/upgrade/utils.py +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -108,8 +108,8 @@ def get_parent_timestamps( def fix_corrupt_nodes(cg: ChunkedGraph, nodes: list, children_d: dict): """ - Iteratively removes a node from parent column of its children. - Then removes the node iteself, effectively erasing it. + For each node: delete it from parent column of its children. + Then deletes the node itself, effectively erasing it from hierarchy. """ table = cg.client._table batcher = table.mutations_batcher(flush_count=500) diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 5c51242ac..9bb4b0452 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -9,7 +9,7 @@ import numpy as np import tensorstore as ts -from rq import Queue, Worker +from rq import Queue, Retry, Worker from rq.worker import WorkerStatus from . import IngestConfig @@ -199,6 +199,7 @@ def queue_layer_helper(parent_layer: int, imanager: IngestionManager, fn): result_ttl=0, job_id=chunk_id_str(parent_layer, chunk_coord), timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", + retry=Retry(int(environ.get("RETRY_COUNT", 1))), ) ) q.enqueue_many(job_datas) From 53d78e736f698675a8a9d38112530d9292bec1df Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 12 Nov 2025 21:41:19 +0000 Subject: [PATCH 127/367] =?UTF-8?q?Bump=20version:=203.1.2=20=E2=86=92=203?= =?UTF-8?q?.1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e71cbe7fc..1d4b6de4c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.2 +current_version = 3.1.3 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 911557b86..f7493720d 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.1.2" +__version__ = "3.1.3" From 0a7ed8bd6ebb9fa9e463ac54b5f4b9d86285c444 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 13 Nov 2025 15:21:01 +0000 Subject: [PATCH 128/367] fix(edits/migration): don't use lru parents cache with edits --- pychunkedgraph/graph/edges/__init__.py | 19 +++++++----- pychunkedgraph/ingest/upgrade/parent_layer.py | 31 +++++++++++++------ 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 2e478af4c..75c896429 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -41,7 +41,7 @@ ] ) ZSTD_EDGE_COMPRESSION = 17 -PARENTS_CACHE = LRUCache(256 * 1024) +PARENTS_CACHE: LRUCache = None class Edges: @@ -368,13 +368,16 @@ def _get_parents_b(edges, parent_ts, layer): Searches for new partners that may have any edges to `edges[:,0]`. """ children_b = cg.get_children(edges[:, 1], flatten=True) - _populate_parents_cache(children_b) - _parents_b, missing = get_parents_at_timestamp( - children_b, PARENTS_CACHE, time_stamp=parent_ts, unique=True - ) - # handle cache miss cases - _parents_b_missing = np.unique(cg.get_parents(missing, time_stamp=parent_ts)) - parents_b = np.concatenate([_parents_b, _parents_b_missing]) + if PARENTS_CACHE is None: + parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) + else: + _populate_parents_cache(children_b) + _parents_b, missing = get_parents_at_timestamp( + children_b, PARENTS_CACHE, time_stamp=parent_ts, unique=True + ) + # handle cache miss cases + _parents_b_missing = np.unique(cg.get_parents(missing, time_stamp=parent_ts)) + parents_b = np.concatenate([_parents_b, _parents_b_missing]) parents_a = edges[:, 0] stale_a = get_stale_nodes(cg, parents_a, parent_ts=parent_ts) diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 06962c38f..f023152bf 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -1,14 +1,14 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member -import logging, math, random, time, os +import logging, random, time, os import multiprocessing as mp from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import fastremap import numpy as np from tqdm import tqdm +from cachetools import LRUCache from pychunkedgraph.graph import ChunkedGraph, edges from pychunkedgraph.graph.attributes import Connectivity, Hierarchy @@ -21,6 +21,7 @@ CHILDREN = {} CX_EDGES = {} +CG: ChunkedGraph = None def _populate_nodes_and_children( @@ -104,7 +105,7 @@ def _populate_cx_edges_with_timestamps( row_id = serializers.serialize_uint64(node) val_dict = {Hierarchy.StaleTimeStamp: 0} rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=node_end_ts)) - cg.client.write(rows) + # cg.client.write(rows) def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: @@ -117,7 +118,9 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: for ts, cx_edges_d in CX_EDGES[node].items(): if ts < node_ts: continue - cx_edges_d, edge_nodes = edges.get_latest_edges_wrapper(cg, cx_edges_d, parent_ts=ts) + cx_edges_d, edge_nodes = edges.get_latest_edges_wrapper( + cg, cx_edges_d, parent_ts=ts + ) if edge_nodes.size == 0: continue @@ -137,19 +140,26 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: def _update_cross_edges_helper(args): + global CG + edges.PARENTS_CACHE = LRUCache(64 * 1024) clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" cg_info, layer, nodes, nodes_ts = args - cg = ChunkedGraph(**cg_info) + + if CG is None: + CG = ChunkedGraph(**cg_info) + cg = CG parents = cg.get_parents(nodes, fail_to_zero=True) tasks = [] corrupt_nodes = [] - earliest_ts = cg.get_earliest_timestamp() + earliest_ts = None + if clean_task: + earliest_ts = cg.get_earliest_timestamp() for node, parent, node_ts in zip(nodes, parents, nodes_ts): if parent == 0: # ignore invalid nodes from failed ingest tasks, w/o parent column entry # retain invalid nodes from edits to fix the hierarchy - if node_ts > earliest_ts: + if clean_task and node_ts > earliest_ts: corrupt_nodes.append(node) else: tasks.append((cg, layer, node, node_ts)) @@ -162,7 +172,8 @@ def _update_cross_edges_helper(args): rows = [] for task in tasks: rows.extend(update_cross_edges(*task)) - cg.client.write(rows) + edges.PARENTS_CACHE.clear() + # cg.client.write(rows) def update_chunk( @@ -199,7 +210,7 @@ def update_chunk( logging.info(f"total elaspsed time: {time.time() - start}") return - task_size = int(math.ceil(len(nodes) / mp.cpu_count())) + task_size = int(os.environ.get("TASK_SIZE", 10)) chunked_nodes = chunked(nodes, task_size) chunked_nodes_ts = chunked(nodes_ts, task_size) cg_info = cg.get_serialized_info() @@ -209,7 +220,7 @@ def update_chunk( args = (cg_info, layer, chunk, ts_chunk) tasks.append(args) - processes = min(mp.cpu_count() * 2, len(tasks)) + processes = min(mp.cpu_count() * 5, len(tasks)) logging.info(f"processing {len(nodes)} nodes with {processes} workers.") with mp.Pool(processes) as pool: _ = list( From ef258a377abd5287b232fc8cd8172c376c016b37 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 13 Nov 2025 18:57:29 +0000 Subject: [PATCH 129/367] fix(migration): use larger cache, faster parent search with bisect, cache cg instance --- pychunkedgraph/graph/edges/__init__.py | 27 ++++++++++++++++--- pychunkedgraph/graph/utils/generic.py | 12 ++++++--- pychunkedgraph/ingest/upgrade/parent_layer.py | 26 ++++++++++-------- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 75c896429..ebaebe895 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -42,6 +42,7 @@ ) ZSTD_EDGE_COMPRESSION = 17 PARENTS_CACHE: LRUCache = None +CHILDREN_CACHE: LRUCache = None class Edges: @@ -249,6 +250,22 @@ def get_latest_edges( layers_d = dict(zip(_nodes, layers)) coords_d = dict(zip(_nodes, coords)) + def _get_children_from_cache(nodes): + children = [] + non_cached = [] + for node in nodes: + try: + v = CHILDREN_CACHE[node] + children.append(v) + except KeyError: + non_cached.append(node) + + children_map = cg.get_children(non_cached) + for k, v in children_map.items(): + CHILDREN_CACHE[k] = v + children.append(v) + return np.concatenate(children) + def _get_normalized_coords(node_a, node_b) -> tuple: max_layer = layers_d[node_a] coord_a, coord_b = coords_d[node_a], coords_d[node_b] @@ -323,7 +340,9 @@ def _filter(node): mask = cg.get_chunk_layers(children) > 2 if children[mask].size == 0: break - children = cg.get_children(children[mask], flatten=True) + # children0 = cg.get_children(children[mask], flatten=True) + children = _get_children_from_cache(children[mask]) + # assert np.array_equal(children, children0) return np.concatenate(result) mlayer, coord_a, coord_b = _get_normalized_coords(node_a, node_b) @@ -367,7 +386,9 @@ def _get_parents_b(edges, parent_ts, layer): Gets new partners at parent_ts using supervoxels, at `parent_ts`. Searches for new partners that may have any edges to `edges[:,0]`. """ - children_b = cg.get_children(edges[:, 1], flatten=True) + # children1 = cg.get_children(edges[:, 1], flatten=True) + children_b = _get_children_from_cache(edges[:, 1]) + # assert np.array_equal(children_b, children1) if PARENTS_CACHE is None: parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) else: @@ -503,7 +524,7 @@ def get_latest_edges_wrapper( stale_edge_layers, parent_ts=parent_ts, ) - logging.debug(f"{stale_edges} -> {latest_edges[:,1].tolist()}; {parent_ts}") + logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") _new_cx_edges.append(latest_edges) new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) nodes.append(np.unique(new_cx_edges_d[layer])) diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 799fc5332..2f9f5c955 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -3,6 +3,7 @@ TODO categorize properly """ +import bisect import datetime from typing import Dict from typing import Iterable @@ -192,10 +193,13 @@ def get_parents_at_timestamp(nodes, parents_ts_map, time_stamp, unique: bool = F parents = set() if unique else [] for node in nodes: try: - for ts, parent in parents_ts_map[node].items(): - if time_stamp >= ts: - parents.add(parent) if unique else parents.append(parent) - break + ts_parent_map = parents_ts_map[node] + ts_list = list(ts_parent_map.keys()) + asc_ts_list = ts_list[::-1] + idx = bisect.bisect_right(asc_ts_list, time_stamp) + ts = asc_ts_list[idx - 1] + parent = ts_parent_map[ts] + parents.add(parent) if unique else parents.append(parent) except KeyError: skipped_nodes.append(node) return list(parents), skipped_nodes diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index f023152bf..ce36159cd 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -105,7 +105,7 @@ def _populate_cx_edges_with_timestamps( row_id = serializers.serialize_uint64(node) val_dict = {Hierarchy.StaleTimeStamp: 0} rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=node_end_ts)) - # cg.client.write(rows) + cg.client.write(rows) def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: @@ -115,19 +115,17 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: """ rows = [] row_id = serializers.serialize_uint64(node) - for ts, cx_edges_d in CX_EDGES[node].items(): + for ts, edges_d in CX_EDGES[node].items(): if ts < node_ts: continue - cx_edges_d, edge_nodes = edges.get_latest_edges_wrapper( - cg, cx_edges_d, parent_ts=ts - ) - if edge_nodes.size == 0: + edges_d, _nodes = edges.get_latest_edges_wrapper(cg, edges_d, parent_ts=ts) + if _nodes.size == 0: continue - parents = cg.get_roots(edge_nodes, time_stamp=ts, stop_layer=layer, ceil=False) - edge_parents_d = dict(zip(edge_nodes, parents)) + parents = cg.get_roots(_nodes, time_stamp=ts, stop_layer=layer, ceil=False) + edge_parents_d = dict(zip(_nodes, parents)) val_dict = {} - for _layer, layer_edges in cx_edges_d.items(): + for _layer, layer_edges in edges_d.items(): layer_edges = fastremap.remap( layer_edges, edge_parents_d, preserve_missing_labels=True ) @@ -141,7 +139,8 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: def _update_cross_edges_helper(args): global CG - edges.PARENTS_CACHE = LRUCache(64 * 1024) + edges.PARENTS_CACHE = LRUCache(256 * 1024) + edges.CHILDREN_CACHE = LRUCache(1 * 1024) clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" cg_info, layer, nodes, nodes_ts = args @@ -173,7 +172,8 @@ def _update_cross_edges_helper(args): for task in tasks: rows.extend(update_cross_edges(*task)) edges.PARENTS_CACHE.clear() - # cg.client.write(rows) + edges.CHILDREN_CACHE.clear() + cg.client.write(rows) def update_chunk( @@ -204,9 +204,13 @@ def update_chunk( if debug: rows = [] + edges.PARENTS_CACHE = LRUCache(256 * 1024) + edges.CHILDREN_CACHE = LRUCache(1 * 1024) logging.info(f"processing {len(nodes)} nodes with 1 worker.") for node, node_ts in zip(nodes, nodes_ts): rows.extend(update_cross_edges(cg, layer, node, node_ts)) + edges.PARENTS_CACHE.clear() + edges.CHILDREN_CACHE.clear() logging.info(f"total elaspsed time: {time.time() - start}") return From 254ed86e572b342b13d3e36d1d9f84e05836fe87 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 13 Nov 2025 19:25:13 +0000 Subject: [PATCH 130/367] =?UTF-8?q?Bump=20version:=203.1.3=20=E2=86=92=203?= =?UTF-8?q?.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1d4b6de4c..ec24786cf 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.3 +current_version = 3.1.4 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index f7493720d..1fe90f6ac 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.1.3" +__version__ = "3.1.4" From c7ea47aec115b31878e1a4d0d9867b378e91514f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 13 Nov 2025 21:03:16 +0000 Subject: [PATCH 131/367] fix(migration): add env for cache size --- pychunkedgraph/ingest/upgrade/parent_layer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index ce36159cd..6e2eed253 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -22,6 +22,7 @@ CHILDREN = {} CX_EDGES = {} CG: ChunkedGraph = None +PARENT_CACHE_LIMIT = int(os.environ.get("PARENT_CACHE_LIMIT", 256)) * 1024 def _populate_nodes_and_children( @@ -139,7 +140,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: def _update_cross_edges_helper(args): global CG - edges.PARENTS_CACHE = LRUCache(256 * 1024) + edges.PARENTS_CACHE = LRUCache(PARENT_CACHE_LIMIT) edges.CHILDREN_CACHE = LRUCache(1 * 1024) clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" cg_info, layer, nodes, nodes_ts = args @@ -204,7 +205,7 @@ def update_chunk( if debug: rows = [] - edges.PARENTS_CACHE = LRUCache(256 * 1024) + edges.PARENTS_CACHE = LRUCache(PARENT_CACHE_LIMIT) edges.CHILDREN_CACHE = LRUCache(1 * 1024) logging.info(f"processing {len(nodes)} nodes with 1 worker.") for node, node_ts in zip(nodes, nodes_ts): From 49ab255c14eb86f8f64dd1d46c093b488bd3a30b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 13 Nov 2025 21:38:15 +0000 Subject: [PATCH 132/367] fix(edits): dont use children cache for edits --- pychunkedgraph/graph/edges/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index ebaebe895..cb96f2b73 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -340,9 +340,10 @@ def _filter(node): mask = cg.get_chunk_layers(children) > 2 if children[mask].size == 0: break - # children0 = cg.get_children(children[mask], flatten=True) - children = _get_children_from_cache(children[mask]) - # assert np.array_equal(children, children0) + if PARENTS_CACHE is None: + children = cg.get_children(children[mask], flatten=True) + else: + children = _get_children_from_cache(children[mask]) return np.concatenate(result) mlayer, coord_a, coord_b = _get_normalized_coords(node_a, node_b) @@ -386,12 +387,11 @@ def _get_parents_b(edges, parent_ts, layer): Gets new partners at parent_ts using supervoxels, at `parent_ts`. Searches for new partners that may have any edges to `edges[:,0]`. """ - # children1 = cg.get_children(edges[:, 1], flatten=True) - children_b = _get_children_from_cache(edges[:, 1]) - # assert np.array_equal(children_b, children1) if PARENTS_CACHE is None: + children_b = cg.get_children(edges[:, 1], flatten=True) parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) else: + children_b = _get_children_from_cache(edges[:, 1]) _populate_parents_cache(children_b) _parents_b, missing = get_parents_at_timestamp( children_b, PARENTS_CACHE, time_stamp=parent_ts, unique=True From 120fd9c63fe6b0aabd9981a546cd5686df9bb698 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 13 Nov 2025 21:38:50 +0000 Subject: [PATCH 133/367] =?UTF-8?q?Bump=20version:=203.1.4=20=E2=86=92=203?= =?UTF-8?q?.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ec24786cf..36e92a471 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.4 +current_version = 3.1.5 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 1fe90f6ac..0aff436e6 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.1.4" +__version__ = "3.1.5" From 66c3293ab006549748920a69f0a65d67f9f935a0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 15 Nov 2025 21:07:36 +0000 Subject: [PATCH 134/367] feat(migration): split chunks tasks for faster completion --- pychunkedgraph/ingest/cli_upgrade.py | 5 +-- pychunkedgraph/ingest/cluster.py | 9 +++-- pychunkedgraph/ingest/upgrade/parent_layer.py | 32 +++++++++++++---- pychunkedgraph/ingest/utils.py | 36 +++++++++++++------ 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index 84939544b..3c4e6f7f8 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -97,8 +97,9 @@ def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): @upgrade_cli.command("layer") @click.argument("parent_layer", type=int) +@click.option("--splits", default=0, help="Split chunks into multiple tasks.") @job_type_guard(group_name) -def queue_layer(parent_layer): +def queue_layer(parent_layer:int, splits:int=0): """ Queue all chunk tasks at a given layer. Must be used when all the chunks at `parent_layer - 1` have completed. @@ -106,7 +107,7 @@ def queue_layer(parent_layer): assert parent_layer > 2, "This command is for layers 3 and above." redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - queue_layer_helper(parent_layer, imanager, upgrade_parent_chunk) + queue_layer_helper(parent_layer, imanager, upgrade_parent_chunk, splits=splits) @upgrade_cli.command("status") diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 608d7b332..8d5271b88 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -38,8 +38,11 @@ def _post_task_completion( imanager: IngestionManager, layer: int, coords: np.ndarray, + split:int=None ): chunk_str = "_".join(map(str, coords)) + if split is not None: + chunk_str += f"_{split}" # mark chunk as completed - "c" imanager.redis.sadd(f"{layer}c", chunk_str) @@ -66,11 +69,13 @@ def create_parent_chunk( def upgrade_parent_chunk( parent_layer: int, parent_coords: Sequence[int], + split:int=None, + splits:int=None ) -> None: redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - update_parent_chunk(imanager.cg, parent_coords, layer=parent_layer) - _post_task_completion(imanager, parent_layer, parent_coords) + update_parent_chunk(imanager.cg, parent_coords, layer=parent_layer, split=split, splits=splits) + _post_task_completion(imanager, parent_layer, parent_coords, split=split) def _get_atomic_chunk_data( diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 6e2eed253..61a08363f 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -1,6 +1,7 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member -import logging, random, time, os +from math import ceil +import logging, random, time, os, gc import multiprocessing as mp from collections import defaultdict from datetime import datetime, timezone @@ -175,10 +176,16 @@ def _update_cross_edges_helper(args): edges.PARENTS_CACHE.clear() edges.CHILDREN_CACHE.clear() cg.client.write(rows) + gc.collect() def update_chunk( - cg: ChunkedGraph, chunk_coords: list[int], layer: int, nodes: list = None + cg: ChunkedGraph, + chunk_coords: list[int], + layer: int, + nodes: list = None, + split: int = None, + splits: int = None, ): """ Iterate over all layer IDs in a chunk and update their cross chunk edges. @@ -192,9 +199,20 @@ def update_chunk( logging.info(f"_populate_nodes_and_children: {time.time() - start}") if not CHILDREN: return - nodes = list(CHILDREN.keys()) - random.shuffle(nodes) + allnodes = list(CHILDREN.keys()) + if splits is not None: + nodes = [] + split_size = int(ceil(len(allnodes) / splits)) + split_nodes = chunked(allnodes, split_size) + for i, _nodes in enumerate(split_nodes): + if i == split: + nodes = list(_nodes) + break + else: + nodes = allnodes + + random.shuffle(nodes) start = time.time() nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) logging.info(f"get_node_timestamps: {time.time() - start}") @@ -215,7 +233,7 @@ def update_chunk( logging.info(f"total elaspsed time: {time.time() - start}") return - task_size = int(os.environ.get("TASK_SIZE", 10)) + task_size = int(os.environ.get("TASK_SIZE", 1)) chunked_nodes = chunked(nodes, task_size) chunked_nodes_ts = chunked(nodes_ts, task_size) cg_info = cg.get_serialized_info() @@ -225,7 +243,8 @@ def update_chunk( args = (cg_info, layer, chunk, ts_chunk) tasks.append(args) - processes = min(mp.cpu_count() * 5, len(tasks)) + process_multiplier = int(os.environ.get("PROCESS_MULTIPLIER", 5)) + processes = min(mp.cpu_count() * process_multiplier, len(tasks)) logging.info(f"processing {len(nodes)} nodes with {processes} workers.") with mp.Pool(processes) as pool: _ = list( @@ -235,3 +254,4 @@ def update_chunk( ) ) logging.info(f"total elaspsed time: {time.time() - start}") + gc.collect() diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 9bb4b0452..5472c0454 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -171,7 +171,9 @@ def print_status(imanager: IngestionManager, redis, upgrade: bool = False): print(f"l{layer}\t| queued: {q:<10} failed: {f:<10} busy: {wb}") -def queue_layer_helper(parent_layer: int, imanager: IngestionManager, fn): +def queue_layer_helper( + parent_layer: int, imanager: IngestionManager, fn, splits: int = 0 +): if parent_layer == imanager.cg_meta.layer_count: chunk_coords = [(0, 0, 0)] else: @@ -192,16 +194,30 @@ def queue_layer_helper(parent_layer: int, imanager: IngestionManager, fn): job_datas = [] for chunk_coord in _coords: - job_datas.append( - Queue.prepare_data( - fn, - args=(parent_layer, chunk_coord), - result_ttl=0, - job_id=chunk_id_str(parent_layer, chunk_coord), - timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", - retry=Retry(int(environ.get("RETRY_COUNT", 1))), + if splits > 0: + for split in range(splits): + jid = chunk_id_str(parent_layer, chunk_coord) + f"_{split}" + job_datas.append( + Queue.prepare_data( + fn, + args=(parent_layer, chunk_coord, split, splits), + result_ttl=0, + job_id=jid, + timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", + retry=Retry(int(environ.get("RETRY_COUNT", 1))), + ) + ) + else: + job_datas.append( + Queue.prepare_data( + fn, + args=(parent_layer, chunk_coord), + result_ttl=0, + job_id=chunk_id_str(parent_layer, chunk_coord), + timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", + retry=Retry(int(environ.get("RETRY_COUNT", 1))), + ) ) - ) q.enqueue_many(job_datas) logging.info(f"Queued {len(job_datas)} chunks.") From b27fcc247f263e697c91a3afe694362b3feae8bc Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 16 Nov 2025 17:37:21 +0000 Subject: [PATCH 135/367] fix(migration): fallback to atomic edges when everything else fails, use bisect --- pychunkedgraph/graph/edges/__init__.py | 28 ++++++++++++------- pychunkedgraph/ingest/upgrade/parent_layer.py | 24 ++++++++++------ 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index cb96f2b73..3574f9b6c 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -381,7 +381,7 @@ def _populate_parents_cache(children: np.ndarray): for parent, ts in parents: PARENTS_CACHE[child][ts] = parent - def _get_parents_b(edges, parent_ts, layer): + def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): """ Attempts to find new partner side nodes. Gets new partners at parent_ts using supervoxels, at `parent_ts`. @@ -397,12 +397,12 @@ def _get_parents_b(edges, parent_ts, layer): children_b, PARENTS_CACHE, time_stamp=parent_ts, unique=True ) # handle cache miss cases - _parents_b_missing = np.unique(cg.get_parents(missing, time_stamp=parent_ts)) - parents_b = np.concatenate([_parents_b, _parents_b_missing]) + _parents_b_missed = np.unique(cg.get_parents(missing, time_stamp=parent_ts)) + parents_b = np.concatenate([_parents_b, _parents_b_missed]) parents_a = edges[:, 0] stale_a = get_stale_nodes(cg, parents_a, parent_ts=parent_ts) - if stale_a.size == parents_a.size: + if stale_a.size == parents_a.size or fallback: # this is applicable only for v2 to v3 migration # handle cases when source nodes in `edges[:,0]` are stale atomic_edges_d = cg.get_atomic_cross_edges(stale_a) @@ -433,7 +433,7 @@ def _get_parents_b_with_chunk_mask( assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {parent_ts}" return parents_b - def _get_new_edge(edge, edge_layer, parent_ts, padding): + def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): """ Attempts to find new edge(s) for the stale `edge`. * Find L2 IDs on opposite sides of the face in L2 chunks along the face. @@ -465,9 +465,14 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding): else: # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges # so get the new identities of `l2ids_b` by using chunk mask - parents_b = _get_parents_b_with_chunk_mask( - l2ids_b, _edges[:, 1], max_node_ts, edge - ) + try: + parents_b = _get_parents_b_with_chunk_mask( + l2ids_b, _edges[:, 1], max_node_ts, edge + ) + except AssertionError: + parents_b = [] + if fallback: + parents_b = _get_parents_b(_edges, parent_ts, edge_layer, True) parents_b = np.unique( cg.get_roots(parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts) @@ -479,8 +484,11 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding): result = [types.empty_2d] for edge_layer, _edge in zip(edge_layers, stale_edges): max_chebyshev_distance = int(environ.get("MAX_CHEBYSHEV_DISTANCE", 3)) - for pad in range(0, max_chebyshev_distance): - _new_edges = _get_new_edge(_edge, edge_layer, parent_ts, padding=pad) + for pad in range(0, max_chebyshev_distance + 1): + fallback = pad == max_chebyshev_distance + _new_edges = _get_new_edge( + _edge, edge_layer, parent_ts, padding=pad, fallback=fallback + ) if _new_edges.size: break logging.info(f"{_edge}, expanding search with padding {pad+1}.") diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 61a08363f..cea44ebc1 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -1,7 +1,7 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member from math import ceil -import logging, random, time, os, gc +import bisect, logging, random, time, os, gc import multiprocessing as mp from collections import defaultdict from datetime import datetime, timezone @@ -44,11 +44,16 @@ def _get_cx_edges_at_timestamp(node, response, ts): if child not in response: continue for key, cells in response[child].items(): - for cell in cells: - # cells are sorted in descending order of timestamps - if ts >= cell.timestamp: - result[key.index].append(cell.value) - break + # cells are sorted in descending order of timestamps + asc_ts = [c.timestamp for c in reversed(cells)] + k = bisect.bisect_right(asc_ts, ts) - 1 + if k >= 0: + idx = len(cells) - 1 - k + try: + result[key.index].append(cells[idx].value) + except IndexError as e: + logging.error(f"{k}, {idx}, {len(cells)}, {asc_ts}") + raise IndexError from e for layer, edges in result.items(): result[layer] = np.concatenate(edges) return result @@ -62,7 +67,6 @@ def _populate_cx_edges_with_timestamps( for all IDs involved in an edit, we can use the timestamps of when cross edges of children were updated. """ - clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" # this data is not needed for clean tasks if clean_task: @@ -75,7 +79,7 @@ def _populate_cx_edges_with_timestamps( response = cg.client.read_nodes(node_ids=all_children, properties=attrs) timestamps_d = get_parent_timestamps(cg, nodes) end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, CHILDREN, layer=layer) - logging.info(f"_populate_nodes_and_children init: {time.time() - start}") + logging.info(f"_populate_cx_edges_with_timestamps init: {time.time() - start}") start = time.time() partners_map = {} @@ -212,6 +216,10 @@ def update_chunk( else: nodes = allnodes + if len(nodes) == 0: + return + + logging.info(f"processing {len(nodes)} nodes.") random.shuffle(nodes) start = time.time() nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) From a579a39ed7052268602aa7b55b95c38ba8c788e2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 10 Jan 2026 21:47:31 +0000 Subject: [PATCH 136/367] fix(upgrade_cli): check for completion when splits>0 --- pychunkedgraph/ingest/utils.py | 44 ++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 5472c0454..cd801a8fd 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -101,13 +101,27 @@ def randomize_grid_points(X: int, Y: int, Z: int) -> Generator[int, int, int]: yield np.unravel_index(index, (X, Y, Z)) -def get_chunks_not_done(imanager: IngestionManager, layer: int, coords: list) -> list: +def get_chunks_not_done( + imanager: IngestionManager, layer: int, coords: list, splits: int = 0 +) -> list: """check for set membership in redis in batches""" - coords_strs = ["_".join(map(str, coord)) for coord in coords] + coords_strs = [] + if splits > 0: + split_coords = [] + for coord in coords: + for split in range(splits): + jid = "_".join(map(str, coord)) + f"_{split}" + coords_strs.append(jid) + split_coords.append((coord, split)) + else: + coords_strs = ["_".join(map(str, coord)) for coord in coords] try: completed = imanager.redis.smismember(f"{layer}c", coords_strs) except Exception: - return coords + return split_coords if splits > 0 else coords + + if splits > 0: + return [coord for coord, c in zip(split_coords, completed) if not c] return [coord for coord, c in zip(coords, completed) if not c] @@ -185,7 +199,7 @@ def queue_layer_helper( timeout_scale = int(environ.get("TIMEOUT_SCALE_FACTOR", 1)) batches = chunked(chunk_coords, batch_size) for batch in batches: - _coords = get_chunks_not_done(imanager, parent_layer, batch) + _coords = get_chunks_not_done(imanager, parent_layer, batch, splits=splits) # buffer for optimal use of redis memory if len(q) > int(environ.get("QUEUE_SIZE", 100000)): interval = int(environ.get("QUEUE_INTERVAL", 300)) @@ -195,18 +209,18 @@ def queue_layer_helper( job_datas = [] for chunk_coord in _coords: if splits > 0: - for split in range(splits): - jid = chunk_id_str(parent_layer, chunk_coord) + f"_{split}" - job_datas.append( - Queue.prepare_data( - fn, - args=(parent_layer, chunk_coord, split, splits), - result_ttl=0, - job_id=jid, - timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", - retry=Retry(int(environ.get("RETRY_COUNT", 1))), - ) + coord, split = chunk_coord + jid = chunk_id_str(parent_layer, coord) + f"_{split}" + job_datas.append( + Queue.prepare_data( + fn, + args=(parent_layer, coord, split, splits), + result_ttl=0, + job_id=jid, + timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", + retry=Retry(int(environ.get("RETRY_COUNT", 1))), ) + ) else: job_datas.append( Queue.prepare_data( From ebe4f21f342028769711b75186486040eabd0c66 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 19 Jan 2026 00:22:25 +0000 Subject: [PATCH 137/367] fix(locks): improve locking latency --- .../graph/client/bigtable/client.py | 154 +++++++++++++----- pychunkedgraph/graph/locks.py | 62 +++++-- 2 files changed, 162 insertions(+), 54 deletions(-) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 8a1d596ff..9912772a6 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -6,6 +6,7 @@ import logging from datetime import datetime from datetime import timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed import numpy as np from multiwrapper import multiprocessing_utils as mu @@ -193,7 +194,9 @@ def read_nodes( for (row_key, data) in rows.items() } return { - deserialize_uint64(row_key, fake_edges=fake_edges): {k.key:v for k,v in data.items()} + deserialize_uint64(row_key, fake_edges=fake_edges): { + k.key: v for k, v in data.items() + } for (row_key, data) in rows.items() } @@ -451,11 +454,9 @@ def lock_roots( max_tries: int = 1, waittime_s: float = 0.5, ) -> typing.Tuple[bool, typing.Iterable]: - """Attempts to lock multiple nodes with same operation id""" + """Attempts to lock multiple nodes with same operation id in parallel""" i_try = 0 while i_try < max_tries: - lock_acquired = False - # Collect latest root ids new_root_ids: typing.List[np.uint64] = [] for root_id in root_ids: future_root_ids = future_root_ids_d[root_id] @@ -464,18 +465,36 @@ def lock_roots( else: new_root_ids.extend(future_root_ids) - # Attempt to lock all latest root ids + lock_results = {} root_ids = np.unique(new_root_ids) - for root_id in root_ids: - lock_acquired = self.lock_root(root_id, operation_id) - # Roll back locks if one root cannot be locked - if not lock_acquired: - for id_ in root_ids: - self.unlock_root(id_, operation_id) - break - - if lock_acquired: + max_workers = max(1, len(root_ids) // 2) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_root = { + executor.submit(self.lock_root, root_id, operation_id): root_id + for root_id in root_ids + } + for future in as_completed(future_to_root): + root_id = future_to_root[future] + try: + lock_results[root_id] = future.result() + except Exception as e: + self.logger.error(f"Failed to lock root {root_id}: {e}") + lock_results[root_id] = False + + all_locked = all(lock_results.values()) + if all_locked: return True, root_ids + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + unlock_futures = [ + executor.submit(self.unlock_root, root_id, operation_id) + for root_id in root_ids + ] + for future in as_completed(unlock_futures): + try: + future.result() + except Exception as e: + self.logger.error(f"Failed to unlock root: {e}") time.sleep(waittime_s) i_try += 1 self.logger.debug(f"Try {i_try}") @@ -486,9 +505,8 @@ def lock_roots_indefinitely( root_ids: typing.Sequence[np.uint64], operation_id: np.uint64, future_root_ids_d: typing.Dict, - ) -> typing.Tuple[bool, typing.Iterable]: + ) -> typing.Tuple[bool, typing.Iterable, typing.Iterable]: """Attempts to indefinitely lock multiple nodes with same operation id""" - lock_acquired = False # Collect latest root ids new_root_ids: typing.List[np.uint64] = [] for _id in root_ids: @@ -498,21 +516,45 @@ def lock_roots_indefinitely( else: new_root_ids.extend(future_root_ids) - # Attempt to lock all latest root ids - failed_to_lock_id = None root_ids = np.unique(new_root_ids) - for _id in root_ids: - self.logger.debug(f"operation {operation_id} root_id {_id}") - lock_acquired = self.lock_root_indefinitely(_id, operation_id) - # Roll back locks if one root cannot be locked - if not lock_acquired: - failed_to_lock_id = _id - for id_ in root_ids: - self.unlock_indefinitely_locked_root(id_, operation_id) - break - if lock_acquired: - return True, root_ids, failed_to_lock_id - return False, root_ids, failed_to_lock_id + lock_results = {} + max_workers = max(1, len(root_ids) // 2) + failed_to_lock = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_root = { + executor.submit( + self.lock_root_indefinitely, root_id, operation_id + ): root_id + for root_id in root_ids + } + for future in as_completed(future_to_root): + root_id = future_to_root[future] + try: + lock_results[root_id] = future.result() + if lock_results[root_id] is False: + failed_to_lock.append(root_id) + except Exception as e: + self.logger.error(f"Failed to lock root {root_id}: {e}") + lock_results[root_id] = False + failed_to_lock.append(root_id) + + all_locked = all(lock_results.values()) + if all_locked: + return True, root_ids, failed_to_lock + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + unlock_futures = [ + executor.submit( + self.unlock_indefinitely_locked_root, root_id, operation_id + ) + for root_id in root_ids + ] + for future in as_completed(unlock_futures): + try: + future.result() + except Exception as e: + self.logger.error(f"Failed to unlock root: {e}") + return False, root_ids, failed_to_lock def unlock_root(self, root_id: np.uint64, operation_id: np.uint64): """Unlocks root node that is locked with operation_id.""" @@ -559,10 +601,22 @@ def renew_lock(self, root_id: np.uint64, operation_id: np.uint64) -> bool: def renew_locks(self, root_ids: np.uint64, operation_id: np.uint64) -> bool: """Renews existing root node locks with operation_id to extend time.""" - for root_id in root_ids: - if not self.renew_lock(root_id, operation_id): - self.logger.warning(f"renew_lock failed - {root_id}") - return False + max_workers = max(1, len(root_ids) // 2) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(self.renew_lock, root_id, operation_id): root_id + for root_id in root_ids + } + for future in as_completed(futures): + root_id = futures[future] + try: + result = future.result() + if not result: + self.logger.warning(f"renew_lock failed - {root_id}") + return False + except Exception as e: + self.logger.error(f"Exception during renew_lock({root_id}): {e}") + return False return True def get_lock_timestamp( @@ -584,15 +638,31 @@ def get_consolidated_lock_timestamp( operation_ids: typing.Sequence[np.uint64], ) -> typing.Union[datetime, None]: """Minimum of multiple lock timestamps.""" - time_stamps = [] - for root_id, operation_id in zip(root_ids, operation_ids): - time_stamp = self.get_lock_timestamp(root_id, operation_id) - if time_stamp is None: - return None - time_stamps.append(time_stamp) - if len(time_stamps) == 0: + if len(root_ids) == 0: + return None + max_workers = max(1, len(root_ids) // 2) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(self.get_lock_timestamp, root_id, op_id): ( + root_id, + op_id, + ) + for root_id, op_id in zip(root_ids, operation_ids) + } + timestamps = [] + for future in as_completed(futures): + root_id, op_id = futures[future] + try: + ts = future.result() + if ts is None: + return None + timestamps.append(ts) + except Exception as exc: + self.logger.warning(f"({root_id}, {op_id}): {exc}") + return None + if not timestamps: return None - return np.min(time_stamps) + return np.min(timestamps) # IDs def create_node_ids( diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index b3a3a0eb7..e3918f0ea 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -1,12 +1,15 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Union from typing import Sequence from collections import defaultdict +from warnings import warn +import networkx as nx import numpy as np from . import exceptions from .types import empty_1d -from .lineage import get_future_root_ids +from .lineage import lineage_graph class RootLock: @@ -48,16 +51,21 @@ def __init__( def __enter__(self): if self.privileged_mode: assert self.operation_id is not None, "Please provide operation ID." - from warnings import warn - warn("Warning: Privileged mode without acquiring lock.") return self if not self.operation_id: self.operation_id = self.cg.id_client.create_operation_id() + nodes_ts = self.cg.get_node_timestamps(self.root_ids, return_numpy=0) + min_ts = min(nodes_ts) + lgraph = lineage_graph(self.cg, self.root_ids, timestamp_past=min_ts) future_root_ids_d = defaultdict(lambda: empty_1d) for id_ in self.root_ids: - future_root_ids_d[id_] = get_future_root_ids(self.cg, id_) + node_descendants = nx.descendants(lgraph, id_) + node_descendants = np.unique( + np.array(list(node_descendants), dtype=np.uint64) + ) + future_root_ids_d[id_] = node_descendants self.lock_acquired, self.locked_root_ids = self.cg.client.lock_roots( root_ids=self.root_ids, @@ -71,8 +79,19 @@ def __enter__(self): def __exit__(self, exception_type, exception_value, traceback): if self.lock_acquired: - for locked_root_id in self.locked_root_ids: - self.cg.client.unlock_root(locked_root_id, self.operation_id) + max_workers = max(1, len(self.locked_root_ids) // 2) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + unlock_futures = [ + executor.submit( + self.cg.client.unlock_root, root_id, self.operation_id + ) + for root_id in self.locked_root_ids + ] + for future in as_completed(unlock_futures): + try: + future.result() + except Exception as e: + self.logger.warning(f"Failed to unlock root: {e}") class IndefiniteRootLock: @@ -114,21 +133,40 @@ def __enter__(self): if not self.cg.client.renew_locks(self.root_ids, self.operation_id): raise exceptions.LockingError("Could not renew locks before writing.") + nodes_ts = self.cg.get_node_timestamps(self.root_ids, return_numpy=0) + min_ts = min(nodes_ts) + lgraph = lineage_graph(self.cg, self.root_ids, timestamp_past=min_ts) future_root_ids_d = defaultdict(lambda: empty_1d) for id_ in self.root_ids: - future_root_ids_d[id_] = get_future_root_ids(self.cg, id_) + node_descendants = nx.descendants(lgraph, id_) + node_descendants = np.unique( + np.array(list(node_descendants), dtype=np.uint64) + ) + future_root_ids_d[id_] = node_descendants + self.acquired, self.root_ids, failed = self.cg.client.lock_roots_indefinitely( root_ids=self.root_ids, operation_id=self.operation_id, future_root_ids_d=future_root_ids_d, ) if not self.acquired: - raise exceptions.LockingError(f"{failed} has been locked indefinitely.") + raise exceptions.LockingError(f"{failed} have been locked indefinitely.") return self def __exit__(self, exception_type, exception_value, traceback): if self.acquired: - for locked_root_id in self.root_ids: - self.cg.client.unlock_indefinitely_locked_root( - locked_root_id, self.operation_id - ) + max_workers = max(1, len(self.root_ids) // 2) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + unlock_futures = [ + executor.submit( + self.cg.client.unlock_indefinitely_locked_root, + root_id, + self.operation_id, + ) + for root_id in self.root_ids + ] + for future in as_completed(unlock_futures): + try: + future.result() + except Exception as e: + self.logger.warning(f"Failed to unlock root: {e}") From ea2f6754a4a8770282b0f8277406dde43ac761c7 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 21 Jan 2026 00:22:39 +0000 Subject: [PATCH 138/367] feat(upgrade): cache earliest ts, live status refresh --- pychunkedgraph/ingest/upgrade/atomic_layer.py | 7 +- pychunkedgraph/ingest/upgrade/parent_layer.py | 7 +- pychunkedgraph/ingest/utils.py | 94 +++++++++++-------- 3 files changed, 69 insertions(+), 39 deletions(-) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index a1d44a8d9..43270081b 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -121,7 +121,12 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int]): nodes = [] nodes_ts = [] - earliest_ts = cg.get_earliest_timestamp() + try: + earliest_ts = os.environ["EARLIEST_TS"] + earliest_ts = datetime.fromisoformat(earliest_ts) + except KeyError: + earliest_ts = cg.get_earliest_timestamp() + corrupt_nodes = [] for k, v in rr.items(): try: diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index cea44ebc1..90d7b7ca6 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -159,7 +159,12 @@ def _update_cross_edges_helper(args): corrupt_nodes = [] earliest_ts = None if clean_task: - earliest_ts = cg.get_earliest_timestamp() + try: + earliest_ts = os.environ["EARLIEST_TS"] + earliest_ts = datetime.fromisoformat(earliest_ts) + except KeyError: + earliest_ts = cg.get_earliest_timestamp() + for node, parent, node_ts in zip(nodes, parents, nodes_ts): if parent == 0: # ignore invalid nodes from failed ingest tasks, w/o parent column entry diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index cd801a8fd..1c8c8dd43 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -2,7 +2,7 @@ import logging import functools -import math +import math, sys from os import environ from time import sleep from typing import Any, Generator, Tuple @@ -51,6 +51,10 @@ def bootstrap( return (meta, ingest_config, client_info) +def move_up(lines: int = 1): + sys.stdout.write(f"\033[{lines}A") + + def postprocess_edge_data(im, edge_dict): data_version = im.cg_meta.data_source.DATA_VERSION if data_version == 2: @@ -125,13 +129,16 @@ def get_chunks_not_done( return [coord for coord, c in zip(coords, completed) if not c] -def print_completion_rate(imanager: IngestionManager, layer: int, span: int = 10): - counts = [] - for _ in range(span + 1): - counts.append(imanager.redis.scard(f"{layer}c")) - sleep(1) - rate = np.diff(counts).sum() / span - print(f"{rate} chunks per second.") +def print_completion_rate(imanager: IngestionManager, layer: int, span: int = 30): + rate = 0.0 + while True: + counts = [] + print(f"{rate} chunks per second.") + for _ in range(span + 1): + counts.append(imanager.redis.scard(f"{layer}c")) + sleep(1) + rate = np.diff(counts).sum() / span + move_up() def print_status(imanager: IngestionManager, redis, upgrade: bool = False): @@ -143,32 +150,38 @@ def print_status(imanager: IngestionManager, redis, upgrade: bool = False): layers = range(2, imanager.cg_meta.layer_count + 1) if upgrade: layers = range(2, imanager.cg_meta.layer_count) - layer_counts = imanager.cg_meta.layer_chunk_counts - pipeline = redis.pipeline() - pipeline.get(r_keys.JOB_TYPE) - worker_busy = [] - for layer in layers: - pipeline.scard(f"{layer}c") - queue = Queue(f"l{layer}", connection=redis) - pipeline.llen(queue.key) - pipeline.zcard(queue.failed_job_registry.key) - workers = Worker.all(queue=queue) - worker_busy.append(sum([w.get_state() == WorkerStatus.BUSY for w in workers])) - - results = pipeline.execute() - job_type = "not_available" - if results[0] is not None: - job_type = results[0].decode() - completed = [] - queued = [] - failed = [] - for i in range(1, len(results), 3): - result = results[i : i + 3] - completed.append(result[0]) - queued.append(result[1]) - failed.append(result[2]) + def _refresh_status(): + pipeline = redis.pipeline() + pipeline.get(r_keys.JOB_TYPE) + worker_busy = [] + for layer in layers: + pipeline.scard(f"{layer}c") + queue = Queue(f"l{layer}", connection=redis) + pipeline.llen(queue.key) + pipeline.zcard(queue.failed_job_registry.key) + workers = Worker.all(queue=queue) + worker_busy.append( + sum([w.get_state() == WorkerStatus.BUSY for w in workers]) + ) + + results = pipeline.execute() + job_type = "not_available" + if results[0] is not None: + job_type = results[0].decode() + completed = [] + queued = [] + failed = [] + for i in range(1, len(results), 3): + result = results[i : i + 3] + completed.append(result[0]) + queued.append(result[1]) + failed.append(result[2]) + return job_type, completed, queued, failed, worker_busy + + job_type, completed, queued, failed, worker_busy = _refresh_status() + layer_counts = imanager.cg_meta.layer_chunk_counts header = ( f"\njob_type: \t{job_type}" f"\nversion: \t{imanager.cg.version}" @@ -177,12 +190,19 @@ def print_status(imanager: IngestionManager, redis, upgrade: bool = False): "\n\nlayer status:" ) print(header) - for layer, done, count in zip(layers, completed, layer_counts): - print(f"{layer}\t| {done:9} / {count} \t| {math.floor((done/count)*100):6}%") + while True: + for layer, done, count in zip(layers, completed, layer_counts): + print( + f"{layer}\t| {done:9} / {count} \t| {math.floor((done/count)*100):6}%" + ) - print("\n\nqueue status:") - for layer, q, f, wb in zip(layers, queued, failed, worker_busy): - print(f"l{layer}\t| queued: {q:<10} failed: {f:<10} busy: {wb}") + print("\n\nqueue status:") + for layer, q, f, wb in zip(layers, queued, failed, worker_busy): + print(f"l{layer}\t| queued: {q:<10} failed: {f:<10} busy: {wb}") + + sleep(1) + _, completed, queued, failed, worker_busy = _refresh_status() + move_up(lines=2 * len(layers) + 3) def queue_layer_helper( From 218de1ec8cd0c0ac3569bbd1b14ddb62272d02c3 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 21 Jan 2026 00:25:32 +0000 Subject: [PATCH 139/367] =?UTF-8?q?Bump=20version:=203.1.5=20=E2=86=92=203?= =?UTF-8?q?.1.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 36e92a471..002585678 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.5 +current_version = 3.1.6 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 0aff436e6..a5761891f 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1 @@ -__version__ = "3.1.5" +__version__ = "3.1.6" From e69b0b21b115291a89d3a5e946e7241a0d43d485 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 22 Jan 2026 21:05:57 +0000 Subject: [PATCH 140/367] fix(upgrade): avoid reading the entire chunk for split tasks --- pychunkedgraph/ingest/cluster.py | 7 +++- pychunkedgraph/ingest/upgrade/parent_layer.py | 38 ++++++++++--------- pychunkedgraph/ingest/utils.py | 18 +++++---- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 8d5271b88..219cae07b 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -45,6 +45,7 @@ def _post_task_completion( chunk_str += f"_{split}" # mark chunk as completed - "c" imanager.redis.sadd(f"{layer}c", chunk_str) + logging.info(f"{chunk_str} marked as complete") def create_parent_chunk( @@ -197,6 +198,8 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl q = imanager.get_task_queue(queue_name) batch_size = int(environ.get("JOB_BATCH_SIZE", 10000)) batches = chunked(coords, batch_size) + retry = int(environ.get("RETRY_COUNT", 0)) + failure_ttl = int(environ.get("FAILURE_TTL", 300)) for batch in batches: _coords = get_chunks_not_done(imanager, 2, batch) # buffer for optimal use of redis memory @@ -214,7 +217,9 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl timeout=environ.get("L2JOB_TIMEOUT", "3m"), result_ttl=0, job_id=chunk_id_str(2, chunk_coord), - retry=Retry(int(environ.get("RETRY_COUNT", 1))), + retry=Retry(retry) if retry > 1 else None, + description="", + failure_ttl=failure_ttl ) ) q.enqueue_many(job_datas) diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 90d7b7ca6..54dec6001 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -13,7 +13,7 @@ from pychunkedgraph.graph import ChunkedGraph, edges from pychunkedgraph.graph.attributes import Connectivity, Hierarchy -from pychunkedgraph.graph.utils import serializers +from pychunkedgraph.graph.utils import serializers, basetypes from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked @@ -31,7 +31,10 @@ def _populate_nodes_and_children( ) -> dict: global CHILDREN if nodes: - CHILDREN = cg.get_children(nodes) + children_map = cg.get_children(nodes) + for k, v in children_map.items(): + if len(v): + CHILDREN[k] = v return response = cg.range_read_chunk(chunk_id, properties=Hierarchy.Child) for k, v in response.items(): @@ -188,6 +191,17 @@ def _update_cross_edges_helper(args): gc.collect() +def _get_split_nodes( + cg: ChunkedGraph, chunk_id: basetypes.CHUNK_ID, split: int, splits: int +): + max_id = cg.client.get_max_node_id(chunk_id) + total = max_id - chunk_id + split_size = int(ceil(total / splits)) + start = int(chunk_id + np.uint64(split * split_size)) + end = int(start + split_size) + return range(int(start), int(end)) + + def update_chunk( cg: ChunkedGraph, chunk_coords: list[int], @@ -204,23 +218,12 @@ def update_chunk( x, y, z = chunk_coords chunk_id = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) - _populate_nodes_and_children(cg, chunk_id, nodes=nodes) - logging.info(f"_populate_nodes_and_children: {time.time() - start}") - if not CHILDREN: - return - - allnodes = list(CHILDREN.keys()) if splits is not None: - nodes = [] - split_size = int(ceil(len(allnodes) / splits)) - split_nodes = chunked(allnodes, split_size) - for i, _nodes in enumerate(split_nodes): - if i == split: - nodes = list(_nodes) - break - else: - nodes = allnodes + nodes = _get_split_nodes(cg, chunk_id, split, splits) + _populate_nodes_and_children(cg, chunk_id, nodes=nodes) + logging.info(f"_populate_nodes_and_children: {time.time() - start}") + nodes = list(CHILDREN.keys()) if len(nodes) == 0: return @@ -267,4 +270,3 @@ def update_chunk( ) ) logging.info(f"total elaspsed time: {time.time() - start}") - gc.collect() diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 1c8c8dd43..83d2716d8 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -2,7 +2,7 @@ import logging import functools -import math, sys +import math, random, sys from os import environ from time import sleep from typing import Any, Generator, Tuple @@ -154,16 +154,12 @@ def print_status(imanager: IngestionManager, redis, upgrade: bool = False): def _refresh_status(): pipeline = redis.pipeline() pipeline.get(r_keys.JOB_TYPE) - worker_busy = [] + worker_busy = ["-"] * len(layers) for layer in layers: pipeline.scard(f"{layer}c") queue = Queue(f"l{layer}", connection=redis) pipeline.llen(queue.key) pipeline.zcard(queue.failed_job_registry.key) - workers = Worker.all(queue=queue) - worker_busy.append( - sum([w.get_state() == WorkerStatus.BUSY for w in workers]) - ) results = pipeline.execute() job_type = "not_available" @@ -218,6 +214,7 @@ def queue_layer_helper( batch_size = int(environ.get("JOB_BATCH_SIZE", 10000)) timeout_scale = int(environ.get("TIMEOUT_SCALE_FACTOR", 1)) batches = chunked(chunk_coords, batch_size) + failure_ttl = int(environ.get("FAILURE_TTL", 300)) for batch in batches: _coords = get_chunks_not_done(imanager, parent_layer, batch, splits=splits) # buffer for optimal use of redis memory @@ -227,6 +224,7 @@ def queue_layer_helper( sleep(interval) job_datas = [] + retry = int(environ.get("RETRY_COUNT", 0)) for chunk_coord in _coords: if splits > 0: coord, split = chunk_coord @@ -238,7 +236,9 @@ def queue_layer_helper( result_ttl=0, job_id=jid, timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", - retry=Retry(int(environ.get("RETRY_COUNT", 1))), + retry=Retry(retry) if retry > 1 else None, + description="", + failure_ttl=failure_ttl, ) ) else: @@ -249,7 +249,9 @@ def queue_layer_helper( result_ttl=0, job_id=chunk_id_str(parent_layer, chunk_coord), timeout=f"{timeout_scale * int(parent_layer * parent_layer)}m", - retry=Retry(int(environ.get("RETRY_COUNT", 1))), + retry=Retry(retry) if retry > 1 else None, + description="", + failure_ttl=failure_ttl, ) ) q.enqueue_many(job_datas) From e228cb14c6a641327b32b90b46118bd6ad1a24cd Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 25 Jan 2026 01:22:18 +0000 Subject: [PATCH 141/367] fix(edits): search entire hierarchy upto stop to layer to find new edges --- pychunkedgraph/graph/chunkedgraph.py | 2 +- pychunkedgraph/graph/chunks/utils.py | 1 + pychunkedgraph/graph/edges/__init__.py | 42 +++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 1754315d8..0b27a9391 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -496,7 +496,7 @@ def get_root( else: time.sleep(0.5) - if self.get_chunk_layer(parent_id) < stop_layer: + if ceil and self.get_chunk_layer(parent_id) < stop_layer: raise exceptions.ChunkedGraphError( f"Cannot find root id {node_id}, {stop_layer}, {time_stamp}" ) diff --git a/pychunkedgraph/graph/chunks/utils.py b/pychunkedgraph/graph/chunks/utils.py index f22a4d84a..3b6e19665 100644 --- a/pychunkedgraph/graph/chunks/utils.py +++ b/pychunkedgraph/graph/chunks/utils.py @@ -128,6 +128,7 @@ def get_chunk_id( def get_chunk_ids_from_coords(meta, layer: int, coords: np.ndarray): + layer = int(layer) result = np.zeros(len(coords), dtype=np.uint64) s_bits_per_dim = meta.bitmasks[layer] diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 3574f9b6c..195283091 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -414,12 +414,46 @@ def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): return np.unique(cg.get_parents(partners, time_stamp=parent_ts)) _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) + _hierarchy_a = [parents_a] + for _a in parents_a: + _hierarchy_a.append( + cg.get_root( + _a, + time_stamp=parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + ) + ) + _hierarchy_a = np.concatenate(_hierarchy_a) + _parents_b = [] for _node, _edges_d in _cx_edges_d.items(): - for _edges in _edges_d.values(): - _mask = np.isin(_edges[:, 1], parents_a) - if np.any(_mask): - _parents_b.append(_node) + _edges = _edges_d.get(layer, types.empty_2d) + _hierarchy_a_from_b = [_edges[:, 1]] + for _a in _edges[:, 1]: + _hierarchy_a_from_b.append( + cg.get_root( + _a, + time_stamp=parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + ) + ) + _children = cg.get_children(_a) + _children_layers = cg.get_chunk_layers(_children) + _children = _children[_children_layers > 2] + while _children.size: + _hierarchy_a_from_b.append(_children) + _children = cg.get_children(_children, flatten=True) + _children_layers = cg.get_chunk_layers(_children) + _children = _children[_children_layers > 2] + + _hierarchy_a_from_b = np.concatenate(_hierarchy_a_from_b) + _mask = np.isin(_hierarchy_a_from_b, _hierarchy_a) + if np.any(_mask): + _parents_b.append(_node) return np.array(_parents_b, dtype=basetypes.NODE_ID) def _get_parents_b_with_chunk_mask( From c9bb1cfefd64d3f340d55eff6971a5a8e4fa3ff7 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 25 Jan 2026 04:00:57 +0000 Subject: [PATCH 142/367] fix(copy): add shim to replace graph_id in meta for copied graphs --- pychunkedgraph/graph/chunkedgraph.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 0b27a9391..50cf57704 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -18,7 +18,7 @@ from .client import BackendClientInfo from .client import get_default_client_info from .cache import CacheService -from .meta import ChunkedGraphMeta +from .meta import ChunkedGraphMeta, GraphConfig from .utils import basetypes from .utils import id_helpers from .utils import serializers @@ -66,6 +66,16 @@ def __init__( self._cache_service = None self.mock_edges = None # hack for unit tests + # shim to update graph_id in meta for copied graphs + if graph_id != self.graph_id: + gc = self.meta.graph_config._asdict() + gc["ID"] = graph_id + new_meta = ChunkedGraphMeta( + GraphConfig(**gc), self.meta.data_source, self.meta.custom_data + ) + self.update_meta(new_meta, overwrite=True) + self._meta = new_meta + @property def meta(self) -> ChunkedGraphMeta: return self._meta From cb18972fe774f08f26c3d94eb91b581868887383 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 26 Jan 2026 01:08:12 +0000 Subject: [PATCH 143/367] fix(edits): stale edges - search hierarchies from both sides for potential matches of new nodes --- pychunkedgraph/graph/chunkedgraph.py | 13 ++-- pychunkedgraph/graph/edges/__init__.py | 101 +++++++++++++++++-------- pychunkedgraph/graph/edits.py | 2 +- 3 files changed, 77 insertions(+), 39 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 50cf57704..183420979 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -649,7 +649,7 @@ def get_fake_edges( ) for id_, val in fake_edges_d.items(): edges = np.concatenate( - [np.array(e.value, dtype=basetypes.NODE_ID, copy=False) for e in val] + [np.asarray(e.value, dtype=basetypes.NODE_ID) for e in val] ) result[id_] = Edges(edges[:, 0], edges[:, 1]) return result @@ -757,7 +757,10 @@ def get_node_timestamps( result = [] earliest_ts = self.get_earliest_timestamp() for n in node_ids: - ts = children[n][0].timestamp + try: + ts = children[n][0].timestamp + except KeyError: + ts = datetime.datetime.now(datetime.timezone.utc) if normalize: ts = earliest_ts if ts < earliest_ts else ts result.append(ts) @@ -917,11 +920,9 @@ def get_chunk_coordinates(self, node_or_chunk_id: basetypes.NODE_ID): return chunk_utils.get_chunk_coordinates(self.meta, node_or_chunk_id) def get_chunk_coordinates_multiple(self, node_or_chunk_ids: typing.Sequence): - node_or_chunk_ids = np.array( - node_or_chunk_ids, dtype=basetypes.NODE_ID, copy=False - ) + node_or_chunk_ids = np.asarray(node_or_chunk_ids, dtype=basetypes.NODE_ID) layers = self.get_chunk_layers(node_or_chunk_ids) - assert np.all(layers == layers[0]), "All IDs must have the same layer." + assert len(layers) == 0 or np.all(layers == layers[0]), "All IDs must have the same layer." return chunk_utils.get_chunk_coordinates_multiple(self.meta, node_or_chunk_ids) def get_chunk_id( diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 195283091..16c3ec557 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -381,6 +381,57 @@ def _populate_parents_cache(children: np.ndarray): for parent, ts in parents: PARENTS_CACHE[child][ts] = parent + def _check_cross_edges_from_a(node_b, nodes_a, layer, parent_ts): + """ + Checks to match cross edges from partners_a + to hierarchy of potential node from partner b. + """ + _node_hierarchy = cg.get_root( + node_b, + time_stamp=parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + ) + _node_hierarchy = np.append(_node_hierarchy, node_b) + _cx_edges_d_from_a = cg.get_cross_chunk_edges(nodes_a, time_stamp=parent_ts) + for _edges_d_from_a in _cx_edges_d_from_a.values(): + _edges_from_a = _edges_d_from_a.get(layer, types.empty_2d) + _mask = np.isin(_edges_from_a[:, 1], _node_hierarchy) + if np.any(_mask): + return True + return False + + def _check_hierarchy_a_from_b(nodes_a, hierarchy_a, layer, parent_ts): + """ + Checks for overlap between hierarchy of a, + and hierarchy of a identified from partners of b. + """ + _hierarchy_a_from_b = [nodes_a] + for _a in nodes_a: + _hierarchy_a_from_b.append( + cg.get_root( + _a, + time_stamp=parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + ) + ) + _children = cg.get_children(_a) + _children_layers = cg.get_chunk_layers(_children) + _hierarchy_a_from_b.append(_children[_children_layers == 2]) + _children = _children[_children_layers > 2] + while _children.size: + _hierarchy_a_from_b.append(_children) + _children = cg.get_children(_children, flatten=True) + _children_layers = cg.get_chunk_layers(_children) + _hierarchy_a_from_b.append(_children[_children_layers == 2]) + _children = _children[_children_layers > 2] + + _hierarchy_a_from_b = np.concatenate(_hierarchy_a_from_b) + return np.isin(_hierarchy_a_from_b, hierarchy_a) + def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): """ Attempts to find new partner side nodes. @@ -430,29 +481,11 @@ def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): _parents_b = [] for _node, _edges_d in _cx_edges_d.items(): _edges = _edges_d.get(layer, types.empty_2d) - _hierarchy_a_from_b = [_edges[:, 1]] - for _a in _edges[:, 1]: - _hierarchy_a_from_b.append( - cg.get_root( - _a, - time_stamp=parent_ts, - stop_layer=layer, - get_all_parents=True, - ceil=False, - ) - ) - _children = cg.get_children(_a) - _children_layers = cg.get_chunk_layers(_children) - _children = _children[_children_layers > 2] - while _children.size: - _hierarchy_a_from_b.append(_children) - _children = cg.get_children(_children, flatten=True) - _children_layers = cg.get_chunk_layers(_children) - _children = _children[_children_layers > 2] - - _hierarchy_a_from_b = np.concatenate(_hierarchy_a_from_b) - _mask = np.isin(_hierarchy_a_from_b, _hierarchy_a) - if np.any(_mask): + if _check_cross_edges_from_a(_node, _edges[:, 1], layer, parent_ts): + _parents_b.append(_node) + elif _check_hierarchy_a_from_b( + _edges[:, 1], _hierarchy_a, layer, parent_ts + ): _parents_b.append(_node) return np.array(_parents_b, dtype=basetypes.NODE_ID) @@ -467,6 +500,16 @@ def _get_parents_b_with_chunk_mask( assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {parent_ts}" return parents_b + def _get_cx_edges(l2ids_a, max_node_ts, raw_only: bool = True): + _edges_d = cg.get_cross_chunk_edges( + node_ids=l2ids_a, time_stamp=max_node_ts, raw_only=raw_only + ) + _edges = [] + for v in _edges_d.values(): + if edge_layer in v: + _edges.append(v[edge_layer]) + return np.concatenate(_edges) + def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): """ Attempts to find new edge(s) for the stale `edge`. @@ -480,16 +523,10 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): return types.empty_2d.copy() max_node_ts = max(nodes_ts_map[node_a], nodes_ts_map[node_b]) - _edges_d = cg.get_cross_chunk_edges( - node_ids=l2ids_a, time_stamp=max_node_ts, raw_only=True - ) - _edges = [] - for v in _edges_d.values(): - if edge_layer in v: - _edges.append(v[edge_layer]) - try: - _edges = np.concatenate(_edges) + _edges = _get_cx_edges(l2ids_a, max_node_ts) + except ValueError: + _edges = _get_cx_edges(l2ids_a, max_node_ts, raw_only=False) except ValueError: return types.empty_2d.copy() diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 4e66a12d3..e4a052919 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -15,7 +15,7 @@ from . import types from . import attributes from . import cache as cache_utils -from .edges import get_latest_edges, get_latest_edges_wrapper, get_stale_nodes +from .edges import get_latest_edges_wrapper from .edges.utils import concatenate_cross_edge_dicts from .edges.utils import merge_cross_edge_dicts from .utils import basetypes From c130507964e12fe5dc6705720e3cf8ba6a1ab116 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 28 Jan 2026 00:37:09 +0000 Subject: [PATCH 144/367] fix(edits): look for new edges by dilating lifted edges down to l2 --- pychunkedgraph/debug/utils.py | 19 ++-------- pychunkedgraph/graph/chunkedgraph.py | 15 ++++++++ pychunkedgraph/graph/edges/__init__.py | 52 +++++++++++++++++--------- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/pychunkedgraph/debug/utils.py b/pychunkedgraph/debug/utils.py index 130d85500..f8a641738 100644 --- a/pychunkedgraph/debug/utils.py +++ b/pychunkedgraph/debug/utils.py @@ -26,19 +26,6 @@ def print_node(cg, node: np.uint64, indent: int = 0, stop_layer: int = 2) -> Non print_node(cg, child, indent=indent + 4, stop_layer=stop_layer) -def get_l2children(cg, node: np.uint64) -> np.ndarray: - nodes = np.array([node], dtype=np.uint64) - layers = cg.get_chunk_layers(nodes) - assert np.all(layers >= 2), "nodes must be at layers >= 2" - l2children = [] - while nodes.size: - children = cg.get_children(nodes, flatten=True) - layers = cg.get_chunk_layers(children) - l2children.append(children[layers == 2]) - nodes = children[layers > 2] - return np.concatenate(l2children) - - def sanity_check(cg, new_roots, operation_id): """ Check for duplicates in hierarchy, useful for debugging. @@ -46,17 +33,17 @@ def sanity_check(cg, new_roots, operation_id): # print(f"{len(new_roots)} new ids from {operation_id}") l2c_d = {} for new_root in new_roots: - l2c_d[new_root] = get_l2children(cg, new_root) + l2c_d[new_root] = cg.get_l2children([new_root]) success = True for k, v in l2c_d.items(): success = success and (len(v) == np.unique(v).size) # print(f"{k}: {np.unique(v).size}, {len(v)}") if not success: - raise RuntimeError("Some ids are not valid.") + raise RuntimeError(f"{operation_id}: some ids are not valid.") def sanity_check_single(cg, node, operation_id): - v = get_l2children(cg, node) + v = cg.get_l2children([node]) msg = f"invalid node {node}:" msg += f" found {len(v)} l2 ids, must be {np.unique(v).size}" assert np.unique(v).size == len(v), f"{msg}, from {operation_id}." diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 183420979..8cd6fc833 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -1036,3 +1036,18 @@ def get_chunk_layers_and_coordinates(self, node_or_chunk_ids: typing.Sequence): self.meta, _nodes ) return layers, chunk_coords + + def get_l2children(self, node_ids) -> np.ndarray: + """ + Get L2 children of all node_ids, returns a flat array. + """ + node_ids = np.asarray(node_ids, dtype=basetypes.NODE_ID) + layers = self.get_chunk_layers(node_ids) + assert np.all(layers >= 2), "nodes must be at layers >= 2" + l2children = [types.empty_1d] + while node_ids.size: + children = self.get_children(node_ids, flatten=True) + layers = self.get_chunk_layers(children) + l2children.append(children[layers == 2]) + node_ids = children[layers > 2] + return np.concatenate(l2children) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 16c3ec557..e0bdacd3c 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -54,22 +54,20 @@ def __init__( affinities: Optional[np.ndarray] = None, areas: Optional[np.ndarray] = None, ): - self.node_ids1 = np.array(node_ids1, dtype=basetypes.NODE_ID, copy=False) - self.node_ids2 = np.array(node_ids2, dtype=basetypes.NODE_ID, copy=False) + self.node_ids1 = np.array(node_ids1, dtype=basetypes.NODE_ID) + self.node_ids2 = np.array(node_ids2, dtype=basetypes.NODE_ID) assert self.node_ids1.size == self.node_ids2.size self._as_pairs = None if affinities is not None and len(affinities) > 0: - self._affinities = np.array( - affinities, dtype=basetypes.EDGE_AFFINITY, copy=False - ) + self._affinities = np.array(affinities, dtype=basetypes.EDGE_AFFINITY) assert self.node_ids1.size == self._affinities.size else: self._affinities = np.full(len(self.node_ids1), DEFAULT_AFFINITY) if areas is not None and len(areas) > 0: - self._areas = np.array(areas, dtype=basetypes.EDGE_AREA, copy=False) + self._areas = np.array(areas, dtype=basetypes.EDGE_AREA) assert self.node_ids1.size == self._areas.size else: self._areas = np.full(len(self.node_ids1), DEFAULT_AREA) @@ -430,7 +428,7 @@ def _check_hierarchy_a_from_b(nodes_a, hierarchy_a, layer, parent_ts): _children = _children[_children_layers > 2] _hierarchy_a_from_b = np.concatenate(_hierarchy_a_from_b) - return np.isin(_hierarchy_a_from_b, hierarchy_a) + return np.any(np.isin(_hierarchy_a_from_b, hierarchy_a)) def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): """ @@ -451,7 +449,7 @@ def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): _parents_b_missed = np.unique(cg.get_parents(missing, time_stamp=parent_ts)) parents_b = np.concatenate([_parents_b, _parents_b_missed]) - parents_a = edges[:, 0] + parents_a = np.unique(edges[:, 0]) stale_a = get_stale_nodes(cg, parents_a, parent_ts=parent_ts) if stale_a.size == parents_a.size or fallback: # this is applicable only for v2 to v3 migration @@ -510,6 +508,18 @@ def _get_cx_edges(l2ids_a, max_node_ts, raw_only: bool = True): _edges.append(v[edge_layer]) return np.concatenate(_edges) + def _get_dilated_edges(edges): + layers_b = cg.get_chunk_layers(edges[:, 1]) + _mask = layers_b == 2 + _l2_edges = [edges[_mask]] + for _edge in edges[~_mask]: + _node_a, _node_b = _edge + _nodes_b = cg.get_l2children([_node_b]) + _l2_edges.append( + np.array([[_node_a, _b] for _b in _nodes_b], dtype=basetypes.NODE_ID) + ) + return np.unique(np.concatenate(_l2_edges), axis=0) + def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): """ Attempts to find new edge(s) for the stale `edge`. @@ -534,16 +544,22 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): if np.any(mask): parents_b = _get_parents_b(_edges[mask], parent_ts, edge_layer) else: - # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges - # so get the new identities of `l2ids_b` by using chunk mask - try: - parents_b = _get_parents_b_with_chunk_mask( - l2ids_b, _edges[:, 1], max_node_ts, edge - ) - except AssertionError: - parents_b = [] - if fallback: - parents_b = _get_parents_b(_edges, parent_ts, edge_layer, True) + # partner edges likely lifted, dilate and retry + _edges = _get_dilated_edges(_edges) + mask = np.isin(_edges[:, 1], l2ids_b) + if np.any(mask): + parents_b = _get_parents_b(_edges[mask], parent_ts, edge_layer) + else: + # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges + # so get the new identities of `l2ids_b` by using chunk mask + try: + parents_b = _get_parents_b_with_chunk_mask( + l2ids_b, _edges[:, 1], max_node_ts, edge + ) + except AssertionError: + parents_b = [] + if fallback: + parents_b = _get_parents_b(_edges, parent_ts, edge_layer, True) parents_b = np.unique( cg.get_roots(parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts) From 30e51abddf8c9546237d732f1da46902310cf056 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 28 Jan 2026 23:26:45 +0000 Subject: [PATCH 145/367] edits: batch parents dict and neighbor cx edges updates to reduce io latency, add cache logging and profiler, initial stitch mode --- pychunkedgraph/__init__.py | 62 ++++++ pychunkedgraph/debug/profiler.py | 121 ++++++++++++ pychunkedgraph/debug/utils.py | 17 ++ pychunkedgraph/graph/cache.py | 92 +++++++++ pychunkedgraph/graph/chunkedgraph.py | 31 ++- pychunkedgraph/graph/chunks/utils.py | 45 +++++ pychunkedgraph/graph/edges/__init__.py | 65 ++----- pychunkedgraph/graph/edits.py | 254 +++++++++++++++---------- pychunkedgraph/graph/operation.py | 16 ++ pychunkedgraph/repair/fake_edges.py | 78 -------- 10 files changed, 552 insertions(+), 229 deletions(-) create mode 100644 pychunkedgraph/debug/profiler.py delete mode 100644 pychunkedgraph/repair/fake_edges.py diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index a5761891f..0831294a3 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1 +1,63 @@ __version__ = "3.1.6" + +import sys +import warnings +import logging as stdlib_logging # Use alias to avoid conflict with pychunkedgraph.logging + +# Suppress annoying warning from python_jsonschema_objects dependency +warnings.filterwarnings( + "ignore", message="Schema id not specified", module="python_jsonschema_objects" +) + +# Export logging levels for convenience +DEBUG = stdlib_logging.DEBUG +INFO = stdlib_logging.INFO +WARNING = stdlib_logging.WARNING +ERROR = stdlib_logging.ERROR + +# Set up library-level logger with NullHandler (Python logging best practice) +stdlib_logging.getLogger(__name__).addHandler(stdlib_logging.NullHandler()) + + +def configure_logging(level=stdlib_logging.INFO, format_str=None, stream=None): + """ + Configure logging for pychunkedgraph. Call this to enable log output. + + Works in Jupyter notebooks and scripts. + + Args: + level: Logging level (default: INFO). Use pychunkedgraph.DEBUG, .INFO, .WARNING, .ERROR + format_str: Custom format string (optional) + stream: Output stream (default: sys.stdout for Jupyter compatibility) + + Example: + import pychunkedgraph + pychunkedgraph.configure_logging() # Enable INFO level logging + pychunkedgraph.configure_logging(pychunkedgraph.DEBUG) # Enable DEBUG level + """ + if format_str is None: + format_str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + if stream is None: + stream = sys.stdout + + # Get root logger for pychunkedgraph + logger = stdlib_logging.getLogger(__name__) + logger.setLevel(level) + + # Remove existing handlers and add fresh StreamHandler + # This allows reconfiguring with different levels/formats + for h in logger.handlers[:]: + if isinstance(h, stdlib_logging.StreamHandler) and not isinstance( + h, stdlib_logging.NullHandler + ): + logger.removeHandler(h) + + handler = stdlib_logging.StreamHandler(stream) + handler.setLevel(level) + handler.setFormatter(stdlib_logging.Formatter(format_str)) + logger.addHandler(handler) + + return logger + + +configure_logging() diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/debug/profiler.py new file mode 100644 index 000000000..37eb799fd --- /dev/null +++ b/pychunkedgraph/debug/profiler.py @@ -0,0 +1,121 @@ +from typing import Dict +from typing import List +from typing import Tuple + +import os +import time +from collections import defaultdict +from contextlib import contextmanager + + +class HierarchicalProfiler: + """ + Hierarchical profiler for detailed timing breakdowns. + Tracks timing at multiple levels and prints a breakdown at the end. + """ + + def __init__(self, enabled: bool = True): + self.enabled = enabled + self.timings: Dict[str, List[float]] = defaultdict(list) + self.call_counts: Dict[str, int] = defaultdict(int) + self.stack: List[Tuple[str, float]] = [] + self.current_path: List[str] = [] + + @contextmanager + def profile(self, name: str): + """Context manager for profiling a code block.""" + if not self.enabled: + yield + return + + full_path = ".".join(self.current_path + [name]) + self.current_path.append(name) + start_time = time.perf_counter() + + try: + yield + finally: + elapsed = time.perf_counter() - start_time + self.timings[full_path].append(elapsed) + self.call_counts[full_path] += 1 + self.current_path.pop() + + def print_report(self, operation_id=None): + """Print a detailed timing breakdown.""" + if not self.enabled or not self.timings: + return + + print("\n" + "=" * 80) + print( + f"PROFILER REPORT{f' (operation_id={operation_id})' if operation_id else ''}" + ) + print("=" * 80) + + # Group by depth level + by_depth: Dict[int, List[Tuple[str, float, int]]] = defaultdict(list) + for path, times in self.timings.items(): + depth = path.count(".") + total_time = sum(times) + count = self.call_counts[path] + by_depth[depth].append((path, total_time, count)) + + # Sort each level by total time + for depth in sorted(by_depth.keys()): + items = sorted(by_depth[depth], key=lambda x: -x[1]) + for path, total_time, count in items: + indent = " " * depth + avg_time = total_time / count if count > 0 else 0 + if count > 1: + print( + f"{indent}{path}: {total_time*1000:.2f}ms total " + f"({count} calls, {avg_time*1000:.2f}ms avg)" + ) + else: + print(f"{indent}{path}: {total_time*1000:.2f}ms") + + # Print summary + print("-" * 80) + top_level_total = sum( + sum(times) for path, times in self.timings.items() if "." not in path + ) + print(f"Total top-level time: {top_level_total*1000:.2f}ms") + + # Print top 10 slowest operations + print("\nTop 10 slowest operations:") + all_ops = [ + (path, sum(times), self.call_counts[path]) + for path, times in self.timings.items() + ] + all_ops.sort(key=lambda x: -x[1]) + for i, (path, total_time, count) in enumerate(all_ops[:10]): + pct = (total_time / top_level_total * 100) if top_level_total > 0 else 0 + print(f" {i+1}. {path}: {total_time*1000:.2f}ms ({pct:.1f}%)") + + print("=" * 80 + "\n") + + def reset(self): + """Reset all timing data.""" + self.timings.clear() + self.call_counts.clear() + self.stack.clear() + self.current_path.clear() + + +# Global profiler instance - enable via environment variable +PROFILER_ENABLED = os.environ.get("PCG_PROFILER_ENABLED", "1") == "1" +_profiler: HierarchicalProfiler = None + + +def get_profiler() -> HierarchicalProfiler: + """Get or create the global profiler instance.""" + global _profiler + if _profiler is None: + _profiler = HierarchicalProfiler(enabled=PROFILER_ENABLED) + return _profiler + + +def reset_profiler(): + """Reset the global profiler.""" + global _profiler + if _profiler is not None: + _profiler.reset() diff --git a/pychunkedgraph/debug/utils.py b/pychunkedgraph/debug/utils.py index f8a641738..ad12103b2 100644 --- a/pychunkedgraph/debug/utils.py +++ b/pychunkedgraph/debug/utils.py @@ -56,3 +56,20 @@ def update_graph_id(cg, new_graph_id:str): new_gc = GraphConfig(**old_gc) new_meta = ChunkedGraphMeta(new_gc, cg.meta.data_source, cg.meta.custom_data) cg.update_meta(new_meta, overwrite=True) + + +def get_random_l1_ids(cg, n_chunks=100, n_per_chunk=10, seed=None): + """Generate random layer 1 IDs from different chunks.""" + if seed: + np.random.seed(seed) + bounds = cg.meta.layer_chunk_bounds[2] + ids = [] + for _ in range(n_chunks): + cx, cy, cz = [np.random.randint(0, b) for b in bounds] + chunk_id = cg.get_chunk_id(layer=2, x=cx, y=cy, z=cz) + max_seg = cg.get_segment_id(cg.id_client.get_max_node_id(chunk_id)) + if max_seg < 2: + continue + for seg in np.random.randint(1, max_seg + 1, n_per_chunk): + ids.append(cg.get_node_id(np.uint64(seg), np.uint64(chunk_id))) + return np.array(ids, dtype=np.uint64) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index 13fa962ae..e0ee6dc2e 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -2,6 +2,8 @@ """ Cache nodes, parents, children and cross edges. """ +import traceback +from collections import defaultdict from sys import maxsize from datetime import datetime @@ -40,6 +42,30 @@ def __init__(self, cg): self.children_cache = LRUCache(maxsize=maxsize) self.cross_chunk_edges_cache = LRUCache(maxsize=maxsize) + # Stats tracking for cache hits/misses + self.stats = { + "parents": {"hits": 0, "misses": 0, "calls": 0}, + "children": {"hits": 0, "misses": 0, "calls": 0}, + "cross_chunk_edges": {"hits": 0, "misses": 0, "calls": 0}, + } + # Track where calls/misses come from + self.call_sources = defaultdict(lambda: defaultdict(lambda: {"calls": 0, "misses": 0})) + + def _get_caller(self, skip_frames=2): + """Get caller info (filename:line:function).""" + stack = traceback.extract_stack() + # Skip frames: _get_caller, the cache method, and go to actual caller + if len(stack) > skip_frames: + frame = stack[-(skip_frames + 1)] + return f"{frame.filename.split('/')[-1]}:{frame.lineno}:{frame.name}" + return "unknown" + + def _record_call(self, cache_type, misses=0): + """Record a call and its source.""" + caller = self._get_caller(skip_frames=3) + self.call_sources[cache_type][caller]["calls"] += 1 + self.call_sources[cache_type][caller]["misses"] += misses + def __len__(self): return ( len(self.parents_cache) @@ -52,7 +78,37 @@ def clear(self): self.children_cache.clear() self.cross_chunk_edges_cache.clear() + def get_stats(self): + """Return stats with hit rates calculated.""" + result = {} + for name, s in self.stats.items(): + total = s["hits"] + s["misses"] + hit_rate = s["hits"] / total if total > 0 else 0 + result[name] = { + **s, + "total": total, + "hit_rate": f"{hit_rate:.1%}", + "sources": dict(self.call_sources[name]), + } + return result + + def reset_stats(self): + for s in self.stats.values(): + s["hits"] = 0 + s["misses"] = 0 + s["calls"] = 0 + self.call_sources.clear() + def parent(self, node_id: np.uint64, *, time_stamp: datetime = None): + self.stats["parents"]["calls"] += 1 + is_cached = node_id in self.parents_cache + miss_count = 0 if is_cached else 1 + if is_cached: + self.stats["parents"]["hits"] += 1 + else: + self.stats["parents"]["misses"] += 1 + self._record_call("parents", misses=miss_count) + @cached(cache=self.parents_cache, key=lambda node_id: node_id) def parent_decorated(node_id): return self._cg.get_parent(node_id, raw_only=True, time_stamp=time_stamp) @@ -60,6 +116,15 @@ def parent_decorated(node_id): return parent_decorated(node_id) def children(self, node_id): + self.stats["children"]["calls"] += 1 + is_cached = node_id in self.children_cache + miss_count = 0 if is_cached else 1 + if is_cached: + self.stats["children"]["hits"] += 1 + else: + self.stats["children"]["misses"] += 1 + self._record_call("children", misses=miss_count) + @cached(cache=self.children_cache, key=lambda node_id: node_id) def children_decorated(node_id): children = self._cg.get_children(node_id, raw_only=True) @@ -69,6 +134,15 @@ def children_decorated(node_id): return children_decorated(node_id) def cross_chunk_edges(self, node_id, *, time_stamp: datetime = None): + self.stats["cross_chunk_edges"]["calls"] += 1 + is_cached = node_id in self.cross_chunk_edges_cache + miss_count = 0 if is_cached else 1 + if is_cached: + self.stats["cross_chunk_edges"]["hits"] += 1 + else: + self.stats["cross_chunk_edges"]["misses"] += 1 + self._record_call("cross_chunk_edges", misses=miss_count) + @cached(cache=self.cross_chunk_edges_cache, key=lambda node_id: node_id) def cross_edges_decorated(node_id): edges = self._cg.get_cross_chunk_edges( @@ -82,7 +156,13 @@ def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None) node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) if not node_ids.size: return node_ids + self.stats["parents"]["calls"] += 1 mask = np.in1d(node_ids, np.fromiter(self.parents_cache.keys(), dtype=NODE_ID)) + hits = int(np.sum(mask)) + misses = len(node_ids) - hits + self.stats["parents"]["hits"] += hits + self.stats["parents"]["misses"] += misses + self._record_call("parents", misses=misses) parents = node_ids.copy() parents[mask] = self._parent_vec(node_ids[mask]) parents[~mask] = self._cg.get_parents( @@ -96,7 +176,13 @@ def children_multiple(self, node_ids: np.ndarray, *, flatten=False): node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) if not node_ids.size: return result + self.stats["children"]["calls"] += 1 mask = np.in1d(node_ids, np.fromiter(self.children_cache.keys(), dtype=NODE_ID)) + hits = int(np.sum(mask)) + misses = len(node_ids) - hits + self.stats["children"]["hits"] += hits + self.stats["children"]["misses"] += misses + self._record_call("children", misses=misses) cached_children_ = self._children_vec(node_ids[mask]) result.update({id_: c_ for id_, c_ in zip(node_ids[mask], cached_children_)}) result.update(self._cg.get_children(node_ids[~mask], raw_only=True)) @@ -114,9 +200,15 @@ def cross_chunk_edges_multiple( node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) if not node_ids.size: return result + self.stats["cross_chunk_edges"]["calls"] += 1 mask = np.in1d( node_ids, np.fromiter(self.cross_chunk_edges_cache.keys(), dtype=NODE_ID) ) + hits = int(np.sum(mask)) + misses = len(node_ids) - hits + self.stats["cross_chunk_edges"]["hits"] += hits + self.stats["cross_chunk_edges"]["misses"] += misses + self._record_call("cross_chunk_edges", misses=misses) cached_edges_ = self._cross_chunk_edges_vec(node_ids[mask]) result.update( {id_: edges_ for id_, edges_ in zip(node_ids[mask], cached_edges_)} diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 8cd6fc833..636e1843e 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -553,6 +553,35 @@ def get_all_parents_dict( ) return dict(zip(self.get_chunk_layers(parent_ids), parent_ids)) + def get_all_parents_dict_multiple(self, node_ids, *, time_stamp=None): + """Batch fetch all parent hierarchies layer by layer.""" + result = {node: {} for node in node_ids} + nodes = np.array(node_ids, dtype=basetypes.NODE_ID) + layers_map = {} + child_parent_map = {} + + while nodes.size > 0: + parents = self.get_parents(nodes, time_stamp=time_stamp) + parent_layers = self.get_chunk_layers(parents) + for node, parent, layer in zip(nodes, parents, parent_layers): + layers_map[parent] = layer + child_parent_map[node] = parent + nodes = parents[parent_layers < self.meta.layer_count] + + for node in node_ids: + current = node + node_result = {} + while True: + try: + parent = child_parent_map[current] + except KeyError: + break + parent_layer = layers_map[parent] + node_result[parent_layer] = parent + current = parent + result[node] = node_result + return result + def get_subgraph( self, node_id_or_ids: typing.Union[np.uint64, typing.Iterable], @@ -922,7 +951,7 @@ def get_chunk_coordinates(self, node_or_chunk_id: basetypes.NODE_ID): def get_chunk_coordinates_multiple(self, node_or_chunk_ids: typing.Sequence): node_or_chunk_ids = np.asarray(node_or_chunk_ids, dtype=basetypes.NODE_ID) layers = self.get_chunk_layers(node_or_chunk_ids) - assert len(layers) == 0 or np.all(layers == layers[0]), "All IDs must have the same layer." + assert len(layers) == 0 or np.all(layers == layers[0]), "must be same layer." return chunk_utils.get_chunk_coordinates_multiple(self.meta, node_or_chunk_ids) def get_chunk_id( diff --git a/pychunkedgraph/graph/chunks/utils.py b/pychunkedgraph/graph/chunks/utils.py index 3b6e19665..5546d2650 100644 --- a/pychunkedgraph/graph/chunks/utils.py +++ b/pychunkedgraph/graph/chunks/utils.py @@ -6,6 +6,7 @@ from typing import Tuple from typing import Iterable +from copy import copy from functools import lru_cache import numpy as np @@ -239,3 +240,47 @@ def get_bounding_children_chunks( if return_unique: return np.unique(result, axis=0) if result.size else result return result + + +@lru_cache() +def get_l2chunkids_along_boundary(cg_meta, mlayer: int, coord_a, coord_b, padding: int = 0): + """ + Gets L2 Chunk IDs along opposing faces for larger chunks. + If padding is enabled, more faces of L2 chunks are padded on both sides. + This is necessary to find fake edges that can span more than 2 L2 chunks. + """ + bounds_a = get_bounding_children_chunks(cg_meta, mlayer, tuple(coord_a), 2) + bounds_b = get_bounding_children_chunks(cg_meta, mlayer, tuple(coord_b), 2) + + coord_a, coord_b = np.array(coord_a, dtype=int), np.array(coord_b, dtype=int) + direction = coord_a - coord_b + major_axis = np.argmax(np.abs(direction)) + + l2chunk_count = 2 ** (mlayer - 2) + max_coord = coord_a if direction[major_axis] > 0 else coord_b + + skip = abs(direction[major_axis]) - 1 + l2_skip = skip * l2chunk_count + + mid = max_coord[major_axis] * l2chunk_count + face_a = mid if direction[major_axis] > 0 else (mid - l2_skip - 1) + face_b = mid if direction[major_axis] < 0 else (mid - l2_skip - 1) + + l2chunks_a = [bounds_a[bounds_a[:, major_axis] == face_a]] + l2chunks_b = [bounds_b[bounds_b[:, major_axis] == face_b]] + + step_a, step_b = (1, -1) if direction[major_axis] > 0 else (-1, 1) + for _ in range(padding): + _l2_chunks_a = copy(l2chunks_a[-1]) + _l2_chunks_b = copy(l2chunks_b[-1]) + _l2_chunks_a[:, major_axis] += step_a + _l2_chunks_b[:, major_axis] += step_b + l2chunks_a.append(_l2_chunks_a) + l2chunks_b.append(_l2_chunks_b) + + l2chunks_a = np.concatenate(l2chunks_a) + l2chunks_b = np.concatenate(l2chunks_b) + + l2chunk_ids_a = get_chunk_ids_from_coords(cg_meta, 2, l2chunks_a) + l2chunk_ids_b = get_chunk_ids_from_coords(cg_meta, 2, l2chunks_b) + return l2chunk_ids_a, l2chunk_ids_b diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index e0bdacd3c..9f861c945 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -5,7 +5,6 @@ from collections import namedtuple import datetime, logging from os import environ -from copy import copy from typing import Iterable, Optional import numpy as np @@ -15,10 +14,7 @@ from cachetools import LRUCache from pychunkedgraph.graph import types -from pychunkedgraph.graph.chunks.utils import ( - get_bounding_children_chunks, - get_chunk_ids_from_coords, -) +from pychunkedgraph.graph.chunks.utils import get_l2chunkids_along_boundary from pychunkedgraph.graph.utils import basetypes from ..utils import basetypes @@ -273,47 +269,7 @@ def _get_normalized_coords(node_a, node_b) -> tuple: chunk_a = cg.get_parent_chunk_id(node_a, parent_layer=max_layer) chunk_b = cg.get_parent_chunk_id(node_b, parent_layer=max_layer) coord_a, coord_b = cg.get_chunk_coordinates_multiple([chunk_a, chunk_b]) - return max_layer, coord_a, coord_b - - def _get_l2chunkids_along_boundary(mlayer: int, coord_a, coord_b, padding: int = 0): - """ - Gets L2 Chunk IDs along opposing faces for larger chunks. - If padding is enabled, more faces of L2 chunks are padded on both sides. - This is necessary to find fake edges that can span more than 2 L2 chunks. - """ - direction = coord_a - coord_b - major_axis = np.argmax(np.abs(direction)) - bounds_a = get_bounding_children_chunks(cg.meta, mlayer, tuple(coord_a), 2) - bounds_b = get_bounding_children_chunks(cg.meta, mlayer, tuple(coord_b), 2) - - l2chunk_count = 2 ** (mlayer - 2) - max_coord = coord_a if direction[major_axis] > 0 else coord_b - - skip = abs(direction[major_axis]) - 1 - l2_skip = skip * l2chunk_count - - mid = max_coord[major_axis] * l2chunk_count - face_a = mid if direction[major_axis] > 0 else (mid - l2_skip - 1) - face_b = mid if direction[major_axis] < 0 else (mid - l2_skip - 1) - - l2chunks_a = [bounds_a[bounds_a[:, major_axis] == face_a]] - l2chunks_b = [bounds_b[bounds_b[:, major_axis] == face_b]] - - step_a, step_b = (1, -1) if direction[major_axis] > 0 else (-1, 1) - for _ in range(padding): - _l2_chunks_a = copy(l2chunks_a[-1]) - _l2_chunks_b = copy(l2chunks_b[-1]) - _l2_chunks_a[:, major_axis] += step_a - _l2_chunks_b[:, major_axis] += step_b - l2chunks_a.append(_l2_chunks_a) - l2chunks_b.append(_l2_chunks_b) - - l2chunks_a = np.concatenate(l2chunks_a) - l2chunks_b = np.concatenate(l2chunks_b) - - l2chunk_ids_a = get_chunk_ids_from_coords(cg.meta, 2, l2chunks_a) - l2chunk_ids_b = get_chunk_ids_from_coords(cg.meta, 2, l2chunks_b) - return l2chunk_ids_a, l2chunk_ids_b + return max_layer, tuple(coord_a), tuple(coord_b) def _get_filtered_l2ids(node_a, node_b, padding: int): """ @@ -345,8 +301,8 @@ def _filter(node): return np.concatenate(result) mlayer, coord_a, coord_b = _get_normalized_coords(node_a, node_b) - chunks_a, chunks_b = _get_l2chunkids_along_boundary( - mlayer, coord_a, coord_b, padding + chunks_a, chunks_b = get_l2chunkids_along_boundary( + cg.meta, mlayer, coord_a, coord_b, padding ) chunks_map[node_a] = [[cg.get_chunk_id(node_a)]] @@ -593,21 +549,26 @@ def get_latest_edges_wrapper( """ nodes = [types.empty_1d] new_cx_edges_d = {0: types.empty_2d} + + all_edges = np.concatenate(list(cx_edges_d.values())) + all_edge_nodes = np.unique(all_edges) + all_stale_nodes = get_stale_nodes(cg, all_edge_nodes, parent_ts=parent_ts) + if all_stale_nodes.size == 0: + return cx_edges_d, all_edge_nodes + for layer, _cx_edges in cx_edges_d.items(): if _cx_edges.size == 0: continue _new_cx_edges = [types.empty_2d] _edge_layers = np.array([layer] * len(_cx_edges), dtype=int) - edge_nodes = np.unique(_cx_edges) - stale_nodes = get_stale_nodes(cg, edge_nodes, parent_ts=parent_ts) - stale_source_mask = np.isin(_cx_edges[:, 0], stale_nodes) + stale_source_mask = np.isin(_cx_edges[:, 0], all_stale_nodes) _new_cx_edges.append(_cx_edges[stale_source_mask]) _cx_edges = _cx_edges[~stale_source_mask] _edge_layers = _edge_layers[~stale_source_mask] - stale_destination_mask = np.isin(_cx_edges[:, 1], stale_nodes) + stale_destination_mask = np.isin(_cx_edges[:, 1], all_stale_nodes) _new_cx_edges.append(_cx_edges[~stale_destination_mask]) if np.any(stale_destination_mask): diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index e4a052919..81f12b742 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -10,7 +10,8 @@ import fastremap import numpy as np -import fastremap + +from pychunkedgraph.debug.profiler import HierarchicalProfiler, get_profiler from . import types from . import attributes @@ -21,18 +22,28 @@ from .utils import basetypes from .utils import flatgraph from .utils.serializers import serialize_uint64 -from ..logging.log_db import TimeIt from ..utils.general import in2d from ..debug.utils import sanity_check, sanity_check_single def _init_old_hierarchy(cg, l2ids: np.ndarray, parent_ts: datetime.datetime = None): + """ + Populates old hierarcy from child to root and also gets children of intermediate nodes. + These will be needed later and cached in cg.cache used during an edit. + """ + all_parents = [] old_hierarchy_d = {id_: {2: id_} for id_ in l2ids} + node_layer_parent_map = cg.get_all_parents_dict_multiple( + l2ids, time_stamp=parent_ts + ) for id_ in l2ids: - layer_parent_d = cg.get_all_parents_dict(id_, time_stamp=parent_ts) + layer_parent_d = node_layer_parent_map[id_] old_hierarchy_d[id_].update(layer_parent_d) for parent in layer_parent_d.values(): + all_parents.append(parent) old_hierarchy_d[parent] = old_hierarchy_d[id_] + children = cg.get_children(all_parents, flatten=True) + _ = cg.get_parents(children, time_stamp=parent_ts) return old_hierarchy_d @@ -67,8 +78,8 @@ def _analyze_affected_edges( return parent_edges, cross_edges_d -def _get_relevant_components(edges: np.ndarray, supervoxels: np.ndarray) -> Tuple: - edges = np.concatenate([edges, np.vstack([supervoxels, supervoxels]).T]) +def _get_relevant_components(edges: np.ndarray, svs: np.ndarray) -> Tuple: + edges = np.concatenate([edges, np.vstack([svs, svs]).T]).astype(basetypes.NODE_ID) graph, _, _, graph_ids = flatgraph.build_gt_graph(edges, make_directed=True) ccs = flatgraph.connected_components(graph) relevant_ccs = [] @@ -76,7 +87,7 @@ def _get_relevant_components(edges: np.ndarray, supervoxels: np.ndarray) -> Tupl # when merging, there must be only two components for cc_idx in ccs: cc = graph_ids[cc_idx] - if np.any(np.in1d(supervoxels, cc)): + if np.any(np.isin(svs, cc)): relevant_ccs.append(cc) assert len(relevant_ccs) == 2, "must be 2 components" return relevant_ccs @@ -107,19 +118,20 @@ def merge_preprocess( active_edges.append(active) inactive_edges.append(inactive) - relevant_ccs = _get_relevant_components(np.concatenate(active_edges), supervoxels) - inactive = np.concatenate(inactive_edges) + active_edges = np.concatenate(active_edges).astype(basetypes.NODE_ID) + inactive_edges = np.concatenate(inactive_edges).astype(basetypes.NODE_ID) + relevant_ccs = _get_relevant_components(active_edges, supervoxels) _inactive = [types.empty_2d] # source to sink edges - source_mask = np.in1d(inactive[:, 0], relevant_ccs[0]) - sink_mask = np.in1d(inactive[:, 1], relevant_ccs[1]) - _inactive.append(inactive[source_mask & sink_mask]) + source_mask = np.isin(inactive_edges[:, 0], relevant_ccs[0]) + sink_mask = np.isin(inactive_edges[:, 1], relevant_ccs[1]) + _inactive.append(inactive_edges[source_mask & sink_mask]) # sink to source edges - sink_mask = np.in1d(inactive[:, 1], relevant_ccs[0]) - source_mask = np.in1d(inactive[:, 0], relevant_ccs[1]) - _inactive.append(inactive[source_mask & sink_mask]) - _inactive = np.concatenate(_inactive) + sink_mask = np.isin(inactive_edges[:, 1], relevant_ccs[0]) + source_mask = np.isin(inactive_edges[:, 0], relevant_ccs[1]) + _inactive.append(inactive_edges[source_mask & sink_mask]) + _inactive = np.concatenate(_inactive).astype(basetypes.NODE_ID) return np.unique(_inactive, axis=0) if _inactive.size else types.empty_2d @@ -187,14 +199,15 @@ def add_edges( time_stamp: datetime.datetime = None, parent_ts: datetime.datetime = None, allow_same_segment_merge=False, + stitch_mode: bool = False, ): edges, l2_cross_edges_d = _analyze_affected_edges( cg, atomic_edges, parent_ts=parent_ts ) l2ids = np.unique(edges) - if not allow_same_segment_merge: + if not allow_same_segment_merge and not stitch_mode: roots = cg.get_roots(l2ids, assert_roots=True, time_stamp=parent_ts) - assert np.unique(roots).size == 2, "L2 IDs must belong to different roots." + assert np.unique(roots).size >= 2, "L2 IDs must belong to different roots." new_old_id_d = defaultdict(set) old_new_id_d = defaultdict(set) @@ -217,7 +230,8 @@ def add_edges( # update cache # map parent to new merged children and vice versa - merged_children = np.concatenate([atomic_children_d[l2id] for l2id in l2ids_]) + merged_children = [atomic_children_d[l2id] for l2id in l2ids_] + merged_children = np.concatenate(merged_children).astype(basetypes.NODE_ID) cg.cache.children_cache[new_id] = merged_children cache_utils.update(cg.cache.parents_cache, merged_children, new_id) @@ -235,20 +249,26 @@ def add_edges( assert np.all(edges[:, 0] == new_id) cg.cache.cross_chunk_edges_cache[new_id] = new_cx_edges_d - create_parents = CreateParentNodes( - cg, - new_l2_ids=new_l2_ids, - old_hierarchy_d=old_hierarchy_d, - new_old_id_d=new_old_id_d, - old_new_id_d=old_new_id_d, - operation_id=operation_id, - time_stamp=time_stamp, - parent_ts=parent_ts, - ) + profiler = get_profiler() + profiler.reset() + with profiler.profile("run"): + create_parents = CreateParentNodes( + cg, + new_l2_ids=new_l2_ids, + old_hierarchy_d=old_hierarchy_d, + new_old_id_d=new_old_id_d, + old_new_id_d=old_new_id_d, + operation_id=operation_id, + time_stamp=time_stamp, + parent_ts=parent_ts, + stitch_mode=stitch_mode, + profiler=profiler, + ) + new_roots = create_parents.run() - new_roots = create_parents.run() sanity_check(cg, new_roots, operation_id) create_parents.create_new_entries() + profiler.print_report(operation_id) return new_roots, new_l2_ids, create_parents.new_entries @@ -283,11 +303,10 @@ def _split_l2_agglomeration( active_mask = neighbor_roots == root cross_edges = cross_edges[active_mask] cross_edges = cross_edges[~in2d(cross_edges, removed_edges)] - isolated_ids = agg.supervoxels[~np.in1d(agg.supervoxels, chunk_edges)] + isolated_ids = agg.supervoxels[~np.isin(agg.supervoxels, chunk_edges)] isolated_edges = np.column_stack((isolated_ids, isolated_ids)) - graph, _, _, graph_ids = flatgraph.build_gt_graph( - np.concatenate([chunk_edges, isolated_edges]), make_directed=True - ) + _edges = np.concatenate([chunk_edges, isolated_edges]).astype(basetypes.NODE_ID) + graph, _, _, graph_ids = flatgraph.build_gt_graph(_edges, make_directed=True) return flatgraph.connected_components(graph), graph_ids, cross_edges @@ -298,7 +317,7 @@ def _filter_component_cross_edges( Filters cross edges for a connected component `cc_ids` from `cross_edges` of the complete chunk. """ - mask = np.in1d(cross_edges[:, 0], component_ids) + mask = np.isin(cross_edges[:, 0], component_ids) cross_edges_ = cross_edges[mask] cross_edge_layers_ = cross_edge_layers[mask] edges_d = {} @@ -331,7 +350,8 @@ def remove_edges( old_hierarchy_d = _init_old_hierarchy(cg, l2ids, parent_ts=parent_ts) chunk_id_map = dict(zip(l2ids.tolist(), cg.get_chunk_ids_from_node_ids(l2ids))) - removed_edges = np.concatenate([atomic_edges, atomic_edges[:, ::-1]], axis=0) + removed_edges = [atomic_edges, atomic_edges[:, ::-1]] + removed_edges = np.concatenate(removed_edges, axis=0).astype(basetypes.NODE_ID) new_l2_ids = [] for id_ in l2ids: agg = l2id_agglomeration_d[id_] @@ -382,16 +402,13 @@ def remove_edges( return new_roots, new_l2_ids, create_parents.new_entries -def _get_flipped_ids(id_map, node_ids): +def _flip_ids(id_map, node_ids): """ returns old or new ids according to the map """ - ids = [ - np.array(list(id_map[id_]), dtype=basetypes.NODE_ID, copy=False) - for id_ in node_ids - ] + ids = [np.asarray(list(id_map[id_]), dtype=basetypes.NODE_ID) for id_ in node_ids] ids.append(types.empty_1d) # concatenate needs at least one array - return np.concatenate(ids) + return np.concatenate(ids).astype(basetypes.NODE_ID) def _get_descendants(cg, new_id): @@ -412,25 +429,42 @@ def _get_descendants(cg, new_id): return result -def _update_neighbor_cross_edges_single( - cg, new_id: int, cx_edges_d: dict, node_map: dict, *, parent_ts -) -> dict: +def _get_counterparts( + cg, node_id: int, cx_edges_d: dict +) -> Tuple[List[int], Dict[int, int]]: """ - For each new_id, get counterparts and update its cross chunk edges. - Some of them maybe updated multiple times so we need to collect them first - and then write to storage to consolidate the mutations. - Returns updated counterparts. + Extract counterparts and their corresponding layers from cross chunk edges. + Returns (counterparts list, counterpart_layers dict). """ - node_layer = cg.get_chunk_layer(new_id) + node_layer = cg.get_chunk_layer(node_id) counterparts = [] counterpart_layers = {} for layer in range(node_layer, cg.meta.layer_count): layer_edges = cx_edges_d.get(layer, types.empty_2d) + if layer_edges.size == 0: + continue counterparts.extend(layer_edges[:, 1]) layers_d = dict(zip(layer_edges[:, 1], [layer] * len(layer_edges[:, 1]))) counterpart_layers.update(layers_d) + return counterparts, counterpart_layers + - cp_cx_edges_d = cg.get_cross_chunk_edges(counterparts, time_stamp=parent_ts) +def _update_neighbor_cx_edges_single( + cg, + new_id: int, + node_map: dict, + counterpart_layers: dict, + all_counterparts_cx_edges_d: dict, +) -> dict: + """ + For each new_id, update cross chunk edges of its counterparts. + Some of them maybe updated multiple times so we need to collect them first + and then write to storage to consolidate the mutations. + Returns updated counterparts. + """ + node_layer = cg.get_chunk_layer(new_id) + counterparts = list(counterpart_layers.keys()) + cp_cx_edges_d = {cp: all_counterparts_cx_edges_d.get(cp, {}) for cp in counterparts} updated_counterparts = {} for counterpart, edges_d in cp_cx_edges_d.items(): val_dict = {} @@ -442,8 +476,8 @@ def _update_neighbor_cross_edges_single( assert np.all(edges[:, 0] == counterpart) edges = fastremap.remap(edges, node_map, preserve_missing_labels=True) if layer == counterpart_layer: - reverse_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) - edges = np.concatenate([edges, [reverse_edge]]) + flip_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) + edges = np.concatenate([edges, [flip_edge]]).astype(basetypes.NODE_ID) descendants = _get_descendants(cg, new_id) mask = np.isin(edges[:, 1], descendants) if np.any(mask): @@ -460,7 +494,7 @@ def _update_neighbor_cross_edges_single( return updated_counterparts -def _update_neighbor_cross_edges( +def _update_neighbor_cx_edges( cg, new_ids: List[int], new_old_id: dict, @@ -482,12 +516,20 @@ def _update_neighbor_cross_edges( if len(v) == 1: node_map[k] = next(iter(v)) + all_cps = set() + newid_counterpart_info = {} + for _id in new_ids: + counterparts, cp_layers = _get_counterparts(cg, _id, newid_cx_edges_d[_id]) + all_cps.update(counterparts) + newid_counterpart_info[_id] = cp_layers + + all_cx_edges_d = cg.get_cross_chunk_edges(list(all_cps), time_stamp=parent_ts) for new_id in new_ids: - cx_edges_d = newid_cx_edges_d[new_id] - m = {old_id: new_id for old_id in _get_flipped_ids(new_old_id, [new_id])} + m = {old_id: new_id for old_id in _flip_ids(new_old_id, [new_id])} node_map.update(m) - result = _update_neighbor_cross_edges_single( - cg, new_id, cx_edges_d, node_map, parent_ts=parent_ts + cp_layers = newid_counterpart_info[new_id] + result = _update_neighbor_cx_edges_single( + cg, new_id, node_map, cp_layers, all_cx_edges_d ) updated_counterparts.update(result) updated_entries = [] @@ -510,6 +552,8 @@ def __init__( old_new_id_d: Dict[np.uint64, Set[np.uint64]] = None, old_hierarchy_d: Dict[np.uint64, Dict[int, np.uint64]] = None, parent_ts: datetime.datetime = None, + stitch_mode: bool = False, + profiler: HierarchicalProfiler = None, ): self.cg = cg self.new_entries = [] @@ -520,7 +564,9 @@ def __init__( self._new_ids_d = defaultdict(list) self._operation_id = operation_id self._time_stamp = time_stamp - self._last_successful_ts = parent_ts + self._last_ts = parent_ts + self.stitch_mode = stitch_mode + self._profiler = profiler if profiler else get_profiler() def _update_id_lineage( self, @@ -530,7 +576,7 @@ def _update_id_lineage( parent_layer: int, ): # update newly created children; mask others - mask = np.in1d(children, self._new_ids_d[layer]) + mask = np.isin(children, self._new_ids_d[layer]) for child_id in children[mask]: child_old_ids = self._new_old_id_d[child_id] for id_ in child_old_ids: @@ -539,36 +585,32 @@ def _update_id_lineage( self._old_new_id_d[old_id].add(parent) def _get_connected_components(self, node_ids: np.ndarray, layer: int): - with TimeIt( - f"get_cross_chunk_edges.{layer}", - self.cg.graph_id, - self._operation_id, - ): - cross_edges_d = self.cg.get_cross_chunk_edges( - node_ids, time_stamp=self._last_successful_ts - ) - + cross_edges_d = self.cg.get_cross_chunk_edges( + node_ids, time_stamp=self._last_ts + ) cx_edges = [types.empty_2d] for id_ in node_ids: edges_ = cross_edges_d[id_].get(layer, types.empty_2d) cx_edges.append(edges_) - cx_edges = np.concatenate([*cx_edges, np.vstack([node_ids, node_ids]).T]) + + cx_edges = [*cx_edges, np.vstack([node_ids, node_ids]).T] + cx_edges = np.concatenate(cx_edges).astype(basetypes.NODE_ID) graph, _, _, graph_ids = flatgraph.build_gt_graph(cx_edges, make_directed=True) - return flatgraph.connected_components(graph), graph_ids + components = flatgraph.connected_components(graph) + return components, graph_ids def _get_layer_node_ids( self, new_ids: np.ndarray, layer: int ) -> Tuple[np.ndarray, np.ndarray]: # get old identities of new IDs - old_ids = _get_flipped_ids(self._new_old_id_d, new_ids) + old_ids = _flip_ids(self._new_old_id_d, new_ids) # get their parents, then children of those parents - old_parents = self.cg.get_parents(old_ids, time_stamp=self._last_successful_ts) + old_parents = self.cg.get_parents(old_ids, time_stamp=self._last_ts) siblings = self.cg.get_children(np.unique(old_parents), flatten=True) # replace old identities with new IDs - mask = np.in1d(siblings, old_ids) - node_ids = np.concatenate( - [_get_flipped_ids(self._old_new_id_d, old_ids), siblings[~mask], new_ids] - ) + mask = np.isin(siblings, old_ids) + node_ids = [_flip_ids(self._old_new_id_d, old_ids), siblings[~mask], new_ids] + node_ids = np.concatenate(node_ids).astype(basetypes.NODE_ID) node_ids = np.unique(node_ids) layer_mask = self.cg.get_chunk_layers(node_ids) == layer return node_ids[layer_mask] @@ -583,18 +625,19 @@ def _update_cross_edge_cache(self, parent, children): if parent_layer == 2: # l2 cross edges have already been updated by this point return - cx_edges_d = self.cg.get_cross_chunk_edges( - children, time_stamp=self._last_successful_ts - ) + + cx_edges_d = self.cg.get_cross_chunk_edges(children, time_stamp=self._last_ts) cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) - cx_edges_d, edge_nodes = get_latest_edges_wrapper( - self.cg, cx_edges_d, parent_ts=self._last_successful_ts - ) + with self._profiler.profile("latest"): + cx_edges_d, edge_nodes = get_latest_edges_wrapper( + self.cg, cx_edges_d, parent_ts=self._last_ts + ) + edge_parents = self.cg.get_roots( edge_nodes, stop_layer=parent_layer, ceil=False, - time_stamp=self._last_successful_ts, + time_stamp=self._last_ts, ) edge_parents_d = dict(zip(edge_nodes, edge_parents)) new_cx_edges_d = {} @@ -630,15 +673,28 @@ def _create_new_parents(self, layer: int): parent_layer = self.cg.meta.layer_count for l in range(layer + 1, self.cg.meta.layer_count): cx_edges_d = self.cg.get_cross_chunk_edges( - [cc_ids[0]], time_stamp=self._last_successful_ts + [cc_ids[0]], time_stamp=self._last_ts ) if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: parent_layer = l break - parent = self.cg.id_client.create_node_id( - self.cg.get_parent_chunk_id(cc_ids[0], parent_layer), - root_chunk=parent_layer == self.cg.meta.layer_count, - ) + + chunk_id = self.cg.get_parent_chunk_id(cc_ids[0], parent_layer) + is_root = parent_layer == self.cg.meta.layer_count + batch_size = 1 + parent = None + while parent is None: + candidate_ids = self.cg.id_client.create_node_ids( + chunk_id, batch_size, root_chunk=is_root + ) + existing = self.cg.client.read_nodes(node_ids=candidate_ids) + for cid in candidate_ids: + if cid not in existing: + parent = cid + break + if parent is None: + batch_size = min(batch_size * 2, 2**16) + self._new_ids_d[parent_layer].append(parent) self._update_id_lineage(parent, cc_ids, layer, parent_layer) self.cg.cache.children_cache[parent] = cc_ids @@ -647,14 +703,12 @@ def _create_new_parents(self, layer: int): try: sanity_check_single(self.cg, parent, self._operation_id) except AssertionError: - from pychunkedgraph.debug.utils import get_l2children - pairs = [ (a, b) for idx, a in enumerate(cc_ids) for b in cc_ids[idx + 1 :] ] for c1, c2 in pairs: - l2c1 = get_l2children(self.cg, c1) - l2c2 = get_l2children(self.cg, c2) + l2c1 = self.cg.get_l2children([c1]) + l2c2 = self.cg.get_l2children([c2]) if np.intersect1d(l2c1, l2c2).size: c = np.intersect1d(l2c1, l2c2) msg = f"{self._operation_id}: {layer} {c1} {c2} have common children {c}" @@ -671,26 +725,30 @@ def run(self) -> Iterable: continue # all new IDs in this layer have been created # update their cross chunk edges and their neighbors' - m = f"create_new_parents_layer.{layer}" - with TimeIt(m, self.cg.graph_id, self._operation_id): + with self._profiler.profile(f"l{layer}_update_cx_cache"): for new_id in self._new_ids_d[layer]: children = self.cg.get_children(new_id) self._update_cross_edge_cache(new_id, children) - entries = _update_neighbor_cross_edges( + + with self._profiler.profile(f"l{layer}_update_neighbor_cx"): + entries = _update_neighbor_cx_edges( self.cg, self._new_ids_d[layer], self._new_old_id_d, self._old_new_id_d, time_stamp=self._time_stamp, - parent_ts=self._last_successful_ts, + parent_ts=self._last_ts, ) self.new_entries.extend(entries) + with self._profiler.profile(f"l{layer}_create_new_parents"): self._create_new_parents(layer) return self._new_ids_d[self.cg.meta.layer_count] def _update_root_id_lineage(self): + if self.stitch_mode: + return new_roots = self._new_ids_d[self.cg.meta.layer_count] - former_roots = _get_flipped_ids(self._new_old_id_d, new_roots) + former_roots = _flip_ids(self._new_old_id_d, new_roots) former_roots = np.unique(former_roots) err = f"new roots are inconsistent; op {self._operation_id}" @@ -728,7 +786,7 @@ def _get_cross_edges_val_dicts(self): for layer in range(2, self.cg.meta.layer_count): new_ids = np.array(self._new_ids_d[layer], dtype=basetypes.NODE_ID) cross_edges_d = self.cg.get_cross_chunk_edges( - new_ids, time_stamp=self._last_successful_ts + new_ids, time_stamp=self._last_ts ) for id_ in new_ids: val_dict = {} diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 0e865566e..d11a78230 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -1,5 +1,6 @@ # pylint: disable=invalid-name, missing-docstring, too-many-lines, protected-access, broad-exception-raised +import logging from abc import ABC, abstractmethod from collections import namedtuple from datetime import datetime @@ -16,6 +17,8 @@ import numpy as np from google.cloud import bigtable +logger = logging.getLogger(__name__) + from . import locks from . import edits from . import types @@ -446,6 +449,19 @@ def execute( operation_id=lock.operation_id, timestamp=override_ts if override_ts else timestamp, ) + # Log cache stats + if self.cg.cache: + stats = self.cg.cache.get_stats() + lines = [f"[Op {lock.operation_id}] Cache:"] + for name, s in stats.items(): + lines.append(f" {name}: {s['hit_rate']} hit ({s['hits']}/{s['total']}) calls={s['calls']}") + # Show top miss sources if any + if s.get("sources"): + top_sources = sorted(s["sources"].items(), key=lambda x: -x[1]["misses"])[:3] + if top_sources and any(src[1]["misses"] > 0 for src in top_sources): + src_str = ", ".join(f"{k}({v['misses']})" for k, v in top_sources if v["misses"] > 0) + lines.append(f" miss sources: {src_str}") + logger.info("\n".join(lines)) if self.cg.meta.READ_ONLY: # return without persisting changes return GraphEditOperation.Result( diff --git a/pychunkedgraph/repair/fake_edges.py b/pychunkedgraph/repair/fake_edges.py deleted file mode 100644 index b58b93fb9..000000000 --- a/pychunkedgraph/repair/fake_edges.py +++ /dev/null @@ -1,78 +0,0 @@ -# pylint: disable=protected-access,missing-function-docstring,invalid-name,wrong-import-position - -""" -Replay merge operations to check if fake edges need to be added. -""" - -from datetime import datetime -from datetime import timedelta -from os import environ -from typing import Optional - -environ["BIGTABLE_PROJECT"] = "<>" -environ["BIGTABLE_INSTANCE"] = "<>" -environ["GOOGLE_APPLICATION_CREDENTIALS"] = "" - -from pychunkedgraph.graph import edits -from pychunkedgraph.graph import ChunkedGraph -from pychunkedgraph.graph.operation import GraphEditOperation -from pychunkedgraph.graph.operation import MergeOperation -from pychunkedgraph.graph.utils.generic import get_bounding_box as get_bbox - - -def _add_fake_edges(cg: ChunkedGraph, operation_id: int, operation_log: dict) -> bool: - operation = GraphEditOperation.from_operation_id( - cg, operation_id, multicut_as_split=False - ) - - if not isinstance(operation, MergeOperation): - return False - - ts = operation_log["timestamp"] - parent_ts = ts - timedelta(seconds=0.1) - override_ts = (ts + timedelta(microseconds=(ts.microsecond % 1000) + 10),) - - root_ids = set( - cg.get_roots( - operation.added_edges.ravel(), assert_roots=True, time_stamp=parent_ts - ) - ) - - bbox = get_bbox( - operation.source_coords, operation.sink_coords, operation.bbox_offset - ) - edges = cg.get_subgraph( - root_ids, - bbox=bbox, - bbox_is_coordinate=True, - edges_only=True, - ) - - inactive_edges = edits.merge_preprocess( - cg, - subgraph_edges=edges, - supervoxels=operation.added_edges.ravel(), - parent_ts=parent_ts, - ) - - _, fake_edge_rows = edits.check_fake_edges( - cg, - atomic_edges=operation.added_edges, - inactive_edges=inactive_edges, - time_stamp=override_ts, - parent_ts=parent_ts, - ) - - cg.client.write(fake_edge_rows) - return len(fake_edge_rows) > 0 - - -def add_fake_edges( - graph_id: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, -): - cg = ChunkedGraph(graph_id=graph_id) - logs = cg.client.read_log_entries(start_time=start_time, end_time=end_time) - for _id, _log in logs.items(): - _add_fake_edges(cg, _id, _log) From b634c1eacbec75726385da21267ef2bfe1b12460 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 29 Jan 2026 02:07:39 +0000 Subject: [PATCH 146/367] test: batch update cx edges to use the batched stale edge resolution --- pychunkedgraph/graph/edits.py | 60 +++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 81f12b742..9bfe227a1 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -652,6 +652,62 @@ def _update_cross_edge_cache(self, parent, children): ), f"OP {self._operation_id}: parent mismatch {parent} != {np.unique(edges[:, 0])}" self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d + def _update_cross_edge_cache_batched(self, new_ids: list): + """ + Batch update cross chunk edges in cache for all new IDs at a layer. + More efficient than calling _update_cross_edge_cache per new_id. + """ + if not new_ids: + return + + parent_layer = self.cg.get_chunk_layer(new_ids[0]) + if parent_layer == 2: + # L2 cross edges have already been updated + return + + all_children_d = self.cg.get_children(new_ids) + all_children = np.concatenate(list(all_children_d.values())) + all_cx_edges_raw = self.cg.get_cross_chunk_edges( + all_children, time_stamp=self._last_ts + ) + combined_cx_edges = concatenate_cross_edge_dicts(all_cx_edges_raw.values()) + with self._profiler.profile("latest"): + updated_cx_edges, edge_nodes = get_latest_edges_wrapper( + self.cg, combined_cx_edges, parent_ts=self._last_ts + ) + + edge_parents = self.cg.get_roots( + edge_nodes, + stop_layer=parent_layer, + ceil=False, + time_stamp=self._last_ts, + ) + edge_parents_d = dict(zip(edge_nodes, edge_parents)) + + # Distribute results back to each parent's cache + # Key insight: edges[:, 0] are children, map them to their parent + for new_id in new_ids: + children_set = set(all_children_d[new_id]) + parent_cx_edges_d = {} + for layer in range(parent_layer, self.cg.meta.layer_count): + edges = updated_cx_edges.get(layer, types.empty_2d) + if len(edges) == 0: + continue + # Filter to edges whose source is one of this parent's children + mask = np.isin(edges[:, 0], list(children_set)) + if not np.any(mask): + continue + + parent_edges = edges[mask].copy() + parent_edges = fastremap.remap( + parent_edges, edge_parents_d, preserve_missing_labels=True + ) + parent_cx_edges_d[layer] = np.unique(parent_edges, axis=0) + assert np.all( + parent_edges[:, 0] == new_id + ), f"OP {self._operation_id}: parent mismatch {new_id} != {np.unique(parent_edges[:, 0])}" + self.cg.cache.cross_chunk_edges_cache[new_id] = parent_cx_edges_d + def _create_new_parents(self, layer: int): """ keep track of old IDs @@ -726,9 +782,7 @@ def run(self) -> Iterable: # all new IDs in this layer have been created # update their cross chunk edges and their neighbors' with self._profiler.profile(f"l{layer}_update_cx_cache"): - for new_id in self._new_ids_d[layer]: - children = self.cg.get_children(new_id) - self._update_cross_edge_cache(new_id, children) + self._update_cross_edge_cache_batched(self._new_ids_d[layer]) with self._profiler.profile(f"l{layer}_update_neighbor_cx"): entries = _update_neighbor_cx_edges( From f1dd4ff74e71cb65a65e6894d68a186a22306e48 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 4 Feb 2026 22:50:28 +0000 Subject: [PATCH 147/367] fix(edits): search for children when edges are dilated --- pychunkedgraph/graph/edges/__init__.py | 84 ++++++++++++++------------ 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 9f861c945..55a23f10f 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -335,56 +335,81 @@ def _populate_parents_cache(children: np.ndarray): for parent, ts in parents: PARENTS_CACHE[child][ts] = parent + def _get_hierarchy(nodes, layer): + _hierarchy = [nodes] + for _a in nodes: + _hierarchy.append( + cg.get_root( + _a, + time_stamp=parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + raw_only=True, + ) + ) + _children = cg.get_children(_a) + _children_layers = cg.get_chunk_layers(_children) + _hierarchy.append(_children[_children_layers == 2]) + _children = _children[_children_layers > 2] + while _children.size: + _hierarchy.append(_children) + _children = cg.get_children(_children, flatten=True) + _children_layers = cg.get_chunk_layers(_children) + _hierarchy.append(_children[_children_layers == 2]) + _children = _children[_children_layers > 2] + return np.concatenate(_hierarchy) + def _check_cross_edges_from_a(node_b, nodes_a, layer, parent_ts): """ Checks to match cross edges from partners_a to hierarchy of potential node from partner b. """ - _node_hierarchy = cg.get_root( + if len(nodes_a) == 0: + return False + + _hierarchy_b = cg.get_root( node_b, time_stamp=parent_ts, stop_layer=layer, get_all_parents=True, ceil=False, + raw_only=True, ) - _node_hierarchy = np.append(_node_hierarchy, node_b) + _hierarchy_b = np.append(_hierarchy_b, node_b) _cx_edges_d_from_a = cg.get_cross_chunk_edges(nodes_a, time_stamp=parent_ts) for _edges_d_from_a in _cx_edges_d_from_a.values(): _edges_from_a = _edges_d_from_a.get(layer, types.empty_2d) - _mask = np.isin(_edges_from_a[:, 1], _node_hierarchy) + nodes_b_from_a = _edges_from_a[:, 1] + hierarchy_b_from_a = _get_hierarchy(nodes_b_from_a, layer) + _mask = np.isin(hierarchy_b_from_a, _hierarchy_b) if np.any(_mask): return True return False - def _check_hierarchy_a_from_b(nodes_a, hierarchy_a, layer, parent_ts): + def _check_hierarchy_a_from_b(parents_a, nodes_a_from_b, layer, parent_ts): """ Checks for overlap between hierarchy of a, and hierarchy of a identified from partners of b. """ - _hierarchy_a_from_b = [nodes_a] - for _a in nodes_a: - _hierarchy_a_from_b.append( + if len(nodes_a_from_b) == 0: + return False + + _hierarchy_a = [parents_a] + for _a in parents_a: + _hierarchy_a.append( cg.get_root( _a, time_stamp=parent_ts, stop_layer=layer, get_all_parents=True, ceil=False, + raw_only=True, ) ) - _children = cg.get_children(_a) - _children_layers = cg.get_chunk_layers(_children) - _hierarchy_a_from_b.append(_children[_children_layers == 2]) - _children = _children[_children_layers > 2] - while _children.size: - _hierarchy_a_from_b.append(_children) - _children = cg.get_children(_children, flatten=True) - _children_layers = cg.get_chunk_layers(_children) - _hierarchy_a_from_b.append(_children[_children_layers == 2]) - _children = _children[_children_layers > 2] - - _hierarchy_a_from_b = np.concatenate(_hierarchy_a_from_b) - return np.any(np.isin(_hierarchy_a_from_b, hierarchy_a)) + hierarchy_a = np.concatenate(_hierarchy_a) + hierarchy_a_from_b = _get_hierarchy(nodes_a_from_b, layer) + return np.any(np.isin(hierarchy_a_from_b, hierarchy_a)) def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): """ @@ -419,27 +444,12 @@ def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): return np.unique(cg.get_parents(partners, time_stamp=parent_ts)) _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) - _hierarchy_a = [parents_a] - for _a in parents_a: - _hierarchy_a.append( - cg.get_root( - _a, - time_stamp=parent_ts, - stop_layer=layer, - get_all_parents=True, - ceil=False, - ) - ) - _hierarchy_a = np.concatenate(_hierarchy_a) - _parents_b = [] for _node, _edges_d in _cx_edges_d.items(): _edges = _edges_d.get(layer, types.empty_2d) if _check_cross_edges_from_a(_node, _edges[:, 1], layer, parent_ts): _parents_b.append(_node) - elif _check_hierarchy_a_from_b( - _edges[:, 1], _hierarchy_a, layer, parent_ts - ): + elif _check_hierarchy_a_from_b(parents_a, _edges[:, 1], layer, parent_ts): _parents_b.append(_node) return np.array(_parents_b, dtype=basetypes.NODE_ID) @@ -500,7 +510,7 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): if np.any(mask): parents_b = _get_parents_b(_edges[mask], parent_ts, edge_layer) else: - # partner edges likely lifted, dilate and retry + # partner nodes likely lifted, dilate and retry _edges = _get_dilated_edges(_edges) mask = np.isin(_edges[:, 1], l2ids_b) if np.any(mask): From 43e69b8b84c42b02768ab9f98185286957f5759d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 6 Feb 2026 01:53:12 +0000 Subject: [PATCH 148/367] fix(edits): handle simple case when stale edge is at l2; persist resolved stale edges --- pychunkedgraph/graph/cache.py | 14 ++-- pychunkedgraph/graph/edges/__init__.py | 51 ++++++++++----- pychunkedgraph/graph/edits.py | 91 ++++++++++---------------- pychunkedgraph/graph/operation.py | 2 +- 4 files changed, 77 insertions(+), 81 deletions(-) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index e0ee6dc2e..e7e893cb7 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -42,6 +42,8 @@ def __init__(self, cg): self.children_cache = LRUCache(maxsize=maxsize) self.cross_chunk_edges_cache = LRUCache(maxsize=maxsize) + self.new_ids = set() + # Stats tracking for cache hits/misses self.stats = { "parents": {"hits": 0, "misses": 0, "calls": 0}, @@ -153,11 +155,11 @@ def cross_edges_decorated(node_id): return cross_edges_decorated(node_id) def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None): - node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) + node_ids = np.asarray(node_ids, dtype=NODE_ID) if not node_ids.size: return node_ids self.stats["parents"]["calls"] += 1 - mask = np.in1d(node_ids, np.fromiter(self.parents_cache.keys(), dtype=NODE_ID)) + mask = np.isin(node_ids, np.fromiter(self.parents_cache.keys(), dtype=NODE_ID)) hits = int(np.sum(mask)) misses = len(node_ids) - hits self.stats["parents"]["hits"] += hits @@ -173,11 +175,11 @@ def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None) def children_multiple(self, node_ids: np.ndarray, *, flatten=False): result = {} - node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) + node_ids = np.asarray(node_ids, dtype=NODE_ID) if not node_ids.size: return result self.stats["children"]["calls"] += 1 - mask = np.in1d(node_ids, np.fromiter(self.children_cache.keys(), dtype=NODE_ID)) + mask = np.isin(node_ids, np.fromiter(self.children_cache.keys(), dtype=NODE_ID)) hits = int(np.sum(mask)) misses = len(node_ids) - hits self.stats["children"]["hits"] += hits @@ -197,11 +199,11 @@ def cross_chunk_edges_multiple( self, node_ids: np.ndarray, *, time_stamp: datetime = None ): result = {} - node_ids = np.array(node_ids, dtype=NODE_ID, copy=False) + node_ids = np.asarray(node_ids, dtype=NODE_ID) if not node_ids.size: return result self.stats["cross_chunk_edges"]["calls"] += 1 - mask = np.in1d( + mask = np.isin( node_ids, np.fromiter(self.cross_chunk_edges_cache.keys(), dtype=NODE_ID) ) hits = int(np.sum(mask)) diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 55a23f10f..7de862d1e 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -198,6 +198,15 @@ def get_edges(source: str, nodes: np.ndarray) -> Edges: ) +def flip_ids(id_map, node_ids): + """ + returns old or new ids according to the map + """ + ids = [np.asarray(list(id_map[id_]), dtype=basetypes.NODE_ID) for id_ in node_ids] + ids.append(types.empty_1d) # concatenate needs at least one array + return np.concatenate(ids).astype(basetypes.NODE_ID) + + def get_stale_nodes( cg, nodes: Iterable[basetypes.NODE_ID], parent_ts: datetime.datetime = None ): @@ -348,13 +357,13 @@ def _get_hierarchy(nodes, layer): raw_only=True, ) ) - _children = cg.get_children(_a) + _children = cg.get_children(_a, raw_only=True) _children_layers = cg.get_chunk_layers(_children) _hierarchy.append(_children[_children_layers == 2]) _children = _children[_children_layers > 2] while _children.size: _hierarchy.append(_children) - _children = cg.get_children(_children, flatten=True) + _children = cg.get_children(_children, flatten=True, raw_only=True) _children_layers = cg.get_chunk_layers(_children) _hierarchy.append(_children[_children_layers == 2]) _children = _children[_children_layers > 2] @@ -451,18 +460,22 @@ def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): _parents_b.append(_node) elif _check_hierarchy_a_from_b(parents_a, _edges[:, 1], layer, parent_ts): _parents_b.append(_node) + else: + _new_ids = list(cg.cache.new_ids) + if np.any(np.isin(_new_ids, parents_a)): + _parents_b.append(_node) return np.array(_parents_b, dtype=basetypes.NODE_ID) def _get_parents_b_with_chunk_mask( - l2ids_b: np.ndarray, parents_b: np.ndarray, max_ts: datetime.datetime, edge + l2ids_b: np.ndarray, nodes_b_from_a: np.ndarray, max_ts: datetime.datetime, edge ): chunks_old = cg.get_chunk_ids_from_node_ids(l2ids_b) - chunks_new = cg.get_chunk_ids_from_node_ids(parents_b) + chunks_new = cg.get_chunk_ids_from_node_ids(nodes_b_from_a) chunk_mask = np.isin(chunks_new, chunks_old) - parents_b = parents_b[chunk_mask] - _stale_nodes = get_stale_nodes(cg, parents_b, parent_ts=max_ts) - assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {parent_ts}" - return parents_b + nodes_b_from_a = nodes_b_from_a[chunk_mask] + _stale_nodes = get_stale_nodes(cg, nodes_b_from_a, parent_ts=max_ts) + assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {max_ts}" + return nodes_b_from_a def _get_cx_edges(l2ids_a, max_node_ts, raw_only: bool = True): _edges_d = cg.get_cross_chunk_edges( @@ -498,13 +511,17 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): if l2ids_a.size == 0 or l2ids_b.size == 0: return types.empty_2d.copy() - max_node_ts = max(nodes_ts_map[node_a], nodes_ts_map[node_b]) - try: - _edges = _get_cx_edges(l2ids_a, max_node_ts) - except ValueError: - _edges = _get_cx_edges(l2ids_a, max_node_ts, raw_only=False) - except ValueError: - return types.empty_2d.copy() + max_ts = max(nodes_ts_map[node_a], nodes_ts_map[node_b]) + is_l2_edge = node_a in l2ids_a and node_b in l2ids_b + if is_l2_edge and (l2ids_a.size == 1 and l2ids_b.size == 1): + _edges = np.array([edge], dtype=basetypes.NODE_ID) + else: + try: + _edges = _get_cx_edges(l2ids_a, max_ts) + except ValueError: + _edges = _get_cx_edges(l2ids_a, max_ts, raw_only=False) + except ValueError: + return types.empty_2d.copy() mask = np.isin(_edges[:, 1], l2ids_b) if np.any(mask): @@ -520,7 +537,7 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): # so get the new identities of `l2ids_b` by using chunk mask try: parents_b = _get_parents_b_with_chunk_mask( - l2ids_b, _edges[:, 1], max_node_ts, edge + l2ids_b, _edges[:, 1], max_ts, edge ) except AssertionError: parents_b = [] @@ -590,6 +607,8 @@ def get_latest_edges_wrapper( stale_edge_layers, parent_ts=parent_ts, ) + stale_nodes = get_stale_nodes(cg, latest_edges.ravel(), parent_ts=parent_ts) + assert stale_nodes.size == 0, f"latest_edges failed, stale: {stale_nodes}" logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") _new_cx_edges.append(latest_edges) new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 9bfe227a1..e36e766d8 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -16,7 +16,7 @@ from . import types from . import attributes from . import cache as cache_utils -from .edges import get_latest_edges_wrapper +from .edges import get_latest_edges_wrapper, flip_ids from .edges.utils import concatenate_cross_edge_dicts from .edges.utils import merge_cross_edge_dicts from .utils import basetypes @@ -402,15 +402,6 @@ def remove_edges( return new_roots, new_l2_ids, create_parents.new_entries -def _flip_ids(id_map, node_ids): - """ - returns old or new ids according to the map - """ - ids = [np.asarray(list(id_map[id_]), dtype=basetypes.NODE_ID) for id_ in node_ids] - ids.append(types.empty_1d) # concatenate needs at least one array - return np.concatenate(ids).astype(basetypes.NODE_ID) - - def _get_descendants(cg, new_id): """get all descendants at layers >= 2""" result = [] @@ -525,7 +516,7 @@ def _update_neighbor_cx_edges( all_cx_edges_d = cg.get_cross_chunk_edges(list(all_cps), time_stamp=parent_ts) for new_id in new_ids: - m = {old_id: new_id for old_id in _flip_ids(new_old_id, [new_id])} + m = {old_id: new_id for old_id in flip_ids(new_old_id, [new_id])} node_map.update(m) cp_layers = newid_counterpart_info[new_id] result = _update_neighbor_cx_edges_single( @@ -603,67 +594,30 @@ def _get_layer_node_ids( self, new_ids: np.ndarray, layer: int ) -> Tuple[np.ndarray, np.ndarray]: # get old identities of new IDs - old_ids = _flip_ids(self._new_old_id_d, new_ids) + old_ids = flip_ids(self._new_old_id_d, new_ids) # get their parents, then children of those parents old_parents = self.cg.get_parents(old_ids, time_stamp=self._last_ts) siblings = self.cg.get_children(np.unique(old_parents), flatten=True) # replace old identities with new IDs mask = np.isin(siblings, old_ids) - node_ids = [_flip_ids(self._old_new_id_d, old_ids), siblings[~mask], new_ids] + node_ids = [flip_ids(self._old_new_id_d, old_ids), siblings[~mask], new_ids] node_ids = np.concatenate(node_ids).astype(basetypes.NODE_ID) node_ids = np.unique(node_ids) layer_mask = self.cg.get_chunk_layers(node_ids) == layer return node_ids[layer_mask] - # return node_ids - - def _update_cross_edge_cache(self, parent, children): - """ - updates cross chunk edges in cache; - this can only be done after all new components at a layer have IDs - """ - parent_layer = self.cg.get_chunk_layer(parent) - if parent_layer == 2: - # l2 cross edges have already been updated by this point - return - - cx_edges_d = self.cg.get_cross_chunk_edges(children, time_stamp=self._last_ts) - cx_edges_d = concatenate_cross_edge_dicts(cx_edges_d.values()) - with self._profiler.profile("latest"): - cx_edges_d, edge_nodes = get_latest_edges_wrapper( - self.cg, cx_edges_d, parent_ts=self._last_ts - ) - - edge_parents = self.cg.get_roots( - edge_nodes, - stop_layer=parent_layer, - ceil=False, - time_stamp=self._last_ts, - ) - edge_parents_d = dict(zip(edge_nodes, edge_parents)) - new_cx_edges_d = {} - for layer in range(parent_layer, self.cg.meta.layer_count): - edges = cx_edges_d.get(layer, types.empty_2d) - if len(edges) == 0: - continue - edges = fastremap.remap(edges, edge_parents_d, preserve_missing_labels=True) - new_cx_edges_d[layer] = np.unique(edges, axis=0) - assert np.all( - edges[:, 0] == parent - ), f"OP {self._operation_id}: parent mismatch {parent} != {np.unique(edges[:, 0])}" - self.cg.cache.cross_chunk_edges_cache[parent] = new_cx_edges_d def _update_cross_edge_cache_batched(self, new_ids: list): """ Batch update cross chunk edges in cache for all new IDs at a layer. - More efficient than calling _update_cross_edge_cache per new_id. """ + updated_entries = [] if not new_ids: - return + return updated_entries parent_layer = self.cg.get_chunk_layer(new_ids[0]) if parent_layer == 2: # L2 cross edges have already been updated - return + return updated_entries all_children_d = self.cg.get_children(new_ids) all_children = np.concatenate(list(all_children_d.values())) @@ -676,6 +630,27 @@ def _update_cross_edge_cache_batched(self, new_ids: list): self.cg, combined_cx_edges, parent_ts=self._last_ts ) + # update cache with resolved stale edges + val_ds = defaultdict(dict) + children_cx_edges = defaultdict(dict) + for lyr in range(2, self.cg.meta.layer_count): + edges = updated_cx_edges.get(lyr, types.empty_2d) + if len(edges) == 0: + continue + children, inverse = np.unique(edges[:,0], return_inverse=True) + masks = inverse == np.arange(len(children))[:, None] + for child, mask in zip(children, masks): + children_cx_edges[child][lyr] = edges[mask] + val_ds[child][attributes.Connectivity.CrossChunkEdge[lyr]] = edges[mask] + + for c, cx_edges_map in children_cx_edges.items(): + self.cg.cache.cross_chunk_edges_cache[c] = cx_edges_map + rowkey = serialize_uint64(c) + row = self.cg.client.mutate_row(rowkey, val_ds[c], time_stamp=self._last_ts) + updated_entries.append(row) + + # Distribute results back to each parent's cache + # Key insight: edges[:, 0] are children, map them to their parent edge_parents = self.cg.get_roots( edge_nodes, stop_layer=parent_layer, @@ -683,9 +658,6 @@ def _update_cross_edge_cache_batched(self, new_ids: list): time_stamp=self._last_ts, ) edge_parents_d = dict(zip(edge_nodes, edge_parents)) - - # Distribute results back to each parent's cache - # Key insight: edges[:, 0] are children, map them to their parent for new_id in new_ids: children_set = set(all_children_d[new_id]) parent_cx_edges_d = {} @@ -707,6 +679,7 @@ def _update_cross_edge_cache_batched(self, new_ids: list): parent_edges[:, 0] == new_id ), f"OP {self._operation_id}: parent mismatch {new_id} != {np.unique(parent_edges[:, 0])}" self.cg.cache.cross_chunk_edges_cache[new_id] = parent_cx_edges_d + return updated_entries def _create_new_parents(self, layer: int): """ @@ -779,10 +752,12 @@ def run(self) -> Iterable: for layer in range(2, self.cg.meta.layer_count): if len(self._new_ids_d[layer]) == 0: continue + self.cg.cache.new_ids.update(self._new_ids_d[layer]) # all new IDs in this layer have been created # update their cross chunk edges and their neighbors' with self._profiler.profile(f"l{layer}_update_cx_cache"): - self._update_cross_edge_cache_batched(self._new_ids_d[layer]) + entries = self._update_cross_edge_cache_batched(self._new_ids_d[layer]) + self.new_entries.extend(entries) with self._profiler.profile(f"l{layer}_update_neighbor_cx"): entries = _update_neighbor_cx_edges( @@ -802,7 +777,7 @@ def _update_root_id_lineage(self): if self.stitch_mode: return new_roots = self._new_ids_d[self.cg.meta.layer_count] - former_roots = _flip_ids(self._new_old_id_d, new_roots) + former_roots = flip_ids(self._new_old_id_d, new_roots) former_roots = np.unique(former_roots) err = f"new roots are inconsistent; op {self._operation_id}" diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index d11a78230..5e51edfb7 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -461,7 +461,7 @@ def execute( if top_sources and any(src[1]["misses"] > 0 for src in top_sources): src_str = ", ".join(f"{k}({v['misses']})" for k, v in top_sources if v["misses"] > 0) lines.append(f" miss sources: {src_str}") - logger.info("\n".join(lines)) + logger.debug("\n".join(lines)) if self.cg.meta.READ_ONLY: # return without persisting changes return GraphEditOperation.Result( From 6fa1c26ff6b397987791cfbaaf72eec25706f5eb Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 8 Feb 2026 00:44:03 +0000 Subject: [PATCH 149/367] feat(edits): batch get descendents, make sanity checks optional --- pychunkedgraph/graph/cache.py | 25 ++++--- pychunkedgraph/graph/chunkedgraph.py | 8 ++- pychunkedgraph/graph/edges/__init__.py | 41 +++++++---- pychunkedgraph/graph/edits.py | 96 ++++++++++++++++---------- pychunkedgraph/graph/operation.py | 27 ++++---- 5 files changed, 124 insertions(+), 73 deletions(-) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index e7e893cb7..66ffc44b5 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -3,7 +3,7 @@ Cache nodes, parents, children and cross edges. """ import traceback -from collections import defaultdict +from collections import defaultdict as defaultd from sys import maxsize from datetime import datetime @@ -51,7 +51,7 @@ def __init__(self, cg): "cross_chunk_edges": {"hits": 0, "misses": 0, "calls": 0}, } # Track where calls/misses come from - self.call_sources = defaultdict(lambda: defaultdict(lambda: {"calls": 0, "misses": 0})) + self.sources = defaultd(lambda: defaultd(lambda: {"calls": 0, "misses": 0})) def _get_caller(self, skip_frames=2): """Get caller info (filename:line:function).""" @@ -65,8 +65,8 @@ def _get_caller(self, skip_frames=2): def _record_call(self, cache_type, misses=0): """Record a call and its source.""" caller = self._get_caller(skip_frames=3) - self.call_sources[cache_type][caller]["calls"] += 1 - self.call_sources[cache_type][caller]["misses"] += misses + self.sources[cache_type][caller]["calls"] += 1 + self.sources[cache_type][caller]["misses"] += misses def __len__(self): return ( @@ -90,7 +90,7 @@ def get_stats(self): **s, "total": total, "hit_rate": f"{hit_rate:.1%}", - "sources": dict(self.call_sources[name]), + "sources": dict(self.sources[name]), } return result @@ -99,7 +99,7 @@ def reset_stats(self): s["hits"] = 0 s["misses"] = 0 s["calls"] = 0 - self.call_sources.clear() + self.sources.clear() def parent(self, node_id: np.uint64, *, time_stamp: datetime = None): self.stats["parents"]["calls"] += 1 @@ -154,7 +154,13 @@ def cross_edges_decorated(node_id): return cross_edges_decorated(node_id) - def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None): + def parents_multiple( + self, + node_ids: np.ndarray, + *, + time_stamp: datetime = None, + fail_to_zero: bool = False, + ): node_ids = np.asarray(node_ids, dtype=NODE_ID) if not node_ids.size: return node_ids @@ -168,7 +174,10 @@ def parents_multiple(self, node_ids: np.ndarray, *, time_stamp: datetime = None) parents = node_ids.copy() parents[mask] = self._parent_vec(node_ids[mask]) parents[~mask] = self._cg.get_parents( - node_ids[~mask], raw_only=True, time_stamp=time_stamp + node_ids[~mask], + raw_only=True, + time_stamp=time_stamp, + fail_to_zero=fail_to_zero, ) update(self.parents_cache, node_ids[~mask], parents[~mask]) return parents diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 636e1843e..fea80d180 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -241,7 +241,9 @@ def get_parents( else: raise KeyError from exc return parents - return self.cache.parents_multiple(node_ids, time_stamp=time_stamp) + return self.cache.parents_multiple( + node_ids, time_stamp=time_stamp, fail_to_zero=fail_to_zero + ) def get_parent( self, @@ -807,6 +809,7 @@ def add_edges( source_coords: typing.Sequence[int] = None, sink_coords: typing.Sequence[int] = None, allow_same_segment_merge: typing.Optional[bool] = False, + do_sanity_check: typing.Optional[bool] = False, ) -> operation.GraphEditOperation.Result: """ Adds an edge to the chunkedgraph @@ -823,6 +826,7 @@ def add_edges( source_coords=source_coords, sink_coords=sink_coords, allow_same_segment_merge=allow_same_segment_merge, + do_sanity_check=do_sanity_check, ).execute() def remove_edges( @@ -838,6 +842,7 @@ def remove_edges( path_augment: bool = True, disallow_isolating_cut: bool = True, bb_offset: typing.Tuple[int, int, int] = (240, 240, 24), + do_sanity_check: typing.Optional[bool] = False, ) -> operation.GraphEditOperation.Result: """ Removes edges - either directly or after applying a mincut @@ -862,6 +867,7 @@ def remove_edges( bbox_offset=bb_offset, path_augment=path_augment, disallow_isolating_cut=disallow_isolating_cut, + do_sanity_check=do_sanity_check, ).execute() if not atomic_edges: diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 7de862d1e..dcb161482 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -207,6 +207,29 @@ def flip_ids(id_map, node_ids): return np.concatenate(ids).astype(basetypes.NODE_ID) +def _get_new_nodes( + cg, nodes: np.ndarray, layer: int, parent_ts: datetime.datetime = None +): + unique_nodes, inverse = np.unique(nodes, return_inverse=True) + node_root_map = {n: n for n in unique_nodes} + lookup = np.ones(len(unique_nodes), dtype=unique_nodes.dtype) + while np.any(lookup): + roots = np.fromiter(node_root_map.values(), dtype=basetypes.NODE_ID) + roots = cg.get_parents(roots, time_stamp=parent_ts, fail_to_zero=True) + layers = cg.get_chunk_layers(roots) + lookup[layers > layer] = 0 + lookup[roots == 0] = 0 + + layer_mask = layers <= layer + non_zero_mask = roots != 0 + mask = layer_mask & non_zero_mask + for node, root in zip(unique_nodes[mask], roots[mask]): + node_root_map[node] = root + + unique_results = np.fromiter(node_root_map.values(), dtype=basetypes.NODE_ID) + return unique_results[inverse] + + def get_stale_nodes( cg, nodes: Iterable[basetypes.NODE_ID], parent_ts: datetime.datetime = None ): @@ -215,7 +238,9 @@ def get_stale_nodes( This is done by getting a supervoxel of a node and checking if it has a new parent at the same layer as the node. """ - nodes = np.array(nodes, dtype=basetypes.NODE_ID) + nodes = np.unique(np.array(nodes, dtype=basetypes.NODE_ID)) + new_ids = set() if cg.cache is None else cg.cache.new_ids + nodes = nodes[~np.isin(nodes, new_ids)] supervoxels = cg.get_single_leaf_multiple(nodes) # nodes can be at different layers due to skip connections node_layers = cg.get_chunk_layers(nodes) @@ -223,12 +248,7 @@ def get_stale_nodes( for layer in np.unique(node_layers): _mask = node_layers == layer layer_nodes = nodes[_mask] - _nodes = cg.get_roots( - supervoxels[_mask], - stop_layer=layer, - ceil=False, - time_stamp=parent_ts, - ) + _nodes = _get_new_nodes(cg, supervoxels[_mask], layer, parent_ts) stale_mask = layer_nodes != _nodes stale_nodes.append(layer_nodes[stale_mask]) return np.concatenate(stale_nodes) @@ -544,10 +564,7 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): if fallback: parents_b = _get_parents_b(_edges, parent_ts, edge_layer, True) - parents_b = np.unique( - cg.get_roots(parents_b, stop_layer=mlayer, ceil=False, time_stamp=parent_ts) - ) - + parents_b = np.unique(_get_new_nodes(cg, parents_b, mlayer, parent_ts)) parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) return np.column_stack((parents_a, parents_b)) @@ -607,8 +624,6 @@ def get_latest_edges_wrapper( stale_edge_layers, parent_ts=parent_ts, ) - stale_nodes = get_stale_nodes(cg, latest_edges.ravel(), parent_ts=parent_ts) - assert stale_nodes.size == 0, f"latest_edges failed, stale: {stale_nodes}" logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") _new_cx_edges.append(latest_edges) new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index e36e766d8..ba243993e 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -200,6 +200,7 @@ def add_edges( parent_ts: datetime.datetime = None, allow_same_segment_merge=False, stitch_mode: bool = False, + do_sanity_check: bool = True, ): edges, l2_cross_edges_d = _analyze_affected_edges( cg, atomic_edges, parent_ts=parent_ts @@ -262,11 +263,13 @@ def add_edges( time_stamp=time_stamp, parent_ts=parent_ts, stitch_mode=stitch_mode, + do_sanity_check=do_sanity_check, profiler=profiler, ) new_roots = create_parents.run() - sanity_check(cg, new_roots, operation_id) + if do_sanity_check: + sanity_check(cg, new_roots, operation_id) create_parents.create_new_entries() profiler.print_report(operation_id) return new_roots, new_l2_ids, create_parents.new_entries @@ -336,6 +339,7 @@ def remove_edges( operation_id: basetypes.OPERATION_ID = None, # type: ignore time_stamp: datetime.datetime = None, parent_ts: datetime.datetime = None, + do_sanity_check: bool = True, ): edges, _ = _analyze_affected_edges(cg, atomic_edges, parent_ts=parent_ts) l2ids = np.unique(edges) @@ -395,29 +399,41 @@ def remove_edges( operation_id=operation_id, time_stamp=time_stamp, parent_ts=parent_ts, + do_sanity_check=do_sanity_check, ) new_roots = create_parents.run() - sanity_check(cg, new_roots, operation_id) + + if do_sanity_check: + sanity_check(cg, new_roots, operation_id) create_parents.create_new_entries() return new_roots, new_l2_ids, create_parents.new_entries -def _get_descendants(cg, new_id): - """get all descendants at layers >= 2""" - result = [] - children = cg.get_children(new_id) - while True: - mask = cg.get_chunk_layers(children) >= 2 - children = children[mask] - result.extend(children) - - mask = cg.get_chunk_layers(children) > 2 - children = children[mask] - if children.size == 0: - break - - children = cg.get_children(children, flatten=True) - return result +def _get_descendants_batch(cg, node_ids): + """Get all descendants at layers >= 2 for multiple node_ids. + Batches get_children calls by level to reduce IO. + Returns dict {node_id: np.ndarray of descendants}. + """ + if not node_ids: + return {} + results = {nid: [] for nid in node_ids} + # expand_map: {node_to_expand: root_node_id} + expand_map = {nid: nid for nid in node_ids} + + while expand_map: + next_expand = {} + children_d = cg.get_children(list(expand_map.keys())) + for parent, root in expand_map.items(): + children = children_d[parent] + layers = cg.get_chunk_layers(children) + mask = layers >= 2 + results[root].extend(children[mask]) + for c in children[layers > 2]: + next_expand[c] = root + expand_map = next_expand + return { + nid: np.array(desc, dtype=basetypes.NODE_ID) for nid, desc in results.items() + } def _get_counterparts( @@ -446,6 +462,7 @@ def _update_neighbor_cx_edges_single( node_map: dict, counterpart_layers: dict, all_counterparts_cx_edges_d: dict, + descendants_d: dict, ) -> dict: """ For each new_id, update cross chunk edges of its counterparts. @@ -469,7 +486,7 @@ def _update_neighbor_cx_edges_single( if layer == counterpart_layer: flip_edge = np.array([counterpart, new_id], dtype=basetypes.NODE_ID) edges = np.concatenate([edges, [flip_edge]]).astype(basetypes.NODE_ID) - descendants = _get_descendants(cg, new_id) + descendants = descendants_d[new_id] mask = np.isin(edges[:, 1], descendants) if np.any(mask): masked_edges = edges[mask] @@ -515,12 +532,13 @@ def _update_neighbor_cx_edges( newid_counterpart_info[_id] = cp_layers all_cx_edges_d = cg.get_cross_chunk_edges(list(all_cps), time_stamp=parent_ts) + descendants_d = _get_descendants_batch(cg, new_ids) for new_id in new_ids: m = {old_id: new_id for old_id in flip_ids(new_old_id, [new_id])} node_map.update(m) cp_layers = newid_counterpart_info[new_id] result = _update_neighbor_cx_edges_single( - cg, new_id, node_map, cp_layers, all_cx_edges_d + cg, new_id, node_map, cp_layers, all_cx_edges_d, descendants_d ) updated_counterparts.update(result) updated_entries = [] @@ -544,6 +562,7 @@ def __init__( old_hierarchy_d: Dict[np.uint64, Dict[int, np.uint64]] = None, parent_ts: datetime.datetime = None, stitch_mode: bool = False, + do_sanity_check: bool = True, profiler: HierarchicalProfiler = None, ): self.cg = cg @@ -553,10 +572,11 @@ def __init__( self._new_old_id_d = new_old_id_d self._old_new_id_d = old_new_id_d self._new_ids_d = defaultdict(list) - self._operation_id = operation_id + self._opid = operation_id self._time_stamp = time_stamp self._last_ts = parent_ts self.stitch_mode = stitch_mode + self.do_sanity_check = do_sanity_check self._profiler = profiler if profiler else get_profiler() def _update_id_lineage( @@ -637,7 +657,7 @@ def _update_cross_edge_cache_batched(self, new_ids: list): edges = updated_cx_edges.get(lyr, types.empty_2d) if len(edges) == 0: continue - children, inverse = np.unique(edges[:,0], return_inverse=True) + children, inverse = np.unique(edges[:, 0], return_inverse=True) masks = inverse == np.arange(len(children))[:, None] for child, mask in zip(children, masks): children_cx_edges[child][lyr] = edges[mask] @@ -670,14 +690,14 @@ def _update_cross_edge_cache_batched(self, new_ids: list): if not np.any(mask): continue - parent_edges = edges[mask].copy() - parent_edges = fastremap.remap( - parent_edges, edge_parents_d, preserve_missing_labels=True + pedges = edges[mask].copy() + pedges = fastremap.remap( + pedges, edge_parents_d, preserve_missing_labels=True ) - parent_cx_edges_d[layer] = np.unique(parent_edges, axis=0) + parent_cx_edges_d[layer] = np.unique(pedges, axis=0) assert np.all( - parent_edges[:, 0] == new_id - ), f"OP {self._operation_id}: parent mismatch {new_id} != {np.unique(parent_edges[:, 0])}" + pedges[:, 0] == new_id + ), f"OP {self._opid}: mismatch {new_id} != {np.unique(pedges[:, 0])}" self.cg.cache.cross_chunk_edges_cache[new_id] = parent_cx_edges_d return updated_entries @@ -700,10 +720,10 @@ def _create_new_parents(self, layer: int): if len(cc_ids) == 1: # skip connection parent_layer = self.cg.meta.layer_count + cx_edges_d = self.cg.get_cross_chunk_edges( + [cc_ids[0]], time_stamp=self._last_ts + ) for l in range(layer + 1, self.cg.meta.layer_count): - cx_edges_d = self.cg.get_cross_chunk_edges( - [cc_ids[0]], time_stamp=self._last_ts - ) if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: parent_layer = l break @@ -728,9 +748,11 @@ def _create_new_parents(self, layer: int): self._update_id_lineage(parent, cc_ids, layer, parent_layer) self.cg.cache.children_cache[parent] = cc_ids cache_utils.update(self.cg.cache.parents_cache, cc_ids, parent) + if not self.do_sanity_check: + continue try: - sanity_check_single(self.cg, parent, self._operation_id) + sanity_check_single(self.cg, parent, self._opid) except AssertionError: pairs = [ (a, b) for idx, a in enumerate(cc_ids) for b in cc_ids[idx + 1 :] @@ -740,7 +762,7 @@ def _create_new_parents(self, layer: int): l2c2 = self.cg.get_l2children([c2]) if np.intersect1d(l2c1, l2c2).size: c = np.intersect1d(l2c1, l2c2) - msg = f"{self._operation_id}: {layer} {c1} {c2} have common children {c}" + msg = f"{self._opid}: {layer} {c1} {c2} common children {c}" raise ValueError(msg) def run(self) -> Iterable: @@ -780,12 +802,12 @@ def _update_root_id_lineage(self): former_roots = flip_ids(self._new_old_id_d, new_roots) former_roots = np.unique(former_roots) - err = f"new roots are inconsistent; op {self._operation_id}" + err = f"new roots are inconsistent; op {self._opid}" assert len(former_roots) < 2 or len(new_roots) < 2, err for new_root_id in new_roots: val_dict = { attributes.Hierarchy.FormerParent: former_roots, - attributes.OperationLogs.OperationID: self._operation_id, + attributes.OperationLogs.OperationID: self._opid, } self.new_entries.append( self.cg.client.mutate_row( @@ -800,7 +822,7 @@ def _update_root_id_lineage(self): attributes.Hierarchy.NewParent: np.array( new_roots, dtype=basetypes.NODE_ID ), - attributes.OperationLogs.OperationID: self._operation_id, + attributes.OperationLogs.OperationID: self._opid, } self.new_entries.append( self.cg.client.mutate_row( @@ -831,7 +853,7 @@ def create_new_entries(self) -> List: for id_ in new_ids: val_dict = val_dicts.get(id_, {}) children = self.cg.get_children(id_) - err = f"parent layer less than children; op {self._operation_id}" + err = f"parent layer less than children; op {self._opid}" assert np.max( self.cg.get_chunk_layers(children) ) < self.cg.get_chunk_layer(id_), err diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 5e51edfb7..1aa7d14c3 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -47,6 +47,7 @@ class GraphEditOperation(ABC): "sink_coords", "parent_ts", "privileged_mode", + "do_sanity_check", ] Result = namedtuple( "Result", ["operation_id", "new_root_ids", "new_lvl2_ids", "old_root_ids"] @@ -449,19 +450,6 @@ def execute( operation_id=lock.operation_id, timestamp=override_ts if override_ts else timestamp, ) - # Log cache stats - if self.cg.cache: - stats = self.cg.cache.get_stats() - lines = [f"[Op {lock.operation_id}] Cache:"] - for name, s in stats.items(): - lines.append(f" {name}: {s['hit_rate']} hit ({s['hits']}/{s['total']}) calls={s['calls']}") - # Show top miss sources if any - if s.get("sources"): - top_sources = sorted(s["sources"].items(), key=lambda x: -x[1]["misses"])[:3] - if top_sources and any(src[1]["misses"] > 0 for src in top_sources): - src_str = ", ".join(f"{k}({v['misses']})" for k, v in top_sources if v["misses"] > 0) - lines.append(f" miss sources: {src_str}") - logger.debug("\n".join(lines)) if self.cg.meta.READ_ONLY: # return without persisting changes return GraphEditOperation.Result( @@ -584,6 +572,7 @@ class MergeOperation(GraphEditOperation): "affinities", "bbox_offset", "allow_same_segment_merge", + "do_sanity_check", ] def __init__( @@ -597,6 +586,7 @@ def __init__( bbox_offset: Tuple[int, int, int] = (240, 240, 24), affinities: Optional[Sequence[np.float32]] = None, allow_same_segment_merge: Optional[bool] = False, + do_sanity_check: Optional[bool] = True, ) -> None: super().__init__( cg, user_id=user_id, source_coords=source_coords, sink_coords=sink_coords @@ -604,6 +594,7 @@ def __init__( self.added_edges = np.atleast_2d(added_edges).astype(basetypes.NODE_ID) self.bbox_offset = np.atleast_1d(bbox_offset).astype(basetypes.COORDINATES) self.allow_same_segment_merge = allow_same_segment_merge + self.do_sanity_check = do_sanity_check self.affinities = None if affinities is not None: @@ -670,6 +661,7 @@ def _apply( time_stamp=timestamp, parent_ts=self.parent_ts, allow_same_segment_merge=self.allow_same_segment_merge, + do_sanity_check=self.do_sanity_check, ) return new_roots, new_l2_ids, fake_edge_rows + new_entries @@ -728,7 +720,7 @@ class SplitOperation(GraphEditOperation): :type sink_coords: Optional[Sequence[Sequence[int]]], optional """ - __slots__ = ["removed_edges", "bbox_offset"] + __slots__ = ["removed_edges", "bbox_offset", "do_sanity_check"] def __init__( self, @@ -739,12 +731,14 @@ def __init__( source_coords: Optional[Sequence[Sequence[int]]] = None, sink_coords: Optional[Sequence[Sequence[int]]] = None, bbox_offset: Tuple[int] = (240, 240, 24), + do_sanity_check: Optional[bool] = True, ) -> None: super().__init__( cg, user_id=user_id, source_coords=source_coords, sink_coords=sink_coords ) self.removed_edges = np.atleast_2d(removed_edges).astype(basetypes.NODE_ID) self.bbox_offset = np.atleast_1d(bbox_offset).astype(basetypes.COORDINATES) + self.do_sanity_check = do_sanity_check if np.any(np.equal(self.removed_edges[:, 0], self.removed_edges[:, 1])): raise PreconditionError("Requested split contains at least 1 self-loop.") @@ -787,6 +781,7 @@ def _apply( atomic_edges=self.removed_edges, time_stamp=timestamp, parent_ts=self.parent_ts, + do_sanity_check=self.do_sanity_check, ) def _create_log_record( @@ -855,6 +850,7 @@ class MulticutOperation(GraphEditOperation): "bbox_offset", "path_augment", "disallow_isolating_cut", + "do_sanity_check", ] def __init__( @@ -870,6 +866,7 @@ def __init__( removed_edges: Sequence[Sequence[np.uint64]] = types.empty_2d, path_augment: bool = True, disallow_isolating_cut: bool = True, + do_sanity_check: Optional[bool] = True, ) -> None: super().__init__( cg, user_id=user_id, source_coords=source_coords, sink_coords=sink_coords @@ -880,6 +877,7 @@ def __init__( self.bbox_offset = np.atleast_1d(bbox_offset).astype(basetypes.COORDINATES) self.path_augment = path_augment self.disallow_isolating_cut = disallow_isolating_cut + self.do_sanity_check = do_sanity_check if np.any(np.in1d(self.sink_ids, self.source_ids)): raise PreconditionError( "Supervoxels exist in both sink and source, " @@ -953,6 +951,7 @@ def _apply( atomic_edges=self.removed_edges, time_stamp=timestamp, parent_ts=self.parent_ts, + do_sanity_check=self.do_sanity_check, ) def _create_log_record( From 3b01659cdbf59c2c54f94f5106f531191a37daab Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 8 Feb 2026 01:52:00 +0000 Subject: [PATCH 150/367] frontend: enable sanity checks for edits --- pychunkedgraph/app/segmentation/common.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 3250248f2..d10790604 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -384,6 +384,7 @@ def handle_merge(table_id, allow_same_segment_merge=False): source_coords=coords[:1], sink_coords=coords[1:], allow_same_segment_merge=allow_same_segment_merge, + do_sanity_check=True, ) except cg_exceptions.LockingError as e: @@ -451,6 +452,7 @@ def handle_split(table_id): source_coords=coords[node_idents == 0], sink_coords=coords[node_idents == 1], mincut=mincut, + do_sanity_check=True, ) except cg_exceptions.LockingError as e: raise cg_exceptions.InternalServerError(e) From 9f11702a9e445db207b0f0d873c94b5f33670d54 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 8 Feb 2026 17:18:25 +0000 Subject: [PATCH 151/367] =?UTF-8?q?Bump=20version:=203.1.6=20=E2=86=92=203?= =?UTF-8?q?.1.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pychunkedgraph/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 002585678..a0775c7ee 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.6 +current_version = 3.1.7 commit = True tag = True diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 0831294a3..28c0d26dc 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.1.6" +__version__ = "3.1.7" import sys import warnings From 1e41f184e03dec083beaaa2c25e820eaf95c57e3 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 11 Feb 2026 02:36:15 +0000 Subject: [PATCH 152/367] fix(edits): batch create new l2 ids for better performance --- pychunkedgraph/graph/edits.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index ba243993e..c22ae8830 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -1,6 +1,6 @@ # pylint: disable=invalid-name, missing-docstring, too-many-locals, c-extension-no-member -import datetime +import datetime, random from typing import Dict from typing import List from typing import Tuple @@ -217,13 +217,26 @@ def add_edges( cross_edges_d = merge_cross_edge_dicts( cg.get_cross_chunk_edges(l2ids, time_stamp=parent_ts), l2_cross_edges_d ) - graph, _, _, graph_ids = flatgraph.build_gt_graph(edges, make_directed=True) components = flatgraph.connected_components(graph) + + chunk_count_map = defaultdict(int) + for cc_indices in components: + l2ids_ = graph_ids[cc_indices] + chunk = cg.get_chunk_id(l2ids_[0]) + chunk_count_map[chunk] += 1 + + chunk_ids = list(chunk_count_map.keys()) + random.shuffle(chunk_ids) + chunk_new_ids_map = {} + for chunk_id in chunk_ids: + new_ids = cg.id_client.create_node_ids(chunk_id, size=chunk_count_map[chunk_id]) + chunk_new_ids_map[chunk_id] = list(new_ids) + new_l2_ids = [] for cc_indices in components: l2ids_ = graph_ids[cc_indices] - new_id = cg.id_client.create_node_id(cg.get_chunk_id(l2ids_[0])) + new_id = chunk_new_ids_map[cg.get_chunk_id(l2ids_[0])].pop() new_l2_ids.append(new_id) new_old_id_d[new_id].update(l2ids_) for id_ in l2ids_: @@ -728,6 +741,7 @@ def _create_new_parents(self, layer: int): parent_layer = l break + # TODO: handle skip connected root id creation separately chunk_id = self.cg.get_parent_chunk_id(cc_ids[0], parent_layer) is_root = parent_layer == self.cg.meta.layer_count batch_size = 1 From a45a4d52a6ee8029e2aebeb1f932b86b165948d2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 11 Feb 2026 16:14:51 +0000 Subject: [PATCH 153/367] fix(edits): use a valid timestamp when locks are overridden, instead of None; add fallback for get earliest ts --- pychunkedgraph/graph/chunkedgraph.py | 6 +++++- pychunkedgraph/graph/operation.py | 10 ++++++---- pychunkedgraph/graph/utils/generic.py | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index fea80d180..2cd36ba88 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -1022,9 +1022,13 @@ def get_earliest_timestamp(self): from datetime import timedelta for op_id in range(100): - _, timestamp = self.client.read_log_entry(op_id) + _log, timestamp = self.client.read_log_entry(op_id) if timestamp is not None: return timestamp - timedelta(milliseconds=500) + if _log: + return self.client._read_byte_row(serializers.serialize_uint64(op_id))[ + attributes.OperationLogs.Status + ][-1].timestamp def get_operation_ids(self, node_ids: typing.Sequence): response = self.client.read_nodes(node_ids=node_ids) diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 1aa7d14c3..0afe06669 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -31,7 +31,7 @@ from .cutting import run_multicut from .exceptions import PreconditionError from .exceptions import PostconditionError -from .utils.generic import get_bounding_box as get_bbox +from .utils.generic import get_bounding_box as get_bbox, get_valid_timestamp from ..logging.log_db import TimeIt @@ -434,6 +434,8 @@ def execute( lock.locked_root_ids, np.array([lock.operation_id] * len(lock.locked_root_ids)), ) + if timestamp is None: + timestamp = get_valid_timestamp(timestamp) log_record_before_edit = self._create_log_record( operation_id=lock.operation_id, @@ -878,7 +880,7 @@ def __init__( self.path_augment = path_augment self.disallow_isolating_cut = disallow_isolating_cut self.do_sanity_check = do_sanity_check - if np.any(np.in1d(self.sink_ids, self.source_ids)): + if np.any(np.isin(self.sink_ids, self.source_ids)): raise PreconditionError( "Supervoxels exist in both sink and source, " "try placing the points further apart." @@ -927,8 +929,8 @@ def _apply( supervoxels = np.concatenate( [agg.supervoxels for agg in l2id_agglomeration_d.values()] ) - mask0 = np.in1d(edges.node_ids1, supervoxels) - mask1 = np.in1d(edges.node_ids2, supervoxels) + mask0 = np.isin(edges.node_ids1, supervoxels) + mask1 = np.isin(edges.node_ids2, supervoxels) edges = edges[mask0 & mask1] if len(edges) == 0: raise PreconditionError("No local edges found.") diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 2f9f5c955..696a03801 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -98,7 +98,7 @@ def time_min(): def get_valid_timestamp(timestamp): if timestamp is None: - timestamp = datetime.datetime.utcnow() + timestamp = datetime.datetime.now(datetime.timezone.utc) if timestamp.tzinfo is None: timestamp = pytz.UTC.localize(timestamp) # Comply to resolution of BigTables TimeRange From 6f38418c5fb7b44572470720ad25b70c05c2c816 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 15 Feb 2026 17:05:17 +0000 Subject: [PATCH 154/367] fix(edits): batch create higher layer ids, reuse future roots from lock, sanity check defaults to true --- pychunkedgraph/graph/cache.py | 1 + pychunkedgraph/graph/chunkedgraph.py | 10 +- .../graph/client/bigtable/client.py | 8 +- pychunkedgraph/graph/edges/__init__.py | 9 +- pychunkedgraph/graph/edits.py | 116 +++++++++++------- pychunkedgraph/graph/locks.py | 63 ++++++---- pychunkedgraph/graph/operation.py | 1 + 7 files changed, 127 insertions(+), 81 deletions(-) diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index 66ffc44b5..011f4099e 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -179,6 +179,7 @@ def parents_multiple( time_stamp=time_stamp, fail_to_zero=fail_to_zero, ) + mask = mask | (parents == 0) update(self.parents_cache, node_ids[~mask], parents[~mask]) return parents diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 2cd36ba88..38a408e92 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -214,7 +214,7 @@ def get_parents( end_time=time_stamp, end_time_inclusive=True, ) - if not parent_rows: + if not parent_rows and not fail_to_zero: return types.empty_1d parents = [] @@ -736,8 +736,8 @@ def get_l2_agglomerations( else: all_chunk_edges = all_chunk_edges.get_pairs() supervoxels = self.get_children(level2_ids, flatten=True) - mask0 = np.in1d(all_chunk_edges[:, 0], supervoxels) - mask1 = np.in1d(all_chunk_edges[:, 1], supervoxels) + mask0 = np.isin(all_chunk_edges[:, 0], supervoxels) + mask1 = np.isin(all_chunk_edges[:, 1], supervoxels) return all_chunk_edges[mask0 & mask1] l2id_children_d = self.get_children(level2_ids) @@ -809,7 +809,7 @@ def add_edges( source_coords: typing.Sequence[int] = None, sink_coords: typing.Sequence[int] = None, allow_same_segment_merge: typing.Optional[bool] = False, - do_sanity_check: typing.Optional[bool] = False, + do_sanity_check: typing.Optional[bool] = True, ) -> operation.GraphEditOperation.Result: """ Adds an edge to the chunkedgraph @@ -842,7 +842,7 @@ def remove_edges( path_augment: bool = True, disallow_isolating_cut: bool = True, bb_offset: typing.Tuple[int, int, int] = (240, 240, 24), - do_sanity_check: typing.Optional[bool] = False, + do_sanity_check: typing.Optional[bool] = True, ) -> operation.GraphEditOperation.Result: """ Removes edges - either directly or after applying a mincut diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 9912772a6..3935e6f02 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -467,7 +467,7 @@ def lock_roots( lock_results = {} root_ids = np.unique(new_root_ids) - max_workers = max(1, len(root_ids) // 2) + max_workers = max(1, len(root_ids)) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_root = { executor.submit(self.lock_root, root_id, operation_id): root_id @@ -518,7 +518,7 @@ def lock_roots_indefinitely( root_ids = np.unique(new_root_ids) lock_results = {} - max_workers = max(1, len(root_ids) // 2) + max_workers = max(1, len(root_ids)) failed_to_lock = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_root = { @@ -601,7 +601,7 @@ def renew_lock(self, root_id: np.uint64, operation_id: np.uint64) -> bool: def renew_locks(self, root_ids: np.uint64, operation_id: np.uint64) -> bool: """Renews existing root node locks with operation_id to extend time.""" - max_workers = max(1, len(root_ids) // 2) + max_workers = max(1, len(root_ids)) with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(self.renew_lock, root_id, operation_id): root_id @@ -640,7 +640,7 @@ def get_consolidated_lock_timestamp( """Minimum of multiple lock timestamps.""" if len(root_ids) == 0: return None - max_workers = max(1, len(root_ids) // 2) + max_workers = max(1, len(root_ids)) with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(self.get_lock_timestamp, root_id, op_id): ( diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index dcb161482..2603822a2 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -207,7 +207,7 @@ def flip_ids(id_map, node_ids): return np.concatenate(ids).astype(basetypes.NODE_ID) -def _get_new_nodes( +def get_new_nodes( cg, nodes: np.ndarray, layer: int, parent_ts: datetime.datetime = None ): unique_nodes, inverse = np.unique(nodes, return_inverse=True) @@ -248,7 +248,7 @@ def get_stale_nodes( for layer in np.unique(node_layers): _mask = node_layers == layer layer_nodes = nodes[_mask] - _nodes = _get_new_nodes(cg, supervoxels[_mask], layer, parent_ts) + _nodes = get_new_nodes(cg, supervoxels[_mask], layer, parent_ts) stale_mask = layer_nodes != _nodes stale_nodes.append(layer_nodes[stale_mask]) return np.concatenate(stale_nodes) @@ -447,8 +447,11 @@ def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): Searches for new partners that may have any edges to `edges[:,0]`. """ if PARENTS_CACHE is None: + # this cache is set only during migration + # also, fallback is not applicable if no migration children_b = cg.get_children(edges[:, 1], flatten=True) parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) + fallback = False else: children_b = _get_children_from_cache(edges[:, 1]) _populate_parents_cache(children_b) @@ -564,7 +567,7 @@ def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): if fallback: parents_b = _get_parents_b(_edges, parent_ts, edge_layer, True) - parents_b = np.unique(_get_new_nodes(cg, parents_b, mlayer, parent_ts)) + parents_b = np.unique(get_new_nodes(cg, parents_b, mlayer, parent_ts)) parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) return np.column_stack((parents_a, parents_b)) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index c22ae8830..9275fec9b 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -1,6 +1,6 @@ # pylint: disable=invalid-name, missing-docstring, too-many-locals, c-extension-no-member -import datetime, random +import datetime, logging, random from typing import Dict from typing import List from typing import Tuple @@ -16,7 +16,7 @@ from . import types from . import attributes from . import cache as cache_utils -from .edges import get_latest_edges_wrapper, flip_ids +from .edges import get_latest_edges_wrapper, flip_ids, get_new_nodes from .edges.utils import concatenate_cross_edge_dicts from .edges.utils import merge_cross_edge_dicts from .utils import basetypes @@ -25,6 +25,8 @@ from ..utils.general import in2d from ..debug.utils import sanity_check, sanity_check_single +logger = logging.getLogger(__name__) + def _init_old_hierarchy(cg, l2ids: np.ndarray, parent_ts: datetime.datetime = None): """ @@ -684,11 +686,8 @@ def _update_cross_edge_cache_batched(self, new_ids: list): # Distribute results back to each parent's cache # Key insight: edges[:, 0] are children, map them to their parent - edge_parents = self.cg.get_roots( - edge_nodes, - stop_layer=parent_layer, - ceil=False, - time_stamp=self._last_ts, + edge_parents = get_new_nodes( + self.cg, edge_nodes, parent_layer, self._last_ts ) edge_parents_d = dict(zip(edge_nodes, edge_parents)) for new_id in new_ids: @@ -714,6 +713,48 @@ def _update_cross_edge_cache_batched(self, new_ids: list): self.cg.cache.cross_chunk_edges_cache[new_id] = parent_cx_edges_d return updated_entries + def _get_new_ids(self, chunk_id, count, is_root): + batch_size = count + new_ids = [] + while len(new_ids) < count: + candidate_ids = self.cg.id_client.create_node_ids( + chunk_id, batch_size, root_chunk=is_root + ) + existing = self.cg.client.read_nodes(node_ids=candidate_ids) + non_existing = set(candidate_ids) - existing.keys() + new_ids.extend(non_existing) + batch_size = min(batch_size * 2, 2**16) + return new_ids[:count] + + def _get_new_parents(self, layer, ccs, graph_ids) -> tuple[dict, dict]: + cc_layer_chunk_map = {} + size_map = defaultdict(int) + for i, cc_idx in enumerate(ccs): + parent_layer = layer + 1 # must be reset for each connected component + cc_ids = graph_ids[cc_idx] + if len(cc_ids) == 1: + # skip connection + parent_layer = self.cg.meta.layer_count + cx_edges_d = self.cg.get_cross_chunk_edges( + [cc_ids[0]], time_stamp=self._last_ts + ) + for l in range(layer + 1, self.cg.meta.layer_count): + if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: + parent_layer = l + break + chunk_id = self.cg.get_parent_chunk_id(cc_ids[0], parent_layer) + cc_layer_chunk_map[i] = (parent_layer, chunk_id) + size_map[chunk_id] += 1 + + chunk_ids = list(size_map.keys()) + random.shuffle(chunk_ids) + chunk_new_ids_map = {} + layers = self.cg.get_chunk_layers(chunk_ids) + for c, l in zip(chunk_ids, layers): + is_root = l == self.cg.meta.layer_count + chunk_new_ids_map[c] = self._get_new_ids(c, size_map[c], is_root) + return chunk_new_ids_map, cc_layer_chunk_map + def _create_new_parents(self, layer: int): """ keep track of old IDs @@ -726,37 +767,13 @@ def _create_new_parents(self, layer: int): """ new_ids = self._new_ids_d[layer] layer_node_ids = self._get_layer_node_ids(new_ids, layer) - components, graph_ids = self._get_connected_components(layer_node_ids, layer) - for cc_indices in components: - parent_layer = layer + 1 # must be reset for each connected component - cc_ids = graph_ids[cc_indices] - if len(cc_ids) == 1: - # skip connection - parent_layer = self.cg.meta.layer_count - cx_edges_d = self.cg.get_cross_chunk_edges( - [cc_ids[0]], time_stamp=self._last_ts - ) - for l in range(layer + 1, self.cg.meta.layer_count): - if len(cx_edges_d[cc_ids[0]].get(l, types.empty_2d)) > 0: - parent_layer = l - break + ccs, _ids = self._get_connected_components(layer_node_ids, layer) + new_parents_map, cc_layer_chunk_map = self._get_new_parents(layer, ccs, _ids) - # TODO: handle skip connected root id creation separately - chunk_id = self.cg.get_parent_chunk_id(cc_ids[0], parent_layer) - is_root = parent_layer == self.cg.meta.layer_count - batch_size = 1 - parent = None - while parent is None: - candidate_ids = self.cg.id_client.create_node_ids( - chunk_id, batch_size, root_chunk=is_root - ) - existing = self.cg.client.read_nodes(node_ids=candidate_ids) - for cid in candidate_ids: - if cid not in existing: - parent = cid - break - if parent is None: - batch_size = min(batch_size * 2, 2**16) + for i, cc_indices in enumerate(ccs): + cc_ids = _ids[cc_indices] + parent_layer, chunk_id = cc_layer_chunk_map[i] + parent = new_parents_map[chunk_id].pop() self._new_ids_d[parent_layer].append(parent) self._update_id_lineage(parent, cc_ids, layer, parent_layer) @@ -786,19 +803,20 @@ def run(self) -> Iterable: """ self._new_ids_d[2] = self._new_l2_ids for layer in range(2, self.cg.meta.layer_count): - if len(self._new_ids_d[layer]) == 0: + new_nodes = self._new_ids_d[layer] + if len(new_nodes) == 0: continue - self.cg.cache.new_ids.update(self._new_ids_d[layer]) + self.cg.cache.new_ids.update(new_nodes) # all new IDs in this layer have been created # update their cross chunk edges and their neighbors' with self._profiler.profile(f"l{layer}_update_cx_cache"): - entries = self._update_cross_edge_cache_batched(self._new_ids_d[layer]) + entries = self._update_cross_edge_cache_batched(new_nodes) self.new_entries.extend(entries) with self._profiler.profile(f"l{layer}_update_neighbor_cx"): entries = _update_neighbor_cx_edges( self.cg, - self._new_ids_d[layer], + new_nodes, self._new_old_id_d, self._old_new_id_d, time_stamp=self._time_stamp, @@ -861,10 +879,24 @@ def _get_cross_edges_val_dicts(self): return val_dicts def create_new_entries(self) -> List: + max_layer = self.cg.meta.layer_count val_dicts = self._get_cross_edges_val_dicts() - for layer in range(2, self.cg.meta.layer_count + 1): + for layer in range(2, max_layer + 1): new_ids = self._new_ids_d[layer] for id_ in new_ids: + if self.do_sanity_check: + root_layer = self.cg.get_chunk_layer(self.cg.get_root(id_)) + assert root_layer == max_layer, (id_, self.cg.get_root(id_)) + + if layer < max_layer: + try: + _parent = self.cg.get_parent(id_) + _children = self.cg.get_children(_parent) + assert id_ in _children, (layer, id_, _parent, _children) + except TypeError as e: + logger.error(id_, _parent, self.cg.get_root(id_)) + raise TypeError from e + val_dict = val_dicts.get(id_, {}) children = self.cg.get_children(id_) err = f"parent layer less than children; op {self._opid}" diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index e3918f0ea..25cbe4cb3 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -1,8 +1,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed +import logging from typing import Union from typing import Sequence from collections import defaultdict -from warnings import warn import networkx as nx import numpy as np @@ -11,6 +11,7 @@ from .types import empty_1d from .lineage import lineage_graph +logger = logging.getLogger(__name__) class RootLock: """Attempts to lock the requested root IDs using a unique operation ID. @@ -25,6 +26,7 @@ class RootLock: "lock_acquired", "operation_id", "privileged_mode", + "future_root_ids_d", ] # FIXME: `locked_root_ids` is only required and exposed because `cg.client.lock_roots` # currently might lock different (more recent) root IDs than requested. @@ -47,30 +49,30 @@ def __init__( # caused by failed writes. Must be used with `operation_id`, # meaning only existing failed operations can be run this way. self.privileged_mode = privileged_mode + self.future_root_ids_d = defaultdict(lambda: empty_1d) def __enter__(self): - if self.privileged_mode: - assert self.operation_id is not None, "Please provide operation ID." - warn("Warning: Privileged mode without acquiring lock.") - return self if not self.operation_id: self.operation_id = self.cg.id_client.create_operation_id() + if self.privileged_mode: + return self + nodes_ts = self.cg.get_node_timestamps(self.root_ids, return_numpy=0) min_ts = min(nodes_ts) lgraph = lineage_graph(self.cg, self.root_ids, timestamp_past=min_ts) - future_root_ids_d = defaultdict(lambda: empty_1d) + self.future_root_ids_d = defaultdict(lambda: empty_1d) for id_ in self.root_ids: node_descendants = nx.descendants(lgraph, id_) node_descendants = np.unique( np.array(list(node_descendants), dtype=np.uint64) ) - future_root_ids_d[id_] = node_descendants + self.future_root_ids_d[id_] = node_descendants self.lock_acquired, self.locked_root_ids = self.cg.client.lock_roots( root_ids=self.root_ids, operation_id=self.operation_id, - future_root_ids_d=future_root_ids_d, + future_root_ids_d=self.future_root_ids_d, max_tries=7, ) if not self.lock_acquired: @@ -79,7 +81,7 @@ def __enter__(self): def __exit__(self, exception_type, exception_value, traceback): if self.lock_acquired: - max_workers = max(1, len(self.locked_root_ids) // 2) + max_workers = max(1, len(self.locked_root_ids)) with ThreadPoolExecutor(max_workers=max_workers) as executor: unlock_futures = [ executor.submit( @@ -91,7 +93,7 @@ def __exit__(self, exception_type, exception_value, traceback): try: future.result() except Exception as e: - self.logger.warning(f"Failed to unlock root: {e}") + logger.warning(f"Failed to unlock root: {e}") class IndefiniteRootLock: @@ -106,7 +108,14 @@ class IndefiniteRootLock: or when it has already been locked indefinitely. """ - __slots__ = ["cg", "root_ids", "acquired", "operation_id", "privileged_mode"] + __slots__ = [ + "cg", + "root_ids", + "acquired", + "operation_id", + "privileged_mode", + "future_root_ids_d", + ] def __init__( self, @@ -114,6 +123,7 @@ def __init__( operation_id: np.uint64, root_ids: Union[np.uint64, Sequence[np.uint64]], privileged_mode: bool = False, + future_root_ids_d=None, ) -> None: self.cg = cg self.operation_id = operation_id @@ -123,31 +133,30 @@ def __init__( # This is intended to be used in extremely rare cases to fix errors # caused by failed writes. self.privileged_mode = privileged_mode + self.future_root_ids_d = future_root_ids_d def __enter__(self): if self.privileged_mode: - from warnings import warn - - warn("Warning: Privileged mode without acquiring indefinite lock.") return self if not self.cg.client.renew_locks(self.root_ids, self.operation_id): raise exceptions.LockingError("Could not renew locks before writing.") - nodes_ts = self.cg.get_node_timestamps(self.root_ids, return_numpy=0) - min_ts = min(nodes_ts) - lgraph = lineage_graph(self.cg, self.root_ids, timestamp_past=min_ts) - future_root_ids_d = defaultdict(lambda: empty_1d) - for id_ in self.root_ids: - node_descendants = nx.descendants(lgraph, id_) - node_descendants = np.unique( - np.array(list(node_descendants), dtype=np.uint64) - ) - future_root_ids_d[id_] = node_descendants + if self.future_root_ids_d is None: + nodes_ts = self.cg.get_node_timestamps(self.root_ids, return_numpy=0) + min_ts = min(nodes_ts) + lgraph = lineage_graph(self.cg, self.root_ids, timestamp_past=min_ts) + self.future_root_ids_d = defaultdict(lambda: empty_1d) + for id_ in self.root_ids: + node_descendants = nx.descendants(lgraph, id_) + node_descendants = np.unique( + np.array(list(node_descendants), dtype=np.uint64) + ) + self.future_root_ids_d[id_] = node_descendants self.acquired, self.root_ids, failed = self.cg.client.lock_roots_indefinitely( root_ids=self.root_ids, operation_id=self.operation_id, - future_root_ids_d=future_root_ids_d, + future_root_ids_d=self.future_root_ids_d, ) if not self.acquired: raise exceptions.LockingError(f"{failed} have been locked indefinitely.") @@ -155,7 +164,7 @@ def __enter__(self): def __exit__(self, exception_type, exception_value, traceback): if self.acquired: - max_workers = max(1, len(self.root_ids) // 2) + max_workers = max(1, len(self.root_ids)) with ThreadPoolExecutor(max_workers=max_workers) as executor: unlock_futures = [ executor.submit( @@ -169,4 +178,4 @@ def __exit__(self, exception_type, exception_value, traceback): try: future.result() except Exception as e: - self.logger.warning(f"Failed to unlock root: {e}") + logger.warning(f"Failed to unlock root: {e}") diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 0afe06669..dd2809ec4 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -521,6 +521,7 @@ def _write( lock.operation_id, lock.locked_root_ids, privileged_mode=lock.privileged_mode, + future_root_ids_d=lock.future_root_ids_d, ): # indefinite lock for writing, if a node instance or pod dies during this # the roots must stay locked indefinitely to prevent further corruption. From f68a4f0b3f78a5d447e6310e547737349eb26d6a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 19 Feb 2026 15:06:30 +0000 Subject: [PATCH 155/367] fix: use 8 threads at most for locks, refactor stale edge code --- .../graph/client/bigtable/client.py | 8 +- pychunkedgraph/graph/edges/__init__.py | 637 +----------------- pychunkedgraph/graph/edges/definitions.py | 111 +++ pychunkedgraph/graph/edges/ocdbt.py | 87 +++ pychunkedgraph/graph/edges/stale.py | 511 ++++++++++++++ pychunkedgraph/graph/edits.py | 15 +- pychunkedgraph/graph/locks.py | 4 +- pychunkedgraph/ingest/upgrade/parent_layer.py | 21 +- 8 files changed, 745 insertions(+), 649 deletions(-) create mode 100644 pychunkedgraph/graph/edges/definitions.py create mode 100644 pychunkedgraph/graph/edges/ocdbt.py create mode 100644 pychunkedgraph/graph/edges/stale.py diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py index 3935e6f02..260d985ab 100644 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ b/pychunkedgraph/graph/client/bigtable/client.py @@ -467,7 +467,7 @@ def lock_roots( lock_results = {} root_ids = np.unique(new_root_ids) - max_workers = max(1, len(root_ids)) + max_workers = min(8, max(1, len(root_ids))) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_root = { executor.submit(self.lock_root, root_id, operation_id): root_id @@ -518,7 +518,7 @@ def lock_roots_indefinitely( root_ids = np.unique(new_root_ids) lock_results = {} - max_workers = max(1, len(root_ids)) + max_workers = min(8, max(1, len(root_ids))) failed_to_lock = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_root = { @@ -601,7 +601,7 @@ def renew_lock(self, root_id: np.uint64, operation_id: np.uint64) -> bool: def renew_locks(self, root_ids: np.uint64, operation_id: np.uint64) -> bool: """Renews existing root node locks with operation_id to extend time.""" - max_workers = max(1, len(root_ids)) + max_workers = min(8, max(1, len(root_ids))) with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(self.renew_lock, root_id, operation_id): root_id @@ -640,7 +640,7 @@ def get_consolidated_lock_timestamp( """Minimum of multiple lock timestamps.""" if len(root_ids) == 0: return None - max_workers = max(1, len(root_ids)) + max_workers = min(8, max(1, len(root_ids))) with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(self.get_lock_timestamp, root_id, op_id): ( diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 2603822a2..80bc57d4a 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -2,633 +2,12 @@ Classes and types for edges """ -from collections import namedtuple -import datetime, logging -from os import environ -from typing import Iterable, Optional - -import numpy as np -import tensorstore as ts -import zstandard as zstd -from graph_tool import Graph -from cachetools import LRUCache - -from pychunkedgraph.graph import types -from pychunkedgraph.graph.chunks.utils import get_l2chunkids_along_boundary -from pychunkedgraph.graph.utils import basetypes - -from ..utils import basetypes -from ..utils.generic import get_parents_at_timestamp - - -_edge_type_fileds = ("in_chunk", "between_chunk", "cross_chunk") -_edge_type_defaults = ("in", "between", "cross") - -EdgeTypes = namedtuple("EdgeTypes", _edge_type_fileds, defaults=_edge_type_defaults) -EDGE_TYPES = EdgeTypes() - -DEFAULT_AFFINITY = np.finfo(np.float32).tiny -DEFAULT_AREA = np.finfo(np.float32).tiny -ADJACENCY_DTYPE = np.dtype( - [ - ("node", basetypes.NODE_ID), - ("aff", basetypes.EDGE_AFFINITY), - ("area", basetypes.EDGE_AREA), - ] +from .definitions import EDGE_TYPES, Edges +from .ocdbt import put_edges, get_edges + +from .stale import ( + get_new_nodes, + get_stale_nodes, + get_latest_edges, + get_latest_edges_wrapper, ) -ZSTD_EDGE_COMPRESSION = 17 -PARENTS_CACHE: LRUCache = None -CHILDREN_CACHE: LRUCache = None - - -class Edges: - def __init__( - self, - node_ids1: np.ndarray, - node_ids2: np.ndarray, - *, - affinities: Optional[np.ndarray] = None, - areas: Optional[np.ndarray] = None, - ): - self.node_ids1 = np.array(node_ids1, dtype=basetypes.NODE_ID) - self.node_ids2 = np.array(node_ids2, dtype=basetypes.NODE_ID) - assert self.node_ids1.size == self.node_ids2.size - - self._as_pairs = None - - if affinities is not None and len(affinities) > 0: - self._affinities = np.array(affinities, dtype=basetypes.EDGE_AFFINITY) - assert self.node_ids1.size == self._affinities.size - else: - self._affinities = np.full(len(self.node_ids1), DEFAULT_AFFINITY) - - if areas is not None and len(areas) > 0: - self._areas = np.array(areas, dtype=basetypes.EDGE_AREA) - assert self.node_ids1.size == self._areas.size - else: - self._areas = np.full(len(self.node_ids1), DEFAULT_AREA) - - @property - def affinities(self) -> np.ndarray: - return self._affinities - - @affinities.setter - def affinities(self, affinities): - self._affinities = affinities - - @property - def areas(self) -> np.ndarray: - return self._areas - - @areas.setter - def areas(self, areas): - self._areas = areas - - def __add__(self, other): - """add two Edges instances""" - node_ids1 = np.concatenate([self.node_ids1, other.node_ids1]) - node_ids2 = np.concatenate([self.node_ids2, other.node_ids2]) - affinities = np.concatenate([self.affinities, other.affinities]) - areas = np.concatenate([self.areas, other.areas]) - return Edges(node_ids1, node_ids2, affinities=affinities, areas=areas) - - def __iadd__(self, other): - self.node_ids1 = np.concatenate([self.node_ids1, other.node_ids1]) - self.node_ids2 = np.concatenate([self.node_ids2, other.node_ids2]) - self.affinities = np.concatenate([self.affinities, other.affinities]) - self.areas = np.concatenate([self.areas, other.areas]) - return self - - def __len__(self): - return self.node_ids1.size - - def __getitem__(self, key): - """`key` must be a boolean numpy array.""" - try: - return Edges( - self.node_ids1[key], - self.node_ids2[key], - affinities=self.affinities[key], - areas=self.areas[key], - ) - except Exception as err: - raise (err) - - def get_pairs(self) -> np.ndarray: - """ - return numpy array of edge pairs [[sv1, sv2] ... ] - """ - if not self._as_pairs is None: - return self._as_pairs - self._as_pairs = np.column_stack((self.node_ids1, self.node_ids2)) - return self._as_pairs - - -def put_edges(destination: str, nodes: np.ndarray, edges: Edges) -> None: - graph_ids, _edges = np.unique(edges.get_pairs(), return_inverse=True) - graph_ids_reverse = {n: i for i, n in enumerate(graph_ids)} - _edges = _edges.reshape(-1, 2) - - graph = Graph(directed=False) - graph.add_edge_list(_edges) - e_aff = graph.new_edge_property("double", vals=edges.affinities) - e_area = graph.new_edge_property("int", vals=edges.areas) - cctx = zstd.ZstdCompressor(level=ZSTD_EDGE_COMPRESSION) - ocdbt_host = environ["OCDBT_COORDINATOR_HOST"] - ocdbt_port = environ["OCDBT_COORDINATOR_PORT"] - - spec = { - "driver": "ocdbt", - "base": destination, - "coordinator": {"address": f"{ocdbt_host}:{ocdbt_port}"}, - } - dataset = ts.KvStore.open(spec).result() - with ts.Transaction() as txn: - for _node in nodes: - node = graph_ids_reverse[_node] - neighbors = graph.get_all_neighbors(node) - adjacency_list = np.zeros(neighbors.size, dtype=ADJACENCY_DTYPE) - adjacency_list["node"] = graph_ids[neighbors] - adjacency_list["aff"] = [e_aff[(node, neighbor)] for neighbor in neighbors] - adjacency_list["area"] = [ - e_area[(node, neighbor)] for neighbor in neighbors - ] - dataset.with_transaction(txn)[str(graph_ids[node])] = cctx.compress( - adjacency_list.tobytes() - ) - - -def get_edges(source: str, nodes: np.ndarray) -> Edges: - spec = {"driver": "ocdbt", "base": source} - dataset = ts.KvStore.open(spec).result() - zdc = zstd.ZstdDecompressor() - - read_futures = [dataset.read(str(n)) for n in nodes] - read_results = [rf.result() for rf in read_futures] - compressed = [rr.value for rr in read_results] - - try: - n_threads = int(environ.get("ZSTD_THREADS", 1)) - except ValueError: - n_threads = 1 - - decompressed = [] - try: - decompressed = zdc.multi_decompress_to_buffer(compressed, threads=n_threads) - except ValueError: - for content in compressed: - decompressed.append(zdc.decompressobj().decompress(content)) - - node_ids1 = [np.empty(0, dtype=basetypes.NODE_ID)] - node_ids2 = [np.empty(0, dtype=basetypes.NODE_ID)] - affinities = [np.empty(0, dtype=basetypes.EDGE_AFFINITY)] - areas = [np.empty(0, dtype=basetypes.EDGE_AREA)] - for n, content in zip(nodes, compressed): - adjacency_list = np.frombuffer(content, dtype=ADJACENCY_DTYPE) - node_ids1.append([n] * adjacency_list.size) - node_ids2.append(adjacency_list["node"]) - affinities.append(adjacency_list["aff"]) - areas.append(adjacency_list["area"]) - - return Edges( - np.concatenate(node_ids1), - np.concatenate(node_ids2), - affinities=np.concatenate(affinities), - areas=np.concatenate(areas), - ) - - -def flip_ids(id_map, node_ids): - """ - returns old or new ids according to the map - """ - ids = [np.asarray(list(id_map[id_]), dtype=basetypes.NODE_ID) for id_ in node_ids] - ids.append(types.empty_1d) # concatenate needs at least one array - return np.concatenate(ids).astype(basetypes.NODE_ID) - - -def get_new_nodes( - cg, nodes: np.ndarray, layer: int, parent_ts: datetime.datetime = None -): - unique_nodes, inverse = np.unique(nodes, return_inverse=True) - node_root_map = {n: n for n in unique_nodes} - lookup = np.ones(len(unique_nodes), dtype=unique_nodes.dtype) - while np.any(lookup): - roots = np.fromiter(node_root_map.values(), dtype=basetypes.NODE_ID) - roots = cg.get_parents(roots, time_stamp=parent_ts, fail_to_zero=True) - layers = cg.get_chunk_layers(roots) - lookup[layers > layer] = 0 - lookup[roots == 0] = 0 - - layer_mask = layers <= layer - non_zero_mask = roots != 0 - mask = layer_mask & non_zero_mask - for node, root in zip(unique_nodes[mask], roots[mask]): - node_root_map[node] = root - - unique_results = np.fromiter(node_root_map.values(), dtype=basetypes.NODE_ID) - return unique_results[inverse] - - -def get_stale_nodes( - cg, nodes: Iterable[basetypes.NODE_ID], parent_ts: datetime.datetime = None -): - """ - Checks to see if given nodes are stale. - This is done by getting a supervoxel of a node and checking - if it has a new parent at the same layer as the node. - """ - nodes = np.unique(np.array(nodes, dtype=basetypes.NODE_ID)) - new_ids = set() if cg.cache is None else cg.cache.new_ids - nodes = nodes[~np.isin(nodes, new_ids)] - supervoxels = cg.get_single_leaf_multiple(nodes) - # nodes can be at different layers due to skip connections - node_layers = cg.get_chunk_layers(nodes) - stale_nodes = [types.empty_1d] - for layer in np.unique(node_layers): - _mask = node_layers == layer - layer_nodes = nodes[_mask] - _nodes = get_new_nodes(cg, supervoxels[_mask], layer, parent_ts) - stale_mask = layer_nodes != _nodes - stale_nodes.append(layer_nodes[stale_mask]) - return np.concatenate(stale_nodes) - - -def get_latest_edges( - cg, - stale_edges: Iterable, - edge_layers: Iterable, - parent_ts: datetime.datetime = None, -) -> dict: - """ - For each of stale_edges [[`node`, `partner`]], get their L2 edge equivalent. - Then get supervoxels of those L2 IDs and get parent(s) at `node` level. - These parents would be the new identities for the stale `partner`. - """ - _nodes = np.unique(stale_edges) - nodes_ts_map = dict( - zip(_nodes, cg.get_node_timestamps(_nodes, return_numpy=False, normalize=True)) - ) - layers, coords = cg.get_chunk_layers_and_coordinates(_nodes) - layers_d = dict(zip(_nodes, layers)) - coords_d = dict(zip(_nodes, coords)) - - def _get_children_from_cache(nodes): - children = [] - non_cached = [] - for node in nodes: - try: - v = CHILDREN_CACHE[node] - children.append(v) - except KeyError: - non_cached.append(node) - - children_map = cg.get_children(non_cached) - for k, v in children_map.items(): - CHILDREN_CACHE[k] = v - children.append(v) - return np.concatenate(children) - - def _get_normalized_coords(node_a, node_b) -> tuple: - max_layer = layers_d[node_a] - coord_a, coord_b = coords_d[node_a], coords_d[node_b] - if layers_d[node_a] != layers_d[node_b]: - # normalize if nodes are not from the same layer - max_layer = max(layers_d[node_a], layers_d[node_b]) - chunk_a = cg.get_parent_chunk_id(node_a, parent_layer=max_layer) - chunk_b = cg.get_parent_chunk_id(node_b, parent_layer=max_layer) - coord_a, coord_b = cg.get_chunk_coordinates_multiple([chunk_a, chunk_b]) - return max_layer, tuple(coord_a), tuple(coord_b) - - def _get_filtered_l2ids(node_a, node_b, padding: int): - """ - Finds L2 IDs along opposing faces for given nodes. - Filterting is done by first finding L2 chunks along these faces. - Then get their parent chunks iteratively. - Then filter children iteratively using these chunks. - """ - chunks_map = {} - - def _filter(node): - result = [] - children = np.array([node], dtype=basetypes.NODE_ID) - while True: - chunk_ids = cg.get_chunk_ids_from_node_ids(children) - mask = np.isin(chunk_ids, chunks_map[node]) - children = children[mask] - - mask = cg.get_chunk_layers(children) == 2 - result.append(children[mask]) - - mask = cg.get_chunk_layers(children) > 2 - if children[mask].size == 0: - break - if PARENTS_CACHE is None: - children = cg.get_children(children[mask], flatten=True) - else: - children = _get_children_from_cache(children[mask]) - return np.concatenate(result) - - mlayer, coord_a, coord_b = _get_normalized_coords(node_a, node_b) - chunks_a, chunks_b = get_l2chunkids_along_boundary( - cg.meta, mlayer, coord_a, coord_b, padding - ) - - chunks_map[node_a] = [[cg.get_chunk_id(node_a)]] - chunks_map[node_b] = [[cg.get_chunk_id(node_b)]] - _layer = 2 - while _layer < mlayer: - chunks_map[node_a].append(chunks_a) - chunks_map[node_b].append(chunks_b) - chunks_a = np.unique(cg.get_parent_chunk_id_multiple(chunks_a)) - chunks_b = np.unique(cg.get_parent_chunk_id_multiple(chunks_b)) - _layer += 1 - chunks_map[node_a] = np.concatenate(chunks_map[node_a]) - chunks_map[node_b] = np.concatenate(chunks_map[node_b]) - return int(mlayer), _filter(node_a), _filter(node_b) - - def _populate_parents_cache(children: np.ndarray): - global PARENTS_CACHE - - not_cached = [] - for child in children: - try: - # reset lru index, these will be needed soon - _ = PARENTS_CACHE[child] - except KeyError: - not_cached.append(child) - - all_parents = cg.get_parents(not_cached, current=False) - for child, parents in zip(not_cached, all_parents): - PARENTS_CACHE[child] = {} - for parent, ts in parents: - PARENTS_CACHE[child][ts] = parent - - def _get_hierarchy(nodes, layer): - _hierarchy = [nodes] - for _a in nodes: - _hierarchy.append( - cg.get_root( - _a, - time_stamp=parent_ts, - stop_layer=layer, - get_all_parents=True, - ceil=False, - raw_only=True, - ) - ) - _children = cg.get_children(_a, raw_only=True) - _children_layers = cg.get_chunk_layers(_children) - _hierarchy.append(_children[_children_layers == 2]) - _children = _children[_children_layers > 2] - while _children.size: - _hierarchy.append(_children) - _children = cg.get_children(_children, flatten=True, raw_only=True) - _children_layers = cg.get_chunk_layers(_children) - _hierarchy.append(_children[_children_layers == 2]) - _children = _children[_children_layers > 2] - return np.concatenate(_hierarchy) - - def _check_cross_edges_from_a(node_b, nodes_a, layer, parent_ts): - """ - Checks to match cross edges from partners_a - to hierarchy of potential node from partner b. - """ - if len(nodes_a) == 0: - return False - - _hierarchy_b = cg.get_root( - node_b, - time_stamp=parent_ts, - stop_layer=layer, - get_all_parents=True, - ceil=False, - raw_only=True, - ) - _hierarchy_b = np.append(_hierarchy_b, node_b) - _cx_edges_d_from_a = cg.get_cross_chunk_edges(nodes_a, time_stamp=parent_ts) - for _edges_d_from_a in _cx_edges_d_from_a.values(): - _edges_from_a = _edges_d_from_a.get(layer, types.empty_2d) - nodes_b_from_a = _edges_from_a[:, 1] - hierarchy_b_from_a = _get_hierarchy(nodes_b_from_a, layer) - _mask = np.isin(hierarchy_b_from_a, _hierarchy_b) - if np.any(_mask): - return True - return False - - def _check_hierarchy_a_from_b(parents_a, nodes_a_from_b, layer, parent_ts): - """ - Checks for overlap between hierarchy of a, - and hierarchy of a identified from partners of b. - """ - if len(nodes_a_from_b) == 0: - return False - - _hierarchy_a = [parents_a] - for _a in parents_a: - _hierarchy_a.append( - cg.get_root( - _a, - time_stamp=parent_ts, - stop_layer=layer, - get_all_parents=True, - ceil=False, - raw_only=True, - ) - ) - hierarchy_a = np.concatenate(_hierarchy_a) - hierarchy_a_from_b = _get_hierarchy(nodes_a_from_b, layer) - return np.any(np.isin(hierarchy_a_from_b, hierarchy_a)) - - def _get_parents_b(edges, parent_ts, layer, fallback: bool = False): - """ - Attempts to find new partner side nodes. - Gets new partners at parent_ts using supervoxels, at `parent_ts`. - Searches for new partners that may have any edges to `edges[:,0]`. - """ - if PARENTS_CACHE is None: - # this cache is set only during migration - # also, fallback is not applicable if no migration - children_b = cg.get_children(edges[:, 1], flatten=True) - parents_b = np.unique(cg.get_parents(children_b, time_stamp=parent_ts)) - fallback = False - else: - children_b = _get_children_from_cache(edges[:, 1]) - _populate_parents_cache(children_b) - _parents_b, missing = get_parents_at_timestamp( - children_b, PARENTS_CACHE, time_stamp=parent_ts, unique=True - ) - # handle cache miss cases - _parents_b_missed = np.unique(cg.get_parents(missing, time_stamp=parent_ts)) - parents_b = np.concatenate([_parents_b, _parents_b_missed]) - - parents_a = np.unique(edges[:, 0]) - stale_a = get_stale_nodes(cg, parents_a, parent_ts=parent_ts) - if stale_a.size == parents_a.size or fallback: - # this is applicable only for v2 to v3 migration - # handle cases when source nodes in `edges[:,0]` are stale - atomic_edges_d = cg.get_atomic_cross_edges(stale_a) - partners = [types.empty_1d] - for _edges_d in atomic_edges_d.values(): - _edges = _edges_d.get(layer, types.empty_2d) - partners.append(_edges[:, 1]) - partners = np.concatenate(partners) - return np.unique(cg.get_parents(partners, time_stamp=parent_ts)) - - _cx_edges_d = cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) - _parents_b = [] - for _node, _edges_d in _cx_edges_d.items(): - _edges = _edges_d.get(layer, types.empty_2d) - if _check_cross_edges_from_a(_node, _edges[:, 1], layer, parent_ts): - _parents_b.append(_node) - elif _check_hierarchy_a_from_b(parents_a, _edges[:, 1], layer, parent_ts): - _parents_b.append(_node) - else: - _new_ids = list(cg.cache.new_ids) - if np.any(np.isin(_new_ids, parents_a)): - _parents_b.append(_node) - return np.array(_parents_b, dtype=basetypes.NODE_ID) - - def _get_parents_b_with_chunk_mask( - l2ids_b: np.ndarray, nodes_b_from_a: np.ndarray, max_ts: datetime.datetime, edge - ): - chunks_old = cg.get_chunk_ids_from_node_ids(l2ids_b) - chunks_new = cg.get_chunk_ids_from_node_ids(nodes_b_from_a) - chunk_mask = np.isin(chunks_new, chunks_old) - nodes_b_from_a = nodes_b_from_a[chunk_mask] - _stale_nodes = get_stale_nodes(cg, nodes_b_from_a, parent_ts=max_ts) - assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {max_ts}" - return nodes_b_from_a - - def _get_cx_edges(l2ids_a, max_node_ts, raw_only: bool = True): - _edges_d = cg.get_cross_chunk_edges( - node_ids=l2ids_a, time_stamp=max_node_ts, raw_only=raw_only - ) - _edges = [] - for v in _edges_d.values(): - if edge_layer in v: - _edges.append(v[edge_layer]) - return np.concatenate(_edges) - - def _get_dilated_edges(edges): - layers_b = cg.get_chunk_layers(edges[:, 1]) - _mask = layers_b == 2 - _l2_edges = [edges[_mask]] - for _edge in edges[~_mask]: - _node_a, _node_b = _edge - _nodes_b = cg.get_l2children([_node_b]) - _l2_edges.append( - np.array([[_node_a, _b] for _b in _nodes_b], dtype=basetypes.NODE_ID) - ) - return np.unique(np.concatenate(_l2_edges), axis=0) - - def _get_new_edge(edge, edge_layer, parent_ts, padding, fallback: bool = False): - """ - Attempts to find new edge(s) for the stale `edge`. - * Find L2 IDs on opposite sides of the face in L2 chunks along the face. - * Find new edges between them (before the given timestamp). - * If none found, expand search by adding another layer of L2 chunks. - """ - node_a, node_b = edge - mlayer, l2ids_a, l2ids_b = _get_filtered_l2ids(node_a, node_b, padding=padding) - if l2ids_a.size == 0 or l2ids_b.size == 0: - return types.empty_2d.copy() - - max_ts = max(nodes_ts_map[node_a], nodes_ts_map[node_b]) - is_l2_edge = node_a in l2ids_a and node_b in l2ids_b - if is_l2_edge and (l2ids_a.size == 1 and l2ids_b.size == 1): - _edges = np.array([edge], dtype=basetypes.NODE_ID) - else: - try: - _edges = _get_cx_edges(l2ids_a, max_ts) - except ValueError: - _edges = _get_cx_edges(l2ids_a, max_ts, raw_only=False) - except ValueError: - return types.empty_2d.copy() - - mask = np.isin(_edges[:, 1], l2ids_b) - if np.any(mask): - parents_b = _get_parents_b(_edges[mask], parent_ts, edge_layer) - else: - # partner nodes likely lifted, dilate and retry - _edges = _get_dilated_edges(_edges) - mask = np.isin(_edges[:, 1], l2ids_b) - if np.any(mask): - parents_b = _get_parents_b(_edges[mask], parent_ts, edge_layer) - else: - # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges - # so get the new identities of `l2ids_b` by using chunk mask - try: - parents_b = _get_parents_b_with_chunk_mask( - l2ids_b, _edges[:, 1], max_ts, edge - ) - except AssertionError: - parents_b = [] - if fallback: - parents_b = _get_parents_b(_edges, parent_ts, edge_layer, True) - - parents_b = np.unique(get_new_nodes(cg, parents_b, mlayer, parent_ts)) - parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) - return np.column_stack((parents_a, parents_b)) - - result = [types.empty_2d] - for edge_layer, _edge in zip(edge_layers, stale_edges): - max_chebyshev_distance = int(environ.get("MAX_CHEBYSHEV_DISTANCE", 3)) - for pad in range(0, max_chebyshev_distance + 1): - fallback = pad == max_chebyshev_distance - _new_edges = _get_new_edge( - _edge, edge_layer, parent_ts, padding=pad, fallback=fallback - ) - if _new_edges.size: - break - logging.info(f"{_edge}, expanding search with padding {pad+1}.") - assert _new_edges.size, f"No new edge found {_edge}; {edge_layer}, {parent_ts}" - result.append(_new_edges) - return np.concatenate(result) - - -def get_latest_edges_wrapper( - cg, cx_edges_d: dict, parent_ts: datetime.datetime = None -) -> tuple[dict, np.ndarray]: - """ - Helper function to filter stale edges and replace with latest edges. - Filters out edges with nodes stale in source, edges[:,0], at given timestamp. - """ - nodes = [types.empty_1d] - new_cx_edges_d = {0: types.empty_2d} - - all_edges = np.concatenate(list(cx_edges_d.values())) - all_edge_nodes = np.unique(all_edges) - all_stale_nodes = get_stale_nodes(cg, all_edge_nodes, parent_ts=parent_ts) - if all_stale_nodes.size == 0: - return cx_edges_d, all_edge_nodes - - for layer, _cx_edges in cx_edges_d.items(): - if _cx_edges.size == 0: - continue - - _new_cx_edges = [types.empty_2d] - _edge_layers = np.array([layer] * len(_cx_edges), dtype=int) - - stale_source_mask = np.isin(_cx_edges[:, 0], all_stale_nodes) - _new_cx_edges.append(_cx_edges[stale_source_mask]) - - _cx_edges = _cx_edges[~stale_source_mask] - _edge_layers = _edge_layers[~stale_source_mask] - stale_destination_mask = np.isin(_cx_edges[:, 1], all_stale_nodes) - _new_cx_edges.append(_cx_edges[~stale_destination_mask]) - - if np.any(stale_destination_mask): - stale_edges = _cx_edges[stale_destination_mask] - stale_edge_layers = _edge_layers[stale_destination_mask] - latest_edges = get_latest_edges( - cg, - stale_edges, - stale_edge_layers, - parent_ts=parent_ts, - ) - logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") - _new_cx_edges.append(latest_edges) - new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) - nodes.append(np.unique(new_cx_edges_d[layer])) - return new_cx_edges_d, np.concatenate(nodes) diff --git a/pychunkedgraph/graph/edges/definitions.py b/pychunkedgraph/graph/edges/definitions.py new file mode 100644 index 000000000..26a14dd82 --- /dev/null +++ b/pychunkedgraph/graph/edges/definitions.py @@ -0,0 +1,111 @@ +""" +Edge data structures and type definitions. +""" + +from collections import namedtuple +from typing import Optional + +import numpy as np + +from ..utils import basetypes + + +_edge_type_fileds = ("in_chunk", "between_chunk", "cross_chunk") +_edge_type_defaults = ("in", "between", "cross") + +EdgeTypes = namedtuple("EdgeTypes", _edge_type_fileds, defaults=_edge_type_defaults) +EDGE_TYPES = EdgeTypes() + +DEFAULT_AFFINITY = np.finfo(np.float32).tiny +DEFAULT_AREA = np.finfo(np.float32).tiny +ADJACENCY_DTYPE = np.dtype( + [ + ("node", basetypes.NODE_ID), + ("aff", basetypes.EDGE_AFFINITY), + ("area", basetypes.EDGE_AREA), + ] +) +ZSTD_EDGE_COMPRESSION = 17 + + +class Edges: + def __init__( + self, + node_ids1: np.ndarray, + node_ids2: np.ndarray, + *, + affinities: Optional[np.ndarray] = None, + areas: Optional[np.ndarray] = None, + ): + self.node_ids1 = np.array(node_ids1, dtype=basetypes.NODE_ID) + self.node_ids2 = np.array(node_ids2, dtype=basetypes.NODE_ID) + assert self.node_ids1.size == self.node_ids2.size + + self._as_pairs = None + + if affinities is not None and len(affinities) > 0: + self._affinities = np.array(affinities, dtype=basetypes.EDGE_AFFINITY) + assert self.node_ids1.size == self._affinities.size + else: + self._affinities = np.full(len(self.node_ids1), DEFAULT_AFFINITY) + + if areas is not None and len(areas) > 0: + self._areas = np.array(areas, dtype=basetypes.EDGE_AREA) + assert self.node_ids1.size == self._areas.size + else: + self._areas = np.full(len(self.node_ids1), DEFAULT_AREA) + + @property + def affinities(self) -> np.ndarray: + return self._affinities + + @affinities.setter + def affinities(self, affinities): + self._affinities = affinities + + @property + def areas(self) -> np.ndarray: + return self._areas + + @areas.setter + def areas(self, areas): + self._areas = areas + + def __add__(self, other): + """add two Edges instances""" + node_ids1 = np.concatenate([self.node_ids1, other.node_ids1]) + node_ids2 = np.concatenate([self.node_ids2, other.node_ids2]) + affinities = np.concatenate([self.affinities, other.affinities]) + areas = np.concatenate([self.areas, other.areas]) + return Edges(node_ids1, node_ids2, affinities=affinities, areas=areas) + + def __iadd__(self, other): + self.node_ids1 = np.concatenate([self.node_ids1, other.node_ids1]) + self.node_ids2 = np.concatenate([self.node_ids2, other.node_ids2]) + self.affinities = np.concatenate([self.affinities, other.affinities]) + self.areas = np.concatenate([self.areas, other.areas]) + return self + + def __len__(self): + return self.node_ids1.size + + def __getitem__(self, key): + """`key` must be a boolean numpy array.""" + try: + return Edges( + self.node_ids1[key], + self.node_ids2[key], + affinities=self.affinities[key], + areas=self.areas[key], + ) + except Exception as err: + raise (err) + + def get_pairs(self) -> np.ndarray: + """ + return numpy array of edge pairs [[sv1, sv2] ... ] + """ + if not self._as_pairs is None: + return self._as_pairs + self._as_pairs = np.column_stack((self.node_ids1, self.node_ids2)) + return self._as_pairs diff --git a/pychunkedgraph/graph/edges/ocdbt.py b/pychunkedgraph/graph/edges/ocdbt.py new file mode 100644 index 000000000..99fa1ba68 --- /dev/null +++ b/pychunkedgraph/graph/edges/ocdbt.py @@ -0,0 +1,87 @@ +""" +OCDBT storage I/O for edges. +""" + +from os import environ + +import numpy as np +import tensorstore as ts +import zstandard as zstd +from graph_tool import Graph + +from ..utils import basetypes +from .definitions import ADJACENCY_DTYPE, ZSTD_EDGE_COMPRESSION, Edges + + +def put_edges(destination: str, nodes: np.ndarray, edges: Edges) -> None: + graph_ids, _edges = np.unique(edges.get_pairs(), return_inverse=True) + graph_ids_reverse = {n: i for i, n in enumerate(graph_ids)} + _edges = _edges.reshape(-1, 2) + + graph = Graph(directed=False) + graph.add_edge_list(_edges) + e_aff = graph.new_edge_property("double", vals=edges.affinities) + e_area = graph.new_edge_property("int", vals=edges.areas) + cctx = zstd.ZstdCompressor(level=ZSTD_EDGE_COMPRESSION) + ocdbt_host = environ["OCDBT_COORDINATOR_HOST"] + ocdbt_port = environ["OCDBT_COORDINATOR_PORT"] + + spec = { + "driver": "ocdbt", + "base": destination, + "coordinator": {"address": f"{ocdbt_host}:{ocdbt_port}"}, + } + dataset = ts.KvStore.open(spec).result() + with ts.Transaction() as txn: + for _node in nodes: + node = graph_ids_reverse[_node] + neighbors = graph.get_all_neighbors(node) + adjacency_list = np.zeros(neighbors.size, dtype=ADJACENCY_DTYPE) + adjacency_list["node"] = graph_ids[neighbors] + adjacency_list["aff"] = [e_aff[(node, neighbor)] for neighbor in neighbors] + adjacency_list["area"] = [ + e_area[(node, neighbor)] for neighbor in neighbors + ] + dataset.with_transaction(txn)[str(graph_ids[node])] = cctx.compress( + adjacency_list.tobytes() + ) + + +def get_edges(source: str, nodes: np.ndarray) -> Edges: + spec = {"driver": "ocdbt", "base": source} + dataset = ts.KvStore.open(spec).result() + zdc = zstd.ZstdDecompressor() + + read_futures = [dataset.read(str(n)) for n in nodes] + read_results = [rf.result() for rf in read_futures] + compressed = [rr.value for rr in read_results] + + try: + n_threads = int(environ.get("ZSTD_THREADS", 1)) + except ValueError: + n_threads = 1 + + decompressed = [] + try: + decompressed = zdc.multi_decompress_to_buffer(compressed, threads=n_threads) + except ValueError: + for content in compressed: + decompressed.append(zdc.decompressobj().decompress(content)) + + node_ids1 = [np.empty(0, dtype=basetypes.NODE_ID)] + node_ids2 = [np.empty(0, dtype=basetypes.NODE_ID)] + affinities = [np.empty(0, dtype=basetypes.EDGE_AFFINITY)] + areas = [np.empty(0, dtype=basetypes.EDGE_AREA)] + for n, content in zip(nodes, compressed): + adjacency_list = np.frombuffer(content, dtype=ADJACENCY_DTYPE) + node_ids1.append([n] * adjacency_list.size) + node_ids2.append(adjacency_list["node"]) + affinities.append(adjacency_list["aff"]) + areas.append(adjacency_list["area"]) + + return Edges( + np.concatenate(node_ids1), + np.concatenate(node_ids2), + affinities=np.concatenate(affinities), + areas=np.concatenate(areas), + ) diff --git a/pychunkedgraph/graph/edges/stale.py b/pychunkedgraph/graph/edges/stale.py new file mode 100644 index 000000000..e09dbac35 --- /dev/null +++ b/pychunkedgraph/graph/edges/stale.py @@ -0,0 +1,511 @@ +""" +Stale node detection and edge update logic. +""" + +import datetime +import logging +from os import environ +from typing import Iterable + +import numpy as np +from cachetools import LRUCache + +from pychunkedgraph.graph import types +from pychunkedgraph.graph.chunks.utils import get_l2chunkids_along_boundary + +from ..utils import basetypes +from ..utils.generic import get_parents_at_timestamp + + +PARENTS_CACHE: LRUCache = None +CHILDREN_CACHE: LRUCache = None + + +def get_new_nodes( + cg, nodes: np.ndarray, layer: int, parent_ts: datetime.datetime = None +): + unique_nodes, inverse = np.unique(nodes, return_inverse=True) + node_root_map = {n: n for n in unique_nodes} + lookup = np.ones(len(unique_nodes), dtype=unique_nodes.dtype) + while np.any(lookup): + roots = np.fromiter(node_root_map.values(), dtype=basetypes.NODE_ID) + roots = cg.get_parents(roots, time_stamp=parent_ts, fail_to_zero=True) + layers = cg.get_chunk_layers(roots) + lookup[layers > layer] = 0 + lookup[roots == 0] = 0 + + layer_mask = layers <= layer + non_zero_mask = roots != 0 + mask = layer_mask & non_zero_mask + for node, root in zip(unique_nodes[mask], roots[mask]): + node_root_map[node] = root + + unique_results = np.fromiter(node_root_map.values(), dtype=basetypes.NODE_ID) + return unique_results[inverse] + + +def get_stale_nodes( + cg, nodes: Iterable[basetypes.NODE_ID], parent_ts: datetime.datetime = None +): + """ + Checks to see if given nodes are stale. + This is done by getting a supervoxel of a node and checking + if it has a new parent at the same layer as the node. + """ + nodes = np.unique(np.array(nodes, dtype=basetypes.NODE_ID)) + new_ids = set() if cg.cache is None else cg.cache.new_ids + nodes = nodes[~np.isin(nodes, new_ids)] + supervoxels = cg.get_single_leaf_multiple(nodes) + # nodes can be at different layers due to skip connections + node_layers = cg.get_chunk_layers(nodes) + stale_nodes = [types.empty_1d] + for layer in np.unique(node_layers): + _mask = node_layers == layer + layer_nodes = nodes[_mask] + _nodes = get_new_nodes(cg, supervoxels[_mask], layer, parent_ts) + stale_mask = layer_nodes != _nodes + stale_nodes.append(layer_nodes[stale_mask]) + return np.concatenate(stale_nodes) + + +class LatestEdgesFinder: + """ + For each of stale_edges [[`node`, `partner`]], get their L2 edge equivalent. + Then get supervoxels of those L2 IDs and get parent(s) at `node` level. + These parents would be the new identities for the stale `partner`. + """ + + def __init__( + self, + cg, + stale_edges: Iterable, + edge_layers: Iterable, + parent_ts: datetime.datetime = None, + ): + self.cg = cg + self.stale_edges = stale_edges + self.edge_layers = edge_layers + self.parent_ts = parent_ts + + _nodes = np.unique(stale_edges) + self.nodes_ts_map = dict( + zip( + _nodes, + cg.get_node_timestamps(_nodes, return_numpy=False, normalize=True), + ) + ) + layers, coords = cg.get_chunk_layers_and_coordinates(_nodes) + self.layers_d = dict(zip(_nodes, layers)) + self.coords_d = dict(zip(_nodes, coords)) + + def _get_children_from_cache(self, nodes): + children = [] + non_cached = [] + for node in nodes: + try: + v = CHILDREN_CACHE[node] + children.append(v) + except KeyError: + non_cached.append(node) + + children_map = self.cg.get_children(non_cached) + for k, v in children_map.items(): + CHILDREN_CACHE[k] = v + children.append(v) + return np.concatenate(children) + + def _get_normalized_coords(self, node_a, node_b) -> tuple: + max_layer = self.layers_d[node_a] + coord_a, coord_b = self.coords_d[node_a], self.coords_d[node_b] + if self.layers_d[node_a] != self.layers_d[node_b]: + # normalize if nodes are not from the same layer + max_layer = max(self.layers_d[node_a], self.layers_d[node_b]) + chunk_a = self.cg.get_parent_chunk_id(node_a, parent_layer=max_layer) + chunk_b = self.cg.get_parent_chunk_id(node_b, parent_layer=max_layer) + coord_a, coord_b = self.cg.get_chunk_coordinates_multiple( + [chunk_a, chunk_b] + ) + return max_layer, tuple(coord_a), tuple(coord_b) + + def _get_filtered_l2ids(self, node_a, node_b, padding: int): + """ + Finds L2 IDs along opposing faces for given nodes. + Filterting is done by first finding L2 chunks along these faces. + Then get their parent chunks iteratively. + Then filter children iteratively using these chunks. + """ + chunks_map = {} + + def _filter(node): + result = [] + children = np.array([node], dtype=basetypes.NODE_ID) + while True: + chunk_ids = self.cg.get_chunk_ids_from_node_ids(children) + mask = np.isin(chunk_ids, chunks_map[node]) + children = children[mask] + + mask = self.cg.get_chunk_layers(children) == 2 + result.append(children[mask]) + + mask = self.cg.get_chunk_layers(children) > 2 + if children[mask].size == 0: + break + if PARENTS_CACHE is None: + children = self.cg.get_children(children[mask], flatten=True) + else: + children = self._get_children_from_cache(children[mask]) + return np.concatenate(result) + + mlayer, coord_a, coord_b = self._get_normalized_coords(node_a, node_b) + chunks_a, chunks_b = get_l2chunkids_along_boundary( + self.cg.meta, mlayer, coord_a, coord_b, padding + ) + + chunks_map[node_a] = [[self.cg.get_chunk_id(node_a)]] + chunks_map[node_b] = [[self.cg.get_chunk_id(node_b)]] + _layer = 2 + while _layer < mlayer: + chunks_map[node_a].append(chunks_a) + chunks_map[node_b].append(chunks_b) + chunks_a = np.unique(self.cg.get_parent_chunk_id_multiple(chunks_a)) + chunks_b = np.unique(self.cg.get_parent_chunk_id_multiple(chunks_b)) + _layer += 1 + chunks_map[node_a] = np.concatenate(chunks_map[node_a]) + chunks_map[node_b] = np.concatenate(chunks_map[node_b]) + return int(mlayer), _filter(node_a), _filter(node_b) + + def _populate_parents_cache(self, children: np.ndarray): + global PARENTS_CACHE + + not_cached = [] + for child in children: + try: + # reset lru index, these will be needed soon + _ = PARENTS_CACHE[child] + except KeyError: + not_cached.append(child) + + all_parents = self.cg.get_parents(not_cached, current=False) + for child, parents in zip(not_cached, all_parents): + PARENTS_CACHE[child] = {} + for parent, ts in parents: + PARENTS_CACHE[child][ts] = parent + + def _get_hierarchy(self, nodes, layer): + _hierarchy = [nodes] + for _a in nodes: + _hierarchy.append( + self.cg.get_root( + _a, + time_stamp=self.parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + raw_only=True, + ) + ) + _children = self.cg.get_children(_a, raw_only=True) + _children_layers = self.cg.get_chunk_layers(_children) + _hierarchy.append(_children[_children_layers == 2]) + _children = _children[_children_layers > 2] + while _children.size: + _hierarchy.append(_children) + _children = self.cg.get_children( + _children, flatten=True, raw_only=True + ) + _children_layers = self.cg.get_chunk_layers(_children) + _hierarchy.append(_children[_children_layers == 2]) + _children = _children[_children_layers > 2] + return np.concatenate(_hierarchy) + + def _check_cross_edges_from_a(self, node_b, nodes_a, layer, parent_ts): + """ + Checks to match cross edges from partners_a + to hierarchy of potential node from partner b. + """ + if len(nodes_a) == 0: + return False + + _hierarchy_b = self.cg.get_root( + node_b, + time_stamp=parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + raw_only=True, + ) + _hierarchy_b = np.append(_hierarchy_b, node_b) + _cx_edges_d_from_a = self.cg.get_cross_chunk_edges( + nodes_a, time_stamp=parent_ts + ) + for _edges_d_from_a in _cx_edges_d_from_a.values(): + _edges_from_a = _edges_d_from_a.get(layer, types.empty_2d) + nodes_b_from_a = _edges_from_a[:, 1] + hierarchy_b_from_a = self._get_hierarchy(nodes_b_from_a, layer) + _mask = np.isin(hierarchy_b_from_a, _hierarchy_b) + if np.any(_mask): + return True + return False + + def _check_hierarchy_a_from_b(self, parents_a, nodes_a_from_b, layer, parent_ts): + """ + Checks for overlap between hierarchy of a, + and hierarchy of a identified from partners of b. + """ + if len(nodes_a_from_b) == 0: + return False + + _hierarchy_a = [parents_a] + for _a in parents_a: + _hierarchy_a.append( + self.cg.get_root( + _a, + time_stamp=parent_ts, + stop_layer=layer, + get_all_parents=True, + ceil=False, + raw_only=True, + ) + ) + hierarchy_a = np.concatenate(_hierarchy_a) + hierarchy_a_from_b = self._get_hierarchy(nodes_a_from_b, layer) + return np.any(np.isin(hierarchy_a_from_b, hierarchy_a)) + + def _get_parents_b(self, edges, parent_ts, layer, fallback: bool = False): + """ + Attempts to find new partner side nodes. + Gets new partners at parent_ts using supervoxels, at `parent_ts`. + Searches for new partners that may have any edges to `edges[:,0]`. + """ + if PARENTS_CACHE is None: + # this cache is set only during migration + # also, fallback is not applicable if no migration + children_b = self.cg.get_children(edges[:, 1], flatten=True) + parents_b = np.unique( + self.cg.get_parents(children_b, time_stamp=parent_ts) + ) + fallback = False + else: + children_b = self._get_children_from_cache(edges[:, 1]) + self._populate_parents_cache(children_b) + _parents_b, missing = get_parents_at_timestamp( + children_b, PARENTS_CACHE, time_stamp=parent_ts, unique=True + ) + # handle cache miss cases + _parents_b_missed = np.unique( + self.cg.get_parents(missing, time_stamp=parent_ts) + ) + parents_b = np.concatenate([_parents_b, _parents_b_missed]) + + parents_a = np.unique(edges[:, 0]) + stale_a = get_stale_nodes(self.cg, parents_a, parent_ts=parent_ts) + if stale_a.size == parents_a.size or fallback: + # this is applicable only for v2 to v3 migration + # handle cases when source nodes in `edges[:,0]` are stale + atomic_edges_d = self.cg.get_atomic_cross_edges(stale_a) + partners = [types.empty_1d] + for _edges_d in atomic_edges_d.values(): + _edges = _edges_d.get(layer, types.empty_2d) + partners.append(_edges[:, 1]) + partners = np.concatenate(partners) + return np.unique(self.cg.get_parents(partners, time_stamp=parent_ts)) + + _cx_edges_d = self.cg.get_cross_chunk_edges(parents_b, time_stamp=parent_ts) + _parents_b = [] + for _node, _edges_d in _cx_edges_d.items(): + _edges = _edges_d.get(layer, types.empty_2d) + if self._check_cross_edges_from_a( + _node, _edges[:, 1], layer, parent_ts + ): + _parents_b.append(_node) + elif self._check_hierarchy_a_from_b( + parents_a, _edges[:, 1], layer, parent_ts + ): + _parents_b.append(_node) + else: + _new_ids = list(self.cg.cache.new_ids) + if np.any(np.isin(_new_ids, parents_a)): + _parents_b.append(_node) + return np.array(_parents_b, dtype=basetypes.NODE_ID) + + def _get_parents_b_with_chunk_mask( + self, + l2ids_b: np.ndarray, + nodes_b_from_a: np.ndarray, + max_ts: datetime.datetime, + edge, + ): + chunks_old = self.cg.get_chunk_ids_from_node_ids(l2ids_b) + chunks_new = self.cg.get_chunk_ids_from_node_ids(nodes_b_from_a) + chunk_mask = np.isin(chunks_new, chunks_old) + nodes_b_from_a = nodes_b_from_a[chunk_mask] + _stale_nodes = get_stale_nodes(self.cg, nodes_b_from_a, parent_ts=max_ts) + assert _stale_nodes.size == 0, f"{edge}, {_stale_nodes}, {max_ts}" + return nodes_b_from_a + + def _get_cx_edges(self, l2ids_a, max_node_ts, edge_layer, raw_only: bool = True): + _edges_d = self.cg.get_cross_chunk_edges( + node_ids=l2ids_a, time_stamp=max_node_ts, raw_only=raw_only + ) + _edges = [] + for v in _edges_d.values(): + if edge_layer in v: + _edges.append(v[edge_layer]) + return np.concatenate(_edges) + + def _get_dilated_edges(self, edges): + layers_b = self.cg.get_chunk_layers(edges[:, 1]) + _mask = layers_b == 2 + _l2_edges = [edges[_mask]] + for _edge in edges[~_mask]: + _node_a, _node_b = _edge + _nodes_b = self.cg.get_l2children([_node_b]) + _l2_edges.append( + np.array( + [[_node_a, _b] for _b in _nodes_b], dtype=basetypes.NODE_ID + ) + ) + return np.unique(np.concatenate(_l2_edges), axis=0) + + def _get_new_edge( + self, edge, edge_layer, parent_ts, padding, fallback: bool = False + ): + """ + Attempts to find new edge(s) for the stale `edge`. + * Find L2 IDs on opposite sides of the face in L2 chunks along the face. + * Find new edges between them (before the given timestamp). + * If none found, expand search by adding another layer of L2 chunks. + """ + node_a, node_b = edge + mlayer, l2ids_a, l2ids_b = self._get_filtered_l2ids( + node_a, node_b, padding=padding + ) + if l2ids_a.size == 0 or l2ids_b.size == 0: + return types.empty_2d.copy() + + max_ts = max(self.nodes_ts_map[node_a], self.nodes_ts_map[node_b]) + is_l2_edge = node_a in l2ids_a and node_b in l2ids_b + if is_l2_edge and (l2ids_a.size == 1 and l2ids_b.size == 1): + _edges = np.array([edge], dtype=basetypes.NODE_ID) + else: + try: + _edges = self._get_cx_edges(l2ids_a, max_ts, edge_layer) + except ValueError: + _edges = self._get_cx_edges( + l2ids_a, max_ts, edge_layer, raw_only=False + ) + except ValueError: + return types.empty_2d.copy() + + mask = np.isin(_edges[:, 1], l2ids_b) + if np.any(mask): + parents_b = self._get_parents_b(_edges[mask], parent_ts, edge_layer) + else: + # partner nodes likely lifted, dilate and retry + _edges = self._get_dilated_edges(_edges) + mask = np.isin(_edges[:, 1], l2ids_b) + if np.any(mask): + parents_b = self._get_parents_b( + _edges[mask], parent_ts, edge_layer + ) + else: + # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges + # so get the new identities of `l2ids_b` by using chunk mask + try: + parents_b = self._get_parents_b_with_chunk_mask( + l2ids_b, _edges[:, 1], max_ts, edge + ) + except AssertionError: + parents_b = [] + if fallback: + parents_b = self._get_parents_b( + _edges, parent_ts, edge_layer, True + ) + + parents_b = np.unique( + get_new_nodes(self.cg, parents_b, mlayer, parent_ts) + ) + parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) + return np.column_stack((parents_a, parents_b)) + + def run(self): + result = [types.empty_2d] + for edge_layer, _edge in zip(self.edge_layers, self.stale_edges): + max_chebyshev_distance = int(environ.get("MAX_CHEBYSHEV_DISTANCE", 3)) + for pad in range(0, max_chebyshev_distance + 1): + fallback = pad == max_chebyshev_distance + _new_edges = self._get_new_edge( + _edge, + edge_layer, + self.parent_ts, + padding=pad, + fallback=fallback, + ) + if _new_edges.size: + break + logging.info(f"{_edge}, expanding search with padding {pad+1}.") + assert ( + _new_edges.size + ), f"No new edge found {_edge}; {edge_layer}, {self.parent_ts}" + result.append(_new_edges) + return np.concatenate(result) + +def get_latest_edges( + cg, + stale_edges: Iterable, + edge_layers: Iterable, + parent_ts: datetime.datetime = None, +) -> np.ndarray: + """ + For each of stale_edges [[`node`, `partner`]], get their L2 edge equivalent. + Then get supervoxels of those L2 IDs and get parent(s) at `node` level. + These parents would be the new identities for the stale `partner`. + """ + return LatestEdgesFinder(cg, stale_edges, edge_layers, parent_ts).run() + + +def get_latest_edges_wrapper( + cg, cx_edges_d: dict, parent_ts: datetime.datetime = None +) -> tuple[dict, np.ndarray]: + """ + Helper function to filter stale edges and replace with latest edges. + Filters out edges with nodes stale in source, edges[:,0], at given timestamp. + """ + nodes = [types.empty_1d] + new_cx_edges_d = {0: types.empty_2d} + + all_edges = np.concatenate(list(cx_edges_d.values())) + all_edge_nodes = np.unique(all_edges) + all_stale_nodes = get_stale_nodes(cg, all_edge_nodes, parent_ts=parent_ts) + if all_stale_nodes.size == 0: + return cx_edges_d, all_edge_nodes + + for layer, _cx_edges in cx_edges_d.items(): + if _cx_edges.size == 0: + continue + + _new_cx_edges = [types.empty_2d] + _edge_layers = np.array([layer] * len(_cx_edges), dtype=int) + + stale_source_mask = np.isin(_cx_edges[:, 0], all_stale_nodes) + _new_cx_edges.append(_cx_edges[stale_source_mask]) + + _cx_edges = _cx_edges[~stale_source_mask] + _edge_layers = _edge_layers[~stale_source_mask] + stale_destination_mask = np.isin(_cx_edges[:, 1], all_stale_nodes) + _new_cx_edges.append(_cx_edges[~stale_destination_mask]) + + if np.any(stale_destination_mask): + stale_edges = _cx_edges[stale_destination_mask] + stale_edge_layers = _edge_layers[stale_destination_mask] + latest_edges = get_latest_edges( + cg, + stale_edges, + stale_edge_layers, + parent_ts=parent_ts, + ) + logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") + _new_cx_edges.append(latest_edges) + new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) + nodes.append(np.unique(new_cx_edges_d[layer])) + return new_cx_edges_d, np.concatenate(nodes) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 9275fec9b..89ee5b8d2 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -16,7 +16,7 @@ from . import types from . import attributes from . import cache as cache_utils -from .edges import get_latest_edges_wrapper, flip_ids, get_new_nodes +from .edges import get_latest_edges_wrapper, get_new_nodes from .edges.utils import concatenate_cross_edge_dicts from .edges.utils import merge_cross_edge_dicts from .utils import basetypes @@ -49,6 +49,15 @@ def _init_old_hierarchy(cg, l2ids: np.ndarray, parent_ts: datetime.datetime = No return old_hierarchy_d +def flip_ids(id_map, node_ids): + """ + returns old or new ids according to the map + """ + ids = [np.asarray(list(id_map[id_]), dtype=basetypes.NODE_ID) for id_ in node_ids] + ids.append(types.empty_1d) # concatenate needs at least one array + return np.concatenate(ids).astype(basetypes.NODE_ID) + + def _analyze_affected_edges( cg, atomic_edges: Iterable[np.ndarray], parent_ts: datetime.datetime = None ) -> Tuple[Iterable, Dict]: @@ -686,9 +695,7 @@ def _update_cross_edge_cache_batched(self, new_ids: list): # Distribute results back to each parent's cache # Key insight: edges[:, 0] are children, map them to their parent - edge_parents = get_new_nodes( - self.cg, edge_nodes, parent_layer, self._last_ts - ) + edge_parents = get_new_nodes(self.cg, edge_nodes, parent_layer, self._last_ts) edge_parents_d = dict(zip(edge_nodes, edge_parents)) for new_id in new_ids: children_set = set(all_children_d[new_id]) diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index 25cbe4cb3..f7406922f 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -81,7 +81,7 @@ def __enter__(self): def __exit__(self, exception_type, exception_value, traceback): if self.lock_acquired: - max_workers = max(1, len(self.locked_root_ids)) + max_workers = min(8, max(1, len(self.locked_root_ids))) with ThreadPoolExecutor(max_workers=max_workers) as executor: unlock_futures = [ executor.submit( @@ -164,7 +164,7 @@ def __enter__(self): def __exit__(self, exception_type, exception_value, traceback): if self.acquired: - max_workers = max(1, len(self.root_ids)) + max_workers = min(8, max(1, len(self.root_ids))) with ThreadPoolExecutor(max_workers=max_workers) as executor: unlock_futures = [ executor.submit( diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 54dec6001..436aca49c 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -11,7 +11,8 @@ from tqdm import tqdm from cachetools import LRUCache -from pychunkedgraph.graph import ChunkedGraph, edges +from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph.edges import stale, get_latest_edges_wrapper from pychunkedgraph.graph.attributes import Connectivity, Hierarchy from pychunkedgraph.graph.utils import serializers, basetypes from pychunkedgraph.graph.types import empty_2d @@ -127,7 +128,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: for ts, edges_d in CX_EDGES[node].items(): if ts < node_ts: continue - edges_d, _nodes = edges.get_latest_edges_wrapper(cg, edges_d, parent_ts=ts) + edges_d, _nodes = get_latest_edges_wrapper(cg, edges_d, parent_ts=ts) if _nodes.size == 0: continue @@ -148,8 +149,8 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: def _update_cross_edges_helper(args): global CG - edges.PARENTS_CACHE = LRUCache(PARENT_CACHE_LIMIT) - edges.CHILDREN_CACHE = LRUCache(1 * 1024) + stale.PARENTS_CACHE = LRUCache(PARENT_CACHE_LIMIT) + stale.CHILDREN_CACHE = LRUCache(1 * 1024) clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" cg_info, layer, nodes, nodes_ts = args @@ -185,8 +186,8 @@ def _update_cross_edges_helper(args): rows = [] for task in tasks: rows.extend(update_cross_edges(*task)) - edges.PARENTS_CACHE.clear() - edges.CHILDREN_CACHE.clear() + stale.PARENTS_CACHE.clear() + stale.CHILDREN_CACHE.clear() cg.client.write(rows) gc.collect() @@ -239,13 +240,13 @@ def update_chunk( if debug: rows = [] - edges.PARENTS_CACHE = LRUCache(PARENT_CACHE_LIMIT) - edges.CHILDREN_CACHE = LRUCache(1 * 1024) + stale.PARENTS_CACHE = LRUCache(PARENT_CACHE_LIMIT) + stale.CHILDREN_CACHE = LRUCache(1 * 1024) logging.info(f"processing {len(nodes)} nodes with 1 worker.") for node, node_ts in zip(nodes, nodes_ts): rows.extend(update_cross_edges(cg, layer, node, node_ts)) - edges.PARENTS_CACHE.clear() - edges.CHILDREN_CACHE.clear() + stale.PARENTS_CACHE.clear() + stale.CHILDREN_CACHE.clear() logging.info(f"total elaspsed time: {time.time() - start}") return From eaa24bcbf5a68409d8d2c6a4e6c429247bdc92cf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 19 Feb 2026 17:49:42 +0000 Subject: [PATCH 156/367] tests: reorganize, new tests, fix codecov integration --- .github/workflows/main.yml | 26 +- .travis.yml | 60 - README.md | 4 +- pychunkedgraph/debug/profiler.py | 2 +- pychunkedgraph/tests/conftest.py | 229 ++ pychunkedgraph/tests/helpers.py | 189 +- pychunkedgraph/tests/test_graph_build.py | 420 +++ pychunkedgraph/tests/test_graph_queries.py | 222 ++ pychunkedgraph/tests/test_history.py | 135 + pychunkedgraph/tests/test_locks.py | 415 +++ pychunkedgraph/tests/test_merge.py | 708 ++++ pychunkedgraph/tests/test_merge_split.py | 74 + pychunkedgraph/tests/test_mincut.py | 317 ++ pychunkedgraph/tests/test_multicut.py | 67 + pychunkedgraph/tests/test_node_conversion.py | 85 + pychunkedgraph/tests/test_operation.py | 509 ++- pychunkedgraph/tests/test_root_lock.py | 189 +- pychunkedgraph/tests/test_split.py | 696 ++++ pychunkedgraph/tests/test_stale_edges.py | 202 ++ pychunkedgraph/tests/test_uncategorized.py | 3415 ------------------ pychunkedgraph/tests/test_undo_redo.py | 120 + requirements-dev.txt | 1 - 22 files changed, 4049 insertions(+), 4036 deletions(-) delete mode 100644 .travis.yml create mode 100644 pychunkedgraph/tests/conftest.py create mode 100644 pychunkedgraph/tests/test_graph_build.py create mode 100644 pychunkedgraph/tests/test_graph_queries.py create mode 100644 pychunkedgraph/tests/test_history.py create mode 100644 pychunkedgraph/tests/test_locks.py create mode 100644 pychunkedgraph/tests/test_merge.py create mode 100644 pychunkedgraph/tests/test_merge_split.py create mode 100644 pychunkedgraph/tests/test_mincut.py create mode 100644 pychunkedgraph/tests/test_multicut.py create mode 100644 pychunkedgraph/tests/test_node_conversion.py create mode 100644 pychunkedgraph/tests/test_split.py create mode 100644 pychunkedgraph/tests/test_stale_edges.py delete mode 100644 pychunkedgraph/tests/test_uncategorized.py create mode 100644 pychunkedgraph/tests/test_undo_redo.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fd20bf4b7..7729e60c7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,10 +15,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Build image and run tests + - name: Build image + run: docker build --tag seunglab/pychunkedgraph:$GITHUB_SHA . + + - name: Run tests with coverage run: | - docker build --tag seunglab/pychunkedgraph:$GITHUB_SHA . - docker run --rm seunglab/pychunkedgraph:$GITHUB_SHA /bin/sh -c "pytest --cov-config .coveragerc --cov=pychunkedgraph ./pychunkedgraph/tests && codecov" + docker run --name pcg-tests seunglab/pychunkedgraph:$GITHUB_SHA \ + /bin/sh -c "pytest --cov-config .coveragerc --cov=pychunkedgraph --cov-report=xml:/app/coverage.xml ./pychunkedgraph/tests" + + - name: Copy coverage report from container + if: always() + run: docker cp pcg-tests:/app/coverage.xml ./coverage.xml || true + + - name: Upload coverage to Codecov + if: always() + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + - name: Cleanup + if: always() + run: docker rm pcg-tests || true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a5e33242d..000000000 --- a/.travis.yml +++ /dev/null @@ -1,60 +0,0 @@ -sudo: true -services: - docker - -env: - global: - - CLOUDSDK_CORE_DISABLE_PROMPTS=1 - -stages: - - test - - name: merge-deploy -python: 3.6 -notifications: - email: - on_success: change - on_failure: always - -jobs: - include: - - stage: test - name: "Running Tests" - language: minimal - before_script: - # request codecov to detect CI environment to pass through to docker - - ci_env=`bash <(curl -s https://codecov.io/env)` - - script: - - openssl aes-256-cbc -K $encrypted_506e835c2891_key -iv $encrypted_506e835c2891_iv -in key.json.enc -out key.json -d - - curl https://sdk.cloud.google.com | bash > /dev/null - - source "$HOME/google-cloud-sdk/path.bash.inc" - - gcloud auth activate-service-account --key-file=key.json - - gcloud auth configure-docker - - docker build --tag seunglab/pychunkedgraph:$TRAVIS_BRANCH . || travis_terminate 1 - - docker run $ci_env --rm seunglab/pychunkedgraph:$TRAVIS_BRANCH /bin/sh -c "tox -v -- --cov-config .coveragerc --cov=pychunkedgraph && codecov" - - - stage: merge-deploy - name: "version bump and merge into master" - language: python - install: - - pip install bumpversion - - before_script: - - "git clone https://gist.github.com/2c04596a45ccac57fe8dde0718ad58ee.git /tmp/travis-automerge" - - "chmod a+x /tmp/travis-automerge/auto_merge_travis_with_bumpversion.sh" - - script: - - "BRANCHES_TO_MERGE_REGEX='develop' BRANCH_TO_MERGE_INTO=master /tmp/travis-automerge/auto_merge_travis_with_bumpversion.sh" - - - stage: merge-deploy - name: "deploy to pypi" - language: python - install: - - pip install twine - - before_script: - - "git clone https://gist.github.com/cf9b261f26a1bf3fae6b59e7047f007a.git /tmp/travis-autodist" - - "chmod a+x /tmp/travis-autodist/pypi_dist.sh" - - script: - - "BRANCHES_TO_DIST='develop' /tmp/travis-autodist/pypi_dist.sh" diff --git a/README.md b/README.md index ef888b3c6..081ec7b4b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # PyChunkedGraph -[![Build Status](https://travis-ci.org/seung-lab/PyChunkedGraph.svg?branch=master)](https://travis-ci.org/seung-lab/PyChunkedGraph) -[![codecov](https://codecov.io/gh/seung-lab/PyChunkedGraph/branch/master/graph/badge.svg)](https://codecov.io/gh/seung-lab/PyChunkedGraph) +[![Tests](https://github.com/seung-lab/PyChunkedGraph/actions/workflows/main.yml/badge.svg)](https://github.com/seung-lab/PyChunkedGraph/actions/workflows/main.yml) +[![codecov](https://codecov.io/gh/seung-lab/PyChunkedGraph/branch/main/graph/badge.svg)](https://codecov.io/gh/seung-lab/PyChunkedGraph) The PyChunkedGraph is a proofreading and segmentation data management backend powering FlyWire and other proofreading platforms. It builds on an initial agglomeration of supervoxels and facilitates fast and parallel editing of connected components in the agglomeration graph by many users. diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/debug/profiler.py index 37eb799fd..b74ddac76 100644 --- a/pychunkedgraph/debug/profiler.py +++ b/pychunkedgraph/debug/profiler.py @@ -102,7 +102,7 @@ def reset(self): # Global profiler instance - enable via environment variable -PROFILER_ENABLED = os.environ.get("PCG_PROFILER_ENABLED", "1") == "1" +PROFILER_ENABLED = os.environ.get("PCG_PROFILER_ENABLED", "0") == "1" _profiler: HierarchicalProfiler = None diff --git a/pychunkedgraph/tests/conftest.py b/pychunkedgraph/tests/conftest.py new file mode 100644 index 000000000..11572191d --- /dev/null +++ b/pychunkedgraph/tests/conftest.py @@ -0,0 +1,229 @@ +import atexit +import os +import signal +import subprocess +from functools import partial +from datetime import timedelta + +import pytest + +# Skip the old monolithic test file if it still exists (e.g., during branch transitions) +collect_ignore = ["test_uncategorized.py"] +import numpy as np +from google.auth import credentials +from google.cloud import bigtable + +from ..ingest.utils import bootstrap +from ..graph.edges import Edges +from ..graph.chunkedgraph import ChunkedGraph +from ..ingest.create.parent_layer import add_parent_chunk + +from .helpers import ( + CloudVolumeMock, + create_chunk, + to_label, + get_layer_chunk_bounds, +) + +_emulator_proc = None +_emulator_cleaned = False + + +def _cleanup_emulator(): + global _emulator_cleaned + if _emulator_cleaned or _emulator_proc is None: + return + _emulator_cleaned = True + try: + pgid = os.getpgid(_emulator_proc.pid) + os.killpg(pgid, signal.SIGTERM) + try: + _emulator_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(pgid, signal.SIGKILL) + _emulator_proc.wait(timeout=5) + except (ProcessLookupError, OSError, ChildProcessError): + pass + # Hard kill cbtemulator in case it survived the process group signal + subprocess.run(["pkill", "-9", "cbtemulator"], stderr=subprocess.DEVNULL) + + +def setup_emulator_env(): + bt_env_init = subprocess.run( + ["gcloud", "beta", "emulators", "bigtable", "env-init"], stdout=subprocess.PIPE + ) + os.environ["BIGTABLE_EMULATOR_HOST"] = ( + bt_env_init.stdout.decode("utf-8").strip().split("=")[-1] + ) + + c = bigtable.Client( + project="IGNORE_ENVIRONMENT_PROJECT", + credentials=credentials.AnonymousCredentials(), + admin=True, + ) + t = c.instance("emulated_instance").table("emulated_table") + + try: + t.create() + return True + except Exception as err: + print("Bigtable Emulator not yet ready: %s" % err) + return False + + +@pytest.fixture(scope="session", autouse=True) +def bigtable_emulator(request): + global _emulator_proc, _emulator_cleaned + from time import sleep + + _emulator_cleaned = False + + # Kill any leftover emulator processes from previous runs + subprocess.run(["pkill", "-9", "cbtemulator"], stderr=subprocess.DEVNULL) + + # Start Emulator + _emulator_proc = subprocess.Popen( + [ + "gcloud", + "beta", + "emulators", + "bigtable", + "start", + "--host-port=localhost:8539", + ], + preexec_fn=os.setsid, + stdout=subprocess.PIPE, + ) + + # Register atexit handler as safety net for abnormal exits + atexit.register(_cleanup_emulator) + + # Wait for Emulator to start up + print("Waiting for BigTables Emulator to start up...", end="") + retries = 5 + while retries > 0: + if setup_emulator_env() is True: + break + else: + retries -= 1 + sleep(5) + + if retries == 0: + print( + "\nCouldn't start Bigtable Emulator. Make sure it is installed correctly." + ) + _cleanup_emulator() + exit(1) + + request.addfinalizer(_cleanup_emulator) + + +@pytest.fixture(scope="function") +def gen_graph(request): + def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([])): + config = { + "data_source": { + "EDGES": "gs://chunked-graph/minnie65_0/edges", + "COMPONENTS": "gs://chunked-graph/minnie65_0/components", + "WATERSHED": "gs://microns-seunglab/minnie65/ws_minnie65_0", + }, + "graph_config": { + "CHUNK_SIZE": [512, 512, 64], + "FANOUT": 2, + "SPATIAL_BITS": 10, + "ID_PREFIX": "", + "ROOT_LOCK_EXPIRY": timedelta(seconds=5), + }, + "backend_client": { + "TYPE": "bigtable", + "CONFIG": { + "ADMIN": True, + "READ_ONLY": False, + "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", + "INSTANCE": "emulated_instance", + "CREDENTIALS": credentials.AnonymousCredentials(), + "MAX_ROW_KEY_COUNT": 1000, + }, + }, + "ingest_config": {}, + } + + meta, _, client_info = bootstrap("test", config=config) + graph = ChunkedGraph(graph_id="test", meta=meta, client_info=client_info) + graph.mock_edges = Edges([], []) + graph.meta._ws_cv = CloudVolumeMock() + graph.meta.layer_count = n_layers + graph.meta.layer_chunk_bounds = get_layer_chunk_bounds( + n_layers, atomic_chunk_bounds=atomic_chunk_bounds + ) + + graph.create() + + # setup Chunked Graph - Finalizer + def fin(): + graph.client._table.delete() + + request.addfinalizer(fin) + return graph + + return partial(_cgraph, request) + + +@pytest.fixture(scope="function") +def gen_graph_simplequerytest(request, gen_graph): + """ + ┌─────┬─────┬─────┐ + │ A¹ │ B¹ │ C¹ │ + │ 1 │ 3━2━┿━━4 │ + │ │ │ │ + └─────┴─────┴─────┘ + """ + from math import inf + + graph = gen_graph(n_layers=4) + + # Chunk A + create_chunk(graph, vertices=[to_label(graph, 1, 0, 0, 0, 0)], edges=[]) + + # Chunk B + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 1), 0.5), + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), + ], + ) + + # Chunk C + create_chunk( + graph, + vertices=[to_label(graph, 1, 2, 0, 0, 0)], + edges=[(to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf)], + ) + + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + return graph + + +@pytest.fixture(scope="session") +def sv_data(): + test_data_dir = "pychunkedgraph/tests/data" + edges_file = f"{test_data_dir}/sv_edges.npy" + sv_edges = np.load(edges_file) + + source_file = f"{test_data_dir}/sv_sources.npy" + sv_sources = np.load(source_file) + + sinks_file = f"{test_data_dir}/sv_sinks.npy" + sv_sinks = np.load(sinks_file) + + affinity_file = f"{test_data_dir}/sv_affinity.npy" + sv_affinity = np.load(affinity_file) + + area_file = f"{test_data_dir}/sv_area.npy" + sv_area = np.load(area_file) + yield (sv_edges, sv_sources, sv_sinks, sv_affinity, sv_area) diff --git a/pychunkedgraph/tests/helpers.py b/pychunkedgraph/tests/helpers.py index 551c596bf..335b44fd0 100644 --- a/pychunkedgraph/tests/helpers.py +++ b/pychunkedgraph/tests/helpers.py @@ -1,25 +1,11 @@ -import os -import subprocess -from math import inf -from time import sleep -from signal import SIGTERM from functools import reduce -from functools import partial -from datetime import timedelta - -import pytest import numpy as np -from google.auth import credentials -from google.cloud import bigtable -from ..ingest.utils import bootstrap -from ..ingest.create.atomic_layer import add_atomic_chunk from ..graph.edges import Edges from ..graph.edges import EDGE_TYPES from ..graph.utils import basetypes -from ..graph.chunkedgraph import ChunkedGraph -from ..ingest.create.parent_layer import add_parent_chunk +from ..ingest.create.atomic_layer import add_atomic_chunk class CloudVolumeBounds(object): @@ -43,159 +29,6 @@ def __init__(self): self.bounds = CloudVolumeBounds() -def setup_emulator_env(): - bt_env_init = subprocess.run( - ["gcloud", "beta", "emulators", "bigtable", "env-init"], stdout=subprocess.PIPE - ) - os.environ["BIGTABLE_EMULATOR_HOST"] = ( - bt_env_init.stdout.decode("utf-8").strip().split("=")[-1] - ) - - c = bigtable.Client( - project="IGNORE_ENVIRONMENT_PROJECT", - credentials=credentials.AnonymousCredentials(), - admin=True, - ) - t = c.instance("emulated_instance").table("emulated_table") - - try: - t.create() - return True - except Exception as err: - print("Bigtable Emulator not yet ready: %s" % err) - return False - - -@pytest.fixture(scope="session", autouse=True) -def bigtable_emulator(request): - # Start Emulator - bigtable_emulator = subprocess.Popen( - [ - "gcloud", - "beta", - "emulators", - "bigtable", - "start", - "--host-port=localhost:8539", - ], - preexec_fn=os.setsid, - stdout=subprocess.PIPE, - ) - - # Wait for Emulator to start up - print("Waiting for BigTables Emulator to start up...", end="") - retries = 5 - while retries > 0: - if setup_emulator_env() is True: - break - else: - retries -= 1 - sleep(5) - - if retries == 0: - print( - "\nCouldn't start Bigtable Emulator. Make sure it is installed correctly." - ) - exit(1) - - # Setup Emulator-Finalizer - def fin(): - os.killpg(os.getpgid(bigtable_emulator.pid), SIGTERM) - bigtable_emulator.wait() - - request.addfinalizer(fin) - - -@pytest.fixture(scope="function") -def gen_graph(request): - def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([])): - config = { - "data_source": { - "EDGES": "gs://chunked-graph/minnie65_0/edges", - "COMPONENTS": "gs://chunked-graph/minnie65_0/components", - "WATERSHED": "gs://microns-seunglab/minnie65/ws_minnie65_0", - }, - "graph_config": { - "CHUNK_SIZE": [512, 512, 64], - "FANOUT": 2, - "SPATIAL_BITS": 10, - "ID_PREFIX": "", - "ROOT_LOCK_EXPIRY": timedelta(seconds=5), - }, - "backend_client": { - "TYPE": "bigtable", - "CONFIG": { - "ADMIN": True, - "READ_ONLY": False, - "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", - "INSTANCE": "emulated_instance", - "CREDENTIALS": credentials.AnonymousCredentials(), - "MAX_ROW_KEY_COUNT": 1000, - }, - }, - "ingest_config": {}, - } - - meta, _, client_info = bootstrap("test", config=config) - graph = ChunkedGraph(graph_id="test", meta=meta, client_info=client_info) - graph.mock_edges = Edges([], []) - graph.meta._ws_cv = CloudVolumeMock() - graph.meta.layer_count = n_layers - graph.meta.layer_chunk_bounds = get_layer_chunk_bounds( - n_layers, atomic_chunk_bounds=atomic_chunk_bounds - ) - - graph.create() - - # setup Chunked Graph - Finalizer - def fin(): - graph.client._table.delete() - - request.addfinalizer(fin) - return graph - - return partial(_cgraph, request) - - -@pytest.fixture(scope="function") -def gen_graph_simplequerytest(request, gen_graph): - """ - ┌─────┬─────┬─────┐ - │ A¹ │ B¹ │ C¹ │ - │ 1 │ 3━2━┿━━4 │ - │ │ │ │ - └─────┴─────┴─────┘ - """ - - graph = gen_graph(n_layers=4) - - # Chunk A - create_chunk(graph, vertices=[to_label(graph, 1, 0, 0, 0, 0)], edges=[]) - - # Chunk B - create_chunk( - graph, - vertices=[to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 1)], - edges=[ - (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 1), 0.5), - (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), - ], - ) - - # Chunk C - create_chunk( - graph, - vertices=[to_label(graph, 1, 2, 0, 0, 0)], - edges=[(to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf)], - ) - - add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) - add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) - add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) - - return graph - - def create_chunk(cg, vertices=None, edges=None, timestamp=None): """ Helper function to add vertices and edges to the chunkedgraph - no safety checks! @@ -276,23 +109,3 @@ def get_layer_chunk_bounds( layer_bounds = atomic_chunk_bounds / (2 ** (layer - 2)) layer_bounds_d[layer] = np.ceil(layer_bounds).astype(int) return layer_bounds_d - - -@pytest.fixture(scope="session") -def sv_data(): - test_data_dir = "pychunkedgraph/tests/data" - edges_file = f"{test_data_dir}/sv_edges.npy" - sv_edges = np.load(edges_file) - - source_file = f"{test_data_dir}/sv_sources.npy" - sv_sources = np.load(source_file) - - sinks_file = f"{test_data_dir}/sv_sinks.npy" - sv_sinks = np.load(sinks_file) - - affinity_file = f"{test_data_dir}/sv_affinity.npy" - sv_affinity = np.load(affinity_file) - - area_file = f"{test_data_dir}/sv_area.npy" - sv_area = np.load(area_file) - yield (sv_edges, sv_sources, sv_sinks, sv_affinity, sv_area) diff --git a/pychunkedgraph/tests/test_graph_build.py b/pychunkedgraph/tests/test_graph_build.py new file mode 100644 index 000000000..23ffebe0f --- /dev/null +++ b/pychunkedgraph/tests/test_graph_build.py @@ -0,0 +1,420 @@ +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import attributes +from ..graph.utils import basetypes +from ..graph.utils.serializers import serialize_uint64 +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGraphBuild: + @pytest.mark.timeout(30) + def test_build_single_node(self, gen_graph): + """ + Create graph with single RG node 1 in chunk A + ┌─────┐ + │ A¹ │ + │ 1 │ + │ │ + └─────┘ + """ + + cg = gen_graph(n_layers=2) + # Add Chunk A + create_chunk(cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)]) + + res = cg.client._table.read_rows() + res.consume_all() + + assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) + assert parent == to_label(cg, 2, 0, 0, 0, 1) + + # Check for the one Level 2 node that should have been created. + assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + atomic_cross_edge_d = cg.get_atomic_cross_edges( + np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) + ) + attr = attributes.Hierarchy.Child + row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + children = attr.deserialize(row[attr.key][0].value) + + for aces in atomic_cross_edge_d.values(): + assert len(aces) == 0 + + assert len(children) == 1 and children[0] == to_label(cg, 1, 0, 0, 0, 0) + # Make sure there are not any more entries in the table + # include counters, meta and version rows + assert len(res.rows) == 1 + 1 + 1 + 1 + 1 + + @pytest.mark.timeout(30) + def test_build_single_edge(self, gen_graph): + """ + Create graph with edge between RG supervoxels 1 and 2 (same chunk) + ┌─────┐ + │ A¹ │ + │ 1━2 │ + │ │ + └─────┘ + """ + + cg = gen_graph(n_layers=2) + + # Add Chunk A + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], + ) + + res = cg.client._table.read_rows() + res.consume_all() + + assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) + assert parent == to_label(cg, 2, 0, 0, 0, 1) + + assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) + assert parent == to_label(cg, 2, 0, 0, 0, 1) + + # Check for the one Level 2 node that should have been created. + assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + + atomic_cross_edge_d = cg.get_atomic_cross_edges( + np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) + ) + attr = attributes.Hierarchy.Child + row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + children = attr.deserialize(row[attr.key][0].value) + + for aces in atomic_cross_edge_d.values(): + assert len(aces) == 0 + assert ( + len(children) == 2 + and to_label(cg, 1, 0, 0, 0, 0) in children + and to_label(cg, 1, 0, 0, 0, 1) in children + ) + + # Make sure there are not any more entries in the table + # include counters, meta and version rows + assert len(res.rows) == 2 + 1 + 1 + 1 + 1 + + @pytest.mark.timeout(30) + def test_build_single_across_edge(self, gen_graph): + """ + Create graph with edge between RG supervoxels 1 and 2 (neighboring chunks) + ┌─────┌─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + + atomic_chunk_bounds = np.array([2, 1, 1]) + cg = gen_graph(n_layers=3, atomic_chunk_bounds=atomic_chunk_bounds) + + # Chunk A + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], + ) + + # Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], + ) + + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + res = cg.client._table.read_rows() + res.consume_all() + + assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) + assert parent == to_label(cg, 2, 0, 0, 0, 1) + + assert serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + assert parent == to_label(cg, 2, 1, 0, 0, 1) + + # Check for the two Level 2 nodes that should have been created. Since Level 2 has the same + # dimensions as Level 1, we also expect them to be in different chunks + # to_label(cg, 2, 0, 0, 0, 1) + assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + atomic_cross_edge_d = cg.get_atomic_cross_edges( + np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) + ) + atomic_cross_edge_d = atomic_cross_edge_d[ + np.uint64(to_label(cg, 2, 0, 0, 0, 1)) + ] + + attr = attributes.Hierarchy.Child + row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + children = attr.deserialize(row[attr.key][0].value) + + test_ace = np.array( + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], + dtype=np.uint64, + ) + assert len(atomic_cross_edge_d[2]) == 1 + assert test_ace in atomic_cross_edge_d[2] + assert len(children) == 1 and to_label(cg, 1, 0, 0, 0, 0) in children + + # to_label(cg, 2, 1, 0, 0, 1) + assert serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows + atomic_cross_edge_d = cg.get_atomic_cross_edges( + np.array([to_label(cg, 2, 1, 0, 0, 1)], dtype=basetypes.NODE_ID) + ) + atomic_cross_edge_d = atomic_cross_edge_d[ + np.uint64(to_label(cg, 2, 1, 0, 0, 1)) + ] + + attr = attributes.Hierarchy.Child + row = res.rows[serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells["0"] + children = attr.deserialize(row[attr.key][0].value) + + test_ace = np.array( + [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], + dtype=np.uint64, + ) + assert len(atomic_cross_edge_d[2]) == 1 + assert test_ace in atomic_cross_edge_d[2] + assert len(children) == 1 and to_label(cg, 1, 1, 0, 0, 0) in children + + # Check for the one Level 3 node that should have been created. This one combines the two + # connected components of Level 2 + # to_label(cg, 3, 0, 0, 0, 1) + assert serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows + + attr = attributes.Hierarchy.Child + row = res.rows[serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells["0"] + children = attr.deserialize(row[attr.key][0].value) + assert ( + len(children) == 2 + and to_label(cg, 2, 0, 0, 0, 1) in children + and to_label(cg, 2, 1, 0, 0, 1) in children + ) + + # Make sure there are not any more entries in the table + # include counters, meta and version rows + assert len(res.rows) == 2 + 2 + 1 + 3 + 1 + 1 + + @pytest.mark.timeout(30) + def test_build_single_edge_and_single_across_edge(self, gen_graph): + """ + Create graph with edge between RG supervoxels 1 and 2 (same chunk) + and edge between RG supervoxels 1 and 3 (neighboring chunks) + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 2━1━┿━━3 │ + │ │ │ + └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Chunk A + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + ], + ) + + # Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], + ) + + add_parent_chunk(cg, 3, np.array([0, 0, 0]), n_threads=1) + res = cg.client._table.read_rows() + res.consume_all() + + assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) + assert parent == to_label(cg, 2, 0, 0, 0, 1) + + # to_label(cg, 1, 0, 0, 0, 1) + assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) + assert parent == to_label(cg, 2, 0, 0, 0, 1) + + # to_label(cg, 1, 1, 0, 0, 0) + assert serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows + parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + assert parent == to_label(cg, 2, 1, 0, 0, 1) + + # Check for the two Level 2 nodes that should have been created. Since Level 2 has the same + # dimensions as Level 1, we also expect them to be in different chunks + # to_label(cg, 2, 0, 0, 0, 1) + assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + atomic_cross_edge_d = cg.get_atomic_cross_edges([to_label(cg, 2, 0, 0, 0, 1)]) + atomic_cross_edge_d = atomic_cross_edge_d[ + np.uint64(to_label(cg, 2, 0, 0, 0, 1)) + ] + column = attributes.Hierarchy.Child + children = column.deserialize(row[column.key][0].value) + + test_ace = np.array( + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], + dtype=np.uint64, + ) + assert len(atomic_cross_edge_d[2]) == 1 + assert test_ace in atomic_cross_edge_d[2] + assert ( + len(children) == 2 + and to_label(cg, 1, 0, 0, 0, 0) in children + and to_label(cg, 1, 0, 0, 0, 1) in children + ) + + # to_label(cg, 2, 1, 0, 0, 1) + assert serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows + row = res.rows[serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells["0"] + atomic_cross_edge_d = cg.get_atomic_cross_edges([to_label(cg, 2, 1, 0, 0, 1)]) + atomic_cross_edge_d = atomic_cross_edge_d[ + np.uint64(to_label(cg, 2, 1, 0, 0, 1)) + ] + children = column.deserialize(row[column.key][0].value) + + test_ace = np.array( + [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], + dtype=np.uint64, + ) + assert len(atomic_cross_edge_d[2]) == 1 + assert test_ace in atomic_cross_edge_d[2] + assert len(children) == 1 and to_label(cg, 1, 1, 0, 0, 0) in children + + # Check for the one Level 3 node that should have been created. This one combines the two + # connected components of Level 2 + # to_label(cg, 3, 0, 0, 0, 1) + assert serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows + row = res.rows[serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells["0"] + column = attributes.Hierarchy.Child + children = column.deserialize(row[column.key][0].value) + + assert ( + len(children) == 2 + and to_label(cg, 2, 0, 0, 0, 1) in children + and to_label(cg, 2, 1, 0, 0, 1) in children + ) + + # Make sure there are not any more entries in the table + # include counters, meta and version rows + assert len(res.rows) == 3 + 2 + 1 + 3 + 1 + 1 + + @pytest.mark.timeout(120) + def test_build_big_graph(self, gen_graph): + """ + Create graph with RG nodes 1 and 2 in opposite corners of the largest possible dataset + ┌─────┐ ┌─────┐ + │ A¹ │ ... │ Z¹ │ + │ 1 │ │ 2 │ + │ │ │ │ + └─────┘ └─────┘ + """ + + atomic_chunk_bounds = np.array([8, 8, 8]) + cg = gen_graph(n_layers=5, atomic_chunk_bounds=atomic_chunk_bounds) + + # Preparation: Build Chunk A + create_chunk(cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], edges=[]) + + # Preparation: Build Chunk Z + create_chunk(cg, vertices=[to_label(cg, 1, 7, 7, 7, 0)], edges=[]) + + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 3, [3, 3, 3], n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 5, [0, 0, 0], n_threads=1) + + res = cg.client._table.read_rows() + res.consume_all() + + assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + assert serialize_uint64(to_label(cg, 1, 7, 7, 7, 0)) in res.rows + assert serialize_uint64(to_label(cg, 5, 0, 0, 0, 1)) in res.rows + assert serialize_uint64(to_label(cg, 5, 0, 0, 0, 2)) in res.rows + + @pytest.mark.timeout(30) + def test_double_chunk_creation(self, gen_graph): + """ + No connection between 1, 2 and 3 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 3 │ + │ 2 │ │ + └─────┴─────┘ + """ + + atomic_chunk_bounds = np.array([4, 4, 4]) + cg = gen_graph(n_layers=4, atomic_chunk_bounds=atomic_chunk_bounds) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + add_parent_chunk( + cg, + 4, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + assert len(cg.range_read_chunk(cg.get_chunk_id(layer=2, x=0, y=0, z=0))) == 2 + assert len(cg.range_read_chunk(cg.get_chunk_id(layer=2, x=1, y=0, z=0))) == 1 + assert len(cg.range_read_chunk(cg.get_chunk_id(layer=3, x=0, y=0, z=0))) == 0 + assert len(cg.range_read_chunk(cg.get_chunk_id(layer=4, x=0, y=0, z=0))) == 6 + + assert cg.get_chunk_layer(cg.get_root(to_label(cg, 1, 0, 0, 0, 1))) == 4 + assert cg.get_chunk_layer(cg.get_root(to_label(cg, 1, 0, 0, 0, 2))) == 4 + assert cg.get_chunk_layer(cg.get_root(to_label(cg, 1, 1, 0, 0, 1))) == 4 + + root_seg_ids = [ + cg.get_segment_id(cg.get_root(to_label(cg, 1, 0, 0, 0, 1))), + cg.get_segment_id(cg.get_root(to_label(cg, 1, 0, 0, 0, 2))), + cg.get_segment_id(cg.get_root(to_label(cg, 1, 1, 0, 0, 1))), + ] + + assert 4 in root_seg_ids + assert 5 in root_seg_ids + assert 6 in root_seg_ids diff --git a/pychunkedgraph/tests/test_graph_queries.py b/pychunkedgraph/tests/test_graph_queries.py new file mode 100644 index 000000000..9845b121e --- /dev/null +++ b/pychunkedgraph/tests/test_graph_queries.py @@ -0,0 +1,222 @@ +from math import inf + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label + + +class TestGraphSimpleQueries: + """ + ┌─────┬─────┬─────┐ L X Y Z S L X Y Z S L X Y Z S L X Y Z S + │ A¹ │ B¹ │ C¹ │ 1: 1 0 0 0 0 ─── 2 0 0 0 1 ───────────────── 4 0 0 0 1 + │ 1 │ 3━2━┿━━4 │ 2: 1 1 0 0 0 ─┬─ 2 1 0 0 1 ─── 3 0 0 0 1 ─┬─ 4 0 0 0 2 + │ │ │ │ 3: 1 1 0 0 1 ─┘ │ + └─────┴─────┴─────┘ 4: 1 2 0 0 0 ─── 2 2 0 0 1 ─── 3 1 0 0 1 ─┘ + """ + + @pytest.mark.timeout(30) + def test_get_parent_and_children(self, gen_graph_simplequerytest): + cg = gen_graph_simplequerytest + + children10000 = cg.get_children(to_label(cg, 1, 0, 0, 0, 0)) + children11000 = cg.get_children(to_label(cg, 1, 1, 0, 0, 0)) + children11001 = cg.get_children(to_label(cg, 1, 1, 0, 0, 1)) + children12000 = cg.get_children(to_label(cg, 1, 2, 0, 0, 0)) + + parent10000 = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) + parent11000 = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + parent11001 = cg.get_parent(to_label(cg, 1, 1, 0, 0, 1)) + parent12000 = cg.get_parent(to_label(cg, 1, 2, 0, 0, 0)) + + children20001 = cg.get_children(to_label(cg, 2, 0, 0, 0, 1)) + children21001 = cg.get_children(to_label(cg, 2, 1, 0, 0, 1)) + children22001 = cg.get_children(to_label(cg, 2, 2, 0, 0, 1)) + + parent20001 = cg.get_parent(to_label(cg, 2, 0, 0, 0, 1)) + parent21001 = cg.get_parent(to_label(cg, 2, 1, 0, 0, 1)) + parent22001 = cg.get_parent(to_label(cg, 2, 2, 0, 0, 1)) + + children30001 = cg.get_children(to_label(cg, 3, 0, 0, 0, 1)) + children31001 = cg.get_children(to_label(cg, 3, 1, 0, 0, 1)) + + parent30001 = cg.get_parent(to_label(cg, 3, 0, 0, 0, 1)) + parent31001 = cg.get_parent(to_label(cg, 3, 1, 0, 0, 1)) + + children40001 = cg.get_children(to_label(cg, 4, 0, 0, 0, 1)) + children40002 = cg.get_children(to_label(cg, 4, 0, 0, 0, 2)) + + parent40001 = cg.get_parent(to_label(cg, 4, 0, 0, 0, 1)) + parent40002 = cg.get_parent(to_label(cg, 4, 0, 0, 0, 2)) + + # (non-existing) Children of L1 + assert np.array_equal(children10000, []) is True + assert np.array_equal(children11000, []) is True + assert np.array_equal(children11001, []) is True + assert np.array_equal(children12000, []) is True + + # Parent of L1 + assert parent10000 == to_label(cg, 2, 0, 0, 0, 1) + assert parent11000 == to_label(cg, 2, 1, 0, 0, 1) + assert parent11001 == to_label(cg, 2, 1, 0, 0, 1) + assert parent12000 == to_label(cg, 2, 2, 0, 0, 1) + + # Children of L2 + assert len(children20001) == 1 and to_label(cg, 1, 0, 0, 0, 0) in children20001 + assert ( + len(children21001) == 2 + and to_label(cg, 1, 1, 0, 0, 0) in children21001 + and to_label(cg, 1, 1, 0, 0, 1) in children21001 + ) + assert len(children22001) == 1 and to_label(cg, 1, 2, 0, 0, 0) in children22001 + + # Parent of L2 + assert parent20001 == to_label(cg, 4, 0, 0, 0, 1) + assert parent21001 == to_label(cg, 3, 0, 0, 0, 1) + assert parent22001 == to_label(cg, 3, 1, 0, 0, 1) + + # Children of L3 + assert len(children30001) == 1 and len(children31001) == 1 + assert to_label(cg, 2, 1, 0, 0, 1) in children30001 + assert to_label(cg, 2, 2, 0, 0, 1) in children31001 + + # Parent of L3 + assert parent30001 == parent31001 + assert ( + parent30001 == to_label(cg, 4, 0, 0, 0, 1) + and parent20001 == to_label(cg, 4, 0, 0, 0, 2) + ) or ( + parent30001 == to_label(cg, 4, 0, 0, 0, 2) + and parent20001 == to_label(cg, 4, 0, 0, 0, 1) + ) + + # Children of L4 + assert parent10000 in children40001 + assert parent21001 in children40002 and parent22001 in children40002 + + # (non-existing) Parent of L4 + assert parent40001 is None + assert parent40002 is None + + children2_separate = cg.get_children( + [ + to_label(cg, 2, 0, 0, 0, 1), + to_label(cg, 2, 1, 0, 0, 1), + to_label(cg, 2, 2, 0, 0, 1), + ] + ) + assert len(children2_separate) == 3 + assert to_label(cg, 2, 0, 0, 0, 1) in children2_separate and np.all( + np.isin(children2_separate[to_label(cg, 2, 0, 0, 0, 1)], children20001) + ) + assert to_label(cg, 2, 1, 0, 0, 1) in children2_separate and np.all( + np.isin(children2_separate[to_label(cg, 2, 1, 0, 0, 1)], children21001) + ) + assert to_label(cg, 2, 2, 0, 0, 1) in children2_separate and np.all( + np.isin(children2_separate[to_label(cg, 2, 2, 0, 0, 1)], children22001) + ) + + children2_combined = cg.get_children( + [ + to_label(cg, 2, 0, 0, 0, 1), + to_label(cg, 2, 1, 0, 0, 1), + to_label(cg, 2, 2, 0, 0, 1), + ], + flatten=True, + ) + assert ( + len(children2_combined) == 4 + and np.all(np.isin(children20001, children2_combined)) + and np.all(np.isin(children21001, children2_combined)) + and np.all(np.isin(children22001, children2_combined)) + ) + + @pytest.mark.timeout(30) + def test_get_root(self, gen_graph_simplequerytest): + cg = gen_graph_simplequerytest + root10000 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + root11000 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) + root11001 = cg.get_root(to_label(cg, 1, 1, 0, 0, 1)) + root12000 = cg.get_root(to_label(cg, 1, 2, 0, 0, 0)) + + with pytest.raises(Exception): + cg.get_root(0) + + assert ( + root10000 == to_label(cg, 4, 0, 0, 0, 1) + and root11000 == root11001 == root12000 == to_label(cg, 4, 0, 0, 0, 2) + ) or ( + root10000 == to_label(cg, 4, 0, 0, 0, 2) + and root11000 == root11001 == root12000 == to_label(cg, 4, 0, 0, 0, 1) + ) + + @pytest.mark.timeout(30) + def test_get_subgraph_nodes(self, gen_graph_simplequerytest): + cg = gen_graph_simplequerytest + root1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + root2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) + + lvl1_nodes_1 = cg.get_subgraph([root1], leaves_only=True) + lvl1_nodes_2 = cg.get_subgraph([root2], leaves_only=True) + assert len(lvl1_nodes_1) == 1 + assert len(lvl1_nodes_2) == 3 + assert to_label(cg, 1, 0, 0, 0, 0) in lvl1_nodes_1 + assert to_label(cg, 1, 1, 0, 0, 0) in lvl1_nodes_2 + assert to_label(cg, 1, 1, 0, 0, 1) in lvl1_nodes_2 + assert to_label(cg, 1, 2, 0, 0, 0) in lvl1_nodes_2 + + lvl2_parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + lvl1_nodes = cg.get_subgraph([lvl2_parent], leaves_only=True) + assert len(lvl1_nodes) == 2 + assert to_label(cg, 1, 1, 0, 0, 0) in lvl1_nodes + assert to_label(cg, 1, 1, 0, 0, 1) in lvl1_nodes + + @pytest.mark.timeout(30) + def test_get_subgraph_edges(self, gen_graph_simplequerytest): + cg = gen_graph_simplequerytest + root1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + root2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) + + edges = cg.get_subgraph([root1], edges_only=True) + assert len(edges) == 0 + + edges = cg.get_subgraph([root2], edges_only=True) + assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)] in edges or [ + to_label(cg, 1, 1, 0, 0, 1), + to_label(cg, 1, 1, 0, 0, 0), + ] in edges + + assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0)] in edges or [ + to_label(cg, 1, 2, 0, 0, 0), + to_label(cg, 1, 1, 0, 0, 0), + ] in edges + + lvl2_parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + edges = cg.get_subgraph([lvl2_parent], edges_only=True) + assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)] in edges or [ + to_label(cg, 1, 1, 0, 0, 1), + to_label(cg, 1, 1, 0, 0, 0), + ] in edges + + assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0)] in edges or [ + to_label(cg, 1, 2, 0, 0, 0), + to_label(cg, 1, 1, 0, 0, 0), + ] in edges + + assert len(edges) == 1 + + @pytest.mark.timeout(30) + def test_get_subgraph_nodes_bb(self, gen_graph_simplequerytest): + cg = gen_graph_simplequerytest + bb = np.array([[1, 0, 0], [2, 1, 1]], dtype=int) + bb_coord = bb * cg.meta.graph_config.CHUNK_SIZE + childs_1 = cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 1, 0, 0, 1))], bbox=bb, leaves_only=True + ) + childs_2 = cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 1, 0, 0, 1))], + bbox=bb_coord, + bbox_is_coordinate=True, + leaves_only=True, + ) + assert np.all(~(np.sort(childs_1) - np.sort(childs_2))) diff --git a/pychunkedgraph/tests/test_history.py b/pychunkedgraph/tests/test_history.py new file mode 100644 index 000000000..0f0e2fa16 --- /dev/null +++ b/pychunkedgraph/tests/test_history.py @@ -0,0 +1,135 @@ +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import ChunkedGraph +from ..graph.lineage import lineage_graph, get_root_id_history +from ..graph.misc import get_delta_roots +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGraphHistory: + """These test inadvertantly also test merge and split operations""" + + @pytest.mark.timeout(120) + def test_cut_merge_history(self, gen_graph): + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + first_root = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + assert first_root == cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) + timestamp_before_split = datetime.now(UTC) + split_roots = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ).new_root_ids + assert len(split_roots) == 2 + g = lineage_graph(cg, split_roots[0]) + assert g.size() == 1 + g = lineage_graph(cg, split_roots) + assert g.size() == 2 + + timestamp_after_split = datetime.now(UTC) + merge_roots = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], + affinities=0.4, + ).new_root_ids + assert len(merge_roots) == 1 + merge_root = merge_roots[0] + timestamp_after_merge = datetime.now(UTC) + + g = lineage_graph(cg, merge_roots) + assert g.size() == 4 + assert ( + len( + get_root_id_history( + cg, + first_root, + time_stamp_past=datetime.min, + time_stamp_future=datetime.max, + ) + ) + == 4 + ) + assert ( + len( + get_root_id_history( + cg, + split_roots[0], + time_stamp_past=datetime.min, + time_stamp_future=datetime.max, + ) + ) + == 3 + ) + assert ( + len( + get_root_id_history( + cg, + split_roots[1], + time_stamp_past=datetime.min, + time_stamp_future=datetime.max, + ) + ) + == 3 + ) + assert ( + len( + get_root_id_history( + cg, + merge_root, + time_stamp_past=datetime.min, + time_stamp_future=datetime.max, + ) + ) + == 4 + ) + + new_roots, old_roots = get_delta_roots( + cg, timestamp_before_split, timestamp_after_split + ) + assert len(old_roots) == 1 + assert old_roots[0] == first_root + assert len(new_roots) == 2 + assert np.all(np.isin(new_roots, split_roots)) + + new_roots2, old_roots2 = get_delta_roots( + cg, timestamp_after_split, timestamp_after_merge + ) + assert len(new_roots2) == 1 + assert new_roots2[0] == merge_root + assert len(old_roots2) == 2 + assert np.all(np.isin(old_roots2, split_roots)) + + new_roots3, old_roots3 = get_delta_roots( + cg, timestamp_before_split, timestamp_after_merge + ) + assert len(new_roots3) == 1 + assert new_roots3[0] == merge_root + assert len(old_roots3) == 1 + assert old_roots3[0] == first_root diff --git a/pychunkedgraph/tests/test_locks.py b/pychunkedgraph/tests/test_locks.py new file mode 100644 index 000000000..41b59163b --- /dev/null +++ b/pychunkedgraph/tests/test_locks.py @@ -0,0 +1,415 @@ +from time import sleep +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph.lineage import get_future_root_ids +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGraphLocks: + @pytest.mark.timeout(30) + def test_lock_unlock(self, gen_graph): + """ + No connection between 1, 2 and 3 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 3 │ + │ 2 │ │ + └─────┴─────┘ + + (1) Try lock (opid = 1) + (2) Try lock (opid = 2) + (3) Try unlock (opid = 1) + (4) Try lock (opid = 2) + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + operation_id_1 = cg.id_client.create_operation_id() + root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) + + future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_1, + future_root_ids_d=future_root_ids_d, + )[0] + + operation_id_2 = cg.id_client.create_operation_id() + assert not cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + )[0] + + assert cg.client.unlock_root(root_id=root_id, operation_id=operation_id_1) + + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + )[0] + + @pytest.mark.timeout(30) + def test_lock_expiration(self, gen_graph): + """ + No connection between 1, 2 and 3 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 3 │ + │ 2 │ │ + └─────┴─────┘ + + (1) Try lock (opid = 1) + (2) Try lock (opid = 2) + (3) Try lock (opid = 2) with retries + """ + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + operation_id_1 = cg.id_client.create_operation_id() + root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) + future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_1, + future_root_ids_d=future_root_ids_d, + )[0] + + operation_id_2 = cg.id_client.create_operation_id() + assert not cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + )[0] + + sleep(cg.meta.graph_config.ROOT_LOCK_EXPIRY.total_seconds()) + + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + max_tries=10, + waittime_s=0.5, + )[0] + + @pytest.mark.timeout(30) + def test_lock_renew(self, gen_graph): + """ + No connection between 1, 2 and 3 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 3 │ + │ 2 │ │ + └─────┴─────┘ + + (1) Try lock (opid = 1) + (2) Try lock (opid = 2) + (3) Try lock (opid = 2) with retries + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + operation_id_1 = cg.id_client.create_operation_id() + root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) + future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_1, + future_root_ids_d=future_root_ids_d, + )[0] + + assert cg.client.renew_locks(root_ids=[root_id], operation_id=operation_id_1) + + @pytest.mark.timeout(30) + def test_lock_merge_lock_old_id(self, gen_graph): + """ + No connection between 1, 2 and 3 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 3 │ + │ 2 │ │ + └─────┴─────┘ + + (1) Merge (includes lock opid 1) + (2) Try lock opid 2 --> should be successful and return new root id + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) + + new_root_ids = cg.add_edges( + "Chuck Norris", + [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + affinities=1.0, + ).new_root_ids + + assert new_root_ids is not None + + operation_id_2 = cg.id_client.create_operation_id() + future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} + success, new_root_id = cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + max_tries=10, + waittime_s=0.5, + ) + + assert success + assert new_root_ids[0] == new_root_id + + @pytest.mark.timeout(30) + def test_indefinite_lock(self, gen_graph): + """ + No connection between 1, 2 and 3 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 3 │ + │ 2 │ │ + └─────┴─────┘ + + (1) Try indefinite lock (opid = 1), get indefinite lock + (2) Try normal lock (opid = 2), doesn't get the normal lock + (3) Try unlock indefinite lock (opid = 1), should unlock indefinite lock + (4) Try lock (opid = 2), should get the normal lock + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + operation_id_1 = cg.id_client.create_operation_id() + root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) + + future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} + assert cg.client.lock_roots_indefinitely( + root_ids=[root_id], + operation_id=operation_id_1, + future_root_ids_d=future_root_ids_d, + )[0] + + operation_id_2 = cg.id_client.create_operation_id() + assert not cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + )[0] + + assert cg.client.unlock_indefinitely_locked_root( + root_id=root_id, operation_id=operation_id_1 + ) + + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + )[0] + + @pytest.mark.timeout(30) + def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): + """ + No connection between 1, 2 and 3 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 3 │ + │ 2 │ │ + └─────┴─────┘ + + (1) Try normal lock (opid = 1), get normal lock + (2) Try indefinite lock (opid = 1), get indefinite lock + (3) Wait until normal lock expires + (4) Try normal lock (opid = 2), doesn't get the normal lock + (5) Try unlock indefinite lock (opid = 1), should unlock indefinite lock + (6) Try lock (opid = 2), should get the normal lock + """ + + # 1. TODO renew lock test when getting indefinite lock + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + operation_id_1 = cg.id_client.create_operation_id() + root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) + + future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} + + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_1, + future_root_ids_d=future_root_ids_d, + )[0] + + assert cg.client.lock_roots_indefinitely( + root_ids=[root_id], + operation_id=operation_id_1, + future_root_ids_d=future_root_ids_d, + )[0] + + sleep(cg.meta.graph_config.ROOT_LOCK_EXPIRY.total_seconds()) + + operation_id_2 = cg.id_client.create_operation_id() + assert not cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + )[0] + + assert cg.client.unlock_indefinitely_locked_root( + root_id=root_id, operation_id=operation_id_1 + ) + + assert cg.client.lock_roots( + root_ids=[root_id], + operation_id=operation_id_2, + future_root_ids_d=future_root_ids_d, + )[0] diff --git a/pychunkedgraph/tests/test_merge.py b/pychunkedgraph/tests/test_merge.py new file mode 100644 index 000000000..ae60b486e --- /dev/null +++ b/pychunkedgraph/tests/test_merge.py @@ -0,0 +1,708 @@ +from datetime import datetime, timedelta, UTC +from math import inf +from warnings import warn + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import ChunkedGraph +from ..graph.utils.serializers import serialize_uint64 +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGraphMerge: + @pytest.mark.timeout(30) + def test_merge_pair_same_chunk(self, gen_graph): + """ + Add edge between existing RG supervoxels 1 and 2 (same chunk) + Expected: Same (new) parent for RG 1 and 2 on Layer two + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1 2 │ => │ 1━2 │ + │ │ │ │ + └─────┘ └─────┘ + """ + + atomic_chunk_bounds = np.array([1, 1, 1]) + cg = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + + # Merge + new_root_ids = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 0)], + affinities=[0.3], + ).new_root_ids + + assert len(new_root_ids) == 1 + new_root_id = new_root_ids[0] + + # Check + assert cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id + assert cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) == new_root_id + leaves = np.unique(cg.get_subgraph([new_root_id], leaves_only=True)) + assert len(leaves) == 2 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 0, 0, 0, 1) in leaves + + @pytest.mark.timeout(30) + def test_merge_pair_neighboring_chunks(self, gen_graph): + """ + Add edge between existing RG supervoxels 1 and 2 (neighboring chunks) + ┌─────┬─────┐ ┌─────┬─────┐ + │ A¹ │ B¹ │ │ A¹ │ B¹ │ + │ 1 │ 2 │ => │ 1━━┿━━2 │ + │ │ │ │ │ │ + └─────┴─────┘ └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + # Merge + new_root_ids = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], + affinities=0.3, + ).new_root_ids + + assert len(new_root_ids) == 1 + new_root_id = new_root_ids[0] + + # Check + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == new_root_id + leaves = np.unique(cg.get_subgraph([new_root_id], leaves_only=True)) + assert len(leaves) == 2 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 1, 0, 0, 0) in leaves + + @pytest.mark.timeout(120) + def test_merge_pair_disconnected_chunks(self, gen_graph): + """ + Add edge between existing RG supervoxels 1 and 2 (disconnected chunks) + ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ + │ 1 │ │ 2 │ => │ 1━━┿━━━━━┿━━2 │ + │ │ │ │ │ │ │ │ + └─────┘ └─────┘ └─────┘ └─────┘ + """ + + cg = gen_graph(n_layers=5) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk Z + create_chunk( + cg, + vertices=[to_label(cg, 1, 7, 7, 7, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + ) + add_parent_chunk( + cg, 3, [3, 3, 3], time_stamp=fake_timestamp, n_threads=1, + ) + add_parent_chunk( + cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + ) + add_parent_chunk( + cg, 5, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + ) + + # Merge + result = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 0)], + affinities=[0.3], + ) + new_root_ids, lvl2_node_ids = result.new_root_ids, result.new_lvl2_ids + + u_layers = np.unique(cg.get_chunk_layers(lvl2_node_ids)) + assert len(u_layers) == 1 + assert u_layers[0] == 2 + + assert len(new_root_ids) == 1 + new_root_id = new_root_ids[0] + + # Check + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id + assert cg.get_root(to_label(cg, 1, 7, 7, 7, 0)) == new_root_id + leaves = np.unique(cg.get_subgraph(new_root_id, leaves_only=True)) + assert len(leaves) == 2 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 7, 7, 7, 0) in leaves + + @pytest.mark.timeout(30) + def test_merge_pair_already_connected(self, gen_graph): + """ + Add edge between already connected RG supervoxels 1 and 2 (same chunk). + Expected: No change + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1━2 │ => │ 1━2 │ + │ │ │ │ + └─────┘ └─────┘ + """ + + cg = gen_graph(n_layers=2) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], + timestamp=fake_timestamp, + ) + + res_old = cg.client._table.read_rows() + res_old.consume_all() + + # Merge + with pytest.raises(Exception): + cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 0)], + ) + res_new = cg.client._table.read_rows() + res_new.consume_all() + + # Check + if res_old.rows != res_new.rows: + warn( + "Rows were modified when merging a pair of already connected supervoxels. " + "While probably not an error, it is an unnecessary operation." + ) + + @pytest.mark.timeout(30) + def test_merge_triple_chain_to_full_circle_same_chunk(self, gen_graph): + """ + Add edge between indirectly connected RG supervoxels 1 and 2 (same chunk) + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1 2 │ => │ 1━2 │ + │ ┗3┛ │ │ ┗3┛ │ + └─────┘ └─────┘ + """ + + cg = gen_graph(n_layers=2) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[ + to_label(cg, 1, 0, 0, 0, 0), + to_label(cg, 1, 0, 0, 0, 1), + to_label(cg, 1, 0, 0, 0, 2), + ], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 0.5), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 0.5), + ], + timestamp=fake_timestamp, + ) + + # Merge + with pytest.raises(Exception): + cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 0)], + affinities=0.3, + ).new_root_ids + + @pytest.mark.timeout(30) + def test_merge_triple_chain_to_full_circle_neighboring_chunks(self, gen_graph): + """ + Add edge between indirectly connected RG supervoxels 1 and 2 (neighboring chunks) + ┌─────┬─────┐ ┌─────┬─────┐ + │ A¹ │ B¹ │ │ A¹ │ B¹ │ + │ 1 │ 2 │ => │ 1━━┿━━2 │ + │ ┗3━┿━━┛ │ │ ┗3━┿━━┛ │ + └─────┴─────┘ └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), inf)], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + ) + + # Merge + with pytest.raises(Exception): + cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], + affinities=1.0, + ).new_root_ids + + @pytest.mark.timeout(120) + def test_merge_triple_chain_to_full_circle_disconnected_chunks(self, gen_graph): + """ + Add edge between indirectly connected RG supervoxels 1 and 2 (disconnected chunks) + ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ + │ 1 │ │ 2 │ => │ 1━━┿━━━━━┿━━2 │ + │ ┗3━┿━━━━━┿━━┛ │ │ ┗3━┿━━━━━┿━━┛ │ + └─────┘ └─────┘ └─────┘ └─────┘ + """ + + cg = gen_graph(n_layers=5) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 7, 7, 7, 0), inf), + ], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 7, 7, 7, 0)], + edges=[ + (to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 1), inf) + ], + timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [3, 3, 3], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [1, 1, 1], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 5, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Merge + new_root_ids = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 0)], + affinities=1.0, + ).new_root_ids + + assert len(new_root_ids) == 1 + new_root_id = new_root_ids[0] + + # Check + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_id + assert cg.get_root(to_label(cg, 1, 7, 7, 7, 0)) == new_root_id + leaves = np.unique(cg.get_subgraph(new_root_id, leaves_only=True)) + assert len(leaves) == 3 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 0, 0, 0, 1) in leaves + assert to_label(cg, 1, 7, 7, 7, 0) in leaves + + @pytest.mark.timeout(30) + def test_merge_same_node(self, gen_graph): + """ + Try to add loop edge between RG supervoxel 1 and itself + ┌─────┐ + │ A¹ │ + │ 1 │ => Reject + │ │ + └─────┘ + """ + + cg = gen_graph(n_layers=2) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + res_old = cg.client._table.read_rows() + res_old.consume_all() + + # Merge + with pytest.raises(Exception): + cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_merge_pair_abstract_nodes(self, gen_graph): + """ + Try to add edge between RG supervoxel 1 and abstract node "2" + => Reject + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, vertices=[to_label(cg, 1, 1, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + res_old = cg.client._table.read_rows() + res_old.consume_all() + + # Merge + with pytest.raises(Exception): + cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 2, 1, 0, 0, 1)], + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_diagonal_connections(self, gen_graph): + """ + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 2 1━┿━━3 │ + │ / │ │ + ┌─────┬─────┐ + │ | │ │ + │ 4━━┿━━5 │ + │ C¹ │ D¹ │ + └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Chunk A + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf), + ], + ) + + # Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], + ) + + # Chunk C + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 1, 0, 0)], + edges=[ + (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 1, 1, 0, 0), inf), + (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), + ], + ) + + # Chunk D + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 1, 0, 0)], + edges=[(to_label(cg, 1, 1, 1, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf)], + ) + + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + + rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=3, x=0, y=0, z=0)) + root_ids_t0 = list(rr.keys()) + + assert len(root_ids_t0) == 2 + + child_ids = [] + for root_id in root_ids_t0: + child_ids.extend(cg.get_subgraph(root_id, leaves_only=True)) + + new_roots = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + affinities=[0.5], + ).new_root_ids + + root_ids = [] + for child_id in child_ids: + root_ids.append(cg.get_root(child_id)) + + assert len(np.unique(root_ids)) == 1 + + root_id = root_ids[0] + assert root_id == new_roots[0] + + @pytest.mark.timeout(240) + def test_cross_edges(self, gen_graph): + cg = gen_graph(n_layers=5) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 0), inf), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), inf), + ], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), inf), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1), inf), + ], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk C + create_chunk( + cg, vertices=[to_label(cg, 1, 2, 0, 0, 0)], timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 5, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + new_roots = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0)], + affinities=0.9, + ).new_root_ids + + assert len(new_roots) == 1 + + +class TestGraphMergeSkipConnections: + """Tests for skip connection behavior during merge operations.""" + + @pytest.mark.timeout(120) + def test_merge_creates_skip_connection(self, gen_graph): + """ + Merge two isolated nodes in a 5-layer graph. After merge, each + component that has no sibling at its layer should get a skip-connection + parent at a higher layer. + + ┌─────┐ ┌─────┐ + │ A¹ │ │ Z¹ │ + │ 1 │ │ 2 │ + └─────┘ └─────┘ + After merge: 1 and 2 are connected, hierarchy should skip + intermediate empty layers. + """ + cg = gen_graph(n_layers=5) + + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 7, 7, 7, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [3, 3, 3], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 5, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Before merge: verify both nodes have root at layer 5 + root1_pre = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + root2_pre = cg.get_root(to_label(cg, 1, 7, 7, 7, 0)) + assert root1_pre != root2_pre + assert cg.get_chunk_layer(root1_pre) == 5 + assert cg.get_chunk_layer(root2_pre) == 5 + + # Merge + result = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 7, 7, 7, 0)], + affinities=[0.5], + ) + new_root_ids = result.new_root_ids + assert len(new_root_ids) == 1 + + # After merge: single root, both supervoxels reachable + new_root = new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root + assert cg.get_root(to_label(cg, 1, 7, 7, 7, 0)) == new_root + assert cg.get_chunk_layer(new_root) == 5 + + @pytest.mark.timeout(120) + def test_merge_multi_layer_hierarchy_correctness(self, gen_graph): + """ + After a merge across chunks, verify the full parent chain from + each supervoxel to root is valid — every node has a parent at + a higher layer, and the root is reachable. + """ + cg = gen_graph(n_layers=5) + + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 7, 7, 7, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [3, 3, 3], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 5, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + result = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 7, 7, 7, 0)], + affinities=[0.5], + ) + + # Verify parent chain for both supervoxels + for sv in [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 7, 7, 7, 0)]: + parents = cg.get_root(sv, get_all_parents=True) + # Each parent should be at a strictly higher layer + prev_layer = 1 + for p in parents: + layer = cg.get_chunk_layer(p) + assert layer > prev_layer, ( + f"Parent chain not monotonically increasing: {prev_layer} -> {layer}" + ) + prev_layer = layer + # Last parent should be the root + assert parents[-1] == result.new_root_ids[0] + + @pytest.mark.timeout(30) + def test_merge_no_skip_when_siblings_exist(self, gen_graph): + """ + When two nodes in neighboring chunks are merged, they should NOT + create a skip connection — the parent should be at layer+1 since + they are siblings in the same parent chunk. + + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 2 │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=3) + + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Merge + result = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], + affinities=[0.5], + ) + + new_root = result.new_root_ids[0] + # Root should be at layer 3 (the top layer), since the two L2 nodes + # are siblings at layer 3 + assert cg.get_chunk_layer(new_root) == 3 diff --git a/pychunkedgraph/tests/test_merge_split.py b/pychunkedgraph/tests/test_merge_split.py new file mode 100644 index 000000000..45e67a483 --- /dev/null +++ b/pychunkedgraph/tests/test_merge_split.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import types + + +class TestGraphMergeSplit: + @pytest.mark.timeout(240) + def test_multiple_cuts_and_splits(self, gen_graph_simplequerytest): + cg = gen_graph_simplequerytest + + rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=4, x=0, y=0, z=0)) + root_ids_t0 = list(rr.keys()) + child_ids = [types.empty_1d] + for root_id in root_ids_t0: + child_ids.append(cg.get_subgraph([root_id], leaves_only=True)) + child_ids = np.concatenate(child_ids) + + for i in range(10): + new_roots = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], + affinities=0.9, + ).new_root_ids + assert len(new_roots) == 1, new_roots + assert len(cg.get_subgraph([new_roots[0]], leaves_only=True)) == 4 + + root_ids = cg.get_roots(child_ids, assert_roots=True) + u_root_ids = np.unique(root_ids) + assert len(u_root_ids) == 1, u_root_ids + + new_roots = cg.remove_edges( + "John Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 1), + mincut=False, + ).new_root_ids + assert len(new_roots) == 2, new_roots + + root_ids = cg.get_roots(child_ids, assert_roots=True) + u_root_ids = np.unique(root_ids) + these_child_ids = [] + for root_id in u_root_ids: + these_child_ids.extend(cg.get_subgraph([root_id], leaves_only=True)) + + assert len(these_child_ids) == 4 + assert len(u_root_ids) == 2, u_root_ids + + new_roots = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 1), + mincut=False, + ).new_root_ids + assert len(new_roots) == 2, new_roots + + root_ids = cg.get_roots(child_ids, assert_roots=True) + u_root_ids = np.unique(root_ids) + assert len(u_root_ids) == 3, u_root_ids + + new_roots = cg.add_edges( + "Jane Doe", + [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], + affinities=0.9, + ).new_root_ids + assert len(new_roots) == 1, new_roots + + root_ids = cg.get_roots(child_ids, assert_roots=True) + u_root_ids = np.unique(root_ids) + assert len(u_root_ids) == 2, u_root_ids diff --git a/pychunkedgraph/tests/test_mincut.py b/pychunkedgraph/tests/test_mincut.py new file mode 100644 index 000000000..6208c444a --- /dev/null +++ b/pychunkedgraph/tests/test_mincut.py @@ -0,0 +1,317 @@ +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import exceptions +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGraphMinCut: + # TODO: Ideally, those tests should focus only on mincut retrieving the correct edges. + # The edge removal part should be tested exhaustively in TestGraphSplit + @pytest.mark.timeout(30) + def test_cut_regular_link(self, gen_graph): + """ + Regular link between 1 and 2 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + # Mincut + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + source_coords=[0, 0, 0], + sink_coords=[ + 2 * cg.meta.graph_config.CHUNK_SIZE[0], + 2 * cg.meta.graph_config.CHUNK_SIZE[1], + cg.meta.graph_config.CHUNK_SIZE[2], + ], + mincut=True, + disallow_isolating_cut=True, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 2 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( + to_label(cg, 1, 1, 0, 0, 0) + ) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 1, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 1, 0, 0, 0) in leaves + + @pytest.mark.timeout(30) + def test_cut_no_link(self, gen_graph): + """ + No connection between 1 and 2 + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1 │ 2 │ + │ │ │ + └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + res_old = cg.client._table.read_rows() + res_old.consume_all() + + # Mincut + with pytest.raises(exceptions.PreconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + source_coords=[0, 0, 0], + sink_coords=[ + 2 * cg.meta.graph_config.CHUNK_SIZE[0], + 2 * cg.meta.graph_config.CHUNK_SIZE[1], + cg.meta.graph_config.CHUNK_SIZE[2], + ], + mincut=True, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_cut_old_link(self, gen_graph): + """ + Link between 1 and 2 got removed previously (aff = 0.0) + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1┅┅╎┅┅2 │ + │ │ │ + └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + cg.remove_edges( + "John Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ) + + res_old = cg.client._table.read_rows() + res_old.consume_all() + + # Mincut + with pytest.raises(exceptions.PreconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + source_coords=[0, 0, 0], + sink_coords=[ + 2 * cg.meta.graph_config.CHUNK_SIZE[0], + 2 * cg.meta.graph_config.CHUNK_SIZE[1], + cg.meta.graph_config.CHUNK_SIZE[2], + ], + mincut=True, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_cut_indivisible_link(self, gen_graph): + """ + Sink: 1, Source: 2 + Link between 1 and 2 is set to `inf` and must not be cut. + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1══╪══2 │ + │ │ │ + └─────┴─────┘ + """ + + cg = gen_graph(n_layers=3) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], + timestamp=fake_timestamp, + ) + + # Preparation: Build Chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], + timestamp=fake_timestamp, + ) + + add_parent_chunk( + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, + ) + + original_parents_1 = cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), get_all_parents=True + ) + original_parents_2 = cg.get_root( + to_label(cg, 1, 1, 0, 0, 0), get_all_parents=True + ) + + # Mincut + with pytest.raises(exceptions.PostconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + source_coords=[0, 0, 0], + sink_coords=[ + 2 * cg.meta.graph_config.CHUNK_SIZE[0], + 2 * cg.meta.graph_config.CHUNK_SIZE[1], + cg.meta.graph_config.CHUNK_SIZE[2], + ], + mincut=True, + ) + + new_parents_1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0), get_all_parents=True) + new_parents_2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0), get_all_parents=True) + + assert np.all(np.array(original_parents_1) == np.array(new_parents_1)) + assert np.all(np.array(original_parents_2) == np.array(new_parents_2)) + + @pytest.mark.timeout(30) + def test_mincut_disrespects_sources_or_sinks(self, gen_graph): + """ + When the mincut separates sources or sinks, an error should be thrown. + Although the mincut is setup to never cut an edge between two sources or + two sinks, this can happen when an edge along the only path between two + sources or two sinks is cut. + """ + cg = gen_graph(n_layers=2) + + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[ + to_label(cg, 1, 0, 0, 0, 0), + to_label(cg, 1, 0, 0, 0, 1), + to_label(cg, 1, 0, 0, 0, 2), + to_label(cg, 1, 0, 0, 0, 3), + ], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 2), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 3), + (to_label(cg, 1, 0, 0, 0, 2), to_label(cg, 1, 0, 0, 0, 3), 10), + ], + timestamp=fake_timestamp, + ) + + # Mincut + with pytest.raises(exceptions.PreconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + sink_ids=[to_label(cg, 1, 0, 0, 0, 3)], + source_coords=[[0, 0, 0], [10, 0, 0]], + sink_coords=[[5, 5, 0]], + mincut=True, + ) diff --git a/pychunkedgraph/tests/test_multicut.py b/pychunkedgraph/tests/test_multicut.py new file mode 100644 index 000000000..078a74f9e --- /dev/null +++ b/pychunkedgraph/tests/test_multicut.py @@ -0,0 +1,67 @@ +import numpy as np +import pytest + +from ..graph.edges import Edges +from ..graph import exceptions +from ..graph.cutting import run_multicut + + +class TestGraphMultiCut: + @pytest.mark.timeout(30) + def test_cut_multi_tree(self, gen_graph): + """ + Multicut on a graph with multiple sources and sinks and parallel paths. + Sources: [1, 2], Sinks: [5, 6] + Graph: + 1━━3━━5 + ┃ ┃ + 2━━4━━6 + The multicut should find edges to separate {1,2} from {5,6}. + """ + node_ids1 = np.array([1, 2, 3, 4, 3, 1], dtype=np.uint64) + node_ids2 = np.array([3, 4, 5, 6, 4, 2], dtype=np.uint64) + affinities = np.array([0.5, 0.5, 0.5, 0.5, 0.8, 0.9], dtype=np.float32) + edges = Edges(node_ids1, node_ids2, affinities=affinities) + source_ids = np.array([1, 2], dtype=np.uint64) + sink_ids = np.array([5, 6], dtype=np.uint64) + + cut_edges = run_multicut( + edges, source_ids, sink_ids, path_augment=False, disallow_isolating_cut=False + ) + assert cut_edges.shape[0] > 0 + + # Verify the cut actually separates sources from sinks + cut_set = set(map(tuple, cut_edges.tolist())) + remaining = set() + for i in range(len(node_ids1)): + e = (int(node_ids1[i]), int(node_ids2[i])) + if e not in cut_set and (e[1], e[0]) not in cut_set: + remaining.add(e) + + # BFS from sources through remaining edges + reachable = set(source_ids.tolist()) + changed = True + while changed: + changed = False + for a, b in remaining: + if a in reachable and b not in reachable: + reachable.add(b) + changed = True + if b in reachable and a not in reachable: + reachable.add(a) + changed = True + # Sinks should not be reachable from sources + for s in sink_ids: + assert int(s) not in reachable + + @pytest.mark.timeout(30) + def test_path_augmented_multicut(self, sv_data): + sv_edges, sv_sources, sv_sinks, sv_affinity, sv_area = sv_data + edges = Edges( + sv_edges[:, 0], sv_edges[:, 1], affinities=sv_affinity, areas=sv_area + ) + cut_edges_aug = run_multicut(edges, sv_sources, sv_sinks, path_augment=True) + assert cut_edges_aug.shape[0] == 350 + + with pytest.raises(exceptions.PreconditionError): + run_multicut(edges, sv_sources, sv_sinks, path_augment=False) diff --git a/pychunkedgraph/tests/test_node_conversion.py b/pychunkedgraph/tests/test_node_conversion.py new file mode 100644 index 000000000..68ca2810f --- /dev/null +++ b/pychunkedgraph/tests/test_node_conversion.py @@ -0,0 +1,85 @@ +import numpy as np +import pytest + +from .helpers import to_label +from ..graph.utils.serializers import serialize_uint64 +from ..graph.utils.serializers import deserialize_uint64 + + +class TestGraphNodeConversion: + @pytest.mark.timeout(30) + def test_compute_bitmasks(self, gen_graph): + cg = gen_graph(n_layers=10) + # Verify bitmasks for layer and spatial bits + node_id = cg.get_node_id(np.uint64(1), layer=2, x=0, y=0, z=0) + assert cg.get_chunk_layer(node_id) == 2 + assert cg.get_segment_id(node_id) == 1 + + # Different layers should produce different bitmask regions + for layer in range(2, 10): + nid = cg.get_node_id(np.uint64(1), layer=layer, x=0, y=0, z=0) + assert cg.get_chunk_layer(nid) == layer + + @pytest.mark.timeout(30) + def test_node_conversion(self, gen_graph): + cg = gen_graph(n_layers=10) + + node_id = cg.get_node_id(np.uint64(4), layer=2, x=3, y=1, z=0) + assert cg.get_chunk_layer(node_id) == 2 + assert np.all(cg.get_chunk_coordinates(node_id) == np.array([3, 1, 0])) + + chunk_id = cg.get_chunk_id(layer=2, x=3, y=1, z=0) + assert cg.get_chunk_layer(chunk_id) == 2 + assert np.all(cg.get_chunk_coordinates(chunk_id) == np.array([3, 1, 0])) + + assert cg.get_chunk_id(node_id=node_id) == chunk_id + assert cg.get_node_id(np.uint64(4), chunk_id=chunk_id) == node_id + + @pytest.mark.timeout(30) + def test_node_id_adjacency(self, gen_graph): + cg = gen_graph(n_layers=10) + + assert cg.get_node_id(np.uint64(0), layer=2, x=3, y=1, z=0) + np.uint64( + 1 + ) == cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0) + + assert cg.get_node_id( + np.uint64(2**53 - 2), layer=10, x=0, y=0, z=0 + ) + np.uint64(1) == cg.get_node_id( + np.uint64(2**53 - 1), layer=10, x=0, y=0, z=0 + ) + + @pytest.mark.timeout(30) + def test_serialize_node_id(self, gen_graph): + cg = gen_graph(n_layers=10) + + assert serialize_uint64( + cg.get_node_id(np.uint64(0), layer=2, x=3, y=1, z=0) + ) < serialize_uint64(cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0)) + + assert serialize_uint64( + cg.get_node_id(np.uint64(2**53 - 2), layer=10, x=0, y=0, z=0) + ) < serialize_uint64( + cg.get_node_id(np.uint64(2**53 - 1), layer=10, x=0, y=0, z=0) + ) + + @pytest.mark.timeout(30) + def test_deserialize_node_id(self, gen_graph): + cg = gen_graph(n_layers=10) + node_id = cg.get_node_id(np.uint64(4), layer=2, x=3, y=1, z=0) + serialized = serialize_uint64(node_id) + assert deserialize_uint64(serialized) == node_id + + @pytest.mark.timeout(30) + def test_serialization_roundtrip(self, gen_graph): + cg = gen_graph(n_layers=10) + # Test various node IDs across layers and positions + for layer in [2, 5, 10]: + for seg_id in [0, 1, 42, 2**16]: + node_id = cg.get_node_id(np.uint64(seg_id), layer=layer, x=0, y=0, z=0) + assert deserialize_uint64(serialize_uint64(node_id)) == node_id + + @pytest.mark.timeout(30) + def test_serialize_valid_label_id(self): + label = np.uint64(0x01FF031234556789) + assert deserialize_uint64(serialize_uint64(label)) == label diff --git a/pychunkedgraph/tests/test_operation.py b/pychunkedgraph/tests/test_operation.py index ff7cb65bd..e9d81999e 100644 --- a/pychunkedgraph/tests/test_operation.py +++ b/pychunkedgraph/tests/test_operation.py @@ -1,261 +1,248 @@ -# from collections import namedtuple - -# import numpy as np -# import pytest - -# from ..graph.operation import ( -# GraphEditOperation, -# MergeOperation, -# MulticutOperation, -# RedoOperation, -# SplitOperation, -# UndoOperation, -# ) -# from ..graph import attributes - - -# class FakeLogRecords: -# Record = namedtuple("graph_op", ("id", "record")) - -# _records = [ -# { # 0: Merge with coordinates -# attributes.OperationLogs.AddedEdge: np.array([[1, 2]], dtype=np.uint64), -# attributes.OperationLogs.SinkCoordinate: np.array([[1, 2, 3]]), -# attributes.OperationLogs.SourceCoordinate: np.array([[4, 5, 6]]), -# attributes.OperationLogs.UserID: "42", -# }, -# { # 1: Multicut with coordinates -# attributes.OperationLogs.BoundingBoxOffset: np.array([240, 240, 24]), -# attributes.OperationLogs.RemovedEdge: np.array( -# [[1, 3], [4, 1], [1, 5]], dtype=np.uint64 -# ), -# attributes.OperationLogs.SinkCoordinate: np.array([[1, 2, 3]]), -# attributes.OperationLogs.SinkID: np.array([1], dtype=np.uint64), -# attributes.OperationLogs.SourceCoordinate: np.array([[4, 5, 6]]), -# attributes.OperationLogs.SourceID: np.array([2], dtype=np.uint64), -# attributes.OperationLogs.UserID: "42", -# }, -# { # 2: Split with coordinates -# attributes.OperationLogs.RemovedEdge: np.array( -# [[1, 3], [4, 1], [1, 5]], dtype=np.uint64 -# ), -# attributes.OperationLogs.SinkCoordinate: np.array([[1, 2, 3]]), -# attributes.OperationLogs.SinkID: np.array([1], dtype=np.uint64), -# attributes.OperationLogs.SourceCoordinate: np.array([[4, 5, 6]]), -# attributes.OperationLogs.SourceID: np.array([2], dtype=np.uint64), -# attributes.OperationLogs.UserID: "42", -# }, -# { # 3: Undo of records[0] -# attributes.OperationLogs.UndoOperationID: np.uint64(0), -# attributes.OperationLogs.UserID: "42", -# }, -# { # 4: Redo of records[0] -# attributes.OperationLogs.RedoOperationID: np.uint64(0), -# attributes.OperationLogs.UserID: "42", -# }, -# {attributes.OperationLogs.UserID: "42",}, # 5: Unknown record -# ] - -# MERGE = Record(id=np.uint64(0), record=_records[0]) -# MULTICUT = Record(id=np.uint64(1), record=_records[1]) -# SPLIT = Record(id=np.uint64(2), record=_records[2]) -# UNDO = Record(id=np.uint64(3), record=_records[3]) -# REDO = Record(id=np.uint64(4), record=_records[4]) -# UNKNOWN = Record(id=np.uint64(5), record=_records[5]) - -# @classmethod -# def get(cls, idx: int): -# try: -# return cls._records[idx] -# except IndexError as err: -# raise KeyError(err) # Bigtable would throw KeyError instead - - -# @pytest.fixture(scope="function") -# def cg(mocker): -# graph = mocker.MagicMock() -# graph.get_chunk_layer = mocker.MagicMock(return_value=1) -# graph.read_log_row = mocker.MagicMock(side_effect=FakeLogRecords.get) -# return graph - - -# def test_read_from_log_merge(mocker, cg): -# """MergeOperation should be correctly identified by an existing AddedEdge column. -# Coordinates are optional.""" -# graph_operation = GraphEditOperation.from_log_record( -# cg, FakeLogRecords.MERGE.record -# ) -# assert isinstance(graph_operation, MergeOperation) - - -# def test_read_from_log_multicut(mocker, cg): -# """MulticutOperation should be correctly identified by a Sink/Source ID and -# BoundingBoxOffset column. Unless requested as SplitOperation...""" -# graph_operation = GraphEditOperation.from_log_record( -# cg, FakeLogRecords.MULTICUT.record, multicut_as_split=False -# ) -# assert isinstance(graph_operation, MulticutOperation) - -# graph_operation = GraphEditOperation.from_log_record( -# cg, FakeLogRecords.MULTICUT.record, multicut_as_split=True -# ) -# assert isinstance(graph_operation, SplitOperation) - - -# def test_read_from_log_split(mocker, cg): -# """SplitOperation should be correctly identified by the lack of a -# BoundingBoxOffset column.""" -# graph_operation = GraphEditOperation.from_log_record( -# cg, FakeLogRecords.SPLIT.record -# ) -# assert isinstance(graph_operation, SplitOperation) - - -# def test_read_from_log_undo(mocker, cg): -# """UndoOperation should be correctly identified by the UndoOperationID.""" -# graph_operation = GraphEditOperation.from_log_record(cg, FakeLogRecords.UNDO.record) -# assert isinstance(graph_operation, UndoOperation) - - -# def test_read_from_log_redo(mocker, cg): -# """RedoOperation should be correctly identified by the RedoOperationID.""" -# graph_operation = GraphEditOperation.from_log_record(cg, FakeLogRecords.REDO.record) -# assert isinstance(graph_operation, RedoOperation) - - -# def test_read_from_log_undo_undo(mocker, cg): -# """Undo[Undo[Merge]] -> Redo[Merge]""" -# fake_log_record = { -# attributes.OperationLogs.UndoOperationID: np.uint64(FakeLogRecords.UNDO.id), -# attributes.OperationLogs.UserID: "42", -# } - -# graph_operation = GraphEditOperation.from_log_record(cg, fake_log_record) -# assert isinstance(graph_operation, RedoOperation) -# assert isinstance(graph_operation.superseded_operation, MergeOperation) - - -# def test_read_from_log_undo_redo(mocker, cg): -# """Undo[Redo[Merge]] -> Undo[Merge]""" -# fake_log_record = { -# attributes.OperationLogs.UndoOperationID: np.uint64(FakeLogRecords.REDO.id), -# attributes.OperationLogs.UserID: "42", -# } - -# graph_operation = GraphEditOperation.from_log_record(cg, fake_log_record) -# assert isinstance(graph_operation, UndoOperation) -# assert isinstance(graph_operation.inverse_superseded_operation, SplitOperation) - - -# def test_read_from_log_redo_undo(mocker, cg): -# """Redo[Undo[Merge]] -> Undo[Merge]""" -# fake_log_record = { -# attributes.OperationLogs.RedoOperationID: np.uint64(FakeLogRecords.UNDO.id), -# attributes.OperationLogs.UserID: "42", -# } - -# graph_operation = GraphEditOperation.from_log_record(cg, fake_log_record) -# assert isinstance(graph_operation, UndoOperation) -# assert isinstance(graph_operation.inverse_superseded_operation, SplitOperation) - - -# def test_read_from_log_redo_redo(mocker, cg): -# """Redo[Redo[Merge]] -> Redo[Merge]""" -# fake_log_record = { -# attributes.OperationLogs.RedoOperationID: np.uint64(FakeLogRecords.REDO.id), -# attributes.OperationLogs.UserID: "42", -# } - -# graph_operation = GraphEditOperation.from_log_record(cg, fake_log_record) -# assert isinstance(graph_operation, RedoOperation) -# assert isinstance(graph_operation.superseded_operation, MergeOperation) - - -# def test_invert_merge(mocker, cg): -# """Inverse of Merge is a Split""" -# graph_operation = GraphEditOperation.from_log_record( -# cg, FakeLogRecords.MERGE.record -# ) -# inverted_graph_operation = graph_operation.invert() -# assert isinstance(inverted_graph_operation, SplitOperation) -# assert np.all( -# np.equal(graph_operation.added_edges, inverted_graph_operation.removed_edges) -# ) - - -# @pytest.mark.skip( -# reason="Can't test right now - would require recalculting the Multicut" -# ) -# def test_invert_multicut(mocker, cg): -# """Inverse of a Multicut is a Merge""" - - -# def test_invert_split(mocker, cg): -# """Inverse of Split is a Merge""" -# graph_operation = GraphEditOperation.from_log_record( -# cg, FakeLogRecords.SPLIT.record -# ) -# inverted_graph_operation = graph_operation.invert() -# assert isinstance(inverted_graph_operation, MergeOperation) -# assert np.all( -# np.equal(graph_operation.removed_edges, inverted_graph_operation.added_edges) -# ) - - -# def test_invert_undo(mocker, cg): -# """Inverse of Undo[x] is Redo[x]""" -# graph_operation = GraphEditOperation.from_log_record(cg, FakeLogRecords.UNDO.record) -# inverted_graph_operation = graph_operation.invert() -# assert isinstance(inverted_graph_operation, RedoOperation) -# assert ( -# graph_operation.superseded_operation_id -# == inverted_graph_operation.superseded_operation_id -# ) - - -# def test_invert_redo(mocker, cg): -# """Inverse of Redo[x] is Undo[x]""" -# graph_operation = GraphEditOperation.from_log_record(cg, FakeLogRecords.REDO.record) -# inverted_graph_operation = graph_operation.invert() -# assert ( -# graph_operation.superseded_operation_id -# == inverted_graph_operation.superseded_operation_id -# ) - - -# def test_undo_redo_chain_fails(mocker, cg): -# """Prevent creation of Undo/Redo chains""" -# with pytest.raises(ValueError): -# UndoOperation( -# cg, -# user_id="DAU", -# superseded_operation_id=FakeLogRecords.UNDO.id, -# multicut_as_split=False, -# ) -# with pytest.raises(ValueError): -# UndoOperation( -# cg, -# user_id="DAU", -# superseded_operation_id=FakeLogRecords.REDO.id, -# multicut_as_split=False, -# ) -# with pytest.raises(ValueError): -# RedoOperation( -# cg, -# user_id="DAU", -# superseded_operation_id=FakeLogRecords.UNDO.id, -# multicut_as_split=False, -# ) -# with pytest.raises(ValueError): -# UndoOperation( -# cg, -# user_id="DAU", -# superseded_operation_id=FakeLogRecords.REDO.id, -# multicut_as_split=False, -# ) - - -# def test_unknown_log_record_fails(cg, mocker): -# """TypeError when encountering unknown log row""" -# with pytest.raises(TypeError): -# GraphEditOperation.from_log_record(cg, FakeLogRecords.UNKNOWN.record) +"""Integration tests for GraphEditOperation and its subclasses. + +Tests operation type identification from log records, operation inversion, +and undo/redo chain resolution — all using real graph operations through +the BigTable emulator. +""" + +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import attributes +from ..graph.operation import ( + GraphEditOperation, + MergeOperation, + SplitOperation, + RedoOperation, + UndoOperation, +) +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestOperationFromLogRecord: + """Test that GraphEditOperation.from_log_record correctly identifies operation types.""" + + @pytest.fixture() + def merged_graph(self, gen_graph): + """Build a simple 2-chunk graph and perform a merge, returning (cg, operation_id).""" + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Split first to get two separate roots + split_result = cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + + # Now merge them back + merge_result = cg.add_edges( + "test_user", + atomic_edges=[[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)]], + source_coords=[0, 0, 0], + sink_coords=[0, 0, 0], + ) + return cg, merge_result.operation_id, split_result.operation_id + + @pytest.mark.timeout(30) + def test_merge_log_record_type(self, merged_graph): + """MergeOperation should be correctly identified from a real merge log record.""" + cg, merge_op_id, _ = merged_graph + log_record, _ = cg.client.read_log_entry(merge_op_id) + op_type = GraphEditOperation.get_log_record_type(log_record) + assert op_type is MergeOperation + + @pytest.mark.timeout(30) + def test_split_log_record_type(self, merged_graph): + """SplitOperation should be correctly identified from a real split log record.""" + cg, _, split_op_id = merged_graph + log_record, _ = cg.client.read_log_entry(split_op_id) + op_type = GraphEditOperation.get_log_record_type(log_record) + assert op_type is SplitOperation + + @pytest.mark.timeout(30) + def test_merge_from_log_record(self, merged_graph): + """from_log_record should return a MergeOperation for a real merge log.""" + cg, merge_op_id, _ = merged_graph + log_record, _ = cg.client.read_log_entry(merge_op_id) + graph_op = GraphEditOperation.from_log_record(cg, log_record) + assert isinstance(graph_op, MergeOperation) + + @pytest.mark.timeout(30) + def test_split_from_log_record(self, merged_graph): + """from_log_record should return a SplitOperation for a real split log.""" + cg, _, split_op_id = merged_graph + log_record, _ = cg.client.read_log_entry(split_op_id) + graph_op = GraphEditOperation.from_log_record(cg, log_record) + assert isinstance(graph_op, SplitOperation) + + @pytest.mark.timeout(30) + def test_unknown_log_record_fails(self, gen_graph): + """TypeError when encountering a log record with no recognizable operation columns.""" + cg = gen_graph(n_layers=3) + fake_record = {attributes.OperationLogs.UserID: "test_user"} + with pytest.raises(TypeError): + GraphEditOperation.from_log_record(cg, fake_record) + + +class TestOperationInversion: + """Test that operation inversion produces the correct inverse type and edges.""" + + @pytest.fixture() + def split_and_merge_ops(self, gen_graph): + """Build graph, split, merge — return (cg, merge_op_id, split_op_id).""" + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + split_result = cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + merge_result = cg.add_edges( + "test_user", + atomic_edges=[[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)]], + source_coords=[0, 0, 0], + sink_coords=[0, 0, 0], + ) + return cg, merge_result.operation_id, split_result.operation_id + + @pytest.mark.timeout(30) + def test_invert_merge_produces_split(self, split_and_merge_ops): + """Inverse of a MergeOperation should be a SplitOperation with matching edges.""" + cg, merge_op_id, _ = split_and_merge_ops + log_record, _ = cg.client.read_log_entry(merge_op_id) + merge_op = GraphEditOperation.from_log_record(cg, log_record) + inverted = merge_op.invert() + assert isinstance(inverted, SplitOperation) + assert np.all(np.equal(merge_op.added_edges, inverted.removed_edges)) + + @pytest.mark.timeout(30) + def test_invert_split_produces_merge(self, split_and_merge_ops): + """Inverse of a SplitOperation should be a MergeOperation with matching edges.""" + cg, _, split_op_id = split_and_merge_ops + log_record, _ = cg.client.read_log_entry(split_op_id) + split_op = GraphEditOperation.from_log_record(cg, log_record) + inverted = split_op.invert() + assert isinstance(inverted, MergeOperation) + assert np.all(np.equal(split_op.removed_edges, inverted.added_edges)) + + +class TestUndoRedoChainResolution: + """Test undo/redo chain resolution through real graph operations.""" + + @pytest.fixture() + def graph_with_undo(self, gen_graph): + """Build graph, perform split, then undo — return (cg, split_op_id, undo_result).""" + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Split + split_result = cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + # Undo the split (= merge) + undo_result = cg.undo_operation("test_user", split_result.operation_id) + return cg, split_result.operation_id, undo_result + + @pytest.mark.timeout(30) + def test_undo_log_record_type(self, graph_with_undo): + """Undo operation log record should be identified as UndoOperation.""" + cg, _, undo_result = graph_with_undo + log_record, _ = cg.client.read_log_entry(undo_result.operation_id) + op_type = GraphEditOperation.get_log_record_type(log_record) + assert op_type is UndoOperation + + @pytest.mark.timeout(30) + def test_undo_from_log_resolves_correctly(self, graph_with_undo): + """from_log_record on an undo record should resolve the chain to an UndoOperation.""" + cg, split_op_id, undo_result = graph_with_undo + log_record, _ = cg.client.read_log_entry(undo_result.operation_id) + resolved_op = GraphEditOperation.from_log_record(cg, log_record) + # Undo of a split -> UndoOperation whose inverse is a MergeOperation + assert isinstance(resolved_op, UndoOperation) + + @pytest.mark.timeout(30) + def test_redo_after_undo(self, graph_with_undo): + """Redo of the original split (after undo) should produce a RedoOperation log.""" + cg, split_op_id, undo_result = graph_with_undo + + # Redo the original split (which was undone) + redo_result = cg.redo_operation("test_user", split_op_id) + assert redo_result.operation_id is not None + redo_log, _ = cg.client.read_log_entry(redo_result.operation_id) + resolved_op = GraphEditOperation.from_log_record(cg, redo_log) + assert isinstance(resolved_op, RedoOperation) + + @pytest.mark.timeout(30) + def test_undo_redo_chain_prevention(self, graph_with_undo): + """Direct UndoOperation/RedoOperation on undo/redo targets should raise ValueError.""" + cg, _, undo_result = graph_with_undo + + # Direct UndoOperation on an undo record should fail + with pytest.raises(ValueError): + UndoOperation( + cg, + user_id="test_user", + superseded_operation_id=undo_result.operation_id, + multicut_as_split=True, + ) + + # Direct RedoOperation on an undo record should also fail + with pytest.raises(ValueError): + RedoOperation( + cg, + user_id="test_user", + superseded_operation_id=undo_result.operation_id, + multicut_as_split=True, + ) diff --git a/pychunkedgraph/tests/test_root_lock.py b/pychunkedgraph/tests/test_root_lock.py index a5ef7d4d2..1228c8ae9 100644 --- a/pychunkedgraph/tests/test_root_lock.py +++ b/pychunkedgraph/tests/test_root_lock.py @@ -1,104 +1,85 @@ -# from unittest.mock import DEFAULT - -# import numpy as np -# import pytest - -# from ..graph import exceptions -# from ..graph.locks import RootLock - -# G_UINT64 = np.uint64(2 ** 63) - - -# def big_uint64(): -# """Return incremental uint64 values larger than a signed int64""" -# global G_UINT64 -# if G_UINT64 == np.uint64(2 ** 64 - 1): -# G_UINT64 = np.uint64(2 ** 63) -# G_UINT64 = G_UINT64 + np.uint64(1) -# return G_UINT64 - - -# class RootLockTracker: -# def __init__(self): -# self.active_locks = dict() - -# def add_locks(self, root_ids, operation_id, **kwargs): -# if operation_id not in self.active_locks: -# self.active_locks[operation_id] = set(root_ids) -# else: -# self.active_locks[operation_id].update(root_ids) -# return DEFAULT - -# def remove_lock(self, root_id, operation_id, **kwargs): -# if operation_id in self.active_locks: -# self.active_locks[operation_id].discard(root_id) -# return DEFAULT - - -# @pytest.fixture() -# def root_lock_tracker(): -# return RootLockTracker() - - -# def test_successful_lock_acquisition(mocker, root_lock_tracker): -# """Ensure that root locks got released after successful -# root lock acquisition + *successful* graph operation""" -# fake_operation_id = big_uint64() -# fake_locked_root_ids = np.array((big_uint64(), big_uint64())) - -# cg = mocker.MagicMock() -# cg.id_client.create_operation_id = mocker.MagicMock(return_value=fake_operation_id) -# cg.client.lock_roots = mocker.MagicMock( -# return_value=(True, fake_locked_root_ids), -# side_effect=root_lock_tracker.add_locks, -# ) -# cg.client.unlock_root = mocker.MagicMock( -# return_value=True, side_effect=root_lock_tracker.remove_lock -# ) - -# with RootLock(cg, fake_locked_root_ids): -# assert fake_operation_id in root_lock_tracker.active_locks -# assert not root_lock_tracker.active_locks[fake_operation_id].difference( -# fake_locked_root_ids -# ) - -# assert not root_lock_tracker.active_locks[fake_operation_id] - - -# def test_failed_lock_acquisition(mocker): -# """Ensure that LockingError is raised when lock acquisition failed""" -# fake_operation_id = big_uint64() -# fake_locked_root_ids = np.array((big_uint64(), big_uint64())) - -# cg = mocker.MagicMock() -# cg.id_client.create_operation_id = mocker.MagicMock(return_value=fake_operation_id) -# cg.client.lock_roots = mocker.MagicMock( -# return_value=(False, fake_locked_root_ids), side_effect=None -# ) - -# with pytest.raises(exceptions.LockingError): -# with RootLock(cg, fake_locked_root_ids): -# pass - - -# def test_failed_graph_operation(mocker, root_lock_tracker): -# """Ensure that root locks got released after successful -# root lock acquisition + *unsuccessful* graph operation""" -# fake_operation_id = big_uint64() -# fake_locked_root_ids = np.array((big_uint64(), big_uint64())) - -# cg = mocker.MagicMock() -# cg.id_client.create_operation_id = mocker.MagicMock(return_value=fake_operation_id) -# cg.client.lock_roots = mocker.MagicMock( -# return_value=(True, fake_locked_root_ids), -# side_effect=root_lock_tracker.add_locks, -# ) -# cg.client.unlock_root = mocker.MagicMock( -# return_value=True, side_effect=root_lock_tracker.remove_lock -# ) - -# with pytest.raises(exceptions.PreconditionError): -# with RootLock(cg, fake_locked_root_ids): -# raise exceptions.PreconditionError("Something went wrong") - -# assert not root_lock_tracker.active_locks[fake_operation_id] +"""Integration tests for RootLock using real graph operations through the BigTable emulator. + +Tests lock acquisition, release, and behavior on operation failure. +""" + +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import exceptions +from ..graph.locks import RootLock +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestRootLock: + @pytest.fixture() + def simple_graph(self, gen_graph): + """Build a 2-chunk graph with a single edge, return (cg, root_id).""" + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + return cg, root_id + + @pytest.mark.timeout(30) + def test_successful_lock_and_release(self, simple_graph): + """Lock acquired successfully inside context, released after exit.""" + cg, root_id = simple_graph + + with RootLock(cg, np.array([root_id])) as lock: + assert lock.lock_acquired + assert len(lock.locked_root_ids) > 0 + + # After exiting the context, the lock should be released. + # Verify by acquiring the same lock again — if it wasn't released, this would fail. + with RootLock(cg, np.array([root_id])) as lock2: + assert lock2.lock_acquired + + @pytest.mark.timeout(30) + def test_lock_released_on_exception(self, simple_graph): + """Lock should be released even when an exception occurs inside the context.""" + cg, root_id = simple_graph + + with pytest.raises(exceptions.PreconditionError): + with RootLock(cg, np.array([root_id])) as lock: + assert lock.lock_acquired + raise exceptions.PreconditionError("Simulated failure") + + # Lock should still be released — acquiring again should succeed + with RootLock(cg, np.array([root_id])) as lock2: + assert lock2.lock_acquired + + @pytest.mark.timeout(30) + def test_operation_with_lock_succeeds(self, simple_graph): + """A real graph operation (split) should succeed while holding the lock.""" + cg, root_id = simple_graph + + # Use the high-level API which acquires locks internally + result = cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + assert len(result.new_root_ids) == 2 + + # After operation, locks should be released — verify we can re-acquire + new_root = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + with RootLock(cg, np.array([new_root])) as lock: + assert lock.lock_acquired diff --git a/pychunkedgraph/tests/test_split.py b/pychunkedgraph/tests/test_split.py new file mode 100644 index 000000000..6b814268a --- /dev/null +++ b/pychunkedgraph/tests/test_split.py @@ -0,0 +1,696 @@ +from datetime import datetime, timedelta, UTC +from math import inf +from warnings import warn + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph import ChunkedGraph +from ..graph import exceptions +from ..graph.misc import get_latest_roots +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGraphSplit: + @pytest.mark.timeout(30) + def test_split_pair_same_chunk(self, gen_graph): + """ + Remove edge between existing RG supervoxels 1 and 2 (same chunk) + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1━2 │ => │ 1 2 │ + │ │ │ │ + └─────┘ └─────┘ + """ + + cg: ChunkedGraph = gen_graph(n_layers=2) + + # Preparation: Build Chunk A + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], + timestamp=fake_timestamp, + ) + + # Split + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 1), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 2 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( + to_label(cg, 1, 0, 0, 0, 1) + ) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 1))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 1) in leaves + + # verify old state + cg.cache = None + assert cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) == cg.get_root(to_label(cg, 1, 0, 0, 0, 1), time_stamp=fake_timestamp) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], + leaves_only=True, + ) + ) + assert len(leaves) == 2 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 0, 0, 0, 1) in leaves + + assert len(get_latest_roots(cg)) == 2 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + def test_split_nonexisting_edge(self, gen_graph): + """ + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1━2 │ => │ 1━2 │ + │ | │ │ | │ + │ 3 │ │ 3 │ + └─────┘ └─────┘ + """ + cg = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 2), to_label(cg, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_timestamp, + ) + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 2), + mincut=False, + ).new_root_ids + assert len(new_root_ids) == 1 + + @pytest.mark.timeout(30) + def test_split_pair_neighboring_chunks(self, gen_graph): + """ + Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) + ┌─────┬─────┐ ┌─────┬─────┐ + │ A¹ │ B¹ │ │ A¹ │ B¹ │ + │ 1━━┿━━2 │ => │ 1 │ 2 │ + │ │ │ │ │ │ + └─────┴─────┘ └─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 1.0)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 1.0)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 2 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( + to_label(cg, 1, 1, 0, 0, 0) + ) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 1, 0, 0, 0))], leaves_only=True + ) + ) + assert len(leaves) == 1 and to_label(cg, 1, 1, 0, 0, 0) in leaves + + # verify old state + assert cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) == cg.get_root(to_label(cg, 1, 1, 0, 0, 0), time_stamp=fake_timestamp) + leaves = np.unique( + cg.get_subgraph( + [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], + leaves_only=True, + ) + ) + assert len(leaves) == 2 + assert to_label(cg, 1, 0, 0, 0, 0) in leaves + assert to_label(cg, 1, 1, 0, 0, 0) in leaves + assert len(get_latest_roots(cg)) == 2 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_verify_cross_chunk_edges(self, gen_graph): + """ + ┌─────┬─────┬─────┐ ┌─────┬─────┬─────┐ + | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ + | │ 1━━┿━━3 │ => | │ 1━━┿━━3 │ + | │ | │ │ | │ │ │ + | │ 2 │ │ | │ 2 │ │ + └─────┴─────┴─────┘ └─────┴─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1), 0.5), + ], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0)], + edges=[(to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], + timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 1, 0, 0, 1) + ) + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 2, 0, 0, 0) + ) + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 1), + mincut=False, + ).new_root_ids + + assert len(new_root_ids) == 2 + + svs2 = cg.get_subgraph([new_root_ids[0]], leaves_only=True) + svs1 = cg.get_subgraph([new_root_ids[1]], leaves_only=True) + len_set = {1, 2} + assert len(svs1) in len_set + len_set.remove(len(svs1)) + assert len(svs2) in len_set + + # verify new state + assert len(new_root_ids) == 2 + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) != cg.get_root( + to_label(cg, 1, 1, 0, 0, 1) + ) + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 2, 0, 0, 0) + ) + + assert len(get_latest_roots(cg)) == 2 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_verify_loop(self, gen_graph): + """ + ┌─────┬────────┬─────┐ ┌─────┬────────┬─────┐ + | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ + | │ 4━━1━━┿━━5 │ => | │ 4 1━━┿━━5 │ + | │ / │ | │ | │ │ | │ + | │ 3 2━━┿━━6 │ | │ 3 2━━┿━━6 │ + └─────┴────────┴─────┘ └─────┴────────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[ + to_label(cg, 1, 1, 0, 0, 0), + to_label(cg, 1, 1, 0, 0, 1), + to_label(cg, 1, 1, 0, 0, 2), + to_label(cg, 1, 1, 0, 0, 3), + ], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), + (to_label(cg, 1, 1, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 1), inf), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 2), 0.5), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 3), 0.5), + ], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 1), inf), + (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 0), 0.5), + ], + timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 1, 0, 0, 1) + ) + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 2, 0, 0, 0) + ) + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 2), + mincut=False, + ).new_root_ids + assert len(new_root_ids) == 2 + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 3), + mincut=False, + ).new_root_ids + assert len(new_root_ids) == 2 + + assert len(get_latest_roots(cg)) == 3 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_pair_already_disconnected(self, gen_graph): + """ + Try to remove edge between already disconnected RG supervoxels 1 and 2 (same chunk). + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1 2 │ => │ 1 2 │ + │ │ │ │ + └─────┘ └─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_timestamp, + ) + res_old = cg.client._table.read_rows() + res_old.consume_all() + + with pytest.raises(exceptions.PreconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 1), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + + if res_old.rows != res_new.rows: + warn( + "Rows were modified when splitting a pair of already disconnected supervoxels." + "While probably not an error, it is an unnecessary operation." + ) + + @pytest.mark.timeout(30) + def test_split_full_circle_to_triple_chain_same_chunk(self, gen_graph): + """ + Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (same chunk) + ┌─────┐ ┌─────┐ + │ A¹ │ │ A¹ │ + │ 1━2 │ => │ 1 2 │ + │ ┗3┛ │ │ ┗3┛ │ + └─────┘ └─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[ + to_label(cg, 1, 0, 0, 0, 0), + to_label(cg, 1, 0, 0, 0, 1), + to_label(cg, 1, 0, 0, 0, 2), + ], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 0.5), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.3), + ], + timestamp=fake_timestamp, + ) + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 1), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 1 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 2)) == new_root_ids[0] + leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) + assert len(leaves) == 3 + + # verify old state + old_root_id = cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) + assert new_root_ids[0] != old_root_id + assert len(get_latest_roots(cg)) == 1 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_full_circle_to_triple_chain_neighboring_chunks(self, gen_graph): + """ + Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection + ┌─────┬─────┐ ┌─────┬─────┐ + │ A¹ │ B¹ │ │ A¹ │ B¹ │ + │ 1━━┿━━2 │ => │ 1 │ 2 │ + │ ┗3━┿━━┛ │ │ ┗3━┿━━┛ │ + └─────┴─────┘ └─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 0), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.3), + ], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.3), + ], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + new_root_ids = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 1, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ).new_root_ids + + # verify new state + assert len(new_root_ids) == 1 + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] + assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == new_root_ids[0] + leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) + assert len(leaves) == 3 + + # verify old state + old_root_id = cg.get_root( + to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp + ) + assert new_root_ids[0] != old_root_id + assert len(get_latest_roots(cg)) == 1 + assert len(get_latest_roots(cg, fake_timestamp)) == 1 + + @pytest.mark.timeout(30) + def test_split_same_node(self, gen_graph): + """ + Try to remove (non-existing) edge between RG supervoxel 1 and itself + ┌─────┐ + │ A¹ │ + │ 1 │ => Reject + │ │ + └─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=2) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + res_old = cg.client._table.read_rows() + res_old.consume_all() + with pytest.raises(exceptions.PreconditionError): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 0), + mincut=False, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_split_pair_abstract_nodes(self, gen_graph): + """ + Try to remove (non-existing) edge between RG supervoxel 1 and abstract node "2" + => Reject + """ + + cg: ChunkedGraph = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + ) + create_chunk( + cg, vertices=[to_label(cg, 1, 1, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + res_old = cg.client._table.read_rows() + res_old.consume_all() + with pytest.raises((exceptions.PreconditionError, AssertionError)): + cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 2, 1, 0, 0, 1), + mincut=False, + ) + + res_new = cg.client._table.read_rows() + res_new.consume_all() + assert res_new.rows == res_old.rows + + @pytest.mark.timeout(30) + def test_diagonal_connections(self, gen_graph): + """ + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 2━1━┿━━3 │ + │ / │ │ + ┌─────┬─────┐ + │ | │ │ + │ 4━━┿━━5 │ + │ C¹ │ D¹ │ + └─────┴─────┘ + """ + cg: ChunkedGraph = gen_graph(n_layers=3) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf), + ], + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 1, 0, 0)], + edges=[ + (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 1, 1, 0, 0), inf), + (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), + ], + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 1, 0, 0)], + edges=[(to_label(cg, 1, 1, 1, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf)], + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + + rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=3, x=0, y=0, z=0)) + root_ids_t0 = list(rr.keys()) + assert len(root_ids_t0) == 1 + + new_roots = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 0, 0, 0, 1), + mincut=False, + ).new_root_ids + + assert len(new_roots) == 2 + assert cg.get_root(to_label(cg, 1, 1, 1, 0, 0)) == cg.get_root( + to_label(cg, 1, 0, 1, 0, 0) + ) + assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == cg.get_root( + to_label(cg, 1, 0, 0, 0, 0) + ) + + +class TestGraphSplitSkipConnections: + """Tests for skip connection behavior during split operations.""" + + @pytest.mark.timeout(120) + def test_split_multi_layer_hierarchy_correctness(self, gen_graph): + """ + After a split, verify the full parent chain from each supervoxel + to its new root is valid. + + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ => split => two separate roots + │ │ │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + result = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + assert len(result.new_root_ids) == 2 + + # Verify parent chain for both supervoxels + for sv in [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)]: + parents = cg.get_root(sv, get_all_parents=True) + prev_layer = 1 + for p in parents: + layer = cg.get_chunk_layer(p) + assert layer > prev_layer, ( + f"Parent chain not monotonically increasing: {prev_layer} -> {layer}" + ) + prev_layer = layer + # Last parent should be one of the new roots + assert parents[-1] in result.new_root_ids + + @pytest.mark.timeout(120) + def test_split_creates_isolated_components_with_skip_connections(self, gen_graph): + """ + After splitting a 3-node chain in a multi-layer graph, the isolated + node should still have a valid root. + + ┌─────┬─────┬─────┐ + │ A¹ │ B¹ │ C¹ │ + │ 1━━┿━━2━━┿━━3 │ => split 1-2 => 1 becomes isolated, 2-3 stay connected + └─────┴─────┴─────┘ + """ + cg = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5), + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), + ], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0)], + edges=[(to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # All three should share a root before split + root_pre = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + assert root_pre == cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) + assert root_pre == cg.get_root(to_label(cg, 1, 2, 0, 0, 0)) + + # Split 1 from 2 + result = cg.remove_edges( + "Jane Doe", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + assert len(result.new_root_ids) == 2 + + # Node 1 should be isolated, nodes 2 and 3 should share a root + root1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + root2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) + root3 = cg.get_root(to_label(cg, 1, 2, 0, 0, 0)) + assert root1 != root2 + assert root2 == root3 + + # Both roots should be valid + assert root1 in result.new_root_ids + assert root2 in result.new_root_ids diff --git a/pychunkedgraph/tests/test_stale_edges.py b/pychunkedgraph/tests/test_stale_edges.py new file mode 100644 index 000000000..344ef8772 --- /dev/null +++ b/pychunkedgraph/tests/test_stale_edges.py @@ -0,0 +1,202 @@ +"""Integration tests for stale edge detection and resolution. + +Tests get_stale_nodes() and get_new_nodes() from stale.py using real graph +operations through the BigTable emulator. +""" + +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..graph.edges.stale import get_stale_nodes, get_new_nodes +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestStaleEdges: + @pytest.mark.timeout(30) + def test_stale_nodes_detected_after_split(self, gen_graph): + """ + After a split, the old L2 parent IDs become stale. + get_stale_nodes should identify them. + + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Get old parents before edit + old_root = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + + # Split + cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + + # The old root should now be stale + stale = get_stale_nodes(cg, [old_root]) + assert old_root in stale + + @pytest.mark.timeout(30) + def test_no_stale_nodes_for_current_ids(self, gen_graph): + """ + Current (post-edit) node IDs should not be flagged as stale. + + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Split + cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + + # Current roots should not be stale + new_root_1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + new_root_2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) + stale = get_stale_nodes(cg, [new_root_1, new_root_2]) + assert new_root_1 not in stale + assert new_root_2 not in stale + + @pytest.mark.timeout(30) + def test_get_new_nodes_resolves_to_correct_layer(self, gen_graph): + """ + get_new_nodes should follow the parent chain from a supervoxel + to the correct layer and return the current node at that layer. + + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Get L2 parent of SV 1 before edit + sv1 = to_label(cg, 1, 0, 0, 0, 0) + old_l2_parent = cg.get_parent(sv1) + + # Split + cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + + # get_new_nodes should resolve SV to its current L2 parent + new_l2 = get_new_nodes(cg, np.array([sv1], dtype=np.uint64), layer=2) + current_l2_parent = cg.get_parent(sv1) + assert new_l2[0] == current_l2_parent + + @pytest.mark.timeout(30) + def test_no_stale_nodes_in_unaffected_region(self, gen_graph): + """ + Nodes not involved in an edit should not be flagged as stale. + + ┌─────┬─────┬─────┐ + │ A¹ │ B¹ │ C¹ │ + │ 1━━┿━━2 │ 3 │ + │ │ │ │ + └─────┴─────┴─────┘ + """ + cg = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + # Chunk C - isolated node, not connected to A or B + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + # Get the isolated node's root before edit + isolated_root = cg.get_root(to_label(cg, 1, 2, 0, 0, 0)) + + # Split nodes 1 and 2 + cg.remove_edges( + "test_user", + source_ids=to_label(cg, 1, 0, 0, 0, 0), + sink_ids=to_label(cg, 1, 1, 0, 0, 0), + mincut=False, + ) + + # The isolated root should not be stale — it was unaffected + stale = get_stale_nodes(cg, [isolated_root]) + assert isolated_root not in stale diff --git a/pychunkedgraph/tests/test_uncategorized.py b/pychunkedgraph/tests/test_uncategorized.py deleted file mode 100644 index 5c2de29d4..000000000 --- a/pychunkedgraph/tests/test_uncategorized.py +++ /dev/null @@ -1,3415 +0,0 @@ -from time import sleep -from datetime import datetime, timedelta, UTC -from math import inf -from warnings import warn - -import numpy as np -import pytest - -from .helpers import ( - bigtable_emulator, - create_chunk, - gen_graph, - gen_graph_simplequerytest, - to_label, - sv_data, -) -from ..graph import ChunkedGraph -from ..graph import types -from ..graph import attributes -from ..graph import exceptions -from ..graph.edges import Edges -from ..graph.utils import basetypes -from ..graph.lineage import lineage_graph -from ..graph.misc import get_delta_roots, get_latest_roots -from ..graph.cutting import run_multicut -from ..graph.lineage import get_root_id_history -from ..graph.lineage import get_future_root_ids -from ..graph.utils.serializers import serialize_uint64 -from ..graph.utils.serializers import deserialize_uint64 -from ..ingest.create.parent_layer import add_parent_chunk - - -class TestGraphNodeConversion: - @pytest.mark.timeout(30) - def test_compute_bitmasks(self): - pass - - @pytest.mark.timeout(30) - def test_node_conversion(self, gen_graph): - cg = gen_graph(n_layers=10) - - node_id = cg.get_node_id(np.uint64(4), layer=2, x=3, y=1, z=0) - assert cg.get_chunk_layer(node_id) == 2 - assert np.all(cg.get_chunk_coordinates(node_id) == np.array([3, 1, 0])) - - chunk_id = cg.get_chunk_id(layer=2, x=3, y=1, z=0) - assert cg.get_chunk_layer(chunk_id) == 2 - assert np.all(cg.get_chunk_coordinates(chunk_id) == np.array([3, 1, 0])) - - assert cg.get_chunk_id(node_id=node_id) == chunk_id - assert cg.get_node_id(np.uint64(4), chunk_id=chunk_id) == node_id - - @pytest.mark.timeout(30) - def test_node_id_adjacency(self, gen_graph): - cg = gen_graph(n_layers=10) - - assert cg.get_node_id(np.uint64(0), layer=2, x=3, y=1, z=0) + np.uint64( - 1 - ) == cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0) - - assert cg.get_node_id( - np.uint64(2**53 - 2), layer=10, x=0, y=0, z=0 - ) + np.uint64(1) == cg.get_node_id( - np.uint64(2**53 - 1), layer=10, x=0, y=0, z=0 - ) - - @pytest.mark.timeout(30) - def test_serialize_node_id(self, gen_graph): - cg = gen_graph(n_layers=10) - - assert serialize_uint64( - cg.get_node_id(np.uint64(0), layer=2, x=3, y=1, z=0) - ) < serialize_uint64(cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0)) - - assert serialize_uint64( - cg.get_node_id(np.uint64(2**53 - 2), layer=10, x=0, y=0, z=0) - ) < serialize_uint64( - cg.get_node_id(np.uint64(2**53 - 1), layer=10, x=0, y=0, z=0) - ) - - @pytest.mark.timeout(30) - def test_deserialize_node_id(self): - pass - - @pytest.mark.timeout(30) - def test_serialization_roundtrip(self): - pass - - @pytest.mark.timeout(30) - def test_serialize_valid_label_id(self): - label = np.uint64(0x01FF031234556789) - assert deserialize_uint64(serialize_uint64(label)) == label - - -class TestGraphBuild: - @pytest.mark.timeout(30) - def test_build_single_node(self, gen_graph): - """ - Create graph with single RG node 1 in chunk A - ┌─────┐ - │ A¹ │ - │ 1 │ - │ │ - └─────┘ - """ - - cg = gen_graph(n_layers=2) - # Add Chunk A - create_chunk(cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)]) - - res = cg.client._table.read_rows() - res.consume_all() - - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) - assert parent == to_label(cg, 2, 0, 0, 0, 1) - - # Check for the one Level 2 node that should have been created. - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows - atomic_cross_edge_d = cg.get_atomic_cross_edges( - np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) - ) - attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] - children = attr.deserialize(row[attr.key][0].value) - - for aces in atomic_cross_edge_d.values(): - assert len(aces) == 0 - - assert len(children) == 1 and children[0] == to_label(cg, 1, 0, 0, 0, 0) - # Make sure there are not any more entries in the table - # include counters, meta and version rows - assert len(res.rows) == 1 + 1 + 1 + 1 + 1 - - @pytest.mark.timeout(30) - def test_build_single_edge(self, gen_graph): - """ - Create graph with edge between RG supervoxels 1 and 2 (same chunk) - ┌─────┐ - │ A¹ │ - │ 1━2 │ - │ │ - └─────┘ - """ - - cg = gen_graph(n_layers=2) - - # Add Chunk A - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], - ) - - res = cg.client._table.read_rows() - res.consume_all() - - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) - assert parent == to_label(cg, 2, 0, 0, 0, 1) - - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) - assert parent == to_label(cg, 2, 0, 0, 0, 1) - - # Check for the one Level 2 node that should have been created. - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows - - atomic_cross_edge_d = cg.get_atomic_cross_edges( - np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) - ) - attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] - children = attr.deserialize(row[attr.key][0].value) - - for aces in atomic_cross_edge_d.values(): - assert len(aces) == 0 - assert ( - len(children) == 2 - and to_label(cg, 1, 0, 0, 0, 0) in children - and to_label(cg, 1, 0, 0, 0, 1) in children - ) - - # Make sure there are not any more entries in the table - # include counters, meta and version rows - assert len(res.rows) == 2 + 1 + 1 + 1 + 1 - - @pytest.mark.timeout(30) - def test_build_single_across_edge(self, gen_graph): - """ - Create graph with edge between RG supervoxels 1 and 2 (neighboring chunks) - ┌─────┌─────┐ - │ A¹ │ B¹ │ - │ 1━━┿━━2 │ - │ │ │ - └─────┴─────┘ - """ - - atomic_chunk_bounds = np.array([2, 1, 1]) - cg = gen_graph(n_layers=3, atomic_chunk_bounds=atomic_chunk_bounds) - - # Chunk A - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], - ) - - # Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], - ) - - add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) - res = cg.client._table.read_rows() - res.consume_all() - - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) - assert parent == to_label(cg, 2, 0, 0, 0, 1) - - assert serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) - assert parent == to_label(cg, 2, 1, 0, 0, 1) - - # Check for the two Level 2 nodes that should have been created. Since Level 2 has the same - # dimensions as Level 1, we also expect them to be in different chunks - # to_label(cg, 2, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows - atomic_cross_edge_d = cg.get_atomic_cross_edges( - np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) - ) - atomic_cross_edge_d = atomic_cross_edge_d[ - np.uint64(to_label(cg, 2, 0, 0, 0, 1)) - ] - - attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] - children = attr.deserialize(row[attr.key][0].value) - - test_ace = np.array( - [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], - dtype=np.uint64, - ) - assert len(atomic_cross_edge_d[2]) == 1 - assert test_ace in atomic_cross_edge_d[2] - assert len(children) == 1 and to_label(cg, 1, 0, 0, 0, 0) in children - - # to_label(cg, 2, 1, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows - atomic_cross_edge_d = cg.get_atomic_cross_edges( - np.array([to_label(cg, 2, 1, 0, 0, 1)], dtype=basetypes.NODE_ID) - ) - atomic_cross_edge_d = atomic_cross_edge_d[ - np.uint64(to_label(cg, 2, 1, 0, 0, 1)) - ] - - attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells["0"] - children = attr.deserialize(row[attr.key][0].value) - - test_ace = np.array( - [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], - dtype=np.uint64, - ) - assert len(atomic_cross_edge_d[2]) == 1 - assert test_ace in atomic_cross_edge_d[2] - assert len(children) == 1 and to_label(cg, 1, 1, 0, 0, 0) in children - - # Check for the one Level 3 node that should have been created. This one combines the two - # connected components of Level 2 - # to_label(cg, 3, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows - - attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells["0"] - children = attr.deserialize(row[attr.key][0].value) - assert ( - len(children) == 2 - and to_label(cg, 2, 0, 0, 0, 1) in children - and to_label(cg, 2, 1, 0, 0, 1) in children - ) - - # Make sure there are not any more entries in the table - # include counters, meta and version rows - assert len(res.rows) == 2 + 2 + 1 + 3 + 1 + 1 - - @pytest.mark.timeout(30) - def test_build_single_edge_and_single_across_edge(self, gen_graph): - """ - Create graph with edge between RG supervoxels 1 and 2 (same chunk) - and edge between RG supervoxels 1 and 3 (neighboring chunks) - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 2━1━┿━━3 │ - │ │ │ - └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Chunk A - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), - ], - ) - - # Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], - ) - - add_parent_chunk(cg, 3, np.array([0, 0, 0]), n_threads=1) - res = cg.client._table.read_rows() - res.consume_all() - - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) - assert parent == to_label(cg, 2, 0, 0, 0, 1) - - # to_label(cg, 1, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) - assert parent == to_label(cg, 2, 0, 0, 0, 1) - - # to_label(cg, 1, 1, 0, 0, 0) - assert serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows - parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) - assert parent == to_label(cg, 2, 1, 0, 0, 1) - - # Check for the two Level 2 nodes that should have been created. Since Level 2 has the same - # dimensions as Level 1, we also expect them to be in different chunks - # to_label(cg, 2, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] - atomic_cross_edge_d = cg.get_atomic_cross_edges([to_label(cg, 2, 0, 0, 0, 1)]) - atomic_cross_edge_d = atomic_cross_edge_d[ - np.uint64(to_label(cg, 2, 0, 0, 0, 1)) - ] - column = attributes.Hierarchy.Child - children = column.deserialize(row[column.key][0].value) - - test_ace = np.array( - [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], - dtype=np.uint64, - ) - assert len(atomic_cross_edge_d[2]) == 1 - assert test_ace in atomic_cross_edge_d[2] - assert ( - len(children) == 2 - and to_label(cg, 1, 0, 0, 0, 0) in children - and to_label(cg, 1, 0, 0, 0, 1) in children - ) - - # to_label(cg, 2, 1, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows - row = res.rows[serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells["0"] - atomic_cross_edge_d = cg.get_atomic_cross_edges([to_label(cg, 2, 1, 0, 0, 1)]) - atomic_cross_edge_d = atomic_cross_edge_d[ - np.uint64(to_label(cg, 2, 1, 0, 0, 1)) - ] - children = column.deserialize(row[column.key][0].value) - - test_ace = np.array( - [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], - dtype=np.uint64, - ) - assert len(atomic_cross_edge_d[2]) == 1 - assert test_ace in atomic_cross_edge_d[2] - assert len(children) == 1 and to_label(cg, 1, 1, 0, 0, 0) in children - - # Check for the one Level 3 node that should have been created. This one combines the two - # connected components of Level 2 - # to_label(cg, 3, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows - row = res.rows[serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells["0"] - column = attributes.Hierarchy.Child - children = column.deserialize(row[column.key][0].value) - - assert ( - len(children) == 2 - and to_label(cg, 2, 0, 0, 0, 1) in children - and to_label(cg, 2, 1, 0, 0, 1) in children - ) - - # Make sure there are not any more entries in the table - # include counters, meta and version rows - assert len(res.rows) == 3 + 2 + 1 + 3 + 1 + 1 - - @pytest.mark.timeout(120) - def test_build_big_graph(self, gen_graph): - """ - Create graph with RG nodes 1 and 2 in opposite corners of the largest possible dataset - ┌─────┐ ┌─────┐ - │ A¹ │ ... │ Z¹ │ - │ 1 │ │ 2 │ - │ │ │ │ - └─────┘ └─────┘ - """ - - atomic_chunk_bounds = np.array([8, 8, 8]) - cg = gen_graph(n_layers=5, atomic_chunk_bounds=atomic_chunk_bounds) - - # Preparation: Build Chunk A - create_chunk(cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], edges=[]) - - # Preparation: Build Chunk Z - create_chunk(cg, vertices=[to_label(cg, 1, 7, 7, 7, 0)], edges=[]) - - add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) - add_parent_chunk(cg, 3, [3, 3, 3], n_threads=1) - add_parent_chunk(cg, 4, [0, 0, 0], n_threads=1) - add_parent_chunk(cg, 5, [0, 0, 0], n_threads=1) - - res = cg.client._table.read_rows() - res.consume_all() - - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows - assert serialize_uint64(to_label(cg, 1, 7, 7, 7, 0)) in res.rows - assert serialize_uint64(to_label(cg, 5, 0, 0, 0, 1)) in res.rows - assert serialize_uint64(to_label(cg, 5, 0, 0, 0, 2)) in res.rows - - @pytest.mark.timeout(30) - def test_double_chunk_creation(self, gen_graph): - """ - No connection between 1, 2 and 3 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 3 │ - │ 2 │ │ - └─────┴─────┘ - """ - - atomic_chunk_bounds = np.array([4, 4, 4]) - cg = gen_graph(n_layers=4, atomic_chunk_bounds=atomic_chunk_bounds) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 4, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - assert len(cg.range_read_chunk(cg.get_chunk_id(layer=2, x=0, y=0, z=0))) == 2 - assert len(cg.range_read_chunk(cg.get_chunk_id(layer=2, x=1, y=0, z=0))) == 1 - assert len(cg.range_read_chunk(cg.get_chunk_id(layer=3, x=0, y=0, z=0))) == 0 - assert len(cg.range_read_chunk(cg.get_chunk_id(layer=4, x=0, y=0, z=0))) == 6 - - assert cg.get_chunk_layer(cg.get_root(to_label(cg, 1, 0, 0, 0, 1))) == 4 - assert cg.get_chunk_layer(cg.get_root(to_label(cg, 1, 0, 0, 0, 2))) == 4 - assert cg.get_chunk_layer(cg.get_root(to_label(cg, 1, 1, 0, 0, 1))) == 4 - - root_seg_ids = [ - cg.get_segment_id(cg.get_root(to_label(cg, 1, 0, 0, 0, 1))), - cg.get_segment_id(cg.get_root(to_label(cg, 1, 0, 0, 0, 2))), - cg.get_segment_id(cg.get_root(to_label(cg, 1, 1, 0, 0, 1))), - ] - - assert 4 in root_seg_ids - assert 5 in root_seg_ids - assert 6 in root_seg_ids - - -class TestGraphSimpleQueries: - """ - ┌─────┬─────┬─────┐ L X Y Z S L X Y Z S L X Y Z S L X Y Z S - │ A¹ │ B¹ │ C¹ │ 1: 1 0 0 0 0 ─── 2 0 0 0 1 ───────────────── 4 0 0 0 1 - │ 1 │ 3━2━┿━━4 │ 2: 1 1 0 0 0 ─┬─ 2 1 0 0 1 ─── 3 0 0 0 1 ─┬─ 4 0 0 0 2 - │ │ │ │ 3: 1 1 0 0 1 ─┘ │ - └─────┴─────┴─────┘ 4: 1 2 0 0 0 ─── 2 2 0 0 1 ─── 3 1 0 0 1 ─┘ - """ - - @pytest.mark.timeout(30) - def test_get_parent_and_children(self, gen_graph_simplequerytest): - cg = gen_graph_simplequerytest - - children10000 = cg.get_children(to_label(cg, 1, 0, 0, 0, 0)) - children11000 = cg.get_children(to_label(cg, 1, 1, 0, 0, 0)) - children11001 = cg.get_children(to_label(cg, 1, 1, 0, 0, 1)) - children12000 = cg.get_children(to_label(cg, 1, 2, 0, 0, 0)) - - parent10000 = cg.get_parent( - to_label(cg, 1, 0, 0, 0, 0), - ) - parent11000 = cg.get_parent( - to_label(cg, 1, 1, 0, 0, 0), - ) - parent11001 = cg.get_parent( - to_label(cg, 1, 1, 0, 0, 1), - ) - parent12000 = cg.get_parent( - to_label(cg, 1, 2, 0, 0, 0), - ) - - children20001 = cg.get_children(to_label(cg, 2, 0, 0, 0, 1)) - children21001 = cg.get_children(to_label(cg, 2, 1, 0, 0, 1)) - children22001 = cg.get_children(to_label(cg, 2, 2, 0, 0, 1)) - - parent20001 = cg.get_parent( - to_label(cg, 2, 0, 0, 0, 1), - ) - parent21001 = cg.get_parent( - to_label(cg, 2, 1, 0, 0, 1), - ) - parent22001 = cg.get_parent( - to_label(cg, 2, 2, 0, 0, 1), - ) - - children30001 = cg.get_children(to_label(cg, 3, 0, 0, 0, 1)) - # children30002 = cg.get_children(to_label(cg, 3, 0, 0, 0, 2)) - children31001 = cg.get_children(to_label(cg, 3, 1, 0, 0, 1)) - - parent30001 = cg.get_parent( - to_label(cg, 3, 0, 0, 0, 1), - ) - # parent30002 = cg.get_parent(to_label(cg, 3, 0, 0, 0, 2), ) - parent31001 = cg.get_parent( - to_label(cg, 3, 1, 0, 0, 1), - ) - - children40001 = cg.get_children(to_label(cg, 4, 0, 0, 0, 1)) - children40002 = cg.get_children(to_label(cg, 4, 0, 0, 0, 2)) - - parent40001 = cg.get_parent( - to_label(cg, 4, 0, 0, 0, 1), - ) - parent40002 = cg.get_parent( - to_label(cg, 4, 0, 0, 0, 2), - ) - - # (non-existing) Children of L1 - assert np.array_equal(children10000, []) is True - assert np.array_equal(children11000, []) is True - assert np.array_equal(children11001, []) is True - assert np.array_equal(children12000, []) is True - - # Parent of L1 - assert parent10000 == to_label(cg, 2, 0, 0, 0, 1) - assert parent11000 == to_label(cg, 2, 1, 0, 0, 1) - assert parent11001 == to_label(cg, 2, 1, 0, 0, 1) - assert parent12000 == to_label(cg, 2, 2, 0, 0, 1) - - # Children of L2 - assert len(children20001) == 1 and to_label(cg, 1, 0, 0, 0, 0) in children20001 - assert ( - len(children21001) == 2 - and to_label(cg, 1, 1, 0, 0, 0) in children21001 - and to_label(cg, 1, 1, 0, 0, 1) in children21001 - ) - assert len(children22001) == 1 and to_label(cg, 1, 2, 0, 0, 0) in children22001 - - # Parent of L2 - assert parent20001 == to_label(cg, 4, 0, 0, 0, 1) - assert parent21001 == to_label(cg, 3, 0, 0, 0, 1) - assert parent22001 == to_label(cg, 3, 1, 0, 0, 1) - - # Children of L3 - assert len(children30001) == 1 and len(children31001) == 1 - assert to_label(cg, 2, 1, 0, 0, 1) in children30001 - assert to_label(cg, 2, 2, 0, 0, 1) in children31001 - - # Parent of L3 - assert parent30001 == parent31001 - assert ( - parent30001 == to_label(cg, 4, 0, 0, 0, 1) - and parent20001 == to_label(cg, 4, 0, 0, 0, 2) - ) or ( - parent30001 == to_label(cg, 4, 0, 0, 0, 2) - and parent20001 == to_label(cg, 4, 0, 0, 0, 1) - ) - - # Children of L4 - assert parent10000 in children40001 - assert parent21001 in children40002 and parent22001 in children40002 - - # (non-existing) Parent of L4 - assert parent40001 is None - assert parent40002 is None - - children2_separate = cg.get_children( - [ - to_label(cg, 2, 0, 0, 0, 1), - to_label(cg, 2, 1, 0, 0, 1), - to_label(cg, 2, 2, 0, 0, 1), - ] - ) - assert len(children2_separate) == 3 - assert to_label(cg, 2, 0, 0, 0, 1) in children2_separate and np.all( - np.isin(children2_separate[to_label(cg, 2, 0, 0, 0, 1)], children20001) - ) - assert to_label(cg, 2, 1, 0, 0, 1) in children2_separate and np.all( - np.isin(children2_separate[to_label(cg, 2, 1, 0, 0, 1)], children21001) - ) - assert to_label(cg, 2, 2, 0, 0, 1) in children2_separate and np.all( - np.isin(children2_separate[to_label(cg, 2, 2, 0, 0, 1)], children22001) - ) - - children2_combined = cg.get_children( - [ - to_label(cg, 2, 0, 0, 0, 1), - to_label(cg, 2, 1, 0, 0, 1), - to_label(cg, 2, 2, 0, 0, 1), - ], - flatten=True, - ) - assert ( - len(children2_combined) == 4 - and np.all(np.isin(children20001, children2_combined)) - and np.all(np.isin(children21001, children2_combined)) - and np.all(np.isin(children22001, children2_combined)) - ) - - @pytest.mark.timeout(30) - def test_get_root(self, gen_graph_simplequerytest): - cg = gen_graph_simplequerytest - root10000 = cg.get_root( - to_label(cg, 1, 0, 0, 0, 0), - ) - root11000 = cg.get_root( - to_label(cg, 1, 1, 0, 0, 0), - ) - root11001 = cg.get_root( - to_label(cg, 1, 1, 0, 0, 1), - ) - root12000 = cg.get_root( - to_label(cg, 1, 2, 0, 0, 0), - ) - - with pytest.raises(Exception): - cg.get_root(0) - - assert ( - root10000 == to_label(cg, 4, 0, 0, 0, 1) - and root11000 == root11001 == root12000 == to_label(cg, 4, 0, 0, 0, 2) - ) or ( - root10000 == to_label(cg, 4, 0, 0, 0, 2) - and root11000 == root11001 == root12000 == to_label(cg, 4, 0, 0, 0, 1) - ) - - @pytest.mark.timeout(30) - def test_get_subgraph_nodes(self, gen_graph_simplequerytest): - cg = gen_graph_simplequerytest - root1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) - root2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) - - lvl1_nodes_1 = cg.get_subgraph([root1], leaves_only=True) - lvl1_nodes_2 = cg.get_subgraph([root2], leaves_only=True) - assert len(lvl1_nodes_1) == 1 - assert len(lvl1_nodes_2) == 3 - assert to_label(cg, 1, 0, 0, 0, 0) in lvl1_nodes_1 - assert to_label(cg, 1, 1, 0, 0, 0) in lvl1_nodes_2 - assert to_label(cg, 1, 1, 0, 0, 1) in lvl1_nodes_2 - assert to_label(cg, 1, 2, 0, 0, 0) in lvl1_nodes_2 - - lvl2_parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) - lvl1_nodes = cg.get_subgraph([lvl2_parent], leaves_only=True) - assert len(lvl1_nodes) == 2 - assert to_label(cg, 1, 1, 0, 0, 0) in lvl1_nodes - assert to_label(cg, 1, 1, 0, 0, 1) in lvl1_nodes - - @pytest.mark.timeout(30) - def test_get_subgraph_edges(self, gen_graph_simplequerytest): - cg = gen_graph_simplequerytest - root1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) - root2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) - - edges = cg.get_subgraph([root1], edges_only=True) - assert len(edges) == 0 - - edges = cg.get_subgraph([root2], edges_only=True) - assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)] in edges or [ - to_label(cg, 1, 1, 0, 0, 1), - to_label(cg, 1, 1, 0, 0, 0), - ] in edges - - assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0)] in edges or [ - to_label(cg, 1, 2, 0, 0, 0), - to_label(cg, 1, 1, 0, 0, 0), - ] in edges - - lvl2_parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) - edges = cg.get_subgraph([lvl2_parent], edges_only=True) - assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)] in edges or [ - to_label(cg, 1, 1, 0, 0, 1), - to_label(cg, 1, 1, 0, 0, 0), - ] in edges - - assert [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0)] in edges or [ - to_label(cg, 1, 2, 0, 0, 0), - to_label(cg, 1, 1, 0, 0, 0), - ] in edges - - assert len(edges) == 1 - - @pytest.mark.timeout(30) - def test_get_subgraph_nodes_bb(self, gen_graph_simplequerytest): - cg = gen_graph_simplequerytest - bb = np.array([[1, 0, 0], [2, 1, 1]], dtype=int) - bb_coord = bb * cg.meta.graph_config.CHUNK_SIZE - childs_1 = cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 1, 0, 0, 1))], bbox=bb, leaves_only=True - ) - childs_2 = cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 1, 0, 0, 1))], - bbox=bb_coord, - bbox_is_coordinate=True, - leaves_only=True, - ) - assert np.all(~(np.sort(childs_1) - np.sort(childs_2))) - - -class TestGraphMerge: - @pytest.mark.timeout(30) - def test_merge_pair_same_chunk(self, gen_graph): - """ - Add edge between existing RG supervoxels 1 and 2 (same chunk) - Expected: Same (new) parent for RG 1 and 2 on Layer two - ┌─────┐ ┌─────┐ - │ A¹ │ │ A¹ │ - │ 1 2 │ => │ 1━2 │ - │ │ │ │ - └─────┘ └─────┘ - """ - - atomic_chunk_bounds = np.array([1, 1, 1]) - cg = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - # Merge - new_root_ids = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 0)], - affinities=[0.3], - ).new_root_ids - - assert len(new_root_ids) == 1 - new_root_id = new_root_ids[0] - - # Check - assert cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id - assert cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) == new_root_id - leaves = np.unique(cg.get_subgraph([new_root_id], leaves_only=True)) - assert len(leaves) == 2 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 0, 0, 0, 1) in leaves - - @pytest.mark.timeout(30) - def test_merge_pair_neighboring_chunks(self, gen_graph): - """ - Add edge between existing RG supervoxels 1 and 2 (neighboring chunks) - ┌─────┬─────┐ ┌─────┬─────┐ - │ A¹ │ B¹ │ │ A¹ │ B¹ │ - │ 1 │ 2 │ => │ 1━━┿━━2 │ - │ │ │ │ │ │ - └─────┴─────┘ └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - # Merge - new_root_ids = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], - affinities=0.3, - ).new_root_ids - - assert len(new_root_ids) == 1 - new_root_id = new_root_ids[0] - - # Check - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == new_root_id - leaves = np.unique(cg.get_subgraph([new_root_id], leaves_only=True)) - assert len(leaves) == 2 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 1, 0, 0, 0) in leaves - - @pytest.mark.timeout(120) - def test_merge_pair_disconnected_chunks(self, gen_graph): - """ - Add edge between existing RG supervoxels 1 and 2 (disconnected chunks) - ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ - │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ - │ 1 │ │ 2 │ => │ 1━━┿━━━━━┿━━2 │ - │ │ │ │ │ │ │ │ - └─────┘ └─────┘ └─────┘ └─────┘ - """ - - cg = gen_graph(n_layers=5) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk Z - create_chunk( - cg, - vertices=[to_label(cg, 1, 7, 7, 7, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 3, - [3, 3, 3], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 4, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 5, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - # Merge - result = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 0)], - affinities=[0.3], - ) - new_root_ids, lvl2_node_ids = result.new_root_ids, result.new_lvl2_ids - print(f"lvl2_node_ids: {lvl2_node_ids}") - - u_layers = np.unique(cg.get_chunk_layers(lvl2_node_ids)) - assert len(u_layers) == 1 - assert u_layers[0] == 2 - - assert len(new_root_ids) == 1 - new_root_id = new_root_ids[0] - - # Check - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id - assert cg.get_root(to_label(cg, 1, 7, 7, 7, 0)) == new_root_id - leaves = np.unique(cg.get_subgraph(new_root_id, leaves_only=True)) - assert len(leaves) == 2 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 7, 7, 7, 0) in leaves - - @pytest.mark.timeout(30) - def test_merge_pair_already_connected(self, gen_graph): - """ - Add edge between already connected RG supervoxels 1 and 2 (same chunk). - Expected: No change, i.e. same parent (to_label(cg, 2, 0, 0, 0, 1)), affinity (0.5) and timestamp as before - ┌─────┐ ┌─────┐ - │ A¹ │ │ A¹ │ - │ 1━2 │ => │ 1━2 │ - │ │ │ │ - └─────┘ └─────┘ - """ - - cg = gen_graph(n_layers=2) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], - timestamp=fake_timestamp, - ) - - res_old = cg.client._table.read_rows() - res_old.consume_all() - - # Merge - with pytest.raises(Exception): - cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 0)], - ) - res_new = cg.client._table.read_rows() - res_new.consume_all() - - # Check - if res_old.rows != res_new.rows: - warn( - "Rows were modified when merging a pair of already connected supervoxels. " - "While probably not an error, it is an unnecessary operation." - ) - - @pytest.mark.timeout(30) - def test_merge_triple_chain_to_full_circle_same_chunk(self, gen_graph): - """ - Add edge between indirectly connected RG supervoxels 1 and 2 (same chunk) - ┌─────┐ ┌─────┐ - │ A¹ │ │ A¹ │ - │ 1 2 │ => │ 1━2 │ - │ ┗3┛ │ │ ┗3┛ │ - └─────┘ └─────┘ - """ - - cg = gen_graph(n_layers=2) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[ - to_label(cg, 1, 0, 0, 0, 0), - to_label(cg, 1, 0, 0, 0, 1), - to_label(cg, 1, 0, 0, 0, 2), - ], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 0.5), - (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 0.5), - ], - timestamp=fake_timestamp, - ) - - # Merge - with pytest.raises(Exception): - cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 0)], - affinities=0.3, - ).new_root_ids - - @pytest.mark.timeout(30) - def test_merge_triple_chain_to_full_circle_neighboring_chunks(self, gen_graph): - """ - Add edge between indirectly connected RG supervoxels 1 and 2 (neighboring chunks) - ┌─────┬─────┐ ┌─────┬─────┐ - │ A¹ │ B¹ │ │ A¹ │ B¹ │ - │ 1 │ 2 │ => │ 1━━┿━━2 │ - │ ┗3━┿━━┛ │ │ ┗3━┿━━┛ │ - └─────┴─────┘ └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 0), inf), - ], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), inf)], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - # Merge - with pytest.raises(Exception): - cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], - affinities=1.0, - ).new_root_ids - - @pytest.mark.timeout(120) - def test_merge_triple_chain_to_full_circle_disconnected_chunks(self, gen_graph): - """ - Add edge between indirectly connected RG supervoxels 1 and 2 (disconnected chunks) - ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ - │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ - │ 1 │ │ 2 │ => │ 1━━┿━━━━━┿━━2 │ - │ ┗3━┿━━━━━┿━━┛ │ │ ┗3━┿━━━━━┿━━┛ │ - └─────┘ └─────┘ └─────┘ └─────┘ - """ - - cg = gen_graph(n_layers=5) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - ( - to_label(cg, 1, 0, 0, 0, 1), - to_label(cg, 1, 7, 7, 7, 0), - inf, - ), - ], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 7, 7, 7, 0)], - edges=[ - ( - to_label(cg, 1, 7, 7, 7, 0), - to_label(cg, 1, 0, 0, 0, 1), - inf, - ) - ], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 3, - [3, 3, 3], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 4, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 4, - [1, 1, 1], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 5, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - # Merge - new_root_ids = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 0)], - affinities=1.0, - ).new_root_ids - - assert len(new_root_ids) == 1 - new_root_id = new_root_ids[0] - - # Check - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_id - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_id - assert cg.get_root(to_label(cg, 1, 7, 7, 7, 0)) == new_root_id - leaves = np.unique(cg.get_subgraph(new_root_id, leaves_only=True)) - assert len(leaves) == 3 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 0, 0, 0, 1) in leaves - assert to_label(cg, 1, 7, 7, 7, 0) in leaves - - @pytest.mark.timeout(30) - def test_merge_same_node(self, gen_graph): - """ - Try to add loop edge between RG supervoxel 1 and itself - ┌─────┐ - │ A¹ │ - │ 1 │ => Reject - │ │ - └─────┘ - """ - - cg = gen_graph(n_layers=2) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - res_old = cg.client._table.read_rows() - res_old.consume_all() - - # Merge - with pytest.raises(Exception): - cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], - ) - - res_new = cg.client._table.read_rows() - res_new.consume_all() - - assert res_new.rows == res_old.rows - - @pytest.mark.timeout(30) - def test_merge_pair_abstract_nodes(self, gen_graph): - """ - Try to add edge between RG supervoxel 1 and abstract node "2" - ┌─────┐ - │ B² │ - │ "2" │ - │ │ - └─────┘ - ┌─────┐ => Reject - │ A¹ │ - │ 1 │ - │ │ - └─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - res_old = cg.client._table.read_rows() - res_old.consume_all() - - # Merge - with pytest.raises(Exception): - cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 2, 1, 0, 0, 1)], - ) - - res_new = cg.client._table.read_rows() - res_new.consume_all() - - assert res_new.rows == res_old.rows - - @pytest.mark.timeout(30) - def test_diagonal_connections(self, gen_graph): - """ - Create graph with edge between RG supervoxels 1 and 2 (same chunk) - and edge between RG supervoxels 1 and 3 (neighboring chunks) - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 2 1━┿━━3 │ - │ / │ │ - ┌─────┬─────┐ - │ | │ │ - │ 4━━┿━━5 │ - │ C¹ │ D¹ │ - └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Chunk A - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf), - ], - ) - - # Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], - ) - - # Chunk C - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 1, 0, 0)], - edges=[ - (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 1, 1, 0, 0), inf), - (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), - ], - ) - - # Chunk D - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 1, 0, 0)], - edges=[(to_label(cg, 1, 1, 1, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf)], - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - n_threads=1, - ) - - rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=3, x=0, y=0, z=0)) - root_ids_t0 = list(rr.keys()) - - assert len(root_ids_t0) == 2 - - child_ids = [] - for root_id in root_ids_t0: - child_ids.extend(cg.get_subgraph(root_id, leaves_only=True)) - - new_roots = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - affinities=[0.5], - ).new_root_ids - - root_ids = [] - for child_id in child_ids: - root_ids.append(cg.get_root(child_id)) - - assert len(np.unique(root_ids)) == 1 - - root_id = root_ids[0] - assert root_id == new_roots[0] - - @pytest.mark.timeout(240) - def test_cross_edges(self, gen_graph): - """""" - - cg = gen_graph(n_layers=5) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[ - to_label(cg, 1, 0, 0, 0, 0), - to_label(cg, 1, 0, 0, 0, 1), - ], - edges=[ - ( - to_label(cg, 1, 0, 0, 0, 1), - to_label(cg, 1, 1, 0, 0, 0), - inf, - ), - ( - to_label(cg, 1, 0, 0, 0, 0), - to_label(cg, 1, 0, 0, 0, 1), - inf, - ), - ], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[ - to_label(cg, 1, 1, 0, 0, 0), - to_label(cg, 1, 1, 0, 0, 1), - ], - edges=[ - ( - to_label(cg, 1, 1, 0, 0, 0), - to_label(cg, 1, 0, 0, 0, 1), - inf, - ), - ( - to_label(cg, 1, 1, 0, 0, 0), - to_label(cg, 1, 1, 0, 0, 1), - inf, - ), - ], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk C - create_chunk( - cg, - vertices=[ - to_label(cg, 1, 2, 0, 0, 0), - ], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 3, - [1, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 4, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 5, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - new_roots = cg.add_edges( - "Jane Doe", - [ - to_label(cg, 1, 1, 0, 0, 0), - to_label(cg, 1, 2, 0, 0, 0), - ], - affinities=0.9, - ).new_root_ids - - assert len(new_roots) == 1 - - -class TestGraphMergeSplit: - @pytest.mark.timeout(240) - def test_multiple_cuts_and_splits(self, gen_graph_simplequerytest): - """ - ┌─────┬─────┬─────┐ L X Y Z S L X Y Z S L X Y Z S L X Y Z S - │ A¹ │ B¹ │ C¹ │ 1: 1 0 0 0 0 ─── 2 0 0 0 1 ───────────────── 4 0 0 0 1 - │ 1 │ 3━2━┿━━4 │ 2: 1 1 0 0 0 ─┬─ 2 1 0 0 1 ─── 3 0 0 0 1 ─┬─ 4 0 0 0 2 - │ │ │ │ 3: 1 1 0 0 1 ─┘ │ - └─────┴─────┴─────┘ 4: 1 2 0 0 0 ─── 2 2 0 0 1 ─── 3 1 0 0 1 ─┘ - """ - cg = gen_graph_simplequerytest - - rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=4, x=0, y=0, z=0)) - root_ids_t0 = list(rr.keys()) - child_ids = [types.empty_1d] - for root_id in root_ids_t0: - child_ids.append(cg.get_subgraph([root_id], leaves_only=True)) - child_ids = np.concatenate(child_ids) - - for i in range(10): - print(f"\n\nITERATION {i}/10 - MERGE 1 & 3") - new_roots = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], - affinities=0.9, - ).new_root_ids - assert len(new_roots) == 1, new_roots - assert len(cg.get_subgraph([new_roots[0]], leaves_only=True)) == 4 - - root_ids = cg.get_roots(child_ids, assert_roots=True) - print(child_ids) - print(list(root_ids)) - u_root_ids = np.unique(root_ids) - assert len(u_root_ids) == 1, u_root_ids - - # ------------------------------------------------------------------ - print(f"\n\nITERATION {i}/10 - SPLIT 2 & 3") - new_roots = cg.remove_edges( - "John Doe", - source_ids=to_label(cg, 1, 1, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 1), - mincut=False, - ).new_root_ids - assert len(new_roots) == 2, new_roots - - root_ids = cg.get_roots(child_ids, assert_roots=True) - print(child_ids) - print(list(root_ids)) - u_root_ids = np.unique(root_ids) - these_child_ids = [] - for root_id in u_root_ids: - these_child_ids.extend(cg.get_subgraph([root_id], leaves_only=True)) - - assert len(these_child_ids) == 4 - assert len(u_root_ids) == 2, u_root_ids - - # ------------------------------------------------------------------ - print(f"\n\nITERATION {i}/10 - SPLIT 1 & 3") - new_roots = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 1), - mincut=False, - ).new_root_ids - assert len(new_roots) == 2, new_roots - - root_ids = cg.get_roots(child_ids, assert_roots=True) - print(child_ids) - print(list(root_ids)) - u_root_ids = np.unique(root_ids) - assert len(u_root_ids) == 3, u_root_ids - - # ------------------------------------------------------------------ - print(f"\n\nITERATION {i}/10 - MERGE 2 & 3") - new_roots = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], - affinities=0.9, - ).new_root_ids - assert len(new_roots) == 1, new_roots - - root_ids = cg.get_roots(child_ids, assert_roots=True) - print(child_ids) - print(list(root_ids)) - u_root_ids = np.unique(root_ids) - assert len(u_root_ids) == 2, u_root_ids - - # for root_id in root_ids: - # cross_edge_dict_layers = graph_tests.root_cross_edge_test( - # root_id, cg=cg - # ) # dict: layer -> cross_edge_dict - # n_cross_edges_layer = collections.defaultdict(list) - - # for child_layer in cross_edge_dict_layers.keys(): - # for layer in cross_edge_dict_layers[child_layer].keys(): - # n_cross_edges_layer[layer].append( - # len(cross_edge_dict_layers[child_layer][layer]) - # ) - - # for layer in n_cross_edges_layer.keys(): - # assert len(np.unique(n_cross_edges_layer[layer])) == 1 - - -class TestGraphMinCut: - # TODO: Ideally, those tests should focus only on mincut retrieving the correct edges. - # The edge removal part should be tested exhaustively in TestGraphSplit - @pytest.mark.timeout(30) - def test_cut_regular_link(self, gen_graph): - """ - Regular link between 1 and 2 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1━━┿━━2 │ - │ │ │ - └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - # Mincut - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 0), - source_coords=[0, 0, 0], - sink_coords=[ - 2 * cg.meta.graph_config.CHUNK_SIZE[0], - 2 * cg.meta.graph_config.CHUNK_SIZE[1], - cg.meta.graph_config.CHUNK_SIZE[2], - ], - mincut=True, - disallow_isolating_cut=True, - ).new_root_ids - - # verify new state - assert len(new_root_ids) == 2 - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( - to_label(cg, 1, 1, 0, 0, 0) - ) - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True - ) - ) - assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 1, 0, 0, 0))], leaves_only=True - ) - ) - assert len(leaves) == 1 and to_label(cg, 1, 1, 0, 0, 0) in leaves - - @pytest.mark.timeout(30) - def test_cut_no_link(self, gen_graph): - """ - No connection between 1 and 2 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 2 │ - │ │ │ - └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - res_old = cg.client._table.read_rows() - res_old.consume_all() - - # Mincut - with pytest.raises(exceptions.PreconditionError): - cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 0), - source_coords=[0, 0, 0], - sink_coords=[ - 2 * cg.meta.graph_config.CHUNK_SIZE[0], - 2 * cg.meta.graph_config.CHUNK_SIZE[1], - cg.meta.graph_config.CHUNK_SIZE[2], - ], - mincut=True, - ) - - res_new = cg.client._table.read_rows() - res_new.consume_all() - - assert res_new.rows == res_old.rows - - @pytest.mark.timeout(30) - def test_cut_old_link(self, gen_graph): - """ - Link between 1 and 2 got removed previously (aff = 0.0) - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1┅┅╎┅┅2 │ - │ │ │ - └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - cg.remove_edges( - "John Doe", - source_ids=to_label(cg, 1, 1, 0, 0, 0), - sink_ids=to_label(cg, 1, 0, 0, 0, 0), - mincut=False, - ) - - res_old = cg.client._table.read_rows() - res_old.consume_all() - - # Mincut - with pytest.raises(exceptions.PreconditionError): - cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 0), - source_coords=[0, 0, 0], - sink_coords=[ - 2 * cg.meta.graph_config.CHUNK_SIZE[0], - 2 * cg.meta.graph_config.CHUNK_SIZE[1], - cg.meta.graph_config.CHUNK_SIZE[2], - ], - mincut=True, - ) - - res_new = cg.client._table.read_rows() - res_new.consume_all() - - assert res_new.rows == res_old.rows - - @pytest.mark.timeout(30) - def test_cut_indivisible_link(self, gen_graph): - """ - Sink: 1, Source: 2 - Link between 1 and 2 is set to `inf` and must not be cut. - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1══╪══2 │ - │ │ │ - └─────┴─────┘ - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - original_parents_1 = cg.get_root( - to_label(cg, 1, 0, 0, 0, 0), get_all_parents=True - ) - original_parents_2 = cg.get_root( - to_label(cg, 1, 1, 0, 0, 0), get_all_parents=True - ) - - # Mincut - with pytest.raises(exceptions.PostconditionError): - cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 0), - source_coords=[0, 0, 0], - sink_coords=[ - 2 * cg.meta.graph_config.CHUNK_SIZE[0], - 2 * cg.meta.graph_config.CHUNK_SIZE[1], - cg.meta.graph_config.CHUNK_SIZE[2], - ], - mincut=True, - ) - - new_parents_1 = cg.get_root(to_label(cg, 1, 0, 0, 0, 0), get_all_parents=True) - new_parents_2 = cg.get_root(to_label(cg, 1, 1, 0, 0, 0), get_all_parents=True) - - assert np.all(np.array(original_parents_1) == np.array(new_parents_1)) - assert np.all(np.array(original_parents_2) == np.array(new_parents_2)) - - @pytest.mark.timeout(30) - def test_mincut_disrespects_sources_or_sinks(self, gen_graph): - """ - When the mincut separates sources or sinks, an error should be thrown. - Although the mincut is setup to never cut an edge between two sources or - two sinks, this can happen when an edge along the only path between two - sources or two sinks is cut. - """ - cg = gen_graph(n_layers=2) - - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[ - to_label(cg, 1, 0, 0, 0, 0), - to_label(cg, 1, 0, 0, 0, 1), - to_label(cg, 1, 0, 0, 0, 2), - to_label(cg, 1, 0, 0, 0, 3), - ], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 2), - (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 3), - (to_label(cg, 1, 0, 0, 0, 2), to_label(cg, 1, 0, 0, 0, 3), 10), - ], - timestamp=fake_timestamp, - ) - - # Mincut - with pytest.raises(exceptions.PreconditionError): - cg.remove_edges( - "Jane Doe", - source_ids=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - sink_ids=[to_label(cg, 1, 0, 0, 0, 3)], - source_coords=[[0, 0, 0], [10, 0, 0]], - sink_coords=[[5, 5, 0]], - mincut=True, - ) - - -class TestGraphMultiCut: - @pytest.mark.timeout(30) - def test_cut_multi_tree(self, gen_graph): - pass - - @pytest.mark.timeout(30) - def test_path_augmented_multicut(self, sv_data): - sv_edges, sv_sources, sv_sinks, sv_affinity, sv_area = sv_data - edges = Edges( - sv_edges[:, 0], sv_edges[:, 1], affinities=sv_affinity, areas=sv_area - ) - cut_edges_aug = run_multicut(edges, sv_sources, sv_sinks, path_augment=True) - assert cut_edges_aug.shape[0] == 350 - - with pytest.raises(exceptions.PreconditionError): - run_multicut(edges, sv_sources, sv_sinks, path_augment=False) - - -class TestGraphHistory: - """These test inadvertantly also test merge and split operations""" - - @pytest.mark.timeout(120) - def test_cut_merge_history(self, gen_graph): - """ - Regular link between 1 and 2 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1━━┿━━2 │ - │ │ │ - └─────┴─────┘ - (1) Split 1 and 2 - (2) Merge 1 and 2 - """ - cg: ChunkedGraph = gen_graph(n_layers=3) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], - timestamp=fake_timestamp, - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - first_root = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) - assert first_root == cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) - timestamp_before_split = datetime.now(UTC) - split_roots = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 0), - mincut=False, - ).new_root_ids - assert len(split_roots) == 2 - g = lineage_graph(cg, split_roots[0]) - assert g.size() == 1 - g = lineage_graph(cg, split_roots) - assert g.size() == 2 - - timestamp_after_split = datetime.now(UTC) - merge_roots = cg.add_edges( - "Jane Doe", - [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0)], - affinities=0.4, - ).new_root_ids - assert len(merge_roots) == 1 - merge_root = merge_roots[0] - timestamp_after_merge = datetime.now(UTC) - - g = lineage_graph(cg, merge_roots) - assert g.size() == 4 - assert ( - len( - get_root_id_history( - cg, - first_root, - time_stamp_past=datetime.min, - time_stamp_future=datetime.max, - ) - ) - == 4 - ) - assert ( - len( - get_root_id_history( - cg, - split_roots[0], - time_stamp_past=datetime.min, - time_stamp_future=datetime.max, - ) - ) - == 3 - ) - assert ( - len( - get_root_id_history( - cg, - split_roots[1], - time_stamp_past=datetime.min, - time_stamp_future=datetime.max, - ) - ) - == 3 - ) - assert ( - len( - get_root_id_history( - cg, - merge_root, - time_stamp_past=datetime.min, - time_stamp_future=datetime.max, - ) - ) - == 4 - ) - - new_roots, old_roots = get_delta_roots( - cg, timestamp_before_split, timestamp_after_split - ) - assert len(old_roots) == 1 - assert old_roots[0] == first_root - assert len(new_roots) == 2 - assert np.all(np.isin(new_roots, split_roots)) - - new_roots2, old_roots2 = get_delta_roots( - cg, timestamp_after_split, timestamp_after_merge - ) - assert len(new_roots2) == 1 - assert new_roots2[0] == merge_root - assert len(old_roots2) == 2 - assert np.all(np.isin(old_roots2, split_roots)) - - new_roots3, old_roots3 = get_delta_roots( - cg, timestamp_before_split, timestamp_after_merge - ) - assert len(new_roots3) == 1 - assert new_roots3[0] == merge_root - assert len(old_roots3) == 1 - assert old_roots3[0] == first_root - - -class TestGraphLocks: - @pytest.mark.timeout(30) - def test_lock_unlock(self, gen_graph): - """ - No connection between 1, 2 and 3 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 3 │ - │ 2 │ │ - └─────┴─────┘ - - (1) Try lock (opid = 1) - (2) Try lock (opid = 2) - (3) Try unlock (opid = 1) - (4) Try lock (opid = 2) - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - operation_id_1 = cg.id_client.create_operation_id() - root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - - future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_1, - future_root_ids_d=future_root_ids_d, - )[0] - - operation_id_2 = cg.id_client.create_operation_id() - assert not cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - )[0] - - assert cg.client.unlock_root(root_id=root_id, operation_id=operation_id_1) - - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - )[0] - - @pytest.mark.timeout(30) - def test_lock_expiration(self, gen_graph): - """ - No connection between 1, 2 and 3 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 3 │ - │ 2 │ │ - └─────┴─────┘ - - (1) Try lock (opid = 1) - (2) Try lock (opid = 2) - (3) Try lock (opid = 2) with retries - """ - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - operation_id_1 = cg.id_client.create_operation_id() - root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_1, - future_root_ids_d=future_root_ids_d, - )[0] - - operation_id_2 = cg.id_client.create_operation_id() - assert not cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - )[0] - - sleep(cg.meta.graph_config.ROOT_LOCK_EXPIRY.total_seconds()) - - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - max_tries=10, - waittime_s=0.5, - )[0] - - @pytest.mark.timeout(30) - def test_lock_renew(self, gen_graph): - """ - No connection between 1, 2 and 3 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 3 │ - │ 2 │ │ - └─────┴─────┘ - - (1) Try lock (opid = 1) - (2) Try lock (opid = 2) - (3) Try lock (opid = 2) with retries - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - operation_id_1 = cg.id_client.create_operation_id() - root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_1, - future_root_ids_d=future_root_ids_d, - )[0] - - assert cg.client.renew_locks(root_ids=[root_id], operation_id=operation_id_1) - - @pytest.mark.timeout(30) - def test_lock_merge_lock_old_id(self, gen_graph): - """ - No connection between 1, 2 and 3 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 3 │ - │ 2 │ │ - └─────┴─────┘ - - (1) Merge (includes lock opid 1) - (2) Try lock opid 2 --> should be successful and return new root id - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - - new_root_ids = cg.add_edges( - "Chuck Norris", - [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - affinities=1.0, - ).new_root_ids - - assert new_root_ids is not None - - operation_id_2 = cg.id_client.create_operation_id() - future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} - success, new_root_id = cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - max_tries=10, - waittime_s=0.5, - ) - - assert success - assert new_root_ids[0] == new_root_id - - @pytest.mark.timeout(30) - def test_indefinite_lock(self, gen_graph): - """ - No connection between 1, 2 and 3 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 3 │ - │ 2 │ │ - └─────┴─────┘ - - (1) Try indefinite lock (opid = 1), get indefinite lock - (2) Try normal lock (opid = 2), doesn't get the normal lock - (3) Try unlock indefinite lock (opid = 1), should unlock indefinite lock - (4) Try lock (opid = 2), should get the normal lock - """ - - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - operation_id_1 = cg.id_client.create_operation_id() - root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - - future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} - assert cg.client.lock_roots_indefinitely( - root_ids=[root_id], - operation_id=operation_id_1, - future_root_ids_d=future_root_ids_d, - )[0] - - operation_id_2 = cg.id_client.create_operation_id() - assert not cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - )[0] - - assert cg.client.unlock_indefinitely_locked_root( - root_id=root_id, operation_id=operation_id_1 - ) - - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - )[0] - - @pytest.mark.timeout(30) - def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): - """ - No connection between 1, 2 and 3 - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 1 │ 3 │ - │ 2 │ │ - └─────┴─────┘ - - (1) Try normal lock (opid = 1), get normal lock - (2) Try indefinite lock (opid = 1), get indefinite lock - (3) Wait until normal lock expires - (4) Try normal lock (opid = 2), doesn't get the normal lock - (5) Try unlock indefinite lock (opid = 1), should unlock indefinite lock - (6) Try lock (opid = 2), should get the normal lock - """ - - # 1. TODO renew lock test when getting indefinite lock - cg = gen_graph(n_layers=3) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - edges=[], - timestamp=fake_timestamp, - ) - - # Preparation: Build Chunk B - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - operation_id_1 = cg.id_client.create_operation_id() - root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - - future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} - - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_1, - future_root_ids_d=future_root_ids_d, - )[0] - - assert cg.client.lock_roots_indefinitely( - root_ids=[root_id], - operation_id=operation_id_1, - future_root_ids_d=future_root_ids_d, - )[0] - - sleep(cg.meta.graph_config.ROOT_LOCK_EXPIRY.total_seconds()) - - operation_id_2 = cg.id_client.create_operation_id() - assert not cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - )[0] - - assert cg.client.unlock_indefinitely_locked_root( - root_id=root_id, operation_id=operation_id_1 - ) - - assert cg.client.lock_roots( - root_ids=[root_id], - operation_id=operation_id_2, - future_root_ids_d=future_root_ids_d, - )[0] - - # TODO fixme: this scenario can't be tested like this - # @pytest.mark.timeout(30) - # def test_normal_lock_expiration(self, gen_graph): - # """ - # No connection between 1, 2 and 3 - # ┌─────┬─────┐ - # │ A¹ │ B¹ │ - # │ 1 │ 3 │ - # │ 2 │ │ - # └─────┴─────┘ - - # (1) Try normal lock (opid = 1), get normal lock - # (2) Wait until normal lock expires - # (3) Try indefinite lock (opid = 1), doesn't get the indefinite lock - # """ - - # cg = gen_graph(n_layers=3) - - # # Preparation: Build Chunk A - # fake_timestamp = datetime.now(UTC) - timedelta(days=10) - # create_chunk( - # cg, - # vertices=[to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2)], - # edges=[], - # timestamp=fake_timestamp, - # ) - - # # Preparation: Build Chunk B - # create_chunk( - # cg, - # vertices=[to_label(cg, 1, 1, 0, 0, 1)], - # edges=[], - # timestamp=fake_timestamp, - # ) - - # add_parent_chunk( - # cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, - # ) - - # operation_id_1 = cg.id_client.create_operation_id() - # root_id = cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - - # future_root_ids_d = {root_id: get_future_root_ids(cg, root_id)} - - # assert cg.client.lock_roots( - # root_ids=[root_id], - # operation_id=operation_id_1, - # future_root_ids_d=future_root_ids_d, - # )[0] - - # sleep(cg.meta.graph_config.ROOT_LOCK_EXPIRY.total_seconds()+1) - - # assert not cg.client.lock_roots_indefinitely( - # root_ids=[root_id], - # operation_id=operation_id_1, - # future_root_ids_d=future_root_ids_d, - # )[0] - - -class TestGraphSplit: - @pytest.mark.timeout(30) - def test_split_pair_same_chunk(self, gen_graph): - """ - Remove edge between existing RG supervoxels 1 and 2 (same chunk) - Expected: Different (new) parents for RG 1 and 2 on Layer two - ┌─────┐ ┌─────┐ - │ A¹ │ │ A¹ │ - │ 1━2 │ => │ 1 2 │ - │ │ │ │ - └─────┘ └─────┘ - """ - - cg: ChunkedGraph = gen_graph(n_layers=2) - - # Preparation: Build Chunk A - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], - timestamp=fake_timestamp, - ) - - # Split - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 1), - sink_ids=to_label(cg, 1, 0, 0, 0, 0), - mincut=False, - ).new_root_ids - - # verify new state - assert len(new_root_ids) == 2 - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( - to_label(cg, 1, 0, 0, 0, 1) - ) - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True - ) - ) - assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 0, 0, 0, 1))], leaves_only=True - ) - ) - assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 1) in leaves - - # verify old state - cg.cache = None - assert cg.get_root( - to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp - ) == cg.get_root(to_label(cg, 1, 0, 0, 0, 1), time_stamp=fake_timestamp) - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], - leaves_only=True, - ) - ) - assert len(leaves) == 2 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 0, 0, 0, 1) in leaves - - assert len(get_latest_roots(cg)) == 2 - assert len(get_latest_roots(cg, fake_timestamp)) == 1 - - def test_split_nonexisting_edge(self, gen_graph): - """ - Remove edge between existing RG supervoxels 1 and 2 (same chunk) - Expected: Different (new) parents for RG 1 and 2 on Layer two - ┌─────┐ ┌─────┐ - │ A¹ │ │ A¹ │ - │ 1━2 │ => │ 1━2 │ - │ | │ │ | │ - │ 3 │ │ 3 │ - └─────┘ └─────┘ - """ - cg = gen_graph(n_layers=2) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - (to_label(cg, 1, 0, 0, 0, 2), to_label(cg, 1, 0, 0, 0, 1), 0.5), - ], - timestamp=fake_timestamp, - ) - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 0, 0, 0, 2), - mincut=False, - ).new_root_ids - assert len(new_root_ids) == 1 - - @pytest.mark.timeout(30) - def test_split_pair_neighboring_chunks(self, gen_graph): - """ - Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) - ┌─────┬─────┐ ┌─────┬─────┐ - │ A¹ │ B¹ │ │ A¹ │ B¹ │ - │ 1━━┿━━2 │ => │ 1 │ 2 │ - │ │ │ │ │ │ - └─────┴─────┘ └─────┴─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=3) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 1.0)], - timestamp=fake_timestamp, - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 1.0)], - timestamp=fake_timestamp, - ) - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 1, 0, 0, 0), - sink_ids=to_label(cg, 1, 0, 0, 0, 0), - mincut=False, - ).new_root_ids - - # verify new state - assert len(new_root_ids) == 2 - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( - to_label(cg, 1, 1, 0, 0, 0) - ) - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True - ) - ) - assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 1, 0, 0, 0))], leaves_only=True - ) - ) - assert len(leaves) == 1 and to_label(cg, 1, 1, 0, 0, 0) in leaves - - # verify old state - assert cg.get_root( - to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp - ) == cg.get_root(to_label(cg, 1, 1, 0, 0, 0), time_stamp=fake_timestamp) - leaves = np.unique( - cg.get_subgraph( - [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], - leaves_only=True, - ) - ) - assert len(leaves) == 2 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 1, 0, 0, 0) in leaves - assert len(get_latest_roots(cg)) == 2 - assert len(get_latest_roots(cg, fake_timestamp)) == 1 - - @pytest.mark.timeout(30) - def test_split_verify_cross_chunk_edges(self, gen_graph): - """ - Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) - ┌─────┬─────┬─────┐ ┌─────┬─────┬─────┐ - | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ - | │ 1━━┿━━3 │ => | │ 1━━┿━━3 │ - | │ | │ │ | │ │ │ - | │ 2 │ │ | │ 2 │ │ - └─────┴─────┴─────┘ └─────┴─────┴─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=4) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), - (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1), 0.5), - ], - timestamp=fake_timestamp, - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 2, 0, 0, 0)], - edges=[(to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf)], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 3, - [1, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 4, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( - to_label(cg, 1, 1, 0, 0, 1) - ) - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( - to_label(cg, 1, 2, 0, 0, 0) - ) - - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 1, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 1), - mincut=False, - ).new_root_ids - - assert len(new_root_ids) == 2 - - svs2 = cg.get_subgraph([new_root_ids[0]], leaves_only=True) - svs1 = cg.get_subgraph([new_root_ids[1]], leaves_only=True) - len_set = {1, 2} - assert len(svs1) in len_set - len_set.remove(len(svs1)) - assert len(svs2) in len_set - - # verify new state - assert len(new_root_ids) == 2 - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) != cg.get_root( - to_label(cg, 1, 1, 0, 0, 1) - ) - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( - to_label(cg, 1, 2, 0, 0, 0) - ) - - # l2id = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) - # cce = cg.get_atomic_cross_edges([l2id])[l2id] - # assert len(cce[3]) == 1 - # assert cce[3][0][0] == to_label(cg, 1, 1, 0, 0, 0) - # assert cce[3][0][1] == to_label(cg, 1, 2, 0, 0, 0) - - assert len(get_latest_roots(cg)) == 2 - assert len(get_latest_roots(cg, fake_timestamp)) == 1 - - @pytest.mark.timeout(30) - def test_split_verify_loop(self, gen_graph): - """ - Remove edge between existing RG supervoxels 1 and 2 (neighboring chunks) - ┌─────┬────────┬─────┐ ┌─────┬────────┬─────┐ - | │ A¹ │ B¹ │ | │ A¹ │ B¹ │ - | │ 4━━1━━┿━━5 │ => | │ 4 1━━┿━━5 │ - | │ / │ | │ | │ │ | │ - | │ 3 2━━┿━━6 │ | │ 3 2━━┿━━6 │ - └─────┴────────┴─────┘ └─────┴────────┴─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=4) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[ - to_label(cg, 1, 1, 0, 0, 0), - to_label(cg, 1, 1, 0, 0, 1), - to_label(cg, 1, 1, 0, 0, 2), - to_label(cg, 1, 1, 0, 0, 3), - ], - edges=[ - (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), - (to_label(cg, 1, 1, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 1), inf), - (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 2), 0.5), - (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 3), 0.5), - ], - timestamp=fake_timestamp, - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), - (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 1), inf), - (to_label(cg, 1, 2, 0, 0, 1), to_label(cg, 1, 2, 0, 0, 0), 0.5), - ], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 3, - [1, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - add_parent_chunk( - cg, - 4, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( - to_label(cg, 1, 1, 0, 0, 1) - ) - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == cg.get_root( - to_label(cg, 1, 2, 0, 0, 0) - ) - - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 1, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 2), - mincut=False, - ).new_root_ids - assert len(new_root_ids) == 2 - - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 1, 0, 0, 0), - sink_ids=to_label(cg, 1, 1, 0, 0, 3), - mincut=False, - ).new_root_ids - assert len(new_root_ids) == 2 - - # l2id = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) - # cce = cg.get_atomic_cross_edges([l2id]) - # assert len(cce[3]) == 1 - - assert len(get_latest_roots(cg)) == 3 - assert len(get_latest_roots(cg, fake_timestamp)) == 1 - - # @pytest.mark.timeout(30) - # def test_split_pair_disconnected_chunks(self, gen_graph): - # """ - # Remove edge between existing RG supervoxels 1 and 2 (disconnected chunks) - # ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ - # │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ - # │ 1━━┿━━━━━┿━━2 │ => │ 1 │ │ 2 │ - # │ │ │ │ │ │ │ │ - # └─────┘ └─────┘ └─────┘ └─────┘ - # """ - # cg: ChunkedGraph = gen_graph(n_layers=9) - # fake_timestamp = datetime.now(UTC) - timedelta(days=10) - # create_chunk( - # cg, - # vertices=[to_label(cg, 1, 0, 0, 0, 0)], - # edges=[ - # ( - # to_label(cg, 1, 0, 0, 0, 0), - # to_label(cg, 1, 7, 7, 7, 0), - # 1.0, - # ) - # ], - # timestamp=fake_timestamp, - # ) - # create_chunk( - # cg, - # vertices=[to_label(cg, 1, 7, 7, 7, 0)], - # edges=[ - # ( - # to_label(cg, 1, 7, 7, 7, 0), - # to_label(cg, 1, 0, 0, 0, 0), - # 1.0, - # ) - # ], - # timestamp=fake_timestamp, - # ) - - # add_parent_chunk( - # cg, - # 3, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 3, - # [1, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 4, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 4, - # [1, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 5, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 5, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 6, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 6, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 7, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 7, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 8, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 8, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # 9, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - - # new_roots = cg.remove_edges( - # "Jane Doe", - # source_ids=to_label(cg, 1, 7, 7, 7, 0), - # sink_ids=to_label(cg, 1, 0, 0, 0, 0), - # mincut=False, - # ).new_root_ids - - # # verify new state - # assert len(new_roots) == 2 - # assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) != cg.get_root( - # to_label(cg, 1, 7, 7, 7, 0) - # ) - # leaves = np.unique( - # cg.get_subgraph( - # [cg.get_root(to_label(cg, 1, 0, 0, 0, 0))], leaves_only=True - # ) - # ) - # assert len(leaves) == 1 and to_label(cg, 1, 0, 0, 0, 0) in leaves - # leaves = np.unique( - # cg.get_subgraph( - # [cg.get_root(to_label(cg, 1, 7, 7, 7, 0))], leaves_only=True - # ) - # ) - # assert len(leaves) == 1 and to_label(cg, 1, 7, 7, 7, 0) in leaves - - # # verify old state - # assert cg.get_root( - # to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp - # ) == cg.get_root(to_label(cg, 1, 7, 7, 7, 0), time_stamp=fake_timestamp) - # leaves = np.unique( - # cg.get_subgraph( - # [cg.get_root(to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp)], - # leaves_only=True, - # ) - # ) - # assert len(leaves) == 2 - # assert to_label(cg, 1, 0, 0, 0, 0) in leaves - # assert to_label(cg, 1, 7, 7, 7, 0) in leaves - - @pytest.mark.timeout(30) - def test_split_pair_already_disconnected(self, gen_graph): - """ - Try to remove edge between already disconnected RG supervoxels 1 and 2 (same chunk). - Expected: No change, no error - ┌─────┐ ┌─────┐ - │ A¹ │ │ A¹ │ - │ 1 2 │ => │ 1 2 │ - │ │ │ │ - └─────┘ └─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=2) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[], - timestamp=fake_timestamp, - ) - res_old = cg.client._table.read_rows() - res_old.consume_all() - - with pytest.raises(exceptions.PreconditionError): - cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 1), - sink_ids=to_label(cg, 1, 0, 0, 0, 0), - mincut=False, - ) - - res_new = cg.client._table.read_rows() - res_new.consume_all() - - if res_old.rows != res_new.rows: - warn( - "Rows were modified when splitting a pair of already disconnected supervoxels." - "While probably not an error, it is an unnecessary operation." - ) - - @pytest.mark.timeout(30) - def test_split_full_circle_to_triple_chain_same_chunk(self, gen_graph): - """ - Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (same chunk) - ┌─────┐ ┌─────┐ - │ A¹ │ │ A¹ │ - │ 1━2 │ => │ 1 2 │ - │ ┗3┛ │ │ ┗3┛ │ - └─────┘ └─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=2) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[ - to_label(cg, 1, 0, 0, 0, 0), - to_label(cg, 1, 0, 0, 0, 1), - to_label(cg, 1, 0, 0, 0, 2), - ], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 2), 0.5), - (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 2), 0.5), - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.3), - ], - timestamp=fake_timestamp, - ) - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 1), - sink_ids=to_label(cg, 1, 0, 0, 0, 0), - mincut=False, - ).new_root_ids - - # verify new state - assert len(new_root_ids) == 1 - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 2)) == new_root_ids[0] - leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) - assert len(leaves) == 3 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 0, 0, 0, 1) in leaves - assert to_label(cg, 1, 0, 0, 0, 2) in leaves - - # verify old state - old_root_id = cg.get_root( - to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp - ) - assert new_root_ids[0] != old_root_id - assert len(get_latest_roots(cg)) == 1 - assert len(get_latest_roots(cg, fake_timestamp)) == 1 - - @pytest.mark.timeout(30) - def test_split_full_circle_to_triple_chain_neighboring_chunks(self, gen_graph): - """ - Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (neighboring chunks) - ┌─────┬─────┐ ┌─────┬─────┐ - │ A¹ │ B¹ │ │ A¹ │ B¹ │ - │ 1━━┿━━2 │ => │ 1 │ 2 │ - │ ┗3━┿━━┛ │ │ ┗3━┿━━┛ │ - └─────┴─────┘ └─────┴─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=3) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 0), 0.5), - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.3), - ], - timestamp=fake_timestamp, - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[ - (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.3), - ], - timestamp=fake_timestamp, - ) - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - - new_root_ids = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 1, 0, 0, 0), - sink_ids=to_label(cg, 1, 0, 0, 0, 0), - mincut=False, - ).new_root_ids - - # verify new state - assert len(new_root_ids) == 1 - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] - assert cg.get_root(to_label(cg, 1, 1, 0, 0, 0)) == new_root_ids[0] - leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) - assert len(leaves) == 3 - assert to_label(cg, 1, 0, 0, 0, 0) in leaves - assert to_label(cg, 1, 0, 0, 0, 1) in leaves - assert to_label(cg, 1, 1, 0, 0, 0) in leaves - - # verify old state - old_root_id = cg.get_root( - to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp - ) - assert new_root_ids[0] != old_root_id - assert len(get_latest_roots(cg)) == 1 - assert len(get_latest_roots(cg, fake_timestamp)) == 1 - - # @pytest.mark.timeout(30) - # def test_split_full_circle_to_triple_chain_disconnected_chunks(self, gen_graph): - # """ - # Remove direct edge between RG supervoxels 1 and 2, but leave indirect connection (disconnected chunks) - # ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ - # │ A¹ │ ... │ Z¹ │ │ A¹ │ ... │ Z¹ │ - # │ 1━━┿━━━━━┿━━2 │ => │ 1 │ │ 2 │ - # │ ┗3━┿━━━━━┿━━┛ │ │ ┗3━┿━━━━━┿━━┛ │ - # └─────┘ └─────┘ └─────┘ └─────┘ - # """ - # cg: ChunkedGraph = gen_graph(n_layers=9) - # loc = 2 - # fake_timestamp = datetime.now(UTC) - timedelta(days=10) - # create_chunk( - # cg, - # vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - # edges=[ - # (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - # ( - # to_label(cg, 1, 0, 0, 0, 1), - # to_label(cg, 1, loc, loc, loc, 0), - # 0.5, - # ), - # ( - # to_label(cg, 1, 0, 0, 0, 0), - # to_label(cg, 1, loc, loc, loc, 0), - # 0.3, - # ), - # ], - # timestamp=fake_timestamp, - # ) - # create_chunk( - # cg, - # vertices=[to_label(cg, 1, loc, loc, loc, 0)], - # edges=[ - # ( - # to_label(cg, 1, loc, loc, loc, 0), - # to_label(cg, 1, 0, 0, 0, 1), - # 0.5, - # ), - # ( - # to_label(cg, 1, loc, loc, loc, 0), - # to_label(cg, 1, 0, 0, 0, 0), - # 0.3, - # ), - # ], - # timestamp=fake_timestamp, - # ) - # for i_layer in range(3, 10): - # if loc // 2 ** (i_layer - 3) == 1: - # add_parent_chunk( - # cg, - # i_layer, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # elif loc // 2 ** (i_layer - 3) == 0: - # add_parent_chunk( - # cg, - # i_layer, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # else: - # add_parent_chunk( - # cg, - # i_layer, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - # add_parent_chunk( - # cg, - # i_layer, - # [0, 0, 0], - # time_stamp=fake_timestamp, - # n_threads=1, - # ) - - # assert ( - # cg.get_root(to_label(cg, 1, loc, loc, loc, 0)) - # == cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) - # == cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) - # ) - # new_root_ids = cg.remove_edges( - # "Jane Doe", - # source_ids=to_label(cg, 1, loc, loc, loc, 0), - # sink_ids=to_label(cg, 1, 0, 0, 0, 0), - # mincut=False, - # ).new_root_ids - - # # verify new state - # assert len(new_root_ids) == 1 - # assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == new_root_ids[0] - # assert cg.get_root(to_label(cg, 1, 0, 0, 0, 1)) == new_root_ids[0] - # assert cg.get_root(to_label(cg, 1, loc, loc, loc, 0)) == new_root_ids[0] - # leaves = np.unique(cg.get_subgraph([new_root_ids[0]], leaves_only=True)) - # assert len(leaves) == 3 - # assert to_label(cg, 1, 0, 0, 0, 0) in leaves - # assert to_label(cg, 1, 0, 0, 0, 1) in leaves - # assert to_label(cg, 1, loc, loc, loc, 0) in leaves - - # # verify old state - # old_root_id = cg.get_root( - # to_label(cg, 1, 0, 0, 0, 0), time_stamp=fake_timestamp - # ) - # assert new_root_ids[0] != old_root_id - - # assert len(get_latest_roots(cg)) == 1 - # assert len(get_latest_roots(cg, fake_timestamp)) == 1 - - @pytest.mark.timeout(30) - def test_split_same_node(self, gen_graph): - """ - Try to remove (non-existing) edge between RG supervoxel 1 and itself - ┌─────┐ - │ A¹ │ - │ 1 │ => Reject - │ │ - └─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=2) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - res_old = cg.client._table.read_rows() - res_old.consume_all() - with pytest.raises(exceptions.PreconditionError): - cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 0, 0, 0, 0), - mincut=False, - ) - - res_new = cg.client._table.read_rows() - res_new.consume_all() - assert res_new.rows == res_old.rows - - @pytest.mark.timeout(30) - def test_split_pair_abstract_nodes(self, gen_graph): - """ - Try to remove (non-existing) edge between RG supervoxel 1 and abstract node "2" - ┌─────┐ - │ B² │ - │ "2" │ - │ │ - └─────┘ - ┌─────┐ => Reject - │ A¹ │ - │ 1 │ - │ │ - └─────┘ - """ - - cg: ChunkedGraph = gen_graph(n_layers=3) - fake_timestamp = datetime.now(UTC) - timedelta(days=10) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[], - timestamp=fake_timestamp, - ) - - add_parent_chunk( - cg, - 3, - [0, 0, 0], - time_stamp=fake_timestamp, - n_threads=1, - ) - res_old = cg.client._table.read_rows() - res_old.consume_all() - with pytest.raises((exceptions.PreconditionError, AssertionError)): - cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 2, 1, 0, 0, 1), - mincut=False, - ) - - res_new = cg.client._table.read_rows() - res_new.consume_all() - assert res_new.rows == res_old.rows - - @pytest.mark.timeout(30) - def test_diagonal_connections(self, gen_graph): - """ - Create graph with edge between RG supervoxels 1 and 2 (same chunk) - and edge between RG supervoxels 1 and 3 (neighboring chunks) - ┌─────┬─────┐ - │ A¹ │ B¹ │ - │ 2━1━┿━━3 │ - │ / │ │ - ┌─────┬─────┐ - │ | │ │ - │ 4━━┿━━5 │ - │ C¹ │ D¹ │ - └─────┴─────┘ - """ - cg: ChunkedGraph = gen_graph(n_layers=3) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], - edges=[ - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), - (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf), - ], - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 0, 0, 0)], - edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf)], - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 0, 1, 0, 0)], - edges=[ - (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 1, 1, 0, 0), inf), - (to_label(cg, 1, 0, 1, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), - ], - ) - create_chunk( - cg, - vertices=[to_label(cg, 1, 1, 1, 0, 0)], - edges=[(to_label(cg, 1, 1, 1, 0, 0), to_label(cg, 1, 0, 1, 0, 0), inf)], - ) - add_parent_chunk( - cg, - 3, - [0, 0, 0], - n_threads=1, - ) - - rr = cg.range_read_chunk(chunk_id=cg.get_chunk_id(layer=3, x=0, y=0, z=0)) - root_ids_t0 = list(rr.keys()) - assert len(root_ids_t0) == 1 - - child_ids = [] - for root_id in root_ids_t0: - child_ids.extend([cg.get_subgraph([root_id], leaves_only=True)]) - - new_roots = cg.remove_edges( - "Jane Doe", - source_ids=to_label(cg, 1, 0, 0, 0, 0), - sink_ids=to_label(cg, 1, 0, 0, 0, 1), - mincut=False, - ).new_root_ids - - assert len(new_roots) == 2 - assert cg.get_root(to_label(cg, 1, 1, 1, 0, 0)) == cg.get_root( - to_label(cg, 1, 0, 1, 0, 0) - ) - assert cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) == cg.get_root( - to_label(cg, 1, 0, 0, 0, 0) - ) diff --git a/pychunkedgraph/tests/test_undo_redo.py b/pychunkedgraph/tests/test_undo_redo.py new file mode 100644 index 000000000..a49f01fe0 --- /dev/null +++ b/pychunkedgraph/tests/test_undo_redo.py @@ -0,0 +1,120 @@ +"""Integration tests for undo/redo operations through the full graph. + +Tests that undo and redo correctly restore graph state using real graph +operations through the BigTable emulator. +""" + +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestUndoRedo: + @pytest.fixture() + def two_chunk_graph(self, gen_graph): + """ + Build a 2-chunk graph with edge between SVs 1 and 2. + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + return cg + + @pytest.mark.timeout(30) + def test_undo_split_restores_merged_root(self, two_chunk_graph): + """Split two nodes, undo — nodes should share a common root again.""" + cg = two_chunk_graph + sv1 = to_label(cg, 1, 0, 0, 0, 0) + sv2 = to_label(cg, 1, 1, 0, 0, 0) + + # Initially, both SVs share a root + assert cg.get_root(sv1) == cg.get_root(sv2) + + # Split + split_result = cg.remove_edges( + "test_user", source_ids=sv1, sink_ids=sv2, mincut=False + ) + assert len(split_result.new_root_ids) == 2 + assert cg.get_root(sv1) != cg.get_root(sv2) + + # Undo the split + cg.undo_operation("test_user", split_result.operation_id) + + # After undo, both SVs should share a root again + assert cg.get_root(sv1) == cg.get_root(sv2) + + @pytest.mark.timeout(30) + def test_redo_restores_operation_result(self, two_chunk_graph): + """Split, undo, redo the original split — state should match the post-split state.""" + cg = two_chunk_graph + sv1 = to_label(cg, 1, 0, 0, 0, 0) + sv2 = to_label(cg, 1, 1, 0, 0, 0) + + # Split + split_result = cg.remove_edges( + "test_user", source_ids=sv1, sink_ids=sv2, mincut=False + ) + assert cg.get_root(sv1) != cg.get_root(sv2) + + # Undo (merges back) + cg.undo_operation("test_user", split_result.operation_id) + assert cg.get_root(sv1) == cg.get_root(sv2) + + # Redo the original split operation (re-applies the split) + cg.redo_operation("test_user", split_result.operation_id) + + # After redo, nodes should be split again + assert cg.get_root(sv1) != cg.get_root(sv2) + + @pytest.mark.timeout(30) + def test_undo_preserves_subgraph_leaves(self, two_chunk_graph): + """After undo, subgraph leaves should match the pre-operation state.""" + cg = two_chunk_graph + sv1 = to_label(cg, 1, 0, 0, 0, 0) + sv2 = to_label(cg, 1, 1, 0, 0, 0) + + # Get initial leaf set + initial_root = cg.get_root(sv1) + initial_leaves = set( + np.unique(cg.get_subgraph([initial_root], leaves_only=True)) + ) + assert sv1 in initial_leaves + assert sv2 in initial_leaves + + # Split + split_result = cg.remove_edges( + "test_user", source_ids=sv1, sink_ids=sv2, mincut=False + ) + + # Undo + cg.undo_operation("test_user", split_result.operation_id) + + # After undo, the root's subgraph should contain both SVs again + restored_root = cg.get_root(sv1) + restored_leaves = set( + np.unique(cg.get_subgraph([restored_root], leaves_only=True)) + ) + assert sv1 in restored_leaves + assert sv2 in restored_leaves diff --git a/requirements-dev.txt b/requirements-dev.txt index 9b1a97928..cde620b6a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,6 @@ pylint black pyopenssl jupyter -codecov ipython pytest pytest-cov From 9bbc81688317f40c722710c91712cf672c416ac8 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 19 Feb 2026 23:43:18 +0000 Subject: [PATCH 157/367] migrate to p3.12 and numpy 2, consolidate dockerfile --- .github/workflows/main.yml | 13 +- .github/workflows/release.yml | 2 +- Dockerfile | 59 +++- base.Dockerfile | 70 ----- pychunkedgraph/app/common.py | 4 +- pychunkedgraph/app/segmentation/common.py | 14 +- pychunkedgraph/graph/chunks/hierarchy.py | 4 +- pychunkedgraph/graph/chunks/utils.py | 6 +- pychunkedgraph/graph/client/bigtable/utils.py | 5 +- pychunkedgraph/graph/cutting.py | 51 ++-- pychunkedgraph/graph/edges/utils.py | 1 + pychunkedgraph/graph/edits.py | 7 + pychunkedgraph/graph/lineage.py | 4 +- pychunkedgraph/graph/misc.py | 4 +- pychunkedgraph/graph/segmenthistory.py | 6 +- pychunkedgraph/graph/utils/flatgraph.py | 2 +- pychunkedgraph/graph/utils/id_helpers.py | 2 +- pychunkedgraph/graph/utils/serializers.py | 20 +- pychunkedgraph/ingest/simple_tests.py | 8 +- pychunkedgraph/logging/log_db.py | 4 +- pychunkedgraph/meshing/manifest/utils.py | 2 +- pychunkedgraph/meshing/mesh_analysis.py | 4 +- pychunkedgraph/meshing/mesh_io.py | 4 +- pychunkedgraph/meshing/meshengine.py | 4 +- pychunkedgraph/meshing/meshgen.py | 10 +- pychunkedgraph/tests/test_merge.py | 2 + pychunkedgraph/utils/general.py | 20 +- requirements.in | 12 +- requirements.txt | 275 ++++++++++-------- requirements.yml | 8 +- tox.ini | 2 +- 31 files changed, 336 insertions(+), 293 deletions(-) delete mode 100644 base.Dockerfile diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7729e60c7..1aaedfcf2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,12 +17,21 @@ jobs: - name: Check out code uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build image - run: docker build --tag seunglab/pychunkedgraph:$GITHUB_SHA . + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: seunglab/pychunkedgraph:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max - name: Run tests with coverage run: | - docker run --name pcg-tests seunglab/pychunkedgraph:$GITHUB_SHA \ + docker run --name pcg-tests seunglab/pychunkedgraph:${{ github.sha }} \ /bin/sh -c "pytest --cov-config .coveragerc --cov=pychunkedgraph --cov-report=xml:/app/coverage.xml ./pychunkedgraph/tests" - name: Copy coverage report from container diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ee89f6c6..80123fa18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Install Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - name: Install bumpversion run: pip install bumpversion - name: Bump version with bumpversion diff --git a/Dockerfile b/Dockerfile index 2b7eeb151..968f93043 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,66 @@ -FROM caveconnectome/pychunkedgraph:base_042124 +# syntax=docker/dockerfile:1 +ARG PYTHON_VERSION=3.12 +ARG BASE_IMAGE=tiangolo/uwsgi-nginx-flask:python${PYTHON_VERSION} + + +###################################################### +# Stage 1: Conda environment +###################################################### +FROM ${BASE_IMAGE} AS conda-deps +ENV PATH="/root/miniconda3/bin:${PATH}" + +RUN apt-get update && apt-get install build-essential wget -y \ + && wget -q https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh \ + && bash Miniconda3-latest-Linux-x86_64.sh -b \ + && rm Miniconda3-latest-Linux-x86_64.sh \ + && conda config --add channels conda-forge \ + && conda update -y --override-channels -c conda-forge conda \ + && conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main \ + && conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r \ + && conda install -y --override-channels -c conda-forge conda-pack + +COPY requirements.yml requirements.txt requirements-dev.txt ./ + +RUN --mount=type=cache,target=/opt/conda/pkgs \ + conda env create -n pcg -f requirements.yml + +RUN conda-pack -n pcg --ignore-missing-files -o /tmp/env.tar \ + && mkdir -p /app/venv && cd /app/venv \ + && tar xf /tmp/env.tar && rm /tmp/env.tar \ + && /app/venv/bin/conda-unpack + + +###################################################### +# Stage 2: Bigtable emulator +###################################################### +FROM golang:bullseye AS bigtable-emulator +ARG GOOGLE_CLOUD_GO_VERSION=bigtable/v1.19.0 +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + git clone --depth=1 --branch="$GOOGLE_CLOUD_GO_VERSION" \ + https://github.com/googleapis/google-cloud-go.git /usr/src \ + && cd /usr/src/bigtable && go install -v ./cmd/emulator + + +###################################################### +# Stage 3: Production +###################################################### +FROM ${BASE_IMAGE} ENV VIRTUAL_ENV=/app/venv ENV PATH="$VIRTUAL_ENV/bin:$PATH" +COPY --from=conda-deps /app/venv /app/venv +COPY --from=bigtable-emulator /go/bin/emulator /app/venv/bin/cbtemulator COPY override/gcloud /app/venv/bin/gcloud COPY override/timeout.conf /etc/nginx/conf.d/timeout.conf COPY override/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +RUN pip install --no-cache-dir --no-deps --force-reinstall zstandard>=0.23.0 \ + && mkdir -p /home/nginx/.cloudvolume/secrets \ + && chown -R nginx /home/nginx \ + && usermod -d /home/nginx -s /bin/bash nginx COPY requirements.txt . -RUN pip install --upgrade -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --upgrade -r requirements.txt + COPY . /app diff --git a/base.Dockerfile b/base.Dockerfile deleted file mode 100644 index b5123e137..000000000 --- a/base.Dockerfile +++ /dev/null @@ -1,70 +0,0 @@ -ARG PYTHON_VERSION=3.11 -ARG BASE_IMAGE=tiangolo/uwsgi-nginx-flask:python${PYTHON_VERSION} - - -###################################################### -# Build Image - PCG dependencies -###################################################### -FROM ${BASE_IMAGE} AS pcg-build -ENV PATH="/root/miniconda3/bin:${PATH}" -ENV CONDA_ENV="pychunkedgraph" - -# Setup Miniconda -RUN apt-get update && apt-get install build-essential wget -y -RUN wget \ - https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh \ - && mkdir /root/.conda \ - && bash Miniconda3-latest-Linux-x86_64.sh -b \ - && rm -f Miniconda3-latest-Linux-x86_64.sh \ - && conda update conda - -# Install PCG dependencies - especially graph-tool -# Note: uwsgi has trouble with pip and python3.11, so adding this with conda, too -COPY requirements.txt . -COPY requirements.yml . -COPY requirements-dev.txt . -RUN conda env create -n ${CONDA_ENV} -f requirements.yml - -# Shrink conda environment into portable non-conda env -RUN conda install conda-pack -c conda-forge - -RUN conda-pack -n ${CONDA_ENV} --ignore-missing-files -o /tmp/env.tar \ - && mkdir -p /app/venv \ - && cd /app/venv \ - && tar xf /tmp/env.tar \ - && rm /tmp/env.tar -RUN /app/venv/bin/conda-unpack - - -###################################################### -# Build Image - Bigtable Emulator (without Google SDK) -###################################################### -FROM golang:bullseye as bigtable-emulator-build -RUN mkdir -p /usr/src -WORKDIR /usr/src -ENV GOOGLE_CLOUD_GO_VERSION bigtable/v1.19.0 -RUN apt-get update && apt-get install git -y -RUN git clone --depth=1 --branch="$GOOGLE_CLOUD_GO_VERSION" https://github.com/googleapis/google-cloud-go.git . \ - && cd bigtable \ - && go install -v ./cmd/emulator - - -###################################################### -# Production Image -###################################################### -FROM ${BASE_IMAGE} -ENV VIRTUAL_ENV=/app/venv -ENV PATH="$VIRTUAL_ENV/bin:$PATH" - -COPY --from=pcg-build /app/venv /app/venv -COPY --from=bigtable-emulator-build /go/bin/emulator /app/venv/bin/cbtemulator -COPY override/gcloud /app/venv/bin/gcloud -COPY override/timeout.conf /etc/nginx/conf.d/timeout.conf -COPY override/supervisord.conf /etc/supervisor/conf.d/supervisord.conf -# Hack to get zstandard from PyPI - remove if conda-forge linked lib issue is resolved -RUN pip install --no-cache-dir --no-deps --force-reinstall zstandard==0.21.0 -COPY . /app - -RUN mkdir -p /home/nginx/.cloudvolume/secrets \ - && chown -R nginx /home/nginx \ - && usermod -d /home/nginx -s /bin/bash nginx diff --git a/pychunkedgraph/app/common.py b/pychunkedgraph/app/common.py index 237e11fc0..7562762de 100644 --- a/pychunkedgraph/app/common.py +++ b/pychunkedgraph/app/common.py @@ -4,7 +4,7 @@ import json import time import traceback -from datetime import datetime +from datetime import datetime, timezone from cloudvolume import compression from google.api_core.exceptions import GoogleAPIError @@ -50,7 +50,7 @@ def _log_request(response_time): def before_request(): current_app.request_start_time = time.time() - current_app.request_start_date = datetime.utcnow() + current_app.request_start_date = datetime.now(timezone.utc) try: current_app.user_id = g.auth_user["id"] except (AttributeError, KeyError): diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index d10790604..5b44e9379 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -3,7 +3,7 @@ import json import os import time -from datetime import datetime +from datetime import datetime, timezone from functools import reduce from collections import deque, defaultdict @@ -229,7 +229,7 @@ def handle_find_minimal_covering_nodes(table_id, is_binary=True): node_queue[layer].clear() # Return the download list - download_list = np.concatenate([np.array(list(v)) for v in download_list.values()]) + download_list = np.concatenate([np.array(list(v), dtype=np.uint64) for v in download_list.values()]) return download_list @@ -603,7 +603,7 @@ def all_user_operations( target_user_id = request.args.get("user_id", None) start_time = _parse_timestamp("start_time", 0, return_datetime=True) - end_time = _parse_timestamp("end_time", datetime.utcnow(), return_datetime=True) + end_time = _parse_timestamp("end_time", datetime.now(timezone.utc), return_datetime=True) # Call ChunkedGraph cg = app_utils.get_cg(table_id) @@ -613,7 +613,7 @@ def all_user_operations( valid_entry_ids = [] timestamp_list = [] - undone_ids = np.array([]) + undone_ids = np.array([], dtype=np.uint64) entry_ids = np.sort(list(log_rows.keys())) for entry_id in entry_ids: @@ -691,7 +691,7 @@ def handle_children(table_id, parent_id): if layer > 1: children = cg.get_children(parent_id) else: - children = np.array([]) + children = np.array([], dtype=np.uint64) return children @@ -794,8 +794,8 @@ def handle_subgraph(table_id, root_id, only_internal_edges=True): supervoxels = np.concatenate( [agg.supervoxels for agg in l2id_agglomeration_d.values()] ) - mask0 = np.in1d(edges.node_ids1, supervoxels) - mask1 = np.in1d(edges.node_ids2, supervoxels) + mask0 = np.isin(edges.node_ids1, supervoxels) + mask1 = np.isin(edges.node_ids2, supervoxels) edges = edges[mask0 & mask1] return edges diff --git a/pychunkedgraph/graph/chunks/hierarchy.py b/pychunkedgraph/graph/chunks/hierarchy.py index 6128d5914..5ff7823fe 100644 --- a/pychunkedgraph/graph/chunks/hierarchy.py +++ b/pychunkedgraph/graph/chunks/hierarchy.py @@ -37,7 +37,7 @@ def get_children_chunk_ids( layer = utils.get_chunk_layer(meta, node_or_chunk_id) if layer == 1: - return np.array([]) + return np.array([], dtype=np.uint64) elif layer == 2: return np.array([utils.get_chunk_id(meta, layer=layer, x=x, y=y, z=z)]) else: @@ -47,7 +47,7 @@ def get_children_chunk_ids( children_chunk_ids.append( utils.get_chunk_id(meta, layer=layer - 1, x=x, y=y, z=z) ) - return np.array(children_chunk_ids) + return np.array(children_chunk_ids, dtype=np.uint64) def get_parent_chunk_id( diff --git a/pychunkedgraph/graph/chunks/utils.py b/pychunkedgraph/graph/chunks/utils.py index 5546d2650..5b6d0ae78 100644 --- a/pychunkedgraph/graph/chunks/utils.py +++ b/pychunkedgraph/graph/chunks/utils.py @@ -91,7 +91,7 @@ def get_chunk_coordinates_multiple(meta, ids: np.ndarray) -> np.ndarray: Assumes all given IDs are in same layer. """ if len(ids) == 0: - return np.array([]) + return np.array([], dtype=int).reshape(0, 3) layer = get_chunk_layer(meta, ids[0]) bits_per_dim = meta.bitmasks[layer] @@ -99,7 +99,7 @@ def get_chunk_coordinates_multiple(meta, ids: np.ndarray) -> np.ndarray: y_offset = x_offset - bits_per_dim z_offset = y_offset - bits_per_dim - ids = np.array(ids, dtype=int, copy=False) + ids = np.array(ids, dtype=int) X = ids >> x_offset & 2**bits_per_dim - 1 Y = ids >> y_offset & 2**bits_per_dim - 1 Z = ids >> z_offset & 2**bits_per_dim - 1 @@ -154,7 +154,7 @@ def get_chunk_ids_from_node_ids(meta, ids: Iterable[np.uint64]) -> np.ndarray: bits_per_dims = np.array([meta.bitmasks[l] for l in get_chunk_layers(meta, ids)]) offsets = 64 - meta.graph_config.LAYER_ID_BITS - 3 * bits_per_dims - ids = np.array(ids, dtype=int, copy=False) + ids = np.array(ids, dtype=int) cids1 = np.array((ids >> offsets) << offsets, dtype=np.uint64) # cids2 = np.vectorize(get_chunk_id)(meta, ids) # assert np.all(cids1 == cids2) diff --git a/pychunkedgraph/graph/client/bigtable/utils.py b/pychunkedgraph/graph/client/bigtable/utils.py index 2d30eeb32..3f14e125d 100644 --- a/pychunkedgraph/graph/client/bigtable/utils.py +++ b/pychunkedgraph/graph/client/bigtable/utils.py @@ -4,6 +4,7 @@ from typing import Optional from datetime import datetime from datetime import timedelta +from datetime import timezone import numpy as np from google.cloud.bigtable.row_data import PartialRowData @@ -146,7 +147,7 @@ def get_time_range_and_column_filter( def get_root_lock_filter( lock_column, lock_expiry, indefinite_lock_column ) -> ConditionalRowFilter: - time_cutoff = datetime.utcnow() - lock_expiry + time_cutoff = datetime.now(timezone.utc) - lock_expiry # Comply to resolution of BigTables TimeRange time_cutoff -= timedelta(microseconds=time_cutoff.microsecond % 1000) time_filter = TimestampRangeFilter(TimestampRange(start=time_cutoff)) @@ -256,7 +257,7 @@ def get_renew_lock_filter( def get_unlock_root_filter(lock_column, lock_expiry, operation_id) -> RowFilterChain: - time_cutoff = datetime.utcnow() - lock_expiry + time_cutoff = datetime.now(timezone.utc) - lock_expiry # Comply to resolution of BigTables TimeRange time_cutoff -= timedelta(microseconds=time_cutoff.microsecond % 1000) time_filter = TimestampRangeFilter(TimestampRange(start=time_cutoff)) diff --git a/pychunkedgraph/graph/cutting.py b/pychunkedgraph/graph/cutting.py index 8b1583871..a2fca8023 100644 --- a/pychunkedgraph/graph/cutting.py +++ b/pychunkedgraph/graph/cutting.py @@ -62,7 +62,7 @@ def merge_cross_chunk_edges_graph_tool( if len(mapping) > 0: mapping = np.concatenate(mapping) u_nodes = np.unique(edges) - u_unmapped_nodes = u_nodes[~np.in1d(u_nodes, mapping)] + u_unmapped_nodes = u_nodes[~np.isin(u_nodes, mapping)] unmapped_mapping = np.concatenate( [u_unmapped_nodes.reshape(-1, 1), u_unmapped_nodes.reshape(-1, 1)], axis=1 ) @@ -189,9 +189,9 @@ def _build_gt_graph(self, edges, affs): ) = flatgraph.build_gt_graph(comb_edges, comb_affs, make_directed=True) self.source_graph_ids = np.where( - np.in1d(self.unique_supervoxel_ids, self.sources) + np.isin(self.unique_supervoxel_ids, self.sources) )[0] - self.sink_graph_ids = np.where(np.in1d(self.unique_supervoxel_ids, self.sinks))[ + self.sink_graph_ids = np.where(np.isin(self.unique_supervoxel_ids, self.sinks))[ 0 ] @@ -398,7 +398,7 @@ def _remap_cut_edge_set(self, cut_edge_set): remapped_cutset_flattened_view = remapped_cutset.view(dtype="u8,u8") edges_flattened_view = self.cg_edges.view(dtype="u8,u8") - cutset_mask = np.in1d(remapped_cutset_flattened_view, edges_flattened_view) + cutset_mask = np.isin(remapped_cutset_flattened_view, edges_flattened_view).ravel() return remapped_cutset[cutset_mask] @@ -432,8 +432,8 @@ def _get_split_preview_connected_components(self, cut_edge_set): max_sinks = 0 i = 0 for cc in ccs_test_post_cut: - num_sources = np.count_nonzero(np.in1d(self.source_graph_ids, cc)) - num_sinks = np.count_nonzero(np.in1d(self.sink_graph_ids, cc)) + num_sources = np.count_nonzero(np.isin(self.source_graph_ids, cc)) + num_sinks = np.count_nonzero(np.isin(self.sink_graph_ids, cc)) if num_sources > max_sources: max_sources = num_sources max_source_index = i @@ -486,13 +486,15 @@ def _filter_graph_connected_components(self): # If connected component contains no sources or no sinks, # remove its nodes from the mincut computation if not ( - np.any(np.in1d(self.source_graph_ids, cc)) - and np.any(np.in1d(self.sink_graph_ids, cc)) + np.any(np.isin(self.source_graph_ids, cc)) + and np.any(np.isin(self.sink_graph_ids, cc)) ): for node_id in cc: removed[node_id] = True - self.weighted_graph.set_vertex_filter(removed, inverted=True) + keep = self.weighted_graph.new_vertex_property("bool") + keep.a = ~removed.a.astype(bool) + self.weighted_graph.set_vertex_filter(keep) pruned_graph = graph_tool.Graph(self.weighted_graph, prune=True) # Test that there is only one connected component left ccs = flatgraph.connected_components(pruned_graph) @@ -525,13 +527,13 @@ def _gt_mincut_sanity_check(self, partition): np.array(np.where(partition.a == i_cc)[0], dtype=int) ] - if np.any(np.in1d(self.sources, cc_list)): - assert np.all(np.in1d(self.sources, cc_list)) - assert ~np.any(np.in1d(self.sinks, cc_list)) + if np.any(np.isin(self.sources, cc_list)): + assert np.all(np.isin(self.sources, cc_list)) + assert ~np.any(np.isin(self.sinks, cc_list)) - if np.any(np.in1d(self.sinks, cc_list)): - assert np.all(np.in1d(self.sinks, cc_list)) - assert ~np.any(np.in1d(self.sources, cc_list)) + if np.any(np.isin(self.sinks, cc_list)): + assert np.all(np.isin(self.sinks, cc_list)) + assert ~np.any(np.isin(self.sources, cc_list)) def _sink_and_source_connectivity_sanity_check(self, cut_edge_set): """ @@ -547,7 +549,8 @@ def _sink_and_source_connectivity_sanity_check(self, cut_edge_set): for edge_to_remove in parallel_edges: self.edges_to_remove[edge_to_remove] = True - self.weighted_graph.set_edge_filter(self.edges_to_remove, True) + self.edges_to_remove.a = ~self.edges_to_remove.a.astype(bool) + self.weighted_graph.set_edge_filter(self.edges_to_remove) ccs_test_post_cut = flatgraph.connected_components(self.weighted_graph) # Make sure sinks and sources are among each other and not in different sets @@ -555,9 +558,9 @@ def _sink_and_source_connectivity_sanity_check(self, cut_edge_set): illegal_split = False try: for cc in ccs_test_post_cut: - if np.any(np.in1d(self.source_graph_ids, cc)): - assert np.all(np.in1d(self.source_graph_ids, cc)) - assert ~np.any(np.in1d(self.sink_graph_ids, cc)) + if np.any(np.isin(self.source_graph_ids, cc)): + assert np.all(np.isin(self.source_graph_ids, cc)) + assert ~np.any(np.isin(self.sink_graph_ids, cc)) if ( len(self.source_path_vertices) == len(cc) and self.disallow_isolating_cut @@ -565,9 +568,9 @@ def _sink_and_source_connectivity_sanity_check(self, cut_edge_set): if not self.partition_edges_within_label(cc): raise IsolatingCutException("Source") - if np.any(np.in1d(self.sink_graph_ids, cc)): - assert np.all(np.in1d(self.sink_graph_ids, cc)) - assert ~np.any(np.in1d(self.source_graph_ids, cc)) + if np.any(np.isin(self.sink_graph_ids, cc)): + assert np.all(np.isin(self.sink_graph_ids, cc)) + assert ~np.any(np.isin(self.source_graph_ids, cc)) if ( len(self.sink_path_vertices) == len(cc) and self.disallow_isolating_cut @@ -664,8 +667,8 @@ def run_split_preview( supervoxels = np.concatenate( [agg.supervoxels for agg in l2id_agglomeration_d.values()] ) - mask0 = np.in1d(edges.node_ids1, supervoxels) - mask1 = np.in1d(edges.node_ids2, supervoxels) + mask0 = np.isin(edges.node_ids1, supervoxels) + mask1 = np.isin(edges.node_ids2, supervoxels) edges = edges[mask0 & mask1] edges_to_remove, illegal_split = run_multicut( edges, diff --git a/pychunkedgraph/graph/edges/utils.py b/pychunkedgraph/graph/edges/utils.py index b49a9a547..f79debf94 100644 --- a/pychunkedgraph/graph/edges/utils.py +++ b/pychunkedgraph/graph/edges/utils.py @@ -57,6 +57,7 @@ def concatenate_cross_edge_dicts( result_d[layer].append(edges) for layer, edge_lists in result_d.items(): + edge_lists = [np.asarray(e, dtype=basetypes.NODE_ID) for e in edge_lists] edges = np.concatenate(edge_lists) if unique: edges = np.unique(edges, axis=0) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 89ee5b8d2..25f31dd02 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -86,6 +86,13 @@ def _analyze_affected_edges( cross_edges_d[parent0][layer].append([parent0, parent1]) cross_edges_d[parent1][layer].append([parent1, parent0]) parent_edges.extend([[parent0, parent0], [parent1, parent1]]) + # Convert inner Python lists to typed numpy arrays to avoid + # dtype promotion issues when concatenated with uint64 arrays. + for node_id in cross_edges_d: + for layer in cross_edges_d[node_id]: + cross_edges_d[node_id][layer] = np.array( + cross_edges_d[node_id][layer], dtype=basetypes.NODE_ID + ).reshape(-1, 2) return parent_edges, cross_edges_d diff --git a/pychunkedgraph/graph/lineage.py b/pychunkedgraph/graph/lineage.py index 6876ec563..70d112f97 100644 --- a/pychunkedgraph/graph/lineage.py +++ b/pychunkedgraph/graph/lineage.py @@ -4,7 +4,7 @@ from typing import Union from typing import Optional from typing import Iterable -from datetime import datetime +from datetime import datetime, timezone from collections import defaultdict import numpy as np @@ -174,7 +174,7 @@ def lineage_graph( future_ids = np.array(node_ids, dtype=NODE_ID) timestamp_past = float(0) if timestamp_past is None else timestamp_past.timestamp() timestamp_future = ( - datetime.utcnow().timestamp() + datetime.now(timezone.utc).timestamp() if timestamp_future is None else timestamp_future.timestamp() ) diff --git a/pychunkedgraph/graph/misc.py b/pychunkedgraph/graph/misc.py index 0f53c71c3..faaa7fb29 100644 --- a/pychunkedgraph/graph/misc.py +++ b/pychunkedgraph/graph/misc.py @@ -142,7 +142,7 @@ def get_contact_sites( ) # Build area lookup dictionary - cs_svs = edges[~np.in1d(edges, sv_ids).reshape(-1, 2)] + cs_svs = edges[~np.isin(edges, sv_ids)] area_dict = collections.defaultdict(int) for area, sv_id in zip(areas, cs_svs): @@ -165,7 +165,7 @@ def get_contact_sites( cs_dict = collections.defaultdict(list) for cc in ccs: cc_sv_ids = unique_ids[cc] - cc_sv_ids = cc_sv_ids[np.in1d(cc_sv_ids, u_cs_svs)] + cc_sv_ids = cc_sv_ids[np.isin(cc_sv_ids, u_cs_svs)] cs_areas = area_dict_vec(cc_sv_ids) partner_root_id = ( int(cg.get_root(cc_sv_ids[0], time_stamp=time_stamp)) diff --git a/pychunkedgraph/graph/segmenthistory.py b/pychunkedgraph/graph/segmenthistory.py index 30f42d15b..bc4422490 100644 --- a/pychunkedgraph/graph/segmenthistory.py +++ b/pychunkedgraph/graph/segmenthistory.py @@ -1,5 +1,5 @@ import collections -from datetime import datetime +from datetime import datetime, timezone from typing import Iterable import numpy as np @@ -31,7 +31,7 @@ def __init__( if timestamp_past is not None: self.timestamp_past = timestamp_past - self.timestamp_future = datetime.utcnow() + self.timestamp_future = datetime.now(timezone.utc) if timestamp_future is None: self.timestamp_future = timestamp_future @@ -328,7 +328,7 @@ def past_future_id_mapping(self, root_id=None): past_id_mapping = {} future_id_mapping = {} for root_id in root_ids: - ancestors = np.array(list(nx_ancestors(self.lineage_graph, root_id))) + ancestors = np.array(list(nx_ancestors(self.lineage_graph, root_id)), dtype=np.uint64) if len(ancestors) == 0: past_id_mapping[int(root_id)] = [root_id] else: diff --git a/pychunkedgraph/graph/utils/flatgraph.py b/pychunkedgraph/graph/utils/flatgraph.py index 03cb6e2d2..d9504f104 100644 --- a/pychunkedgraph/graph/utils/flatgraph.py +++ b/pychunkedgraph/graph/utils/flatgraph.py @@ -112,7 +112,7 @@ def intersect_nodes(paths_v_s, paths_v_y): def harmonic_mean_paths(x): - return np.power(np.product(x), 1 / len(x)) + return np.power(np.prod(x), 1 / len(x)) def compute_filtered_paths( diff --git a/pychunkedgraph/graph/utils/id_helpers.py b/pychunkedgraph/graph/utils/id_helpers.py index aa486ac84..2a245f79c 100644 --- a/pychunkedgraph/graph/utils/id_helpers.py +++ b/pychunkedgraph/graph/utils/id_helpers.py @@ -89,7 +89,7 @@ def get_atomic_id_from_coord( # sort by frequency and discard those ids that have been checked # previously sorted_atomic_ids = atomic_ids[np.argsort(atomic_id_count)] - sorted_atomic_ids = sorted_atomic_ids[~np.in1d(sorted_atomic_ids, checked)] + sorted_atomic_ids = sorted_atomic_ids[~np.isin(sorted_atomic_ids, checked)] # For each candidate id check whether its root id corresponds to the # given root id diff --git a/pychunkedgraph/graph/utils/serializers.py b/pychunkedgraph/graph/utils/serializers.py index 09c0f63b0..a09094b33 100644 --- a/pychunkedgraph/graph/utils/serializers.py +++ b/pychunkedgraph/graph/utils/serializers.py @@ -41,7 +41,9 @@ def _deserialize(val, dtype, shape=None, order=None): def __init__(self, dtype, shape=None, order=None, compression_level=None): super().__init__( - serializer=lambda x: x.newbyteorder(dtype.byteorder).tobytes(), + serializer=lambda x: np.asarray(x) + .view(x.dtype.newbyteorder(dtype.byteorder)) + .tobytes(), deserializer=lambda x: NumPyArray._deserialize( x, dtype, shape=shape, order=order ), @@ -53,7 +55,9 @@ def __init__(self, dtype, shape=None, order=None, compression_level=None): class NumPyValue(_Serializer): def __init__(self, dtype): super().__init__( - serializer=lambda x: x.newbyteorder(dtype.byteorder).tobytes(), + serializer=lambda x: np.asarray(x) + .view(np.dtype(type(x)).newbyteorder(dtype.byteorder)) + .tobytes(), deserializer=lambda x: np.frombuffer(x, dtype=dtype)[0], basetype=dtype.type, ) @@ -96,7 +100,7 @@ def __init__(self): def pad_node_id(node_id: np.uint64) -> str: - """ Pad node id to 20 digits + """Pad node id to 20 digits :param node_id: int :return: str @@ -105,7 +109,7 @@ def pad_node_id(node_id: np.uint64) -> str: def serialize_uint64(node_id: np.uint64, counter=False, fake_edges=False) -> bytes: - """ Serializes an id to be ingested by a bigtable table row + """Serializes an id to be ingested by a bigtable table row :param node_id: int :return: str @@ -118,7 +122,7 @@ def serialize_uint64(node_id: np.uint64, counter=False, fake_edges=False) -> byt def serialize_uint64s_to_regex(node_ids: Iterable[np.uint64]) -> bytes: - """ Serializes an id to be ingested by a bigtable table row + """Serializes an id to be ingested by a bigtable table row :param node_id: int :return: str @@ -128,7 +132,7 @@ def serialize_uint64s_to_regex(node_ids: Iterable[np.uint64]) -> bytes: def deserialize_uint64(node_id: bytes, fake_edges=False) -> np.uint64: - """ De-serializes a node id from a BigTable row + """De-serializes a node id from a BigTable row :param node_id: bytes :return: np.uint64 @@ -139,7 +143,7 @@ def deserialize_uint64(node_id: bytes, fake_edges=False) -> np.uint64: def serialize_key(key: str) -> bytes: - """ Serializes a key to be ingested by a bigtable table row + """Serializes a key to be ingested by a bigtable table row :param key: str :return: bytes @@ -148,7 +152,7 @@ def serialize_key(key: str) -> bytes: def deserialize_key(key: bytes) -> str: - """ Deserializes a row key + """Deserializes a row key :param key: bytes :return: str diff --git a/pychunkedgraph/ingest/simple_tests.py b/pychunkedgraph/ingest/simple_tests.py index 07a60f5f3..48a49f922 100644 --- a/pychunkedgraph/ingest/simple_tests.py +++ b/pychunkedgraph/ingest/simple_tests.py @@ -4,7 +4,7 @@ Some sanity tests to ensure chunkedgraph was created properly. """ -from datetime import datetime +from datetime import datetime, timezone import numpy as np from pychunkedgraph.graph import attributes, ChunkedGraph @@ -14,7 +14,7 @@ def family(cg: ChunkedGraph): np.random.seed(42) n_chunks = 100 n_segments_per_chunk = 200 - timestamp = datetime.utcnow() + timestamp = datetime.now(timezone.utc) node_ids = [] for layer in range(2, cg.meta.layer_count - 1): @@ -56,7 +56,7 @@ def existence(cg: ChunkedGraph): layer = 2 n_chunks = 100 n_segments_per_chunk = 200 - timestamp = datetime.utcnow() + timestamp = datetime.now(timezone.utc) node_ids = [] for _ in range(n_chunks): c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) @@ -117,7 +117,7 @@ def cross_edges(cg: ChunkedGraph): layer = 2 n_chunks = 10 n_segments_per_chunk = 200 - timestamp = datetime.utcnow() + timestamp = datetime.now(timezone.utc) node_ids = [] for _ in range(n_chunks): c_x = np.random.randint(0, cg.meta.layer_chunk_bounds[layer][0]) diff --git a/pychunkedgraph/logging/log_db.py b/pychunkedgraph/logging/log_db.py index 89680500a..4a4244022 100644 --- a/pychunkedgraph/logging/log_db.py +++ b/pychunkedgraph/logging/log_db.py @@ -4,7 +4,7 @@ import threading import time import queue -from datetime import datetime +from datetime import datetime, timezone from google.api_core.exceptions import GoogleAPIError from datastoreflex import DatastoreFlex @@ -109,7 +109,7 @@ def __init__(self, name: str, graph_id: str, operation_id=-1, **kwargs): self.names.append(name) self._start = None self._graph_id = graph_id - self._ts = datetime.utcnow() + self._ts = datetime.now(timezone.utc) self._kwargs = kwargs if operation_id != -1: self.operation_id = operation_id diff --git a/pychunkedgraph/meshing/manifest/utils.py b/pychunkedgraph/meshing/manifest/utils.py index 67e600653..90963570c 100644 --- a/pychunkedgraph/meshing/manifest/utils.py +++ b/pychunkedgraph/meshing/manifest/utils.py @@ -40,7 +40,7 @@ def _get_children(cg, node_ids: Sequence[np.uint64], children_cache: Dict): if len(node_ids) == 0: return empty_1d.copy() node_ids = np.array(node_ids, dtype=NODE_ID) - mask = np.in1d(node_ids, np.fromiter(children_cache.keys(), dtype=NODE_ID)) + mask = np.isin(node_ids, np.fromiter(children_cache.keys(), dtype=NODE_ID)) children_d = cg.get_children(node_ids[~mask]) children_cache.update(children_d) diff --git a/pychunkedgraph/meshing/mesh_analysis.py b/pychunkedgraph/meshing/mesh_analysis.py index 97bb28f5b..abdf95957 100644 --- a/pychunkedgraph/meshing/mesh_analysis.py +++ b/pychunkedgraph/meshing/mesh_analysis.py @@ -16,10 +16,10 @@ def compute_centroid_with_chunk_boundary(cg, vertices, l2_id, last_l2_id): a path, return the center point of the mesh on the chunk boundary separating the two ids, and the center point of the entire mesh. :param cg: ChunkedGraph object - :param vertices: [[np.float]] + :param vertices: [[np.float64]] :param l2_id: np.uint64 :param last_l2_id: np.uint64 or None - :return: [np.float] + :return: [np.float64] """ centroid_by_range = compute_centroid_by_range(vertices) if last_l2_id is None: diff --git a/pychunkedgraph/meshing/mesh_io.py b/pychunkedgraph/meshing/mesh_io.py index 40c02bba0..1cf1fed66 100644 --- a/pychunkedgraph/meshing/mesh_io.py +++ b/pychunkedgraph/meshing/mesh_io.py @@ -168,8 +168,8 @@ def load_obj(self): faces.append(face) self._faces = np.array(faces, dtype=int) - 1 - self._vertices = np.array(vertices, dtype=np.float) - self._normals = np.array(normals, dtype=np.float) + self._vertices = np.array(vertices, dtype=np.float64) + self._normals = np.array(normals, dtype=np.float64) def load_h5(self): with h5py.File(self.filename, "r") as f: diff --git a/pychunkedgraph/meshing/meshengine.py b/pychunkedgraph/meshing/meshengine.py index 615e6cdb6..e852dfa3a 100644 --- a/pychunkedgraph/meshing/meshengine.py +++ b/pychunkedgraph/meshing/meshengine.py @@ -126,14 +126,14 @@ def mesh_single_layer(self, layer, bounding_box=None, block_factor=2, block_bounding_box_cg /= 2 ** np.max([0, layer - 2]) block_bounding_box_cg = np.ceil(block_bounding_box_cg) - n_jobs = np.product(block_bounding_box_cg[1] - + n_jobs = np.prod(block_bounding_box_cg[1] - block_bounding_box_cg[0]) / \ block_factor ** 2 < n_threads while n_jobs < n_threads and block_factor > 1: block_factor -= 1 - n_jobs = np.product(block_bounding_box_cg[1] - + n_jobs = np.prod(block_bounding_box_cg[1] - block_bounding_box_cg[0]) / \ block_factor ** 2 < n_threads diff --git a/pychunkedgraph/meshing/meshgen.py b/pychunkedgraph/meshing/meshgen.py index a8da89b1f..f6613f7d2 100644 --- a/pychunkedgraph/meshing/meshgen.py +++ b/pychunkedgraph/meshing/meshgen.py @@ -75,7 +75,7 @@ def remap_seg_using_unsafe_dict(seg, unsafe_dict): overlaps.extend(np.unique(seg[:, :, -2][bin_cc_seg[:, :, -1]])) overlaps = np.unique(overlaps) - linked_l2_ids = overlaps[np.in1d(overlaps, unsafe_dict[unsafe_root_id])] + linked_l2_ids = overlaps[np.isin(overlaps, unsafe_dict[unsafe_root_id])] if len(linked_l2_ids) == 0: seg[bin_cc_seg] = 0 @@ -317,7 +317,7 @@ def get_lx_overlapping_remappings(cg, chunk_id, time_stamp=None, n_threads=1): :return: multiples """ if time_stamp is None: - time_stamp = datetime.datetime.utcnow() + time_stamp = datetime.datetime.now(datetime.timezone.utc) if time_stamp.tzinfo is None: time_stamp = UTC.localize(time_stamp) @@ -357,7 +357,7 @@ def get_lx_overlapping_remappings(cg, chunk_id, time_stamp=None, n_threads=1): ) safe_lx_ids = lx_ids[u_idx[c_root_ids == 1]] - unsafe_lx_ids = lx_ids[~np.in1d(lx_ids, safe_lx_ids)] + unsafe_lx_ids = lx_ids[~np.isin(lx_ids, safe_lx_ids)] unsafe_root_ids = np.unique(root_ids[u_idx[c_root_ids != 1]]) lx_root_dict = dict(zip(neigh_lx_ids, neigh_root_ids)) @@ -387,7 +387,7 @@ def get_lx_overlapping_remappings(cg, chunk_id, time_stamp=None, n_threads=1): unsafe_dict = collections.defaultdict(list) for root_id in unsafe_root_ids: - if np.sum(~np.in1d(root_lx_dict[root_id], unsafe_lx_ids)) == 0: + if np.sum(~np.isin(root_lx_dict[root_id], unsafe_lx_ids)) == 0: continue for neigh_lx_id in root_lx_dict[root_id]: @@ -475,7 +475,7 @@ def get_lx_overlapping_remappings_for_nodes_and_svs( :return: multiples """ if time_stamp is None: - time_stamp = datetime.datetime.utcnow() + time_stamp = datetime.datetime.now(datetime.timezone.utc) if time_stamp.tzinfo is None: time_stamp = UTC.localize(time_stamp) diff --git a/pychunkedgraph/tests/test_merge.py b/pychunkedgraph/tests/test_merge.py index ae60b486e..9c6a3148c 100644 --- a/pychunkedgraph/tests/test_merge.py +++ b/pychunkedgraph/tests/test_merge.py @@ -210,6 +210,8 @@ def test_merge_pair_already_connected(self, gen_graph): ) res_new = cg.client._table.read_rows() res_new.consume_all() + res_new.rows.pop(b'ioperations', None) + res_new.rows.pop(b'00000000000000000001', None) # Check if res_old.rows != res_new.rows: diff --git a/pychunkedgraph/utils/general.py b/pychunkedgraph/utils/general.py index ac4929660..533395f47 100644 --- a/pychunkedgraph/utils/general.py +++ b/pychunkedgraph/utils/general.py @@ -5,6 +5,15 @@ from typing import Sequence from itertools import islice +try: + from itertools import batched +except ImportError: + # Python < 3.12 fallback + def batched(iterable, n): + it = iter(iterable) + while batch := tuple(islice(it, n)): + yield batch + import numpy as np @@ -26,18 +35,13 @@ def reverse_dictionary(dictionary): def chunked(l: Sequence, n: int): - """ - Yield successive n-sized chunks from l. - NOTE: Use itertools.batched from python 3.12 - """ + """Yield successive n-sized chunks from l.""" if n < 1: n = len(l) - it = iter(l) - while batch := tuple(islice(it, n)): - yield batch + yield from batched(l, n) def in2d(arr1: np.ndarray, arr2: np.ndarray) -> np.ndarray: arr1_view = arr1.view(dtype="u8,u8").reshape(arr1.shape[0]) arr2_view = arr2.view(dtype="u8,u8").reshape(arr2.shape[0]) - return np.in1d(arr1_view, arr2_view) + return np.isin(arr1_view, arr2_view) diff --git a/requirements.in b/requirements.in index 4fcd353ed..4bd56780b 100644 --- a/requirements.in +++ b/requirements.in @@ -5,12 +5,12 @@ grpcio>=1.36.1 numpy pandas networkx>=2.1 -google-cloud-bigtable>=0.33.0 +google-cloud-bigtable>=2.0.0 google-cloud-datastore>=1.8 flask flask_cors python-json-logger -redis>5 +redis>7 rq>2 pyyaml cachetools @@ -18,17 +18,17 @@ werkzeug tensorstore # PyPI only: -cloud-files>=4.21.1 -cloud-volume>=8.26.0 +cloud-files>=6.0.0 +cloud-volume>=12.0.0 multiwrapper middle-auth-client>=3.11.0 zmesh>=1.7.0 fastremap>=1.14.0 -task-queue>=2.13.0 +task-queue>=2.14.0 messagingclient dracopy>=1.3.0 datastoreflex>=0.5.0 -zstandard==0.21.0 +zstandard>=0.23.0 # Conda only - use requirements.yml (or install manually): # graph-tool \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0eedacb31..5005893d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,118 +1,120 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --output-file=requirements.txt requirements.in # -attrs==23.1.0 +attrs==25.4.0 # via # jsonschema # referencing -blinker==1.6.2 +blinker==1.9.0 # via flask -boto3==1.28.52 +boto3==1.42.53 # via # cloud-files # cloud-volume # task-queue -botocore==1.31.52 +botocore==1.42.53 # via # boto3 # s3transfer -brotli==1.1.0 +brotli==1.2.0 # via # cloud-files # urllib3 -cachetools==5.3.1 +cachetools==7.0.1 # via # -r requirements.in - # google-auth # middle-auth-client -certifi==2023.7.22 +certifi==2026.1.4 # via requests +cffi==2.0.0 + # via cryptography chardet==5.2.0 # via # cloud-files # cloud-volume -charset-normalizer==3.2.0 +charset-normalizer==3.4.4 # via requests -click==8.1.7 +click==8.3.1 # via # -r requirements.in # cloud-files # compressed-segmentation - # compresso # flask + # microviewer # rq # task-queue -cloud-files==4.21.1 +cloud-files==6.2.1 # via # -r requirements.in # cloud-volume # datastoreflex -cloud-volume==8.26.0 +cloud-volume==12.10.0 # via -r requirements.in -compressed-segmentation==2.2.1 - # via cloud-volume -compresso==3.2.1 +compressed-segmentation==2.3.2 # via cloud-volume -crackle-codec==0.7.0 - # via cloud-volume -crc32c==2.3.post0 +crc32c==2.8 # via cloud-files +croniter==6.0.0 + # via rq +cryptography==46.0.5 + # via google-auth datastoreflex==0.5.0 # via -r requirements.in -deflate==0.4.0 +deflate==0.8.1 # via cloud-files -dill==0.3.7 +dill==0.4.1 # via # multiprocess # pathos -dracopy==1.3.0 +dracopy==1.7.0 # via # -r requirements.in # cloud-volume -fasteners==0.19 +fasteners==0.20 # via cloud-files -fastremap==1.14.0 +fastremap==1.17.7 # via # -r requirements.in # cloud-volume - # crackle-codec -flask==2.3.3 + # osteoid +flask==3.1.3 # via # -r requirements.in # flask-cors # middle-auth-client -flask-cors==4.0.0 +flask-cors==6.0.2 # via -r requirements.in -fpzip==1.2.2 - # via cloud-volume -furl==2.1.3 +furl==2.1.4 # via middle-auth-client -gevent==23.9.1 +gevent==25.9.1 # via # cloud-files # cloud-volume # task-queue -google-api-core[grpc]==2.11.1 +google-api-core[grpc]==2.30.0 # via # google-cloud-bigtable # google-cloud-core # google-cloud-datastore # google-cloud-pubsub # google-cloud-storage -google-auth==2.23.0 +google-auth==2.48.0 # via # cloud-files # cloud-volume # google-api-core + # google-cloud-bigtable # google-cloud-core + # google-cloud-datastore + # google-cloud-pubsub # google-cloud-storage # task-queue -google-cloud-bigtable==2.21.0 +google-cloud-bigtable==2.35.0 # via -r requirements.in -google-cloud-core==2.3.3 +google-cloud-core==2.5.0 # via # cloud-files # cloud-volume @@ -120,139 +122,158 @@ google-cloud-core==2.3.3 # google-cloud-datastore # google-cloud-storage # task-queue -google-cloud-datastore==2.18.0 +google-cloud-datastore==2.23.0 # via # -r requirements.in # datastoreflex -google-cloud-pubsub==2.18.4 +google-cloud-pubsub==2.35.0 # via messagingclient -google-cloud-storage==2.11.0 +google-cloud-storage==3.9.0 # via # cloud-files # cloud-volume -google-crc32c==1.5.0 +google-crc32c==1.8.0 # via # cloud-files + # google-cloud-bigtable + # google-cloud-storage # google-resumable-media -google-resumable-media==2.6.0 +google-resumable-media==2.8.0 # via google-cloud-storage -googleapis-common-protos[grpc]==1.60.0 +googleapis-common-protos[grpc]==1.72.0 # via # google-api-core # grpc-google-iam-v1 # grpcio-status -greenlet==3.0.0rc3 +greenlet==3.3.1 # via gevent -grpc-google-iam-v1==0.12.6 +grpc-google-iam-v1==0.14.3 # via # google-cloud-bigtable # google-cloud-pubsub -grpcio==1.58.0 +grpcio==1.78.0 # via # -r requirements.in # google-api-core + # google-cloud-datastore # google-cloud-pubsub # googleapis-common-protos # grpc-google-iam-v1 # grpcio-status -grpcio-status==1.58.0 +grpcio-status==1.78.0 # via # google-api-core # google-cloud-pubsub -idna==3.4 +idna==3.11 # via requests +importlib-metadata==8.7.1 + # via opentelemetry-api inflection==0.5.1 # via python-jsonschema-objects -iniconfig==2.0.0 +iniconfig==2.3.0 # via pytest -itsdangerous==2.1.2 +intervaltree==3.2.1 + # via cloud-files +itsdangerous==2.2.0 # via flask -jinja2==3.1.3 +jinja2==3.1.6 # via flask -jmespath==1.0.1 +jmespath==1.1.0 # via # boto3 # botocore -json5==0.9.14 +json5==0.13.0 # via cloud-volume -jsonschema==4.19.1 +jsonschema==4.26.0 # via # cloud-volume # python-jsonschema-objects -jsonschema-specifications==2023.7.1 +jsonschema-specifications==2025.9.1 # via jsonschema -markdown==3.4.4 +markdown==3.10.2 # via python-jsonschema-objects -markupsafe==2.1.3 +markupsafe==3.0.3 # via + # flask # jinja2 # werkzeug -messagingclient==0.1.3 +messagingclient==0.3.0 # via -r requirements.in -middle-auth-client==3.16.1 +microviewer==1.20.0 + # via cloud-volume +middle-auth-client==3.19.2 # via -r requirements.in -ml-dtypes==0.3.2 +ml-dtypes==0.5.4 # via tensorstore -multiprocess==0.70.15 +multiprocess==0.70.19 # via pathos multiwrapper==0.1.1 # via -r requirements.in -networkx==3.1 +networkx==3.6.1 # via # -r requirements.in # cloud-volume -numpy==1.26.0 + # osteoid +numpy==2.4.2 # via # -r requirements.in + # cloud-files # cloud-volume # compressed-segmentation - # compresso - # crackle-codec # fastremap - # fpzip # messagingclient + # microviewer # ml-dtypes # multiwrapper + # osteoid # pandas - # pyspng-seunglab # simplejpeg # task-queue # tensorstore - # zfpc # zmesh -orderedmultidict==1.0.1 +opentelemetry-api==1.39.1 + # via + # google-cloud-pubsub + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-sdk==1.39.1 + # via google-cloud-pubsub +opentelemetry-semantic-conventions==0.60b1 + # via opentelemetry-sdk +orderedmultidict==1.0.2 # via furl -orjson==3.9.7 +orjson==3.11.7 # via # cloud-files # task-queue -packaging==23.1 +osteoid==0.6.0 + # via cloud-volume +packaging==26.0 # via pytest -pandas==2.1.1 +pandas==3.0.1 # via -r requirements.in -pathos==0.3.1 +pathos==0.3.5 # via # cloud-files # cloud-volume # task-queue -pbr==5.11.1 +pbr==7.0.3 # via task-queue -pillow==10.0.1 - # via cloud-volume -pluggy==1.3.0 +pluggy==1.6.0 # via pytest -posix-ipc==1.1.1 +posix-ipc==1.3.2 # via cloud-volume -pox==0.3.3 +pox==0.3.7 # via pathos -ppft==1.7.6.7 +ppft==1.7.8 # via pathos -proto-plus==1.22.3 +proto-plus==1.27.1 # via + # google-api-core # google-cloud-bigtable # google-cloud-datastore # google-cloud-pubsub -protobuf==4.24.3 +protobuf==6.33.5 # via # -r requirements.in # cloud-files @@ -265,44 +286,47 @@ protobuf==4.24.3 # grpc-google-iam-v1 # grpcio-status # proto-plus -psutil==5.9.5 +psutil==7.2.2 # via cloud-volume -pyasn1==0.5.0 +pyasn1==0.6.2 # via # pyasn1-modules # rsa -pyasn1-modules==0.3.0 +pyasn1-modules==0.4.2 # via google-auth -pybind11==2.11.1 - # via crackle-codec -pysimdjson==5.0.2 - # via cloud-volume -pyspng-seunglab==1.1.0 +pybind11==3.0.2 + # via osteoid +pycparser==3.0 + # via cffi +pygments==2.19.2 + # via pytest +pysimdjson==7.0.2 # via cloud-volume -pytest==7.4.2 +pytest==9.0.2 # via compressed-segmentation -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via # botocore # cloud-volume + # croniter # pandas -python-json-logger==2.0.7 +python-json-logger==4.0.0 # via -r requirements.in -python-jsonschema-objects==0.5.0 +python-jsonschema-objects==0.5.7 # via cloud-volume -pytz==2023.3.post1 - # via pandas -pyyaml==6.0.1 +pytz==2025.2 + # via croniter +pyyaml==6.0.3 # via -r requirements.in -redis==6.4.0 +redis==7.2.0 # via # -r requirements.in # rq -referencing==0.30.2 +referencing==0.37.0 # via # jsonschema # jsonschema-specifications -requests==2.31.0 +requests==2.32.5 # via # -r requirements.in # cloud-files @@ -311,66 +335,69 @@ requests==2.31.0 # google-cloud-storage # middle-auth-client # task-queue -rpds-py==0.10.3 +rpds-py==0.30.0 # via # jsonschema # referencing -rq==2.4.1 +rq==2.6.1 # via -r requirements.in -rsa==4.9 +rsa==4.9.1 # via # cloud-files # google-auth -s3transfer==0.6.2 +s3transfer==0.16.0 # via boto3 -simplejpeg==1.7.2 +simplejpeg==1.9.0 # via cloud-volume -six==1.16.0 +six==1.17.0 # via # cloud-files - # cloud-volume # furl # orderedmultidict # python-dateutil - # python-jsonschema-objects -task-queue==2.13.0 +sortedcontainers==2.4.0 + # via intervaltree +task-queue==2.14.3 # via -r requirements.in -tenacity==8.2.3 +tenacity==9.1.4 # via # cloud-files # cloud-volume # task-queue -tensorstore==0.1.53 +tensorstore==0.1.81 # via -r requirements.in -tqdm==4.66.1 +tqdm==4.67.3 # via # cloud-files # cloud-volume # task-queue -tzdata==2023.3 - # via pandas -urllib3[brotli]==1.26.16 +typing-extensions==4.15.0 + # via + # grpcio + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # referencing +urllib3[brotli]==2.6.3 # via # botocore # cloud-files # cloud-volume - # google-auth # requests -werkzeug==2.3.8 +werkzeug==3.1.6 # via # -r requirements.in # flask -zfpc==0.1.2 - # via cloud-volume -zfpy==1.0.0 - # via zfpc -zmesh==1.7.0 + # flask-cors +zipp==3.23.0 + # via importlib-metadata +zmesh==1.10.0 # via -r requirements.in -zope-event==5.0 +zope-event==6.1 # via gevent -zope-interface==6.0 +zope-interface==8.2 # via gevent -zstandard==0.21.0 +zstandard==0.25.0 # via # -r requirements.in # cloud-files diff --git a/requirements.yml b/requirements.yml index 0bfa5b227..9b8bc536e 100644 --- a/requirements.yml +++ b/requirements.yml @@ -2,12 +2,12 @@ name: pychunkedgraph channels: - conda-forge dependencies: - - python==3.11.4 + - python==3.12.8 - pip - tox - - uwsgi==2.0.21 - - graph-tool-base==2.58 - - zstandard==0.19.0 # ugly hack to force PyPi install 0.21.0 + - numpy + - uwsgi + - graph-tool-base==2.98 - pip: - -r requirements.txt - -r requirements-dev.txt \ No newline at end of file diff --git a/tox.ini b/tox.ini index 5398564e6..bb15fef19 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py311 +envlist = py312 requires = tox-conda [testenv] From 930706b9cbb74133237664516be521490de6624e Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 20 Feb 2026 01:26:26 +0000 Subject: [PATCH 158/367] try to fix codecov --- .github/workflows/main.yml | 5 +++-- README.md | 4 ++-- codecov.yml | 17 +++++++++++++++++ docs/Readme.md | 4 ++-- docs/edges.md | 2 +- docs/edges_and_components.md | 2 +- docs/segmentation_preprocessing.md | 4 ++-- setup.py | 2 +- 8 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1aaedfcf2..b64b1175d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,7 +36,7 @@ jobs: - name: Copy coverage report from container if: always() - run: docker cp pcg-tests:/app/coverage.xml ./coverage.xml || true + run: docker cp pcg-tests:/app/coverage.xml ./coverage.xml - name: Upload coverage to Codecov if: always() @@ -44,7 +44,8 @@ jobs: with: files: ./coverage.xml token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false + slug: CAVEconnectome/PyChunkedGraph + fail_ci_if_error: true - name: Cleanup if: always() diff --git a/README.md b/README.md index 081ec7b4b..ac1c67161 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # PyChunkedGraph -[![Tests](https://github.com/seung-lab/PyChunkedGraph/actions/workflows/main.yml/badge.svg)](https://github.com/seung-lab/PyChunkedGraph/actions/workflows/main.yml) -[![codecov](https://codecov.io/gh/seung-lab/PyChunkedGraph/branch/main/graph/badge.svg)](https://codecov.io/gh/seung-lab/PyChunkedGraph) +[![Tests](https://github.com/CAVEconnectome/PyChunkedGraph/actions/workflows/main.yml/badge.svg)](https://github.com/CAVEconnectome/PyChunkedGraph/actions/workflows/main.yml) +[![codecov](https://codecov.io/gh/CAVEconnectome/PyChunkedGraph/branch/main/graph/badge.svg)](https://codecov.io/gh/CAVEconnectome/PyChunkedGraph) The PyChunkedGraph is a proofreading and segmentation data management backend powering FlyWire and other proofreading platforms. It builds on an initial agglomeration of supervoxels and facilitates fast and parallel editing of connected components in the agglomeration graph by many users. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..31a2abfee --- /dev/null +++ b/codecov.yml @@ -0,0 +1,17 @@ +codecov: + require_ci_to_pass: true + +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: 80% + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: false diff --git a/docs/Readme.md b/docs/Readme.md index 45799326e..c05ad6979 100644 --- a/docs/Readme.md +++ b/docs/Readme.md @@ -10,7 +10,7 @@ pip install -r requirements.txt ## Multiprocessing -Check out [multiprocessing.md](https://github.com/seung-lab/PyChunkedGraph/blob/master/src/pychunkedgraph/multiprocessing.md) for how to use the multiprocessing tools implemented for the ChunkedGraph +Check out [multiprocessing.md](https://github.com/CAVEconnectome/PyChunkedGraph/blob/master/src/pychunkedgraph/multiprocessing.md) for how to use the multiprocessing tools implemented for the ChunkedGraph ## Credentials @@ -30,7 +30,7 @@ The current version of the ChunkedGraph contains supervoxels from `gs://nkem/bas ### Building the graph -[buildgraph.md](https://github.com/seung-lab/PyChunkedGraph/blob/master/src/pychunkedgraph/buildgraph.md) explains how to build a graph from scratch. +[buildgraph.md](https://github.com/CAVEconnectome/PyChunkedGraph/blob/master/src/pychunkedgraph/buildgraph.md) explains how to build a graph from scratch. ### Initialization diff --git a/docs/edges.md b/docs/edges.md index 9dc15a98b..ccda4205b 100644 --- a/docs/edges.md +++ b/docs/edges.md @@ -2,7 +2,7 @@ PyChunkedgraph uses protobuf for serialization and zstandard for compression. -Edges and connected components per chunk are stored using the protobuf definitions in [`pychunkedgraph.io.protobuf`](https://github.com/seung-lab/PyChunkedGraph/pychunkedgraph/io/protobuf/chunkEdges.proto). +Edges and connected components per chunk are stored using the protobuf definitions in [`pychunkedgraph.io.protobuf`](https://github.com/CAVEconnectome/PyChunkedGraph/pychunkedgraph/io/protobuf/chunkEdges.proto). This format is a result of performance tests. It provided the best tradeoff between deserialzation speed and storage size. diff --git a/docs/edges_and_components.md b/docs/edges_and_components.md index 9dc15a98b..ccda4205b 100644 --- a/docs/edges_and_components.md +++ b/docs/edges_and_components.md @@ -2,7 +2,7 @@ PyChunkedgraph uses protobuf for serialization and zstandard for compression. -Edges and connected components per chunk are stored using the protobuf definitions in [`pychunkedgraph.io.protobuf`](https://github.com/seung-lab/PyChunkedGraph/pychunkedgraph/io/protobuf/chunkEdges.proto). +Edges and connected components per chunk are stored using the protobuf definitions in [`pychunkedgraph.io.protobuf`](https://github.com/CAVEconnectome/PyChunkedGraph/pychunkedgraph/io/protobuf/chunkEdges.proto). This format is a result of performance tests. It provided the best tradeoff between deserialzation speed and storage size. diff --git a/docs/segmentation_preprocessing.md b/docs/segmentation_preprocessing.md index 3fb1bf59b..028419a3a 100644 --- a/docs/segmentation_preprocessing.md +++ b/docs/segmentation_preprocessing.md @@ -32,10 +32,10 @@ There are three types of edges: 2. `cross_chunk`: edges between parts of "the same" supervoxel in the unchunked segmentation that has been split across chunk boundary 3. `between_chunk`: edges between supervoxels across chunks -Every pair of touching supervoxels has an edge between them. All edges are stored using [protobuf](https://github.com/seung-lab/PyChunkedGraph/blob/pcgv2/pychunkedgraph/io/protobuf/chunkEdges.proto). During ingest only edges of type 2. and 3. are copied into BigTable, whereas edges of type 1. are always read from storage to reduce cost. Similar to the supervoxel segmentation, we recommed storing these on GCloud in the same zone the ChunkedGraph server will be deployed in to reduce latency. +Every pair of touching supervoxels has an edge between them. All edges are stored using [protobuf](https://github.com/CAVEconnectome/PyChunkedGraph/blob/pcgv2/pychunkedgraph/io/protobuf/chunkEdges.proto). During ingest only edges of type 2. and 3. are copied into BigTable, whereas edges of type 1. are always read from storage to reduce cost. Similar to the supervoxel segmentation, we recommed storing these on GCloud in the same zone the ChunkedGraph server will be deployed in to reduce latency. To denote which edges form a connected component within a chunk, a component mapping needs to be created. This mapping is only used during ingest. -More details on how to create these protobuf files can be found [here](https://github.com/seung-lab/PyChunkedGraph/blob/pcgv2/docs/storage.md). +More details on how to create these protobuf files can be found [here](https://github.com/CAVEconnectome/PyChunkedGraph/blob/pcgv2/docs/storage.md). diff --git a/setup.py b/setup.py index e71fcab1b..077fb23df 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ def find_version(*file_paths): description="Proofreading backend for Neuroglancer", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/seung-lab/PyChunkedGraph", + url="https://github.com/CAVEconnectome/PyChunkedGraph", packages=find_packages(), install_requires=required, dependency_links=dependency_links, From c3ec2a4b3690dc3cbfbd8be628cedd9cc3320ab7 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 20 Feb 2026 05:26:14 +0000 Subject: [PATCH 159/367] add more tests to improve coverage --- .coveragerc | 7 + .pre-commit-config.yaml | 5 + pychunkedgraph/tests/conftest.py | 54 + pychunkedgraph/tests/test_analysis_pathing.py | 558 ++++++ pychunkedgraph/tests/test_attributes.py | 88 + pychunkedgraph/tests/test_cache.py | 152 ++ .../tests/test_chunkedgraph_extended.py | 1591 +++++++++++++++++ pychunkedgraph/tests/test_chunks_hierarchy.py | 87 + pychunkedgraph/tests/test_chunks_utils.py | 133 ++ pychunkedgraph/tests/test_connectivity.py | 119 ++ pychunkedgraph/tests/test_cutting.py | 1418 +++++++++++++++ .../tests/test_edges_definitions.py | 105 ++ pychunkedgraph/tests/test_edges_utils.py | 96 + pychunkedgraph/tests/test_edits_extended.py | 55 + pychunkedgraph/tests/test_exceptions.py | 70 + .../tests/test_ingest_atomic_layer.py | 66 + pychunkedgraph/tests/test_ingest_config.py | 27 + .../tests/test_ingest_cross_edges.py | 368 ++++ pychunkedgraph/tests/test_ingest_manager.py | 131 ++ .../tests/test_ingest_parent_layer.py | 63 + .../tests/test_ingest_ran_agglomeration.py | 1100 ++++++++++++ pychunkedgraph/tests/test_ingest_utils.py | 492 +++++ pychunkedgraph/tests/test_io_components.py | 57 + pychunkedgraph/tests/test_io_edges.py | 79 + pychunkedgraph/tests/test_lineage.py | 458 +++++ pychunkedgraph/tests/test_locks.py | 337 ++++ pychunkedgraph/tests/test_meta.py | 609 +++++++ pychunkedgraph/tests/test_misc.py | 293 +++ pychunkedgraph/tests/test_operation.py | 720 +++++++- pychunkedgraph/tests/test_segmenthistory.py | 627 +++++++ pychunkedgraph/tests/test_serializers.py | 143 ++ pychunkedgraph/tests/test_stale_edges.py | 237 +++ pychunkedgraph/tests/test_subgraph.py | 112 ++ pychunkedgraph/tests/test_types.py | 33 + pychunkedgraph/tests/test_utils_flatgraph.py | 260 +++ pychunkedgraph/tests/test_utils_generic.py | 175 ++ pychunkedgraph/tests/test_utils_id_helpers.py | 232 +++ requirements-dev.txt | 1 + 38 files changed, 11154 insertions(+), 4 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 pychunkedgraph/tests/test_analysis_pathing.py create mode 100644 pychunkedgraph/tests/test_attributes.py create mode 100644 pychunkedgraph/tests/test_cache.py create mode 100644 pychunkedgraph/tests/test_chunkedgraph_extended.py create mode 100644 pychunkedgraph/tests/test_chunks_hierarchy.py create mode 100644 pychunkedgraph/tests/test_chunks_utils.py create mode 100644 pychunkedgraph/tests/test_connectivity.py create mode 100644 pychunkedgraph/tests/test_cutting.py create mode 100644 pychunkedgraph/tests/test_edges_definitions.py create mode 100644 pychunkedgraph/tests/test_edges_utils.py create mode 100644 pychunkedgraph/tests/test_edits_extended.py create mode 100644 pychunkedgraph/tests/test_exceptions.py create mode 100644 pychunkedgraph/tests/test_ingest_atomic_layer.py create mode 100644 pychunkedgraph/tests/test_ingest_config.py create mode 100644 pychunkedgraph/tests/test_ingest_cross_edges.py create mode 100644 pychunkedgraph/tests/test_ingest_manager.py create mode 100644 pychunkedgraph/tests/test_ingest_parent_layer.py create mode 100644 pychunkedgraph/tests/test_ingest_ran_agglomeration.py create mode 100644 pychunkedgraph/tests/test_ingest_utils.py create mode 100644 pychunkedgraph/tests/test_io_components.py create mode 100644 pychunkedgraph/tests/test_io_edges.py create mode 100644 pychunkedgraph/tests/test_lineage.py create mode 100644 pychunkedgraph/tests/test_meta.py create mode 100644 pychunkedgraph/tests/test_misc.py create mode 100644 pychunkedgraph/tests/test_segmenthistory.py create mode 100644 pychunkedgraph/tests/test_serializers.py create mode 100644 pychunkedgraph/tests/test_subgraph.py create mode 100644 pychunkedgraph/tests/test_types.py create mode 100644 pychunkedgraph/tests/test_utils_flatgraph.py create mode 100644 pychunkedgraph/tests/test_utils_generic.py create mode 100644 pychunkedgraph/tests/test_utils_id_helpers.py diff --git a/.coveragerc b/.coveragerc index a38e1c392..d351f3e7e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,6 +5,13 @@ source = pychunkedgraph omit = *test* *benchmarking/* + pychunkedgraph/debug/* + pychunkedgraph/export/* + pychunkedgraph/jobs/* + pychunkedgraph/logging/* + pychunkedgraph/repair/* + pychunkedgraph/meshing/* + pychunkedgraph/app/* [report] # Regexes for lines to exclude from consideration diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..70ceaed90 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: + - repo: https://github.com/psf/black + rev: 26.1.0 + hooks: + - id: black diff --git a/pychunkedgraph/tests/conftest.py b/pychunkedgraph/tests/conftest.py index 11572191d..a502ba505 100644 --- a/pychunkedgraph/tests/conftest.py +++ b/pychunkedgraph/tests/conftest.py @@ -169,6 +169,60 @@ def fin(): return partial(_cgraph, request) +@pytest.fixture(scope="function") +def gen_graph_with_edges(request, tmp_path): + """Like gen_graph but with real edge/component I/O via local filesystem (file:// protocol).""" + + def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([])): + edges_dir = f"file://{tmp_path}/edges" + components_dir = f"file://{tmp_path}/components" + config = { + "data_source": { + "EDGES": edges_dir, + "COMPONENTS": components_dir, + "WATERSHED": "gs://microns-seunglab/minnie65/ws_minnie65_0", + }, + "graph_config": { + "CHUNK_SIZE": [512, 512, 64], + "FANOUT": 2, + "SPATIAL_BITS": 10, + "ID_PREFIX": "", + "ROOT_LOCK_EXPIRY": timedelta(seconds=5), + }, + "backend_client": { + "TYPE": "bigtable", + "CONFIG": { + "ADMIN": True, + "READ_ONLY": False, + "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", + "INSTANCE": "emulated_instance", + "CREDENTIALS": credentials.AnonymousCredentials(), + "MAX_ROW_KEY_COUNT": 1000, + }, + }, + "ingest_config": {}, + } + + meta, _, client_info = bootstrap("test", config=config) + graph = ChunkedGraph(graph_id="test", meta=meta, client_info=client_info) + # No mock_edges - use real I/O via file:// protocol + graph.meta._ws_cv = CloudVolumeMock() + graph.meta.layer_count = n_layers + graph.meta.layer_chunk_bounds = get_layer_chunk_bounds( + n_layers, atomic_chunk_bounds=atomic_chunk_bounds + ) + + graph.create() + + def fin(): + graph.client._table.delete() + + request.addfinalizer(fin) + return graph + + return partial(_cgraph, request) + + @pytest.fixture(scope="function") def gen_graph_simplequerytest(request, gen_graph): """ diff --git a/pychunkedgraph/tests/test_analysis_pathing.py b/pychunkedgraph/tests/test_analysis_pathing.py new file mode 100644 index 000000000..872158c6e --- /dev/null +++ b/pychunkedgraph/tests/test_analysis_pathing.py @@ -0,0 +1,558 @@ +"""Tests for pychunkedgraph.graph.analysis.pathing""" + +from datetime import datetime, timedelta, UTC +from math import inf +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from pychunkedgraph.graph.analysis.pathing import ( + get_first_shared_parent, + get_children_at_layer, + get_lvl2_edge_list, + find_l2_shortest_path, + compute_rough_coordinate_path, +) + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGetFirstSharedParent: + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + def test_same_root(self, gen_graph): + graph = self._build_graph(gen_graph) + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 1, 0, 0, 0) + parent = get_first_shared_parent(graph, sv0, sv1) + assert parent is not None + # The shared parent should be an ancestor of both SVs + root = graph.get_root(sv0) + # Verify the shared parent is on the path to root + assert graph.get_root(parent) == root + + def test_different_roots_returns_none(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + # Create two disconnected chunks + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 1, 0, 0, 0) + parent = get_first_shared_parent(graph, sv0, sv1) + assert parent is None + + +class TestGetChildrenAtLayer: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + children = get_children_at_layer(graph, root, 2) + assert len(children) > 0 + for child in children: + assert graph.get_chunk_layer(child) == 2 + + def test_allow_lower_layers(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + children = get_children_at_layer(graph, root, 2, allow_lower_layers=True) + assert len(children) > 0 + + +class TestGetLvl2EdgeList: + def _build_3chunk_graph(self, gen_graph): + """Build a graph with 3 chunks A(0,0,0), B(1,0,0), C(2,0,0) connected by cross-chunk edges. + + A:sv0 -- B:sv0 -- C:sv0 + """ + graph = gen_graph(n_layers=4) + + # Chunk A: sv0 connected to B:sv0 via cross-chunk edge + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + + # Chunk B: sv0 connected to A:sv0 and C:sv0 via cross-chunk edges + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), + ], + ) + + # Chunk C: sv0 connected to B:sv0 via cross-chunk edge + create_chunk( + graph, + vertices=[to_label(graph, 1, 2, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + return graph + + def test_basic(self, gen_graph): + """get_lvl2_edge_list should return edges between L2 IDs for a connected root.""" + graph = self._build_3chunk_graph(gen_graph) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + edges = get_lvl2_edge_list(graph, root) + + # There should be at least 2 edges: A_l2--B_l2 and B_l2--C_l2 + assert edges.shape[0] >= 2 + assert edges.shape[1] == 2 + + # All edge IDs should be L2 nodes (layer 2) + for edge in edges: + for node_id in edge: + assert graph.get_chunk_layer(node_id) == 2 + + def test_single_chunk_no_cross_edges(self, gen_graph): + """A single isolated chunk should produce no L2 edges.""" + graph = gen_graph(n_layers=4) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + edges = get_lvl2_edge_list(graph, root) + + assert edges.shape[0] == 0 + + +class TestFindL2ShortestPath: + def _build_3chunk_graph(self, gen_graph): + """Build a graph with 3 chunks A(0,0,0), B(1,0,0), C(2,0,0) connected linearly. + + A:sv0 -- B:sv0 -- C:sv0 + """ + graph = gen_graph(n_layers=4) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), + ], + ) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 2, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + return graph + + def test_path_between_endpoints(self, gen_graph): + """find_l2_shortest_path should return a path from source to target L2 IDs.""" + graph = self._build_3chunk_graph(gen_graph) + + # Get L2 parents of the supervoxels + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_c = to_label(graph, 1, 2, 0, 0, 0) + l2_a = graph.get_parent(sv_a) + l2_c = graph.get_parent(sv_c) + + path = find_l2_shortest_path(graph, l2_a, l2_c) + + assert path is not None + assert len(path) == 3 # A_l2 -> B_l2 -> C_l2 + # Path should start at source and end at target + assert path[0] == l2_a + assert path[-1] == l2_c + # All nodes in path should be layer 2 + for node_id in path: + assert graph.get_chunk_layer(node_id) == 2 + + def test_adjacent_l2_ids(self, gen_graph): + """find_l2_shortest_path between directly connected L2 IDs should return length 2 path.""" + graph = self._build_3chunk_graph(gen_graph) + + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + l2_a = graph.get_parent(sv_a) + l2_b = graph.get_parent(sv_b) + + path = find_l2_shortest_path(graph, l2_a, l2_b) + + assert path is not None + assert len(path) == 2 + assert path[0] == l2_a + assert path[-1] == l2_b + + def test_disconnected_returns_none(self, gen_graph): + """find_l2_shortest_path should return None when L2 IDs belong to different roots.""" + graph = gen_graph(n_layers=4) + + # Create two disconnected chunks + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[], + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + l2_a = graph.get_parent(sv_a) + l2_b = graph.get_parent(sv_b) + + path = find_l2_shortest_path(graph, l2_a, l2_b) + assert path is None + + +class TestGetChildrenAtLayerEdgeCases: + """Test get_children_at_layer with various edge cases.""" + + def test_children_at_layer_2_with_multiple_svs(self, gen_graph): + """Query children at layer 2 when root has multiple SVs in same chunk.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + to_label(graph, 1, 0, 0, 0, 2), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 1), to_label(graph, 1, 0, 0, 0, 2), 0.5), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + children = get_children_at_layer(graph, root, 2) + assert len(children) > 0 + for child in children: + assert graph.get_chunk_layer(child) == 2 + + def test_children_at_intermediate_layer(self, gen_graph): + """Query children at layer 3 from root at layer 4.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + children = get_children_at_layer(graph, root, 3) + assert len(children) > 0 + for child in children: + assert graph.get_chunk_layer(child) == 3 + + def test_children_allow_lower_layers_with_cross_chunk(self, gen_graph): + """Query with allow_lower_layers=True should include layer<=target.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + # Ask for layer 3 with allow_lower_layers=True + children = get_children_at_layer(graph, root, 3, allow_lower_layers=True) + assert len(children) > 0 + for child in children: + assert graph.get_chunk_layer(child) <= 3 + + def test_children_at_layer_from_l2_node(self, gen_graph): + """Querying children at layer 2 from a layer 2 node should return the node itself + or its layer-2 children (which is itself).""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + sv = to_label(graph, 1, 0, 0, 0, 0) + l2 = graph.get_parent(sv) + # From l2, get children at layer 2 (with allow_lower=True since + # the children of an L2 node are SVs at layer 1) + children = get_children_at_layer(graph, l2, 2, allow_lower_layers=True) + assert len(children) > 0 + + +class TestGetLvl2EdgeListWithBbox: + """Test get_lvl2_edge_list with a bounding box parameter.""" + + def _build_3chunk_graph(self, gen_graph): + """Build a graph with 3 chunks connected linearly.""" + graph = gen_graph(n_layers=4) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), + ], + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 2, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + return graph + + def test_lvl2_edge_list_with_bbox(self, gen_graph): + """get_lvl2_edge_list with a bbox should return edges within the bbox.""" + graph = self._build_3chunk_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + + # Use a large bbox that encompasses everything + bbox = np.array([[0, 0, 0], [2048, 2048, 256]]) + edges = get_lvl2_edge_list(graph, root, bbox=bbox) + + # Should have edges + assert edges.shape[1] == 2 + # All IDs should be L2 nodes + for edge in edges: + for node_id in edge: + assert graph.get_chunk_layer(node_id) == 2 + + +class TestFindL2ShortestPathEdgeCases: + """Test find_l2_shortest_path with additional edge cases.""" + + def test_path_through_chain(self, gen_graph): + """find_l2_shortest_path through a 4-chunk chain should return correct length.""" + graph = gen_graph(n_layers=4) + + # Build a 4-chunk chain: A(0,0,0)--B(1,0,0)--C(2,0,0)--D(3,0,0) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), + ], + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 2, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + (to_label(graph, 1, 2, 0, 0, 0), to_label(graph, 1, 3, 0, 0, 0), inf), + ], + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 3, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 3, 0, 0, 0), to_label(graph, 1, 2, 0, 0, 0), inf), + ], + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_d = to_label(graph, 1, 3, 0, 0, 0) + l2_a = graph.get_parent(sv_a) + l2_d = graph.get_parent(sv_d) + + path = find_l2_shortest_path(graph, l2_a, l2_d) + assert path is not None + assert len(path) == 4 # A_l2 -> B_l2 -> C_l2 -> D_l2 + assert path[0] == l2_a + assert path[-1] == l2_d + + +class TestComputeRoughCoordinatePath: + """Test compute_rough_coordinate_path returns proper coordinates.""" + + def test_basic_coordinate_path(self, gen_graph): + """compute_rough_coordinate_path should return a list of float32 3D coordinates.""" + graph = gen_graph(n_layers=4) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + l2_a = graph.get_parent(sv_a) + l2_b = graph.get_parent(sv_b) + + path = find_l2_shortest_path(graph, l2_a, l2_b) + assert path is not None + + # Mock cv methods that CloudVolumeMock doesn't have + mock_cv = MagicMock() + mock_cv.mip_voxel_offset = MagicMock(return_value=np.array([0, 0, 0])) + mock_cv.mip_resolution = MagicMock(return_value=np.array([1, 1, 1])) + graph.meta._ws_cv = mock_cv + + coordinate_path = compute_rough_coordinate_path(graph, path) + assert len(coordinate_path) == len(path) + for coord in coordinate_path: + assert isinstance(coord, np.ndarray) + assert coord.dtype == np.float32 + assert len(coord) == 3 diff --git a/pychunkedgraph/tests/test_attributes.py b/pychunkedgraph/tests/test_attributes.py new file mode 100644 index 000000000..e630353d7 --- /dev/null +++ b/pychunkedgraph/tests/test_attributes.py @@ -0,0 +1,88 @@ +"""Tests for pychunkedgraph.graph.attributes""" + +import numpy as np +import pytest + +from pychunkedgraph.graph.attributes import ( + _Attribute, + _AttributeArray, + Concurrency, + Connectivity, + Hierarchy, + GraphMeta, + GraphVersion, + OperationLogs, + from_key, +) +from pychunkedgraph.graph.utils import basetypes + + +class TestAttribute: + def test_serialize_deserialize_numpy(self): + attr = Hierarchy.Child + arr = np.array([1, 2, 3], dtype=basetypes.NODE_ID) + data = attr.serialize(arr) + result = attr.deserialize(data) + np.testing.assert_array_equal(result, arr) + + def test_serialize_deserialize_string(self): + attr = OperationLogs.UserID + data = attr.serialize("test_user") + assert attr.deserialize(data) == "test_user" + + def test_basetype(self): + assert Hierarchy.Child.basetype == basetypes.NODE_ID.type + assert OperationLogs.UserID.basetype == str + + def test_index(self): + attr = Connectivity.CrossChunkEdge[5] + assert attr.index == 5 + + def test_family_id(self): + assert Hierarchy.Child.family_id == "0" + assert Concurrency.Counter.family_id == "1" + assert OperationLogs.UserID.family_id == "2" + + +class TestAttributeArray: + def test_getitem(self): + attr = Connectivity.AtomicCrossChunkEdge[3] + assert isinstance(attr, _Attribute) + assert attr.key == b"atomic_cross_edges_3" + + def test_pattern(self): + assert Connectivity.CrossChunkEdge.pattern == b"cross_edges_%d" + + def test_serialize_deserialize(self): + arr = np.array([[1, 2], [3, 4]], dtype=basetypes.NODE_ID) + data = Connectivity.CrossChunkEdge.serialize(arr) + result = Connectivity.CrossChunkEdge.deserialize(data) + np.testing.assert_array_equal(result, arr) + + +class TestFromKey: + def test_valid_key(self): + result = from_key("0", b"children") + assert result is Hierarchy.Child + + def test_invalid_key_raises(self): + with pytest.raises(KeyError, match="Unknown key"): + from_key("99", b"nonexistent") + + +class TestOperationLogs: + def test_all_returns_list(self): + result = OperationLogs.all() + assert isinstance(result, list) + assert len(result) == 16 + assert OperationLogs.OperationID in result + assert OperationLogs.UserID in result + assert OperationLogs.RootID in result + assert OperationLogs.AddedEdge in result + + def test_status_codes(self): + assert OperationLogs.StatusCodes.SUCCESS.value == 0 + assert OperationLogs.StatusCodes.CREATED.value == 1 + assert OperationLogs.StatusCodes.EXCEPTION.value == 2 + assert OperationLogs.StatusCodes.WRITE_STARTED.value == 3 + assert OperationLogs.StatusCodes.WRITE_FAILED.value == 4 diff --git a/pychunkedgraph/tests/test_cache.py b/pychunkedgraph/tests/test_cache.py new file mode 100644 index 000000000..aadffcd3e --- /dev/null +++ b/pychunkedgraph/tests/test_cache.py @@ -0,0 +1,152 @@ +"""Tests for pychunkedgraph.graph.cache""" + +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from pychunkedgraph.graph.cache import CacheService, update + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestUpdate: + def test_one_to_one(self): + cache = {} + update(cache, [1, 2, 3], [10, 20, 30]) + assert cache == {1: 10, 2: 20, 3: 30} + + def test_many_to_one(self): + cache = {} + update(cache, [1, 2, 3], 99) + assert cache == {1: 99, 2: 99, 3: 99} + + +class TestCacheService: + def _build_simple_graph(self, gen_graph): + """Build a simple 2-chunk graph with 2 SVs per chunk.""" + from math import inf + + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + def test_init(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + assert len(cache) == 0 + + def test_len(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + cache.parent(sv) + assert len(cache) >= 1 + + def test_clear(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + cache.parent(sv) + cache.clear() + assert len(cache) == 0 + + def test_parent_miss_then_hit(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + + # First call is a miss + parent1 = cache.parent(sv) + assert cache.stats["parents"]["misses"] == 1 + + # Second call is a hit + parent2 = cache.parent(sv) + assert cache.stats["parents"]["hits"] == 1 + assert parent1 == parent2 + + def test_children_backfills_parent(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + children = cache.children(root) + assert len(children) > 0 + # Children should be backfilled as parents + for child in children: + assert child in cache.parents_cache + + def test_get_stats(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + cache.parent(sv) + cache.parent(sv) + stats = cache.get_stats() + assert "parents" in stats + assert stats["parents"]["total"] == 2 + assert "hit_rate" in stats["parents"] + + def test_reset_stats(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + cache.parent(sv) + cache.reset_stats() + assert cache.stats["parents"]["hits"] == 0 + assert cache.stats["parents"]["misses"] == 0 + + def test_parents_multiple_empty(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + result = cache.parents_multiple(np.array([], dtype=np.uint64)) + assert len(result) == 0 + + def test_parents_multiple(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + svs = np.array( + [ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + dtype=np.uint64, + ) + result = cache.parents_multiple(svs) + assert len(result) == 2 + + def test_children_multiple(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = cache.children_multiple(np.array([root], dtype=np.uint64)) + assert root in result + + def test_children_multiple_flatten(self, gen_graph): + graph = self._build_simple_graph(gen_graph) + cache = CacheService(graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = cache.children_multiple( + np.array([root], dtype=np.uint64), flatten=True + ) + assert isinstance(result, np.ndarray) diff --git a/pychunkedgraph/tests/test_chunkedgraph_extended.py b/pychunkedgraph/tests/test_chunkedgraph_extended.py new file mode 100644 index 000000000..dd398f27e --- /dev/null +++ b/pychunkedgraph/tests/test_chunkedgraph_extended.py @@ -0,0 +1,1591 @@ +"""Tests for pychunkedgraph.graph.chunkedgraph - extended coverage""" + +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk +from ..graph.operation import GraphEditOperation, MergeOperation, SplitOperation +from ..graph.exceptions import PreconditionError + + +class TestChunkedGraphExtended: + def _build_graph(self, gen_graph): + """Build a simple multi-chunk graph.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + # Chunk A: sv 0, 1 connected + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + + # Chunk B: sv 0 connected cross-chunk to A + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + def test_is_root_true(self, gen_graph): + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + root = graph.get_root(sv) + assert graph.is_root(root) + + def test_is_root_false(self, gen_graph): + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + assert not graph.is_root(sv) + + def test_get_parents_raw_only(self, gen_graph): + graph = self._build_graph(gen_graph) + svs = np.array( + [ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + dtype=np.uint64, + ) + parents = graph.get_parents(svs, raw_only=True) + assert len(parents) == 2 + # Parents should be L2 IDs + for p in parents: + assert graph.get_chunk_layer(p) == 2 + + def test_get_parents_fail_to_zero(self, gen_graph): + graph = self._build_graph(gen_graph) + # Non-existent ID should return 0 with fail_to_zero + bad_id = np.uint64(99999999) + result = graph.get_parents( + np.array([bad_id], dtype=np.uint64), fail_to_zero=True + ) + assert result[0] == 0 + + def test_get_children_flatten(self, gen_graph): + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + children = graph.get_children([root], flatten=True) + assert isinstance(children, np.ndarray) + assert len(children) > 0 + + def test_is_latest_roots(self, gen_graph): + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = graph.is_latest_roots(np.array([root], dtype=np.uint64)) + assert result[0] + + def test_get_node_timestamps(self, gen_graph): + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + root = graph.get_root(sv) + ts = graph.get_node_timestamps(np.array([root]), return_numpy=False) + assert len(ts) == 1 + + def test_get_earliest_timestamp(self, gen_graph): + graph = self._build_graph(gen_graph) + ts = graph.get_earliest_timestamp() + # May return None if no operation logs exist; test the method runs + assert ts is None or isinstance(ts, datetime) + + def test_get_l2children(self, gen_graph): + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + l2_children = graph.get_l2children(np.array([root], dtype=np.uint64)) + assert len(l2_children) > 0 + for child in l2_children: + assert graph.get_chunk_layer(child) == 2 + + # --- helpers for edit-based tests --- + + def _build_and_merge(self, gen_graph): + """Build a single-chunk graph with two disconnected SVs and merge them.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + return graph, result.new_root_ids[0], result + + @pytest.mark.timeout(30) + def test_get_operation_ids(self, gen_graph): + """After a merge, get_operation_ids on the new root should return at least one operation.""" + graph, new_root, result = self._build_and_merge(gen_graph) + op_ids = graph.get_operation_ids([new_root]) + assert new_root in op_ids + assert len(op_ids[new_root]) >= 1 + # Each entry is (operation_id_value, timestamp) + op_id_val, ts = op_ids[new_root][0] + assert op_id_val == result.operation_id + + @pytest.mark.timeout(30) + def test_get_single_leaf_multiple(self, gen_graph): + """get_single_leaf_multiple for an L2 node should return an L1 supervoxel.""" + graph, new_root, _ = self._build_and_merge(gen_graph) + # The new_root in n_layers=2 is actually L2 + assert graph.get_chunk_layer(new_root) == 2 + leaves = graph.get_single_leaf_multiple(np.array([new_root], dtype=np.uint64)) + assert len(leaves) == 1 + assert graph.get_chunk_layer(leaves[0]) == 1 + # The returned leaf should be one of our two SVs + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + assert leaves[0] in [sv0, sv1] + + @pytest.mark.timeout(30) + def test_get_atomic_cross_edges(self, gen_graph): + """get_atomic_cross_edges for an L2 node with cross-chunk connections.""" + graph = self._build_graph(gen_graph) + sv_a0 = to_label(graph, 1, 0, 0, 0, 0) + + # Get the L2 parent of sv_a0 + parent = graph.get_parents(np.array([sv_a0], dtype=np.uint64), raw_only=True)[0] + assert graph.get_chunk_layer(parent) == 2 + + result = graph.get_atomic_cross_edges([parent]) + assert parent in result + # Should have at least one layer of cross edges + assert isinstance(result[parent], dict) + + @pytest.mark.timeout(30) + def test_get_cross_chunk_edges_raw(self, gen_graph): + """get_cross_chunk_edges with raw_only=True should return cross edges.""" + graph = self._build_graph(gen_graph) + sv_a0 = to_label(graph, 1, 0, 0, 0, 0) + + # Get the L2 parent + parent = graph.get_parents(np.array([sv_a0], dtype=np.uint64), raw_only=True)[0] + assert graph.get_chunk_layer(parent) == 2 + + result = graph.get_cross_chunk_edges([parent], raw_only=True) + assert parent in result + assert isinstance(result[parent], dict) + + @pytest.mark.timeout(30) + def test_get_parents_not_current(self, gen_graph): + """get_parents with current=False should return list of (parent, timestamp) tuples.""" + graph, new_root, _ = self._build_and_merge(gen_graph) + sv0 = to_label(graph, 1, 0, 0, 0, 0) + + # current=False returns list of lists of (value, timestamp) pairs + parents = graph.get_parents( + np.array([sv0], dtype=np.uint64), raw_only=True, current=False + ) + assert len(parents) == 1 + # Each element is a list of (parent_value, timestamp) tuples + assert isinstance(parents[0], list) + assert len(parents[0]) >= 1 + parent_val, parent_ts = parents[0][0] + assert parent_val != 0 + assert isinstance(parent_ts, datetime) + + +class TestFromLogRecord: + """Test GraphEditOperation.from_log_record with real merge/split logs.""" + + def _build_two_sv_graph(self, gen_graph): + """Build a 2-layer graph with two disconnected SVs.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + return graph + + @pytest.mark.timeout(30) + def test_merge_from_log(self, gen_graph): + """After a merge, from_log_record should return a MergeOperation.""" + graph = self._build_two_sv_graph(gen_graph) + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + log, ts = graph.client.read_log_entry(result.operation_id) + op = GraphEditOperation.from_log_record(graph, log) + assert isinstance(op, MergeOperation) + + @pytest.mark.timeout(30) + def test_split_from_log(self, gen_graph): + """After a split, from_log_record should return a SplitOperation.""" + graph = self._build_two_sv_graph(gen_graph) + # First merge so the SVs belong to the same root + graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + # Now split them + split_result = graph.remove_edges( + "TestUser", + source_ids=to_label(graph, 1, 0, 0, 0, 0), + sink_ids=to_label(graph, 1, 0, 0, 0, 1), + mincut=False, + ) + log, ts = graph.client.read_log_entry(split_result.operation_id) + op = GraphEditOperation.from_log_record(graph, log) + assert isinstance(op, SplitOperation) + + +class TestCheckIds: + """Test ID validation in MergeOperation.""" + + def _build_two_sv_graph(self, gen_graph): + """Build a 2-layer graph with two disconnected SVs.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + return graph + + @pytest.mark.timeout(30) + def test_source_equals_sink_raises(self, gen_graph): + """MergeOperation with source==sink should raise PreconditionError (self-loop).""" + graph = self._build_two_sv_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + with pytest.raises(PreconditionError): + graph.add_edges( + "TestUser", + [sv, sv], + affinities=[0.3], + ) + + @pytest.mark.timeout(30) + def test_nonexistent_supervoxel_raises(self, gen_graph): + """Using a supervoxel ID that doesn't exist should raise an error.""" + graph = self._build_two_sv_graph(gen_graph) + sv_real = to_label(graph, 1, 0, 0, 0, 0) + # Use a layer-2 ID as a fake "supervoxel", which fails the layer check + sv_fake = to_label(graph, 2, 0, 0, 0, 99) + with pytest.raises(Exception): + graph.add_edges( + "TestUser", + [sv_real, sv_fake], + affinities=[0.3], + ) + + +class TestGetRootsExtended: + """Tests for get_roots with stop_layer and ceil parameters (lines 380-461).""" + + def _build_cross_chunk(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph, fake_ts + + @pytest.mark.timeout(30) + def test_get_roots_with_stop_layer(self, gen_graph): + """get_roots with stop_layer should return IDs at that layer.""" + graph, _ = self._build_cross_chunk(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + # Stop at layer 3 instead of going to root (layer 4) + result = graph.get_roots(np.array([sv], dtype=np.uint64), stop_layer=3) + assert len(result) == 1 + assert graph.get_chunk_layer(result[0]) == 3 + + @pytest.mark.timeout(30) + def test_get_roots_with_stop_layer_and_ceil_false(self, gen_graph): + """get_roots with stop_layer and ceil=False should not exceed stop_layer.""" + graph, _ = self._build_cross_chunk(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + result = graph.get_roots( + np.array([sv], dtype=np.uint64), stop_layer=3, ceil=False + ) + assert len(result) == 1 + assert graph.get_chunk_layer(result[0]) <= 3 + + @pytest.mark.timeout(30) + def test_get_roots_multiple_svs(self, gen_graph): + """get_roots with multiple SVs should return root for each.""" + graph, _ = self._build_cross_chunk(gen_graph) + svs = np.array( + [ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + to_label(graph, 1, 1, 0, 0, 0), + ], + dtype=np.uint64, + ) + roots = graph.get_roots(svs) + assert len(roots) == 3 + # All should reach the top layer + for r in roots: + assert graph.get_chunk_layer(r) == 4 + + @pytest.mark.timeout(30) + def test_get_roots_already_at_stop_layer(self, gen_graph): + """get_roots for a node already at stop_layer should return it unchanged.""" + graph, _ = self._build_cross_chunk(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + root = graph.get_root(sv) + # root is at layer 4; asking for stop_layer=4 should return it + result = graph.get_roots(np.array([root], dtype=np.uint64), stop_layer=4) + assert result[0] == root + + @pytest.mark.timeout(30) + def test_get_roots_fail_to_zero(self, gen_graph): + """get_roots with a zero ID and fail_to_zero should keep it as zero.""" + graph, _ = self._build_cross_chunk(gen_graph) + result = graph.get_roots(np.array([0], dtype=np.uint64), fail_to_zero=True) + assert result[0] == 0 + + @pytest.mark.timeout(30) + def test_get_root_stop_layer_ceil_false(self, gen_graph): + """get_root (singular) with stop_layer and ceil=False.""" + graph, _ = self._build_cross_chunk(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + result = graph.get_root(sv, stop_layer=3, ceil=False) + assert graph.get_chunk_layer(result) <= 3 + + +class TestGetChildrenExtended: + """Tests for get_children with flatten=True and edge cases (lines 271-296).""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_children_flatten_multiple(self, gen_graph): + """get_children with multiple node IDs and flatten=True returns flat array.""" + graph = self._build_graph(gen_graph) + sv_a0 = to_label(graph, 1, 0, 0, 0, 0) + sv_b0 = to_label(graph, 1, 1, 0, 0, 0) + + parent_a = graph.get_parents(np.array([sv_a0], dtype=np.uint64), raw_only=True)[ + 0 + ] + parent_b = graph.get_parents(np.array([sv_b0], dtype=np.uint64), raw_only=True)[ + 0 + ] + + children = graph.get_children([parent_a, parent_b], flatten=True) + assert isinstance(children, np.ndarray) + # Should contain at least sv_a0, sv_a1, sv_b0 + assert len(children) >= 3 + + @pytest.mark.timeout(30) + def test_get_children_flatten_empty(self, gen_graph): + """get_children with flatten=True on empty list returns empty array.""" + graph = self._build_graph(gen_graph) + children = graph.get_children([], flatten=True) + assert isinstance(children, np.ndarray) + assert len(children) == 0 + + @pytest.mark.timeout(30) + def test_get_children_dict(self, gen_graph): + """get_children without flatten returns a dict.""" + graph = self._build_graph(gen_graph) + sv_a0 = to_label(graph, 1, 0, 0, 0, 0) + parent = graph.get_parents(np.array([sv_a0], dtype=np.uint64), raw_only=True)[0] + children_d = graph.get_children([parent]) + assert isinstance(children_d, dict) + assert parent in children_d + + @pytest.mark.timeout(30) + def test_get_children_scalar(self, gen_graph): + """get_children with a scalar node_id returns an array.""" + graph = self._build_graph(gen_graph) + sv_a0 = to_label(graph, 1, 0, 0, 0, 0) + parent = graph.get_parents(np.array([sv_a0], dtype=np.uint64), raw_only=True)[0] + children = graph.get_children(parent, raw_only=True) + assert isinstance(children, np.ndarray) + assert len(children) >= 1 + + +class TestIsLatestRootsExtended: + """Tests for is_latest_roots (lines 524-544).""" + + def _build_and_merge(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + # Get the initial roots + root0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + root1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + + # Merge + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, root0, root1, new_root + + @pytest.mark.timeout(30) + def test_is_latest_roots_after_merge(self, gen_graph): + """After a merge, old roots should not be latest, new root should be.""" + graph, root0, root1, new_root = self._build_and_merge(gen_graph) + result = graph.is_latest_roots( + np.array([root0, root1, new_root], dtype=np.uint64) + ) + # Old roots are superseded + assert not result[0] + assert not result[1] + # New root is latest + assert result[2] + + @pytest.mark.timeout(30) + def test_is_latest_roots_empty(self, gen_graph): + """is_latest_roots with nonexistent IDs should return all False.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + result = graph.is_latest_roots(np.array([99999999], dtype=np.uint64)) + assert not result[0] + + +class TestGetNodeTimestampsExtended: + """Tests for get_node_timestamps (lines 773-800).""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_node_timestamps_return_numpy(self, gen_graph): + """get_node_timestamps with return_numpy=True should return numpy array.""" + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + ts = graph.get_node_timestamps( + np.array([root], dtype=np.uint64), return_numpy=True + ) + assert isinstance(ts, np.ndarray) + assert len(ts) == 1 + + @pytest.mark.timeout(30) + def test_get_node_timestamps_return_list(self, gen_graph): + """get_node_timestamps with return_numpy=False should return a list.""" + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + ts = graph.get_node_timestamps( + np.array([root], dtype=np.uint64), return_numpy=False + ) + assert isinstance(ts, list) + assert len(ts) == 1 + + @pytest.mark.timeout(30) + def test_get_node_timestamps_empty(self, gen_graph): + """get_node_timestamps with nonexistent nodes should handle gracefully.""" + graph = self._build_graph(gen_graph) + ts = graph.get_node_timestamps( + np.array([np.uint64(99999999)], dtype=np.uint64), return_numpy=True + ) + # Should either return empty or return a fallback timestamp + assert isinstance(ts, np.ndarray) + + @pytest.mark.timeout(30) + def test_get_node_timestamps_empty_return_list(self, gen_graph): + """get_node_timestamps with empty dict result and return_numpy=False.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + # Don't create any chunks; asking for timestamps on nonexistent nodes + ts = graph.get_node_timestamps( + np.array([np.uint64(99999999)], dtype=np.uint64), return_numpy=False + ) + assert isinstance(ts, list) + assert len(ts) == 0 + + +class TestGetOperationIdsExtended: + """Tests for get_operation_ids (lines 1033-1042).""" + + @pytest.mark.timeout(30) + def test_get_operation_ids_no_ops(self, gen_graph): + """get_operation_ids on a node with no operations returns empty dict.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = graph.get_operation_ids([root]) + # No operations => root may not be in result, or have empty list + if root in result: + assert isinstance(result[root], list) + + +class TestGetSingleLeafMultipleExtended: + """Tests for get_single_leaf_multiple (lines 1044-1062).""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_single_leaf_from_root(self, gen_graph): + """get_single_leaf_multiple from a root (layer 4) should drill down to layer 1.""" + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + assert graph.get_chunk_layer(root) == 4 + leaves = graph.get_single_leaf_multiple(np.array([root], dtype=np.uint64)) + assert len(leaves) == 1 + assert graph.get_chunk_layer(leaves[0]) == 1 + + @pytest.mark.timeout(30) + def test_get_single_leaf_from_l2(self, gen_graph): + """get_single_leaf_multiple from L2 node should return one of its SV children.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + parent = graph.get_parents(np.array([sv], dtype=np.uint64), raw_only=True)[0] + assert graph.get_chunk_layer(parent) == 2 + leaves = graph.get_single_leaf_multiple(np.array([parent], dtype=np.uint64)) + assert len(leaves) == 1 + assert graph.get_chunk_layer(leaves[0]) == 1 + + @pytest.mark.timeout(30) + def test_get_single_leaf_multiple_nodes(self, gen_graph): + """get_single_leaf_multiple with multiple node IDs should return one leaf each.""" + graph = self._build_graph(gen_graph) + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + parent_a = graph.get_parents(np.array([sv_a], dtype=np.uint64), raw_only=True)[ + 0 + ] + parent_b = graph.get_parents(np.array([sv_b], dtype=np.uint64), raw_only=True)[ + 0 + ] + leaves = graph.get_single_leaf_multiple( + np.array([parent_a, parent_b], dtype=np.uint64) + ) + assert len(leaves) == 2 + for leaf in leaves: + assert graph.get_chunk_layer(leaf) == 1 + + +class TestGetL2ChildrenExtended: + """Tests for get_l2children (lines 1079-1092).""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_l2children_from_root(self, gen_graph): + """get_l2children from a root should return all L2 children.""" + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + l2_children = graph.get_l2children(np.array([root], dtype=np.uint64)) + assert isinstance(l2_children, np.ndarray) + assert len(l2_children) >= 2 + for child in l2_children: + assert graph.get_chunk_layer(child) == 2 + + @pytest.mark.timeout(30) + def test_get_l2children_from_l3(self, gen_graph): + """get_l2children from an L3 node should return L2 children.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + # Get L3 parent + root = graph.get_root(sv, stop_layer=3) + assert graph.get_chunk_layer(root) == 3 + l2_children = graph.get_l2children(np.array([root], dtype=np.uint64)) + assert len(l2_children) >= 1 + for child in l2_children: + assert graph.get_chunk_layer(child) == 2 + + @pytest.mark.timeout(30) + def test_get_l2children_from_l2(self, gen_graph): + """get_l2children from an L2 node drills down to its children, + which are L1 - so no L2 children are found; result is empty.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + parent = graph.get_parents(np.array([sv], dtype=np.uint64), raw_only=True)[0] + assert graph.get_chunk_layer(parent) == 2 + l2_children = graph.get_l2children(np.array([parent], dtype=np.uint64)) + # L2 nodes only have L1 (SV) children, so no L2 descendants found + assert isinstance(l2_children, np.ndarray) + assert len(l2_children) == 0 + + +class TestGetChunkLayersExtended: + """Tests for get_chunk_layers and related helpers (line 951-952, 946).""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_chunk_layers_multiple(self, gen_graph): + """get_chunk_layers for nodes at different layers.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + parent_l2 = graph.get_parents(np.array([sv], dtype=np.uint64), raw_only=True)[0] + root = graph.get_root(sv) + layers = graph.get_chunk_layers( + np.array([sv, parent_l2, root], dtype=np.uint64) + ) + assert layers[0] == 1 + assert layers[1] == 2 + assert layers[2] == 4 + + @pytest.mark.timeout(30) + def test_get_segment_id_limit(self, gen_graph): + """get_segment_id_limit should return a valid limit.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + limit = graph.get_segment_id_limit(sv) + assert limit > 0 + + @pytest.mark.timeout(30) + def test_get_chunk_coordinates(self, gen_graph): + """get_chunk_coordinates should return the chunk coordinates of a node.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + coords = graph.get_chunk_coordinates(sv) + assert len(coords) == 3 + np.testing.assert_array_equal(coords, [0, 0, 0]) + + @pytest.mark.timeout(30) + def test_get_chunk_layers_and_coordinates(self, gen_graph): + """get_chunk_layers_and_coordinates returns layers and coords together.""" + graph = self._build_graph(gen_graph) + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + layers, coords = graph.get_chunk_layers_and_coordinates( + np.array([sv_a, sv_b], dtype=np.uint64) + ) + assert len(layers) == 2 + assert layers[0] == 1 + assert layers[1] == 1 + assert coords.shape == (2, 3) + + +class TestGetAtomicCrossEdgesExtended: + """Tests for get_atomic_cross_edges (lines 315-336).""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_atomic_cross_edges_multiple_l2(self, gen_graph): + """get_atomic_cross_edges with multiple L2 IDs.""" + graph = self._build_graph(gen_graph) + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + parent_a = graph.get_parents(np.array([sv_a], dtype=np.uint64), raw_only=True)[ + 0 + ] + parent_b = graph.get_parents(np.array([sv_b], dtype=np.uint64), raw_only=True)[ + 0 + ] + result = graph.get_atomic_cross_edges([parent_a, parent_b]) + assert parent_a in result + assert parent_b in result + # At least one should have cross edges + has_edges = any(len(v) > 0 for v in result.values()) + assert has_edges + + @pytest.mark.timeout(30) + def test_get_atomic_cross_edges_no_cross(self, gen_graph): + """get_atomic_cross_edges for an L2 node with no cross edges.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + sv = to_label(graph, 1, 0, 0, 0, 0) + parent = graph.get_parents(np.array([sv], dtype=np.uint64), raw_only=True)[0] + result = graph.get_atomic_cross_edges([parent]) + assert parent in result + assert isinstance(result[parent], dict) + # No cross edges + assert len(result[parent]) == 0 + + +class TestGetAllParentsDictExtended: + """Tests for get_all_parents_dict and get_all_parents_dict_multiple.""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_all_parents_dict(self, gen_graph): + """get_all_parents_dict returns a dict mapping layer -> parent.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + d = graph.get_all_parents_dict(sv) + assert isinstance(d, dict) + # Should have entries for layers 2, 3, 4 + assert 2 in d + assert 4 in d + + @pytest.mark.timeout(30) + def test_get_all_parents_dict_multiple(self, gen_graph): + """get_all_parents_dict_multiple for multiple SVs.""" + graph = self._build_graph(gen_graph) + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + result = graph.get_all_parents_dict_multiple( + np.array([sv0, sv1], dtype=np.uint64) + ) + assert sv0 in result + assert sv1 in result + # Both should have parents at layer 2 + assert 2 in result[sv0] + assert 2 in result[sv1] + + +class TestMiscMethods: + """Tests for misc ChunkedGraph methods.""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_serialized_info(self, gen_graph): + """get_serialized_info should return a dict with graph_id.""" + graph = self._build_graph(gen_graph) + info = graph.get_serialized_info() + assert isinstance(info, dict) + assert "graph_id" in info + + @pytest.mark.timeout(30) + def test_get_chunk_id(self, gen_graph): + """get_chunk_id should return a valid chunk id.""" + graph = self._build_graph(gen_graph) + chunk_id = graph.get_chunk_id(layer=2, x=0, y=0, z=0) + assert chunk_id > 0 + assert graph.get_chunk_layer(chunk_id) == 2 + + @pytest.mark.timeout(30) + def test_get_node_id(self, gen_graph): + """get_node_id should construct node IDs correctly.""" + graph = self._build_graph(gen_graph) + node_id = graph.get_node_id(np.uint64(1), layer=1, x=0, y=0, z=0) + assert node_id > 0 + assert graph.get_chunk_layer(node_id) == 1 + + @pytest.mark.timeout(30) + def test_get_segment_id(self, gen_graph): + """get_segment_id should extract segment id from node id.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 5) + seg_id = graph.get_segment_id(sv) + assert seg_id == 5 + + @pytest.mark.timeout(30) + def test_get_parent_chunk_id(self, gen_graph): + """get_parent_chunk_id should return the chunk id of the parent layer.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + parent_chunk = graph.get_parent_chunk_id(sv) + assert graph.get_chunk_layer(parent_chunk) == 2 + + @pytest.mark.timeout(30) + def test_get_children_chunk_ids(self, gen_graph): + """get_children_chunk_ids should return chunk IDs one layer below.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + root = graph.get_root(sv) + # root is at layer 4; children chunks should be at layer 3 + children_chunks = graph.get_children_chunk_ids(root) + for cc in children_chunks: + assert graph.get_chunk_layer(cc) == 3 + + @pytest.mark.timeout(30) + def test_get_cross_chunk_edges_empty(self, gen_graph): + """get_cross_chunk_edges with empty node_ids should return empty dict.""" + graph = self._build_graph(gen_graph) + result = graph.get_cross_chunk_edges([], raw_only=True) + assert isinstance(result, dict) + assert len(result) == 0 + + +class TestIsLatestRootsAfterMerge: + """Test is_latest_roots after a merge operation (lines 524-539, 689-701).""" + + def _build_and_merge(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + old_root0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + old_root1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, old_root0, old_root1, new_root + + @pytest.mark.timeout(30) + def test_is_latest_roots_after_merge(self, gen_graph): + """After merge, old roots are not latest; new root is latest.""" + graph, old_root0, old_root1, new_root = self._build_and_merge(gen_graph) + result = graph.is_latest_roots( + np.array([old_root0, old_root1, new_root], dtype=np.uint64) + ) + assert not result[0], "Old root0 should not be latest after merge" + assert not result[1], "Old root1 should not be latest after merge" + assert result[2], "New root should be latest after merge" + + +class TestGetSubgraphNodesOnly: + """Test get_subgraph with nodes_only=True (lines 602-613).""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_subgraph_nodes_only(self, gen_graph): + """get_subgraph with nodes_only=True should return layer->node_ids dict.""" + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = graph.get_subgraph(root, nodes_only=True) + # Result should be a dict with layer 2 by default + assert isinstance(result, dict) + assert 2 in result + l2_nodes = result[2] + assert len(l2_nodes) >= 2 + for node in l2_nodes: + assert graph.get_chunk_layer(node) == 2 + + @pytest.mark.timeout(30) + def test_get_subgraph_nodes_only_multiple_layers(self, gen_graph): + """get_subgraph with nodes_only=True and return_layers=[2,3].""" + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = graph.get_subgraph(root, nodes_only=True, return_layers=[2, 3]) + assert isinstance(result, dict) + # Should have entries for layer 2 and/or 3 + assert 2 in result or 3 in result + + +class TestGetSubgraphEdgesOnly: + """Test get_subgraph with edges_only=True.""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_subgraph_edges_only(self, gen_graph): + """get_subgraph with edges_only=True should return edges.""" + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = graph.get_subgraph(root, edges_only=True) + # edges_only returns Edges from get_l2_agglomerations + # It should be a tuple of Edges or similar iterable + assert result is not None + + +# =========================================================================== +# is_latest_roots after merge -- detailed tests (lines 689-701) +# =========================================================================== + + +class TestIsLatestRootsDetailed: + """Detailed tests for is_latest_roots checking old roots are not latest after merge.""" + + def _build_and_merge(self, gen_graph): + """Build graph with two disconnected SVs, merge them, return old and new roots.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + old_root0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + old_root1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, old_root0, old_root1, new_root + + @pytest.mark.timeout(30) + def test_is_latest_roots_correct(self, gen_graph): + """After merge, old roots should be flagged as not latest, new root as latest.""" + graph, old_root0, old_root1, new_root = self._build_and_merge(gen_graph) + result = graph.is_latest_roots( + np.array([old_root0, old_root1, new_root], dtype=np.uint64) + ) + assert not result[0], "Old root0 should not be latest after merge" + assert not result[1], "Old root1 should not be latest after merge" + assert result[2], "New root should be latest after merge" + + @pytest.mark.timeout(30) + def test_is_latest_roots_single_old_root(self, gen_graph): + """Check a single old root is not latest after merge.""" + graph, old_root0, _, _ = self._build_and_merge(gen_graph) + result = graph.is_latest_roots(np.array([old_root0], dtype=np.uint64)) + assert not result[0] + + @pytest.mark.timeout(30) + def test_is_latest_roots_single_new_root(self, gen_graph): + """Check a single new root is latest after merge.""" + graph, _, _, new_root = self._build_and_merge(gen_graph) + result = graph.is_latest_roots(np.array([new_root], dtype=np.uint64)) + assert result[0] + + +# =========================================================================== +# get_chunk_coordinates_multiple (lines 958-961) +# =========================================================================== + + +class TestGetChunkCoordinatesMultiple: + """Tests for get_chunk_coordinates_multiple with same/different layer assertions.""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_same_layer(self, gen_graph): + """get_chunk_coordinates_multiple with L2 node IDs should return correct coordinates.""" + graph = self._build_graph(gen_graph) + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + # Get L2 parents + parent_a = graph.get_parents(np.array([sv_a], dtype=np.uint64), raw_only=True)[ + 0 + ] + parent_b = graph.get_parents(np.array([sv_b], dtype=np.uint64), raw_only=True)[ + 0 + ] + assert graph.get_chunk_layer(parent_a) == 2 + assert graph.get_chunk_layer(parent_b) == 2 + + coords = graph.get_chunk_coordinates_multiple( + np.array([parent_a, parent_b], dtype=np.uint64) + ) + assert coords.shape == (2, 3) + # parent_a is in chunk (0,0,0), parent_b is in chunk (1,0,0) + np.testing.assert_array_equal(coords[0], [0, 0, 0]) + np.testing.assert_array_equal(coords[1], [1, 0, 0]) + + @pytest.mark.timeout(30) + def test_different_layers_raises(self, gen_graph): + """get_chunk_coordinates_multiple with nodes at different layers should raise.""" + graph = self._build_graph(gen_graph) + sv_a = to_label(graph, 1, 0, 0, 0, 0) + parent_l2 = graph.get_parents(np.array([sv_a], dtype=np.uint64), raw_only=True)[ + 0 + ] + root = graph.get_root(sv_a) + + assert graph.get_chunk_layer(parent_l2) == 2 + assert graph.get_chunk_layer(root) == 4 + + with pytest.raises(AssertionError, match="must be same layer"): + graph.get_chunk_coordinates_multiple( + np.array([parent_l2, root], dtype=np.uint64) + ) + + @pytest.mark.timeout(30) + def test_empty_array(self, gen_graph): + """get_chunk_coordinates_multiple with empty array should return empty result.""" + graph = self._build_graph(gen_graph) + coords = graph.get_chunk_coordinates_multiple(np.array([], dtype=np.uint64)) + assert len(coords) == 0 + + +# =========================================================================== +# get_parent_chunk_id_multiple and get_parent_chunk_ids (lines 991, 996) +# =========================================================================== + + +class TestParentChunkIdMethods: + """Tests for get_parent_chunk_id_multiple and get_parent_chunk_ids.""" + + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + @pytest.mark.timeout(30) + def test_get_parent_chunk_id_multiple(self, gen_graph): + """get_parent_chunk_id_multiple should return parent chunk IDs for all nodes.""" + graph = self._build_graph(gen_graph) + sv_a = to_label(graph, 1, 0, 0, 0, 0) + sv_b = to_label(graph, 1, 1, 0, 0, 0) + # Get L2 parents + parent_a = graph.get_parents(np.array([sv_a], dtype=np.uint64), raw_only=True)[ + 0 + ] + parent_b = graph.get_parents(np.array([sv_b], dtype=np.uint64), raw_only=True)[ + 0 + ] + + parent_chunks = graph.get_parent_chunk_id_multiple( + np.array([parent_a, parent_b], dtype=np.uint64) + ) + assert len(parent_chunks) == 2 + for pc in parent_chunks: + assert graph.get_chunk_layer(pc) == 3 + + @pytest.mark.timeout(30) + def test_get_parent_chunk_ids(self, gen_graph): + """get_parent_chunk_ids should return all parent chunk IDs up the hierarchy.""" + graph = self._build_graph(gen_graph) + sv = to_label(graph, 1, 0, 0, 0, 0) + parent_chunk_ids = graph.get_parent_chunk_ids(sv) + # Should have parent chunk IDs for layers 2, 3, 4 + assert len(parent_chunk_ids) >= 2 + layers = [graph.get_chunk_layer(pc) for pc in parent_chunk_ids] + # Layers should be ascending (from layer 2 up) + for i in range(len(layers) - 1): + assert layers[i] < layers[i + 1] + + +# =========================================================================== +# read_chunk_edges (lines 1005-1007) +# =========================================================================== + + +class TestReadChunkEdges: + """Tests for read_chunk_edges method.""" + + @pytest.mark.timeout(30) + def test_read_chunk_edges_returns_dict(self, gen_graph): + """read_chunk_edges should return a dict (possibly empty for gs:// edges source).""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + sv = to_label(graph, 1, 0, 0, 0, 0) + parent = graph.get_parents(np.array([sv], dtype=np.uint64), raw_only=True)[0] + # read_chunk_edges uses io.edges.get_chunk_edges which reads from GCS/file. + # With gs:// edges source and no actual files, it should raise or return empty. + try: + result = graph.read_chunk_edges(np.array([parent], dtype=np.uint64)) + assert isinstance(result, dict) + except Exception: + # Expected: GCS access will fail in test env + pass + + +# =========================================================================== +# get_proofread_root_ids (lines 1017-1019) +# =========================================================================== + + +class TestGetProofreadRootIds: + """Tests for get_proofread_root_ids method.""" + + @pytest.mark.timeout(30) + def test_get_proofread_root_ids_no_ops(self, gen_graph): + """get_proofread_root_ids with no operations should return empty arrays.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + old_roots, new_roots = graph.get_proofread_root_ids() + assert len(old_roots) == 0 + assert len(new_roots) == 0 + + @pytest.mark.timeout(30) + def test_get_proofread_root_ids_after_merge(self, gen_graph): + """get_proofread_root_ids after a merge should return the old and new roots.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + old_roots, new_roots = graph.get_proofread_root_ids() + assert len(new_roots) >= 1 + assert result.new_root_ids[0] in new_roots + + +# =========================================================================== +# remove_edges via shim path (line 876) -- source_ids/sink_ids without atomic_edges +# =========================================================================== + + +class TestRemoveEdgesShim: + """Test remove_edges with source_ids and sink_ids but no atomic_edges (shim path).""" + + def _build_connected_graph(self, gen_graph): + """Build a 2-layer graph with two connected SVs.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + return graph + + @pytest.mark.timeout(30) + def test_remove_edges_with_source_sink_ids(self, gen_graph): + """Call remove_edges with source_ids/sink_ids (no atomic_edges) -- exercises shim.""" + graph = self._build_connected_graph(gen_graph) + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + + # Verify they share a root before split + assert graph.get_root(sv0) == graph.get_root(sv1) + + # Use source_ids/sink_ids (the shim path) instead of atomic_edges + result = graph.remove_edges( + "TestUser", + source_ids=sv0, + sink_ids=sv1, + mincut=False, + ) + assert result.new_root_ids is not None + assert len(result.new_root_ids) == 2 + + # After split, they should have different roots + assert graph.get_root(sv0) != graph.get_root(sv1) + + @pytest.mark.timeout(30) + def test_remove_edges_shim_mismatched_lengths(self, gen_graph): + """Shim path with mismatched source_ids/sink_ids lengths should raise.""" + graph = self._build_connected_graph(gen_graph) + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + + with pytest.raises(PreconditionError, match="same number"): + graph.remove_edges( + "TestUser", + source_ids=[sv0, sv0], + sink_ids=[sv1], + mincut=False, + ) + + +# =========================================================================== +# get_earliest_timestamp -- detailed test (bigtable/client.py coverage) +# =========================================================================== + + +class TestEarliestTimestamp: + """Tests for get_earliest_timestamp after operations exist.""" + + @pytest.mark.timeout(30) + def test_get_earliest_timestamp_after_merge(self, gen_graph): + """After creating a graph and performing a merge, get_earliest_timestamp should return a valid datetime.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + ], + edges=[], + timestamp=fake_ts, + ) + # Perform a merge to generate operation logs + graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + ts = graph.get_earliest_timestamp() + assert ts is not None + assert isinstance(ts, datetime) + + @pytest.mark.timeout(30) + def test_get_earliest_timestamp_no_ops(self, gen_graph): + """On a fresh graph with no operations, get_earliest_timestamp should return None.""" + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + ts = graph.get_earliest_timestamp() + # No operation logs, so should be None + assert ts is None or isinstance(ts, datetime) diff --git a/pychunkedgraph/tests/test_chunks_hierarchy.py b/pychunkedgraph/tests/test_chunks_hierarchy.py new file mode 100644 index 000000000..40841997d --- /dev/null +++ b/pychunkedgraph/tests/test_chunks_hierarchy.py @@ -0,0 +1,87 @@ +"""Tests for pychunkedgraph.graph.chunks.hierarchy""" + +import numpy as np + +from pychunkedgraph.graph.chunks import hierarchy +from pychunkedgraph.graph.chunks import utils as chunk_utils + +from .helpers import to_label + + +class TestGetChildrenChunkCoords: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=5) + coords = hierarchy.get_children_chunk_coords(graph.meta, 3, [0, 0, 0]) + # Layer 3 chunk at [0,0,0] has fanout=2 children: 2^3 = 8 max + assert len(coords) > 0 + assert coords.shape[1] == 3 + + +class TestGetChildrenChunkIds: + def test_layer_1_returns_empty(self, gen_graph): + graph = gen_graph(n_layers=4) + node_id = to_label(graph, 1, 0, 0, 0, 1) + result = hierarchy.get_children_chunk_ids(graph.meta, node_id) + assert len(result) == 0 + + def test_layer_2_returns_self(self, gen_graph): + graph = gen_graph(n_layers=4) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=2, x=0, y=0, z=0) + result = hierarchy.get_children_chunk_ids(graph.meta, chunk_id) + assert len(result) == 1 + + def test_layer_3(self, gen_graph): + graph = gen_graph(n_layers=5) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=3, x=0, y=0, z=0) + result = hierarchy.get_children_chunk_ids(graph.meta, chunk_id) + assert len(result) > 0 + + +class TestGetParentChunkId: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=5) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=2, x=0, y=0, z=0) + parent_id = hierarchy.get_parent_chunk_id(graph.meta, chunk_id, 3) + assert chunk_utils.get_chunk_layer(graph.meta, parent_id) == 3 + + def test_parent_coords(self, gen_graph): + graph = gen_graph(n_layers=5) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=2, x=2, y=3, z=1) + parent_id = hierarchy.get_parent_chunk_id(graph.meta, chunk_id, 3) + coords = chunk_utils.get_chunk_coordinates(graph.meta, parent_id) + # With fanout=2, coords should be floor(original / 2) + np.testing.assert_array_equal(coords, [1, 1, 0]) + + +class TestGetParentChunkIdMultiple: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=5) + ids = np.array( + [ + chunk_utils.get_chunk_id(graph.meta, layer=2, x=0, y=0, z=0), + chunk_utils.get_chunk_id(graph.meta, layer=2, x=1, y=0, z=0), + ], + dtype=np.uint64, + ) + result = hierarchy.get_parent_chunk_id_multiple(graph.meta, ids) + assert len(result) == 2 + for pid in result: + assert chunk_utils.get_chunk_layer(graph.meta, pid) == 3 + + +class TestGetParentChunkIds: + def test_returns_chain(self, gen_graph): + graph = gen_graph(n_layers=5) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=2, x=0, y=0, z=0) + result = hierarchy.get_parent_chunk_ids(graph.meta, chunk_id) + # Should include chunk_id + parents up to layer_count + assert len(result) >= 2 + + +class TestGetParentChunkIdDict: + def test_returns_dict(self, gen_graph): + graph = gen_graph(n_layers=5) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=2, x=0, y=0, z=0) + result = hierarchy.get_parent_chunk_id_dict(graph.meta, chunk_id) + assert isinstance(result, dict) + assert 2 in result diff --git a/pychunkedgraph/tests/test_chunks_utils.py b/pychunkedgraph/tests/test_chunks_utils.py new file mode 100644 index 000000000..e5830f80d --- /dev/null +++ b/pychunkedgraph/tests/test_chunks_utils.py @@ -0,0 +1,133 @@ +"""Tests for pychunkedgraph.graph.chunks.utils""" + +import numpy as np +import pytest + +from pychunkedgraph.graph.chunks import utils as chunk_utils + + +class TestGetChunkLayer: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + from .helpers import to_label + + node_id = to_label(graph, 1, 0, 0, 0, 1) + assert chunk_utils.get_chunk_layer(graph.meta, node_id) == 1 + + def test_higher_layer(self, gen_graph): + graph = gen_graph(n_layers=4) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=3, x=0, y=0, z=0) + assert chunk_utils.get_chunk_layer(graph.meta, chunk_id) == 3 + + +class TestGetChunkLayers: + def test_empty(self, gen_graph): + graph = gen_graph(n_layers=4) + result = chunk_utils.get_chunk_layers(graph.meta, []) + assert len(result) == 0 + + def test_multiple(self, gen_graph): + graph = gen_graph(n_layers=4) + from .helpers import to_label + + ids = [ + to_label(graph, 1, 0, 0, 0, 1), + to_label(graph, 1, 1, 0, 0, 2), + ] + layers = chunk_utils.get_chunk_layers(graph.meta, ids) + np.testing.assert_array_equal(layers, [1, 1]) + + +class TestGetChunkCoordinates: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=2, x=1, y=2, z=3) + coords = chunk_utils.get_chunk_coordinates(graph.meta, chunk_id) + np.testing.assert_array_equal(coords, [1, 2, 3]) + + +class TestGetChunkCoordinatesMultiple: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + ids = [ + chunk_utils.get_chunk_id(graph.meta, layer=2, x=0, y=0, z=0), + chunk_utils.get_chunk_id(graph.meta, layer=2, x=1, y=2, z=3), + ] + coords = chunk_utils.get_chunk_coordinates_multiple(graph.meta, ids) + np.testing.assert_array_equal(coords[0], [0, 0, 0]) + np.testing.assert_array_equal(coords[1], [1, 2, 3]) + + def test_empty(self, gen_graph): + graph = gen_graph(n_layers=4) + result = chunk_utils.get_chunk_coordinates_multiple(graph.meta, []) + assert result.shape == (0, 3) + + +class TestGetChunkId: + def test_from_node_id(self, gen_graph): + graph = gen_graph(n_layers=4) + from .helpers import to_label + + node_id = to_label(graph, 1, 2, 3, 1, 5) + chunk_id = chunk_utils.get_chunk_id(graph.meta, node_id=node_id) + coords = chunk_utils.get_chunk_coordinates(graph.meta, chunk_id) + np.testing.assert_array_equal(coords, [2, 3, 1]) + + def test_from_components(self, gen_graph): + graph = gen_graph(n_layers=4) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=2, x=1, y=2, z=3) + assert chunk_utils.get_chunk_layer(graph.meta, chunk_id) == 2 + coords = chunk_utils.get_chunk_coordinates(graph.meta, chunk_id) + np.testing.assert_array_equal(coords, [1, 2, 3]) + + +class TestComputeChunkIdOutOfRange: + def test_raises(self, gen_graph): + graph = gen_graph(n_layers=4) + with pytest.raises(ValueError, match="out of range"): + chunk_utils._compute_chunk_id(graph.meta, layer=2, x=9999, y=0, z=0) + + +class TestGetChunkIdsFromCoords: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + coords = np.array([[0, 0, 0], [1, 0, 0]]) + result = chunk_utils.get_chunk_ids_from_coords(graph.meta, 2, coords) + assert len(result) == 2 + for cid in result: + assert chunk_utils.get_chunk_layer(graph.meta, cid) == 2 + + +class TestGetChunkIdsFromNodeIds: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + from .helpers import to_label + + ids = np.array( + [ + to_label(graph, 1, 0, 0, 0, 1), + to_label(graph, 1, 1, 0, 0, 2), + ], + dtype=np.uint64, + ) + result = chunk_utils.get_chunk_ids_from_node_ids(graph.meta, ids) + assert len(result) == 2 + + def test_empty(self, gen_graph): + graph = gen_graph(n_layers=4) + result = chunk_utils.get_chunk_ids_from_node_ids(graph.meta, []) + assert len(result) == 0 + + +class TestNormalizeBoundingBox: + def test_none(self, gen_graph): + graph = gen_graph(n_layers=4) + assert chunk_utils.normalize_bounding_box(graph.meta, None, False) is None + + +class TestGetBoundingChildrenChunks: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=5) + result = chunk_utils.get_bounding_children_chunks(graph.meta, 3, (0, 0, 0), 2) + assert len(result) > 0 + assert result.shape[1] == 3 diff --git a/pychunkedgraph/tests/test_connectivity.py b/pychunkedgraph/tests/test_connectivity.py new file mode 100644 index 000000000..c27d99b6b --- /dev/null +++ b/pychunkedgraph/tests/test_connectivity.py @@ -0,0 +1,119 @@ +"""Tests for pychunkedgraph.graph.connectivity.nodes""" + +import numpy as np + +from pychunkedgraph.graph.types import Agglomeration +from pychunkedgraph.graph.connectivity.nodes import edge_exists + + +def _make_agg(node_id, supervoxels, out_edges): + """Helper to create an Agglomeration with the fields needed by edge_exists.""" + return Agglomeration( + node_id=np.uint64(node_id), + supervoxels=np.array(supervoxels, dtype=np.uint64), + in_edges=np.empty((0, 2), dtype=np.uint64), + out_edges=np.array(out_edges, dtype=np.uint64).reshape(-1, 2), + cross_edges=np.empty((0, 2), dtype=np.uint64), + ) + + +class TestEdgeExists: + def test_edge_exists_true(self): + """Two agglomerations with edges pointing to each other's supervoxels.""" + # agg1 owns supervoxels [10, 11], agg2 owns supervoxels [20, 21]. + # agg1 has an out_edge from sv 10 -> sv 20 (which belongs to agg2) + # agg2 has an out_edge from sv 20 -> sv 10 (which belongs to agg1) + agg1 = _make_agg( + node_id=1, + supervoxels=[10, 11], + out_edges=[[10, 20]], + ) + agg2 = _make_agg( + node_id=2, + supervoxels=[20, 21], + out_edges=[[20, 10]], + ) + assert edge_exists([agg1, agg2]) is True + + def test_edge_exists_true_one_direction(self): + """Edge exists is True even if only one direction has a cross-reference.""" + # agg1 out_edge target (sv 20) belongs to agg2 -> True on the first condition + agg1 = _make_agg( + node_id=1, + supervoxels=[10, 11], + out_edges=[[10, 20]], + ) + agg2 = _make_agg( + node_id=2, + supervoxels=[20, 21], + out_edges=[[20, 30]], # target 30 is not in agg1 + ) + # For this to work, sv 30 must be in the supervoxel_parent_d. + # Since 30 is not in either agglomeration's supervoxels, a KeyError + # would occur when checking supervoxel_parent_d[t2]. + # The function iterates zip(targets1, targets2), checking t1 first. + # If t1 matches, it returns True before checking t2. + # So agg1.out_edges target=20 (belongs to agg2) triggers True. + # BUT: zip pairs them, and both t1 and t2 are checked. + # Actually, the condition uses OR: if t1 belongs to agg2 OR t2 belongs to agg1. + # However, supervoxel_parent_d[t2] will KeyError if t2=30 is not in the dict. + # Let's fix: put sv 30 in a third agg, or just make the targets safe. + # Instead, let's set up so that sv 30 doesn't cause a problem: + # We need all targets to be in the supervoxel_parent_d. + # Add sv 30 to agg2's supervoxels. + agg2_fixed = _make_agg( + node_id=2, + supervoxels=[20, 21, 30], + out_edges=[[20, 30]], # target 30 belongs to agg2 itself (not agg1) + ) + assert edge_exists([agg1, agg2_fixed]) is True + + def test_edge_exists_false(self): + """Two agglomerations with no cross-references between them.""" + # agg1 out_edge targets sv 11 (its own supervoxel), + # agg2 out_edge targets sv 21 (its own supervoxel). + # Neither target belongs to the other agglomeration. + agg1 = _make_agg( + node_id=1, + supervoxels=[10, 11], + out_edges=[[10, 11]], + ) + agg2 = _make_agg( + node_id=2, + supervoxels=[20, 21], + out_edges=[[20, 21]], + ) + assert edge_exists([agg1, agg2]) is False + + def test_edge_exists_single_agg(self): + """Single agglomeration returns False (no combinations to iterate).""" + agg = _make_agg( + node_id=1, + supervoxels=[10, 11], + out_edges=[[10, 11]], + ) + assert edge_exists([agg]) is False + + def test_edge_exists_empty_list(self): + """Empty list of agglomerations returns False.""" + assert edge_exists([]) is False + + def test_edge_exists_three_agglomerations(self): + """Three agglomerations where only two have a cross-reference.""" + agg1 = _make_agg( + node_id=1, + supervoxels=[10, 11], + out_edges=[[10, 20]], + ) + agg2 = _make_agg( + node_id=2, + supervoxels=[20, 21], + out_edges=[[20, 10]], + ) + agg3 = _make_agg( + node_id=3, + supervoxels=[30, 31], + out_edges=[[30, 31]], + ) + # The combination (agg1, agg2) has cross-references, so True. + assert edge_exists([agg1, agg2, agg3]) is True diff --git a/pychunkedgraph/tests/test_cutting.py b/pychunkedgraph/tests/test_cutting.py new file mode 100644 index 000000000..40a1842d6 --- /dev/null +++ b/pychunkedgraph/tests/test_cutting.py @@ -0,0 +1,1418 @@ +"""Tests for pychunkedgraph.graph.cutting""" + +import numpy as np +import pytest + +from pychunkedgraph.graph.cutting import ( + IsolatingCutException, + LocalMincutGraph, + merge_cross_chunk_edges_graph_tool, + run_multicut, +) +from pychunkedgraph.graph.edges import Edges +from pychunkedgraph.graph.exceptions import PostconditionError, PreconditionError + + +class TestIsolatingCutException: + def test_is_exception_subclass(self): + """IsolatingCutException is a proper Exception subclass.""" + assert issubclass(IsolatingCutException, Exception) + + def test_can_be_raised_and_caught(self): + with pytest.raises(IsolatingCutException): + raise IsolatingCutException("Source") + + def test_message_preserved(self): + exc = IsolatingCutException("Sink") + assert str(exc) == "Sink" + + +class TestMergeCrossChunkEdgesGraphTool: + def test_merge_cross_chunk_edges_basic(self): + """Cross-chunk edges (inf affinity) cause their endpoints to be merged. + + Edges: + 1--2 (aff=0.5, regular) + 2--3 (aff=inf, cross-chunk -> merge 2 and 3) + 3--4 (aff=0.3, regular) + + After merging, node 3 is remapped to node 2 (min of {2,3}). + The cross-chunk edge (2--3) is removed from the output. + The remaining edges become: + 1--2 (aff=0.5) + 2--4 (aff=0.3) [was 3--4, but 3 is now remapped to 2] + """ + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, np.inf, 0.3], dtype=np.float32) + + mapped_edges, mapped_affs, mapping, complete_mapping, remapping = ( + merge_cross_chunk_edges_graph_tool(edges, affs) + ) + + # Cross-chunk edge is removed; 2 output edges remain + assert mapped_edges.shape[0] == 2 + assert mapped_affs.shape[0] == 2 + + # Affinities of the non-cross-chunk edges are preserved + np.testing.assert_array_almost_equal( + np.sort(mapped_affs), np.array([0.3, 0.5], dtype=np.float32) + ) + + # The mapping should show that 2 and 3 map to the same representative (min=2) + assert len(remapping) == 1 + rep_node = list(remapping.keys())[0] + assert rep_node == 2 + merged_nodes = set(remapping[rep_node]) + assert 2 in merged_nodes + assert 3 in merged_nodes + + # All unique nodes appear in complete_mapping + all_mapped_from = set(complete_mapping[:, 0]) + assert {1, 2, 3, 4}.issubset(all_mapped_from) + + def test_merge_cross_chunk_edges_no_cross_chunk(self): + """When all affinities are finite, no merging occurs. + + All edges are returned as-is (no cross-chunk edges to remove). + """ + edges = np.array([[10, 20], [20, 30], [30, 40]], dtype=np.uint64) + affs = np.array([0.5, 0.8, 0.3], dtype=np.float32) + + mapped_edges, mapped_affs, mapping, complete_mapping, remapping = ( + merge_cross_chunk_edges_graph_tool(edges, affs) + ) + + # No edges removed + assert mapped_edges.shape[0] == 3 + assert mapped_affs.shape[0] == 3 + + # No remapping occurred + assert len(remapping) == 0 + + # Affinities are unchanged + np.testing.assert_array_almost_equal(mapped_affs, affs) + + # All nodes map to themselves in complete_mapping + for row in complete_mapping: + assert row[0] == row[1] + + def test_merge_cross_chunk_edges_all_cross_chunk(self): + """When all edges are cross-chunk, all edges are removed from output.""" + edges = np.array([[1, 2], [2, 3]], dtype=np.uint64) + affs = np.array([np.inf, np.inf], dtype=np.float32) + + mapped_edges, mapped_affs, mapping, complete_mapping, remapping = ( + merge_cross_chunk_edges_graph_tool(edges, affs) + ) + + # All edges were cross-chunk, so no mapped edges remain + assert mapped_edges.shape[0] == 0 + assert mapped_affs.shape[0] == 0 + + def test_merge_cross_chunk_edges_multiple_components(self): + """Multiple separate cross-chunk merges in a single call. + + Edges: + 1--2 (inf) -> merge into {1,2}, rep=1 + 3--4 (inf) -> merge into {3,4}, rep=3 + 1--3 (0.7) -> becomes 1--3 after remapping + """ + edges = np.array([[1, 2], [3, 4], [1, 3]], dtype=np.uint64) + affs = np.array([np.inf, np.inf, 0.7], dtype=np.float32) + + mapped_edges, mapped_affs, mapping, complete_mapping, remapping = ( + merge_cross_chunk_edges_graph_tool(edges, affs) + ) + + # Only 1 non-cross-chunk edge remains + assert mapped_edges.shape[0] == 1 + assert mapped_affs.shape[0] == 1 + np.testing.assert_array_almost_equal(mapped_affs, [0.7]) + + # Two remapping groups + assert len(remapping) == 2 + + +class TestLocalMincutGraph: + """Tests for LocalMincutGraph initialization and mincut computation.""" + + def test_init_basic(self): + """Create a simple 4-node line graph with a weak middle edge. + + Graph: 1 --0.9-- 2 --0.1-- 3 --0.9-- 4 + Sources: [1], Sinks: [4] + + The graph should initialize successfully and have the expected + source/sink graph ids set. + """ + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.9, 0.1, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=True, + ) + assert graph.weighted_graph is not None + assert graph.unique_supervoxel_ids is not None + assert len(graph.source_graph_ids) == 1 + assert len(graph.sink_graph_ids) == 1 + # Sources and sinks should be mapped correctly + assert np.array_equal(graph.sources, sources) + assert np.array_equal(graph.sinks, sinks) + + def test_init_with_cross_chunk_edges(self): + """Initialization with a mix of regular and cross-chunk edges. + + Graph: 1 --0.5-- 2 --inf-- 3 --0.5-- 4 + The inf edge merges 2 and 3 into one node. + Sources: [1], Sinks: [4] + """ + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, np.inf, 0.5], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=True, + ) + # After merging cross chunk edges 2 and 3, we should have fewer unique ids + assert graph.weighted_graph is not None + assert len(graph.cross_chunk_edge_remapping) == 1 + + def test_init_only_cross_chunk_raises(self): + """All inf affinities should raise PostconditionError. + + When every edge is a cross-chunk edge, all edges are removed after + merging, leaving an empty graph. This should raise PostconditionError. + """ + edges = np.array([[1, 2], [2, 3]], dtype=np.uint64) + affs = np.array([np.inf, np.inf], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + with pytest.raises(PostconditionError, match="cross chunk edges"): + LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + ) + + def test_compute_mincut_direct(self): + """Compute mincut with path_augment=False on a simple 2-node graph. + + Graph: 1 --0.5-- 2 + Sources: [1], Sinks: [2] + + The only possible cut is the single edge between 1 and 2. + """ + edges = np.array([[1, 2]], dtype=np.uint64) + affs = np.array([0.5], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([2], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + # The mincut should return edges to cut + assert len(result) > 0 + # The returned edges should contain the edge (1,2) or (2,1) + result_set = set(map(tuple, result)) + assert (1, 2) in result_set or (2, 1) in result_set + + def test_compute_mincut_path_augmented(self): + """Compute mincut with path_augment=True (default) on a line graph. + + Graph: 1 --0.9-- 2 --0.1-- 3 --0.9-- 4 + Sources: [1], Sinks: [4] + + The weakest edge is 2--3, so the mincut should cut there. + """ + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.9, 0.1, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + assert len(result) > 0 + # The cut should include the weak edge (2,3) or (3,2) + result_set = set(map(tuple, result)) + assert (2, 3) in result_set or (3, 2) in result_set + # The strong edges should NOT be in the cut + assert (1, 2) not in result_set + assert (3, 4) not in result_set + + def test_compute_mincut_line_graph_cuts_weakest(self): + """Line graph with clear weakest edge - mincut should cut it. + + Graph: 10 --0.8-- 20 --0.01-- 30 --0.8-- 40 + Sources: [10], Sinks: [40] + """ + edges = np.array([[10, 20], [20, 30], [30, 40]], dtype=np.uint64) + affs = np.array([0.8, 0.01, 0.8], dtype=np.float32) + sources = np.array([10], dtype=np.uint64) + sinks = np.array([40], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + assert len(result) > 0 + result_set = set(map(tuple, result)) + assert (20, 30) in result_set or (30, 20) in result_set + + def test_compute_mincut_split_preview(self): + """Compute mincut with split_preview=True returns connected components. + + Graph: 1 --0.9-- 2 --0.1-- 3 --0.9-- 4 + Sources: [1], Sinks: [4] + """ + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.9, 0.1, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + # split_preview returns (supervoxel_ccs, illegal_split) + supervoxel_ccs, illegal_split = result + assert isinstance(supervoxel_ccs, list) + assert len(supervoxel_ccs) >= 2 + assert isinstance(illegal_split, bool) + # First component should contain source(s), second should contain sink(s) + assert 1 in supervoxel_ccs[0] or 2 in supervoxel_ccs[0] + assert 4 in supervoxel_ccs[1] or 3 in supervoxel_ccs[1] + + +class TestRunMulticut: + """Tests for the run_multicut function.""" + + def test_basic_split(self): + """Two groups connected by a weak edge -- mincut should cut that edge. + + Graph: 1 --0.9-- 2 --0.05-- 3 --0.9-- 4 + Sources: [1], Sinks: [4] + """ + node_ids1 = np.array([1, 2, 3], dtype=np.uint64) + node_ids2 = np.array([2, 3, 4], dtype=np.uint64) + affinities = np.array([0.9, 0.05, 0.9], dtype=np.float32) + + edges = Edges(node_ids1, node_ids2, affinities=affinities) + result = run_multicut( + edges, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(4)], + path_augment=True, + disallow_isolating_cut=False, + ) + assert len(result) > 0 + result_set = set(map(tuple, result)) + assert (2, 3) in result_set or (3, 2) in result_set + + def test_basic_split_direct(self): + """Same as test_basic_split but with path_augment=False.""" + node_ids1 = np.array([1, 2, 3], dtype=np.uint64) + node_ids2 = np.array([2, 3, 4], dtype=np.uint64) + affinities = np.array([0.9, 0.05, 0.9], dtype=np.float32) + + edges = Edges(node_ids1, node_ids2, affinities=affinities) + result = run_multicut( + edges, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(4)], + path_augment=False, + disallow_isolating_cut=False, + ) + assert len(result) > 0 + result_set = set(map(tuple, result)) + assert (2, 3) in result_set or (3, 2) in result_set + + def test_no_edges_raises(self): + """Graph with only cross-chunk edges raises PostconditionError. + + When all edges have infinite affinity, the local graph is empty after + merging cross-chunk edges, and LocalMincutGraph raises PostconditionError. + """ + node_ids1 = np.array([1, 2], dtype=np.uint64) + node_ids2 = np.array([2, 3], dtype=np.uint64) + affinities = np.array([np.inf, np.inf], dtype=np.float32) + + edges = Edges(node_ids1, node_ids2, affinities=affinities) + with pytest.raises(PostconditionError, match="cross chunk edges"): + run_multicut( + edges, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(3)], + ) + + def test_split_preview_mode(self): + """run_multicut with split_preview=True returns (ccs, illegal_split).""" + node_ids1 = np.array([1, 2, 3], dtype=np.uint64) + node_ids2 = np.array([2, 3, 4], dtype=np.uint64) + affinities = np.array([0.9, 0.05, 0.9], dtype=np.float32) + + edges = Edges(node_ids1, node_ids2, affinities=affinities) + result = run_multicut( + edges, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(4)], + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + supervoxel_ccs, illegal_split = result + assert isinstance(supervoxel_ccs, list) + assert len(supervoxel_ccs) >= 2 + assert isinstance(illegal_split, bool) + + +class TestMergeCrossChunkEdgesOverlap: + """Test edge cases in merge_cross_chunk_edges_graph_tool.""" + + def test_duplicate_cross_chunk_edges(self): + """Duplicate cross-chunk edges should still merge correctly.""" + edges = np.array([[1, 2], [1, 2], [2, 3]], dtype=np.uint64) + affs = np.array([np.inf, np.inf, 0.5], dtype=np.float32) + + mapped_edges, mapped_affs, mapping, complete_mapping, remapping = ( + merge_cross_chunk_edges_graph_tool(edges, affs) + ) + + # Only the finite-affinity edge should remain + assert mapped_edges.shape[0] == 1 + assert mapped_affs[0] == pytest.approx(0.5) + + def test_self_loop_after_merge(self): + """When merging creates a self-loop, it should be present but with correct count.""" + # 1-2 inf, 1-2 finite -> after merge, 1-1 (self-loop) is created + edges = np.array([[1, 2], [1, 2]], dtype=np.uint64) + affs = np.array([np.inf, 0.5], dtype=np.float32) + + mapped_edges, mapped_affs, mapping, complete_mapping, remapping = ( + merge_cross_chunk_edges_graph_tool(edges, affs) + ) + + # One non-inf edge remains, but both endpoints map to same node + assert mapped_edges.shape[0] == 1 + assert mapped_edges[0][0] == mapped_edges[0][1] + + def test_chain_of_cross_chunk_edges(self): + """A chain of cross-chunk edges: 1-2(inf), 2-3(inf), 3-4(inf). + All should merge into one component.""" + edges = np.array([[1, 2], [2, 3], [3, 4], [1, 5]], dtype=np.uint64) + affs = np.array([np.inf, np.inf, np.inf, 0.7], dtype=np.float32) + + mapped_edges, mapped_affs, mapping, complete_mapping, remapping = ( + merge_cross_chunk_edges_graph_tool(edges, affs) + ) + + # Only 1 non-cross edge remains + assert mapped_edges.shape[0] == 1 + assert mapped_affs[0] == pytest.approx(0.7) + # All of 1,2,3,4 should be in one remapping group + assert len(remapping) == 1 + rep = list(remapping.keys())[0] + assert rep == 1 # min of {1,2,3,4} + assert set(remapping[rep]) == {1, 2, 3, 4} + + +class TestRemapCutEdgeSet: + """Test _remap_cut_edge_set handles cross-chunk remapping correctly.""" + + def test_remap_with_cross_chunk_remapping(self): + """When cross-chunk edge remapping is present, cut edges should expand to all + mapped supervoxels.""" + # Graph: 1 --0.5-- 2 --inf-- 3 --0.5-- 4 + # Nodes 2 and 3 merge -> rep=2, remapping[2]=[2,3] + # Source: [1], Sink: [4] + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, np.inf, 0.5], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + + # The cross_chunk_edge_remapping should exist + assert len(graph.cross_chunk_edge_remapping) == 1 + result = graph.compute_mincut() + + # Result should contain edges from the original edge set + result_set = set(map(tuple, result)) + # At least one of the original edges should appear + assert len(result_set) > 0 + # All returned edges should be from the original cg_edges + for edge in result: + assert tuple(edge) in {(1, 2), (2, 1), (2, 3), (3, 2), (3, 4), (4, 3)} + + def test_remap_no_cross_chunk(self): + """Without cross-chunk edges, remap should just return original supervoxel ids.""" + edges = np.array([[1, 2], [2, 3]], dtype=np.uint64) + affs = np.array([0.9, 0.1], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + + assert len(graph.cross_chunk_edge_remapping) == 0 + result = graph.compute_mincut() + result_set = set(map(tuple, result)) + # The weak edge 2-3 should be cut + assert (2, 3) in result_set or (3, 2) in result_set + + +class TestSplitPreviewConnectedComponents: + """Test _get_split_preview_connected_components orders CCs correctly.""" + + def test_source_first_sink_second(self): + """split_preview should return sources in ccs[0] and sinks in ccs[1].""" + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.9, 0.01, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + + supervoxel_ccs, illegal_split = graph.compute_mincut() + assert isinstance(supervoxel_ccs, list) + assert len(supervoxel_ccs) >= 2 + # First CC should contain source supervoxels + assert 1 in supervoxel_ccs[0] + # Second CC should contain sink supervoxels + assert 4 in supervoxel_ccs[1] + assert isinstance(illegal_split, bool) + assert not illegal_split + + def test_multiple_sources_and_sinks(self): + """With multiple sources and sinks, each group stays in its own CC.""" + # 1-2-3-4-5-6, cut between 3-4 + edges = np.array([[1, 2], [2, 3], [3, 4], [4, 5], [5, 6]], dtype=np.uint64) + affs = np.array([0.9, 0.9, 0.01, 0.9, 0.9], dtype=np.float32) + sources = np.array([1, 2], dtype=np.uint64) + sinks = np.array([5, 6], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + + supervoxel_ccs, illegal_split = graph.compute_mincut() + # Both sources should be in ccs[0] + assert 1 in supervoxel_ccs[0] + assert 2 in supervoxel_ccs[0] + # Both sinks should be in ccs[1] + assert 5 in supervoxel_ccs[1] + assert 6 in supervoxel_ccs[1] + + def test_split_preview_with_cross_chunk(self): + """split_preview with cross-chunk edges should expand remapped nodes in CCs.""" + # 1 --0.5-- 2 --inf-- 3 --0.01-- 4 + # Nodes 2,3 merge. Cut between merged(2,3) and 4. + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, np.inf, 0.01], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + + supervoxel_ccs, illegal_split = graph.compute_mincut() + assert len(supervoxel_ccs) >= 2 + # After expanding cross-chunk remapping, source CC should contain 1, 2, 3 + all_source_svs = set(supervoxel_ccs[0]) + assert 1 in all_source_svs + # 2 and 3 were merged and should appear in same CC as source + assert 2 in all_source_svs or 3 in all_source_svs + # Sink CC should contain 4 + assert 4 in set(supervoxel_ccs[1]) + + +class TestSanityCheck: + """Test _sink_and_source_connectivity_sanity_check edge cases.""" + + def test_split_preview_illegal_split_flag(self): + """In split_preview mode, when sanity check would normally raise, + illegal_split should be True rather than raising an error.""" + # Create a graph where the cut might produce an unusual partition. + edges = np.array([[1, 2], [2, 3], [1, 3]], dtype=np.uint64) + affs = np.array([0.01, 0.01, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + + result = graph.compute_mincut() + supervoxel_ccs, illegal_split = result + # Should return valid result without raising + assert isinstance(supervoxel_ccs, list) + assert isinstance(illegal_split, bool) + + def test_non_preview_postcondition_error_on_empty_cut(self): + """run_multicut raises PostconditionError when mincut produces empty cut set.""" + # When all edges are cross-chunk, PostconditionError is raised + node_ids1 = np.array([1, 2], dtype=np.uint64) + node_ids2 = np.array([2, 3], dtype=np.uint64) + affinities = np.array([np.inf, np.inf], dtype=np.float32) + + edges_obj = Edges(node_ids1, node_ids2, affinities=affinities) + with pytest.raises(PostconditionError): + run_multicut( + edges_obj, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(3)], + ) + + +class TestRunMulticutSplitPreview: + """Test run_multicut in split_preview mode returns correct structure.""" + + def test_split_preview_returns_ccs_and_flag(self): + """run_multicut with split_preview=True should return (ccs, illegal_split).""" + node_ids1 = np.array([1, 2, 3], dtype=np.uint64) + node_ids2 = np.array([2, 3, 4], dtype=np.uint64) + affinities = np.array([0.9, 0.01, 0.9], dtype=np.float32) + + edges_obj = Edges(node_ids1, node_ids2, affinities=affinities) + result = run_multicut( + edges_obj, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(4)], + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + + supervoxel_ccs, illegal_split = result + assert isinstance(supervoxel_ccs, list) + assert len(supervoxel_ccs) >= 2 + assert isinstance(illegal_split, bool) + + # Source side CC + assert 1 in supervoxel_ccs[0] + # Sink side CC + assert 4 in supervoxel_ccs[1] + + def test_split_preview_with_path_augment(self): + """run_multicut with split_preview=True and path_augment=True.""" + node_ids1 = np.array([1, 2, 3, 4], dtype=np.uint64) + node_ids2 = np.array([2, 3, 4, 5], dtype=np.uint64) + affinities = np.array([0.9, 0.9, 0.01, 0.9], dtype=np.float32) + + edges_obj = Edges(node_ids1, node_ids2, affinities=affinities) + result = run_multicut( + edges_obj, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(5)], + split_preview=True, + path_augment=True, + disallow_isolating_cut=False, + ) + + supervoxel_ccs, illegal_split = result + assert len(supervoxel_ccs) >= 2 + # Source side + assert 1 in supervoxel_ccs[0] + # Sink side + assert 5 in supervoxel_ccs[1] + + def test_split_preview_larger_graph(self): + """split_preview on a larger graph with a clear cut point.""" + # Two clusters connected by a single weak edge + # Cluster A: 1-2, 1-3, 2-3 (all strong) + # Cluster B: 4-5, 4-6, 5-6 (all strong) + # Bridge: 3-4 (weak) + node_ids1 = np.array([1, 1, 2, 4, 4, 5, 3], dtype=np.uint64) + node_ids2 = np.array([2, 3, 3, 5, 6, 6, 4], dtype=np.uint64) + affinities = np.array([0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.01], dtype=np.float32) + + edges_obj = Edges(node_ids1, node_ids2, affinities=affinities) + result = run_multicut( + edges_obj, + source_ids=[np.uint64(1)], + sink_ids=[np.uint64(6)], + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + + supervoxel_ccs, illegal_split = result + source_cc = set(supervoxel_ccs[0]) + sink_cc = set(supervoxel_ccs[1]) + # Source cluster + assert {1, 2, 3}.issubset(source_cc) + # Sink cluster + assert {4, 5, 6}.issubset(sink_cc) + assert not illegal_split + + +class TestLocalMincutGraphWithLogger: + """Test that logging branches are exercised without errors.""" + + def test_init_with_logger(self): + """Passing a logger should not break initialization.""" + import logging + + logger = logging.getLogger("test_cutting_logger") + logger.setLevel(logging.DEBUG) + + edges = np.array([[1, 2], [2, 3]], dtype=np.uint64) + affs = np.array([0.9, 0.1], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + logger=logger, + ) + + assert graph.weighted_graph is not None + + def test_compute_mincut_with_logger(self): + """Compute mincut with a logger should produce debug messages.""" + import logging + + logger = logging.getLogger("test_cutting_mincut_logger") + logger.setLevel(logging.DEBUG) + + edges = np.array([[1, 2], [2, 3]], dtype=np.uint64) + affs = np.array([0.9, 0.1], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + logger=logger, + ) + + result = graph.compute_mincut() + assert len(result) > 0 + + +class TestFilterGraphConnectedComponents: + """Test edge cases in _filter_graph_connected_components.""" + + def test_disconnected_source_sink_raises(self): + """When sources and sinks are in different connected components, should raise.""" + # Two disconnected components: {1,2} and {3,4} + # Sources in one, sinks in other + edges = np.array([[1, 2], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, 0.5], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + + with pytest.raises(PreconditionError): + graph.compute_mincut() + + +class TestPartitionEdgesWithinLabel: + """Test the partition_edges_within_label method.""" + + def test_all_edges_within_labels(self): + """When all out-edges of a component go to labeled nodes, returns True.""" + # Simple triangle: 1-2-3-1, sources=[1,2], sinks=[3] + edges = np.array([[1, 2], [2, 3], [1, 3]], dtype=np.uint64) + affs = np.array([0.9, 0.9, 0.9], dtype=np.float32) + sources = np.array([1, 2], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + + # All nodes are labeled, so any CC should return True + result = graph.partition_edges_within_label(graph.source_graph_ids) + assert isinstance(result, bool) + + def test_edges_outside_labels_returns_false(self): + """When a node has edges to an unlabeled node, returns False.""" + # 1 --0.9-- 2 --0.9-- 3 --0.9-- 4 + # sources=[1], sinks=[4], so nodes 2 and 3 are unlabeled + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.9, 0.9, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + + # The source node 1 has edges to node 2 which is not a label node + result = graph.partition_edges_within_label(graph.source_graph_ids) + assert result is False + + +class TestAugmentMincutCapacityOverlap: + """Test path augmentation when source and sink paths overlap.""" + + def test_overlapping_paths_resolved(self): + """Graph with overlapping shortest paths between sources and sinks. + + Graph topology: + 1--2--3--4--5 + | | + 6--7--8 + + Sources: [1, 6], Sinks: [5, 8] + Paths from 1->5 and 6->8 overlap at nodes 2, 3, 4. + The path augmentation should resolve this overlap. + """ + edges = np.array( + [ + [1, 2], + [2, 3], + [3, 4], + [4, 5], + [2, 6], + [6, 7], + [7, 8], + [8, 4], + ], + dtype=np.uint64, + ) + affs = np.array([0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9], dtype=np.float32) + sources = np.array([1, 6], dtype=np.uint64) + sinks = np.array([5, 8], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=False, + ) + # The graph should initialize and compute the augmented capacity + # without errors, even with overlapping paths + result = graph.compute_mincut() + assert len(result) > 0 + + def test_overlapping_paths_with_weak_bridge(self): + """Graph with overlapping paths and a clear weak bridge to cut. + + Graph: + 1--2--3--4--5 + | | + 6--7--8 + + Edge 3-4 is weak (0.01), all others strong (0.9). + Sources: [1, 6], Sinks: [5, 8] + """ + edges = np.array( + [ + [1, 2], + [2, 3], + [3, 4], + [4, 5], + [2, 6], + [6, 7], + [7, 8], + [8, 4], + ], + dtype=np.uint64, + ) + affs = np.array([0.9, 0.9, 0.01, 0.9, 0.9, 0.9, 0.9, 0.9], dtype=np.float32) + sources = np.array([1, 6], dtype=np.uint64) + sinks = np.array([5, 8], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + assert len(result) > 0 + result_set = set(map(tuple, result)) + # The weak edge 3-4 should be among the cut edges + assert (3, 4) in result_set or (4, 3) in result_set + + def test_path_augment_multiple_sources_sinks_no_overlap(self): + """Multiple sources and sinks where paths do not overlap. + + Graph: + 1--2--3--4 + | + 5--6 + + Sources: [1], Sinks: [4] + """ + edges = np.array( + [[1, 2], [2, 3], [3, 4], [3, 5], [5, 6]], + dtype=np.uint64, + ) + affs = np.array([0.9, 0.01, 0.9, 0.9, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + assert len(result) > 0 + result_set = set(map(tuple, result)) + assert (2, 3) in result_set or (3, 2) in result_set + + +class TestSplitPreviewMultipleCCs: + """Test _get_split_preview_connected_components with more than 2 components.""" + + def test_three_components(self): + """A graph that splits into 3 components after cut. + + Graph: 1--2--3--4--5 with weak links at 2-3 and 3-4. + After cutting both weak links, we get 3 components: + {1,2}, {3}, {4,5} + """ + edges = np.array( + [[1, 2], [2, 3], [3, 4], [4, 5]], + dtype=np.uint64, + ) + affs = np.array([0.9, 0.01, 0.01, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([5], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + supervoxel_ccs, illegal_split = graph.compute_mincut() + assert isinstance(supervoxel_ccs, list) + assert len(supervoxel_ccs) >= 2 + # Source should be in first CC + assert 1 in supervoxel_ccs[0] + # Sink should be in second CC + assert 5 in supervoxel_ccs[1] + assert isinstance(illegal_split, bool) + + def test_split_preview_preserves_all_nodes(self): + """All nodes should appear across the CCs.""" + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.9, 0.01, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + supervoxel_ccs, _ = graph.compute_mincut() + all_nodes = set() + for cc in supervoxel_ccs: + all_nodes.update(set(cc)) + # All original nodes should appear in some CC + assert {1, 2, 3, 4}.issubset(all_nodes) + + +class TestRunSplitPreview: + """Test the module-level run_split_preview function. + + Note: The full run_split_preview requires a ChunkedGraph instance, + so we test through run_multicut with split_preview=True which exercises + the same _get_split_preview_connected_components code path. + """ + + def test_basic_split_preview(self): + """run_multicut with split_preview should return CCs and a flag.""" + edges_sv = Edges( + np.array([1, 2, 3, 4], dtype=np.uint64), + np.array([2, 3, 4, 5], dtype=np.uint64), + affinities=np.array([0.9, 0.1, 0.9, 0.9], dtype=np.float32), + areas=np.array([1, 1, 1, 1], dtype=np.float32), + ) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([5], dtype=np.uint64) + ccs, illegal_split = run_multicut( + edges_sv, + sources, + sinks, + split_preview=True, + disallow_isolating_cut=False, + ) + assert isinstance(ccs, list) + assert isinstance(illegal_split, bool) + assert len(ccs) >= 2 + + def test_split_preview_with_areas(self): + """Split preview with areas provided.""" + edges_sv = Edges( + np.array([10, 20, 30], dtype=np.uint64), + np.array([20, 30, 40], dtype=np.uint64), + affinities=np.array([0.9, 0.01, 0.9], dtype=np.float32), + areas=np.array([100, 5, 100], dtype=np.float32), + ) + sources = np.array([10], dtype=np.uint64) + sinks = np.array([40], dtype=np.uint64) + ccs, illegal_split = run_multicut( + edges_sv, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=False, + ) + assert isinstance(ccs, list) + assert len(ccs) >= 2 + # Source side should contain 10 + assert 10 in ccs[0] + # Sink side should contain 40 + assert 40 in ccs[1] + + def test_split_preview_path_augment(self): + """Split preview with path_augment=True.""" + edges_sv = Edges( + np.array([1, 2, 3, 4, 5], dtype=np.uint64), + np.array([2, 3, 4, 5, 6], dtype=np.uint64), + affinities=np.array([0.9, 0.9, 0.01, 0.9, 0.9], dtype=np.float32), + ) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([6], dtype=np.uint64) + ccs, illegal_split = run_multicut( + edges_sv, + sources, + sinks, + split_preview=True, + path_augment=True, + disallow_isolating_cut=False, + ) + assert isinstance(ccs, list) + assert len(ccs) >= 2 + assert 1 in ccs[0] + assert 6 in ccs[1] + assert not illegal_split + + +class TestFilterGraphCCsWithLogger: + """Test _filter_graph_connected_components logs a warning when sources + and sinks are in different connected components.""" + + def test_disconnected_with_logger_raises(self): + """Disconnected graph with logger should log warning and raise.""" + import logging + + logger = logging.getLogger("test_filter_cc_logger") + logger.setLevel(logging.DEBUG) + + edges = np.array([[1, 2], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, 0.5], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + logger=logger, + ) + + with pytest.raises( + PreconditionError, match="Sinks and sources are not connected" + ): + graph.compute_mincut() + + +class TestGtMincutSanityCheck: + """Test the _gt_mincut_sanity_check debug method.""" + + def test_sanity_check_valid_partition(self): + """A valid partition should pass the sanity check without error.""" + import graph_tool + import graph_tool.flow + + edges = np.array([[1, 2], [2, 3]], dtype=np.uint64) + affs = np.array([0.9, 0.1], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=False, + ) + + # Manually compute partition to test the sanity check + graph._filter_graph_connected_components() + src = graph.weighted_graph.vertex(graph.source_graph_ids[0]) + tgt = graph.weighted_graph.vertex(graph.sink_graph_ids[0]) + residuals = graph_tool.flow.push_relabel_max_flow( + graph.weighted_graph, src, tgt, graph.capacities + ) + partition = graph_tool.flow.min_st_cut( + graph.weighted_graph, src, graph.capacities, residuals + ) + # This should not raise any assertion error + graph._gt_mincut_sanity_check(partition) + + +class TestIsolatingCutPath: + """Test the IsolatingCutException path in _sink_and_source_connectivity_sanity_check.""" + + def test_isolating_cut_raises_precondition_error(self): + """When mincut isolates exactly the labeled points and they have edges + to non-label nodes, PreconditionError is raised. + + Graph: 1 --0.01-- 2 --0.9-- 3 --0.9-- 4 + Sources: [1], Sinks: [4] + disallow_isolating_cut=True + + The mincut cuts edge 1-2 (weakest). After cut, source CC = {1}. + source_path_vertices = source_graph_ids = {1} (path_augment=False). + len(source_path_vertices) == len(cc) == 1. + In the raw graph, node 1 has neighbor 2 which is NOT a label node. + partition_edges_within_label returns False -> IsolatingCutException -> PreconditionError. + """ + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.01, 0.9, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=False, + disallow_isolating_cut=True, + ) + # This should raise PreconditionError about isolating cut + with pytest.raises(PreconditionError, match="cut off only the labeled"): + graph.compute_mincut() + + def test_isolating_cut_split_preview_returns_illegal(self): + """In split_preview mode, isolating cut should set illegal_split=True.""" + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.01, 0.9, 0.9], dtype=np.float32) + sources = np.array([1], dtype=np.uint64) + sinks = np.array([4], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=False, + disallow_isolating_cut=True, + ) + supervoxel_ccs, illegal_split = graph.compute_mincut() + assert isinstance(supervoxel_ccs, list) + assert illegal_split is True + + +class TestRerunPathsWithoutOverlap: + """Test that the rerun_paths_without_overlap code path is exercised + when source and sink shortest paths overlap and removing overlap + breaks connectedness.""" + + def test_forced_overlap_resolution(self): + """Create graph where source/sink paths overlap, forcing rerun. + + Graph: + 1--2--3 + | | | + 4--5--6 + + Sources: [1, 4], Sinks: [3, 6] + Paths from 1->3 and 4->6 both go through 2 and 5, causing overlap. + The path augmentation should resolve the overlap via rerun_paths_without_overlap. + """ + edges = np.array( + [ + [1, 2], + [2, 3], + [4, 5], + [5, 6], + [1, 4], + [2, 5], + [3, 6], + ], + dtype=np.uint64, + ) + affs = np.array([0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3], dtype=np.float32) + sources = np.array([1, 4], dtype=np.uint64) + sinks = np.array([3, 6], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + assert len(result) > 0 + + def test_forced_overlap_resolution_asymmetric(self): + """Asymmetric graph where one team wins overlap by harmonic mean. + + Graph: + 1--2--3--4 + | | | | + 5--6--7--8 + + Sources: [1, 5], Sinks: [4, 8] + Paths overlap at intermediate nodes 2,3,6,7. + The path augmentation should resolve the overlap. + """ + edges = np.array( + [ + [1, 2], + [2, 3], + [3, 4], + [5, 6], + [6, 7], + [7, 8], + [1, 5], + [2, 6], + [3, 7], + [4, 8], + ], + dtype=np.uint64, + ) + affs = np.array( + [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5], + dtype=np.float32, + ) + sources = np.array([1, 5], dtype=np.uint64) + sinks = np.array([4, 8], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + assert len(result) > 0 + + def test_overlap_resolution_with_clear_cut(self): + """Graph with overlap at a bottleneck, but weak bridge for the cut. + + Graph: + 1--2--3--4--5 + | | + 6--7--8 + + Sources: [1, 6], Sinks: [5, 8] + Edge 3-4 is very weak (0.01), all others strong. + Overlap is forced at node 2 or 4. + """ + edges = np.array( + [ + [1, 2], + [2, 3], + [3, 4], + [4, 5], + [2, 6], + [6, 7], + [7, 8], + [8, 4], + ], + dtype=np.uint64, + ) + # Make the bridge edge very weak + affs = np.array([0.9, 0.9, 0.01, 0.9, 0.9, 0.9, 0.9, 0.9], dtype=np.float32) + sources = np.array([1, 6], dtype=np.uint64) + sinks = np.array([5, 8], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=False, + path_augment=True, + disallow_isolating_cut=False, + ) + result = graph.compute_mincut() + assert len(result) > 0 + result_set = set(map(tuple, result)) + # The weak edge 3-4 should be among the cut edges + assert (3, 4) in result_set or (4, 3) in result_set + + def test_overlap_with_split_preview(self): + """Split preview mode with overlapping paths should produce valid CCs.""" + edges = np.array( + [ + [1, 2], + [2, 3], + [4, 5], + [5, 6], + [1, 4], + [2, 5], + [3, 6], + ], + dtype=np.uint64, + ) + affs = np.array([0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3], dtype=np.float32) + sources = np.array([1, 4], dtype=np.uint64) + sinks = np.array([3, 6], dtype=np.uint64) + + graph = LocalMincutGraph( + edges, + affs, + sources, + sinks, + split_preview=True, + path_augment=True, + disallow_isolating_cut=False, + ) + supervoxel_ccs, illegal_split = graph.compute_mincut() + assert isinstance(supervoxel_ccs, list) + assert len(supervoxel_ccs) >= 2 + assert isinstance(illegal_split, bool) diff --git a/pychunkedgraph/tests/test_edges_definitions.py b/pychunkedgraph/tests/test_edges_definitions.py new file mode 100644 index 000000000..e1ab45288 --- /dev/null +++ b/pychunkedgraph/tests/test_edges_definitions.py @@ -0,0 +1,105 @@ +"""Tests for pychunkedgraph.graph.edges.definitions""" + +import pytest +import numpy as np + +from pychunkedgraph.graph.edges.definitions import ( + Edges, + EDGE_TYPES, + DEFAULT_AFFINITY, + DEFAULT_AREA, +) +from pychunkedgraph.graph.utils import basetypes + + +class TestEdgeTypes: + def test_fields(self): + assert EDGE_TYPES.in_chunk == "in" + assert EDGE_TYPES.between_chunk == "between" + assert EDGE_TYPES.cross_chunk == "cross" + + +class TestEdges: + def test_creation_defaults(self): + ids1 = np.array([1, 2], dtype=basetypes.NODE_ID) + ids2 = np.array([3, 4], dtype=basetypes.NODE_ID) + e = Edges(ids1, ids2) + np.testing.assert_array_equal(e.node_ids1, ids1) + np.testing.assert_array_equal(e.node_ids2, ids2) + assert np.all(e.affinities == DEFAULT_AFFINITY) + assert np.all(e.areas == DEFAULT_AREA) + + def test_creation_explicit(self): + ids1 = np.array([1, 2], dtype=basetypes.NODE_ID) + ids2 = np.array([3, 4], dtype=basetypes.NODE_ID) + affs = np.array([0.5, 0.9], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([10.0, 20.0], dtype=basetypes.EDGE_AREA) + e = Edges(ids1, ids2, affinities=affs, areas=areas) + np.testing.assert_array_almost_equal(e.affinities, affs) + np.testing.assert_array_almost_equal(e.areas, areas) + + def test_creation_empty(self): + e = Edges([], []) + assert len(e) == 0 + pairs = e.get_pairs() + assert pairs.shape[0] == 0 + + def test_len(self): + e = Edges([1, 2, 3], [4, 5, 6]) + assert len(e) == 3 + + def test_add(self): + e1 = Edges([1], [2], affinities=[0.5], areas=[10.0]) + e2 = Edges([3], [4], affinities=[0.9], areas=[20.0]) + e3 = e1 + e2 + assert len(e3) == 2 + np.testing.assert_array_equal(e3.node_ids1, [1, 3]) + np.testing.assert_array_equal(e3.node_ids2, [2, 4]) + + def test_iadd(self): + e1 = Edges([1], [2]) + e2 = Edges([3], [4]) + e1 += e2 + assert len(e1) == 2 + np.testing.assert_array_equal(e1.node_ids1, [1, 3]) + + def test_getitem_boolean(self): + e = Edges([1, 2, 3], [4, 5, 6], affinities=[0.1, 0.5, 0.9], areas=[1, 2, 3]) + mask = np.array([True, False, True]) + filtered = e[mask] + assert len(filtered) == 2 + np.testing.assert_array_equal(filtered.node_ids1, [1, 3]) + + def test_getitem_error(self): + e = Edges([1, 2], [3, 4]) + with pytest.raises(Exception): + e["invalid_key"] + + def test_get_pairs(self): + e = Edges([1, 2], [3, 4]) + pairs = e.get_pairs() + assert pairs.shape == (2, 2) + np.testing.assert_array_equal(pairs[:, 0], [1, 2]) + np.testing.assert_array_equal(pairs[:, 1], [3, 4]) + + def test_get_pairs_caching(self): + e = Edges([1, 2], [3, 4]) + p1 = e.get_pairs() + p2 = e.get_pairs() + assert p1 is p2 + + def test_size_mismatch_raises(self): + with pytest.raises(AssertionError): + Edges([1, 2], [3]) + + def test_affinities_setter(self): + e = Edges([1], [2]) + new_affs = np.array([0.99], dtype=basetypes.EDGE_AFFINITY) + e.affinities = new_affs + np.testing.assert_array_almost_equal(e.affinities, new_affs) + + def test_areas_setter(self): + e = Edges([1], [2]) + new_areas = np.array([42.0], dtype=basetypes.EDGE_AREA) + e.areas = new_areas + np.testing.assert_array_almost_equal(e.areas, new_areas) diff --git a/pychunkedgraph/tests/test_edges_utils.py b/pychunkedgraph/tests/test_edges_utils.py new file mode 100644 index 000000000..775823870 --- /dev/null +++ b/pychunkedgraph/tests/test_edges_utils.py @@ -0,0 +1,96 @@ +"""Tests for pychunkedgraph.graph.edges.utils""" + +import numpy as np + +from pychunkedgraph.graph.edges import Edges, EDGE_TYPES +from pychunkedgraph.graph.edges.utils import ( + concatenate_chunk_edges, + concatenate_cross_edge_dicts, + merge_cross_edge_dicts, + get_cross_chunk_edges_layer, +) +from pychunkedgraph.graph.utils import basetypes + +from .helpers import to_label + + +class TestConcatenateChunkEdges: + def test_basic(self): + d1 = { + EDGE_TYPES.in_chunk: Edges([1, 2], [3, 4]), + EDGE_TYPES.between_chunk: Edges([5], [6]), + EDGE_TYPES.cross_chunk: Edges([], []), + } + d2 = { + EDGE_TYPES.in_chunk: Edges([7], [8]), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([9], [10]), + } + result = concatenate_chunk_edges([d1, d2]) + assert len(result[EDGE_TYPES.in_chunk]) == 3 + assert len(result[EDGE_TYPES.between_chunk]) == 1 + assert len(result[EDGE_TYPES.cross_chunk]) == 1 + + def test_empty(self): + result = concatenate_chunk_edges([]) + for edge_type in EDGE_TYPES: + assert len(result[edge_type]) == 0 + + +class TestConcatenateCrossEdgeDicts: + def test_no_unique(self): + d1 = {3: np.array([[1, 2]], dtype=basetypes.NODE_ID)} + d2 = {3: np.array([[1, 2], [3, 4]], dtype=basetypes.NODE_ID)} + result = concatenate_cross_edge_dicts([d1, d2], unique=False) + assert len(result[3]) == 3 # duplicates kept + + def test_unique(self): + d1 = {3: np.array([[1, 2]], dtype=basetypes.NODE_ID)} + d2 = {3: np.array([[1, 2], [3, 4]], dtype=basetypes.NODE_ID)} + result = concatenate_cross_edge_dicts([d1, d2], unique=True) + assert len(result[3]) == 2 # duplicates removed + + def test_different_layers(self): + d1 = {3: np.array([[1, 2]], dtype=basetypes.NODE_ID)} + d2 = {4: np.array([[5, 6]], dtype=basetypes.NODE_ID)} + result = concatenate_cross_edge_dicts([d1, d2]) + assert 3 in result + assert 4 in result + + +class TestMergeCrossEdgeDicts: + def test_basic(self): + d1 = { + np.uint64(100): {3: np.array([[1, 2]], dtype=basetypes.NODE_ID)}, + } + d2 = { + np.uint64(100): {3: np.array([[3, 4]], dtype=basetypes.NODE_ID)}, + np.uint64(200): {4: np.array([[5, 6]], dtype=basetypes.NODE_ID)}, + } + result = merge_cross_edge_dicts(d1, d2) + assert np.uint64(100) in result + assert np.uint64(200) in result + assert len(result[np.uint64(100)][3]) == 2 + + +class TestGetCrossChunkEdgesLayer: + def test_empty(self, gen_graph): + graph = gen_graph(n_layers=4) + result = get_cross_chunk_edges_layer(graph.meta, []) + assert len(result) == 0 + + def test_same_chunk(self, gen_graph): + graph = gen_graph(n_layers=4) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + sv2 = to_label(graph, 1, 0, 0, 0, 2) + edges = np.array([[sv1, sv2]], dtype=basetypes.NODE_ID) + result = get_cross_chunk_edges_layer(graph.meta, edges) + assert result[0] == 1 # same chunk -> layer 1 + + def test_adjacent_chunks(self, gen_graph): + graph = gen_graph(n_layers=4) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + sv2 = to_label(graph, 1, 1, 0, 0, 1) + edges = np.array([[sv1, sv2]], dtype=basetypes.NODE_ID) + result = get_cross_chunk_edges_layer(graph.meta, edges) + assert result[0] >= 2 # different chunks -> higher layer diff --git a/pychunkedgraph/tests/test_edits_extended.py b/pychunkedgraph/tests/test_edits_extended.py new file mode 100644 index 000000000..bc1227de7 --- /dev/null +++ b/pychunkedgraph/tests/test_edits_extended.py @@ -0,0 +1,55 @@ +"""Tests for pychunkedgraph.graph.edits - extended coverage""" + +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from pychunkedgraph.graph.edits import flip_ids +from pychunkedgraph.graph.utils import basetypes + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestFlipIds: + def test_basic(self): + id_map = { + np.uint64(1): {np.uint64(10), np.uint64(11)}, + np.uint64(2): {np.uint64(20)}, + } + result = flip_ids(id_map, [np.uint64(1), np.uint64(2)]) + assert np.uint64(10) in result + assert np.uint64(11) in result + assert np.uint64(20) in result + + def test_empty(self): + id_map = {} + result = flip_ids(id_map, []) + assert len(result) == 0 + + +class TestInitOldHierarchy: + def test_basic(self, gen_graph): + from pychunkedgraph.graph.edits import _init_old_hierarchy + + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + sv = to_label(graph, 1, 0, 0, 0, 0) + l2_parent = graph.get_parent(sv) + result = _init_old_hierarchy(graph, np.array([l2_parent], dtype=np.uint64)) + assert l2_parent in result + assert 2 in result[l2_parent] diff --git a/pychunkedgraph/tests/test_exceptions.py b/pychunkedgraph/tests/test_exceptions.py new file mode 100644 index 000000000..2c054bfb0 --- /dev/null +++ b/pychunkedgraph/tests/test_exceptions.py @@ -0,0 +1,70 @@ +"""Tests for pychunkedgraph.graph.exceptions""" + +import pytest +from http.client import BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, CONFLICT +from http.client import INTERNAL_SERVER_ERROR, GATEWAY_TIMEOUT + +from pychunkedgraph.graph.exceptions import ( + ChunkedGraphError, + LockingError, + PreconditionError, + PostconditionError, + ChunkedGraphAPIError, + ClientError, + BadRequest, + Unauthorized, + Forbidden, + Conflict, + ServerError, + InternalServerError, + GatewayTimeout, +) + + +class TestExceptionHierarchy: + def test_base_error(self): + with pytest.raises(ChunkedGraphError): + raise ChunkedGraphError("test") + + def test_locking_error_inherits(self): + assert issubclass(LockingError, ChunkedGraphError) + with pytest.raises(ChunkedGraphError): + raise LockingError("locked") + + def test_precondition_error(self): + assert issubclass(PreconditionError, ChunkedGraphError) + + def test_postcondition_error(self): + assert issubclass(PostconditionError, ChunkedGraphError) + + def test_api_error_str(self): + err = ChunkedGraphAPIError("test message") + assert err.message == "test message" + assert err.status_code is None + assert "[None]: test message" == str(err) + + def test_client_error_inherits(self): + assert issubclass(ClientError, ChunkedGraphAPIError) + + def test_bad_request(self): + err = BadRequest("bad") + assert err.status_code == BAD_REQUEST + assert issubclass(BadRequest, ClientError) + + def test_unauthorized(self): + assert Unauthorized.status_code == UNAUTHORIZED + + def test_forbidden(self): + assert Forbidden.status_code == FORBIDDEN + + def test_conflict(self): + assert Conflict.status_code == CONFLICT + + def test_server_error_inherits(self): + assert issubclass(ServerError, ChunkedGraphAPIError) + + def test_internal_server_error(self): + assert InternalServerError.status_code == INTERNAL_SERVER_ERROR + + def test_gateway_timeout(self): + assert GatewayTimeout.status_code == GATEWAY_TIMEOUT diff --git a/pychunkedgraph/tests/test_ingest_atomic_layer.py b/pychunkedgraph/tests/test_ingest_atomic_layer.py new file mode 100644 index 000000000..c55318c8f --- /dev/null +++ b/pychunkedgraph/tests/test_ingest_atomic_layer.py @@ -0,0 +1,66 @@ +"""Tests for pychunkedgraph.ingest.create.atomic_layer""" + +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest + +from pychunkedgraph.ingest.create.atomic_layer import ( + _get_chunk_nodes_and_edges, + _get_remapping, +) +from pychunkedgraph.graph.edges import Edges, EDGE_TYPES +from pychunkedgraph.graph.utils import basetypes + + +class TestGetChunkNodesAndEdges: + def test_basic(self): + chunk_edges_d = { + EDGE_TYPES.in_chunk: Edges( + np.array([1, 2], dtype=basetypes.NODE_ID), + np.array([3, 4], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges( + np.array([1], dtype=basetypes.NODE_ID), + np.array([5], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.cross_chunk: Edges([], []), + } + isolated = np.array([10], dtype=np.uint64) + node_ids, edge_ids = _get_chunk_nodes_and_edges(chunk_edges_d, isolated) + assert 10 in node_ids + assert 1 in node_ids + assert 3 in node_ids + assert len(edge_ids) > 0 + + def test_isolated_only(self): + chunk_edges_d = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + isolated = np.array([10, 20], dtype=np.uint64) + node_ids, edge_ids = _get_chunk_nodes_and_edges(chunk_edges_d, isolated) + assert 10 in node_ids + assert 20 in node_ids + + +class TestGetRemapping: + def test_basic(self): + chunk_edges_d = { + EDGE_TYPES.in_chunk: Edges( + np.array([1, 2], dtype=basetypes.NODE_ID), + np.array([3, 4], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges( + np.array([1], dtype=basetypes.NODE_ID), + np.array([5], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.cross_chunk: Edges( + np.array([2], dtype=basetypes.NODE_ID), + np.array([6], dtype=basetypes.NODE_ID), + ), + } + sparse_indices, remapping = _get_remapping(chunk_edges_d) + assert EDGE_TYPES.between_chunk in remapping + assert EDGE_TYPES.cross_chunk in remapping diff --git a/pychunkedgraph/tests/test_ingest_config.py b/pychunkedgraph/tests/test_ingest_config.py new file mode 100644 index 000000000..f068f5da1 --- /dev/null +++ b/pychunkedgraph/tests/test_ingest_config.py @@ -0,0 +1,27 @@ +"""Tests for pychunkedgraph.ingest IngestConfig""" + +from pychunkedgraph.ingest import IngestConfig + + +class TestIngestConfig: + def test_defaults(self): + config = IngestConfig() + assert config.AGGLOMERATION is None + assert config.WATERSHED is None + assert config.USE_RAW_EDGES is False + assert config.USE_RAW_COMPONENTS is False + assert config.TEST_RUN is False + + def test_custom_values(self): + config = IngestConfig( + AGGLOMERATION="gs://bucket/agg", + WATERSHED="gs://bucket/ws", + USE_RAW_EDGES=True, + USE_RAW_COMPONENTS=True, + TEST_RUN=True, + ) + assert config.AGGLOMERATION == "gs://bucket/agg" + assert config.WATERSHED == "gs://bucket/ws" + assert config.USE_RAW_EDGES is True + assert config.USE_RAW_COMPONENTS is True + assert config.TEST_RUN is True diff --git a/pychunkedgraph/tests/test_ingest_cross_edges.py b/pychunkedgraph/tests/test_ingest_cross_edges.py new file mode 100644 index 000000000..1084fb4a9 --- /dev/null +++ b/pychunkedgraph/tests/test_ingest_cross_edges.py @@ -0,0 +1,368 @@ +"""Tests for pychunkedgraph.ingest.create.cross_edges""" + +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from pychunkedgraph.ingest.create.cross_edges import ( + _find_min_layer, + get_children_chunk_cross_edges, + get_chunk_nodes_cross_edge_layer, + _get_chunk_nodes_cross_edge_layer_helper, +) +from pychunkedgraph.graph.utils import basetypes + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestFindMinLayer: + """Pure unit tests for _find_min_layer helper.""" + + def test_single_batch(self): + """One array of node_ids and layers results in correct min layers.""" + node_layer_d = {} + node_ids_shared = [np.array([10, 20, 30], dtype=basetypes.NODE_ID)] + node_layers_shared = [np.array([3, 5, 4], dtype=np.uint8)] + + _find_min_layer(node_layer_d, node_ids_shared, node_layers_shared) + + assert node_layer_d[10] == 3 + assert node_layer_d[20] == 5 + assert node_layer_d[30] == 4 + assert len(node_layer_d) == 3 + + def test_multiple_batches_min_wins(self): + """Two batches with the same node_id but different layers; smallest layer wins.""" + node_layer_d = {} + node_ids_shared = [ + np.array([10, 20], dtype=basetypes.NODE_ID), + np.array([20, 30], dtype=basetypes.NODE_ID), + ] + node_layers_shared = [ + np.array([5, 7], dtype=np.uint8), + np.array([3, 4], dtype=np.uint8), + ] + + _find_min_layer(node_layer_d, node_ids_shared, node_layers_shared) + + assert node_layer_d[10] == 5 + # node 20 appears in both batches with layers 7 and 3; min is 3 + assert node_layer_d[20] == 3 + assert node_layer_d[30] == 4 + + def test_empty_batches(self): + """Empty arrays produce an empty dict.""" + node_layer_d = {} + node_ids_shared = [np.array([], dtype=basetypes.NODE_ID)] + node_layers_shared = [np.array([], dtype=np.uint8)] + + _find_min_layer(node_layer_d, node_ids_shared, node_layers_shared) + + assert len(node_layer_d) == 0 + + +class TestGetChildrenChunkCrossEdges: + """Integration tests for get_children_chunk_cross_edges using gen_graph.""" + + def test_no_cross_edges(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + + result = get_children_chunk_cross_edges(graph, 3, [0, 0, 0], use_threads=False) + # Should return empty or no cross edges + assert len(result) == 0 or result.size == 0 + + def test_with_cross_edges(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + + result = get_children_chunk_cross_edges(graph, 3, [0, 0, 0], use_threads=False) + assert len(result) > 0 + + @pytest.mark.timeout(30) + def test_no_atomic_chunks_returns_empty(self, gen_graph): + """When the chunk coordinate is out of bounds, get_touching_atomic_chunks + returns empty and the function returns early with an empty list.""" + cg = gen_graph(n_layers=3, atomic_chunk_bounds=np.array([1, 1, 1])) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + + # chunk_coord [1,0,0] is out of bounds for atomic_chunk_bounds=[1,1,1] + # so get_touching_atomic_chunks returns empty, triggering early return + result = get_children_chunk_cross_edges( + cg, layer=3, chunk_coord=[1, 0, 0], use_threads=False + ) + assert len(result) == 0 + + @pytest.mark.timeout(30) + def test_basic_cross_edges(self, gen_graph): + """A 4-layer graph with cross-chunk connected SVs returns cross edges + when called with use_threads=False.""" + cg = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + # Chunk A (0,0,0): sv 0 connected cross-chunk to chunk B + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + + # Chunk B (1,0,0): sv 0 connected cross-chunk to chunk A + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + + # Build parent layer so L3 nodes exist + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + + # Layer 3, chunk [0,0,0] should have cross edges connecting children chunks + result = get_children_chunk_cross_edges( + cg, layer=3, chunk_coord=[0, 0, 0], use_threads=False + ) + result = np.array(result) + assert result.size > 0 + assert result.ndim == 2 + assert result.shape[1] == 2 + + +class TestGetChildrenChunkCrossEdgesAdditional: + """Additional tests for get_children_chunk_cross_edges (serial path).""" + + @pytest.mark.timeout(30) + def test_multiple_cross_edges(self, gen_graph): + """Multiple SVs with cross-chunk edges should all be found.""" + cg = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + # Chunk A: two SVs, each cross-chunk connected + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + (to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 1, 0, 0, 1), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), + (to_label(cg, 1, 1, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 1), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + + result = get_children_chunk_cross_edges( + cg, layer=3, chunk_coord=[0, 0, 0], use_threads=False + ) + result = np.array(result) + assert result.size > 0 + assert result.ndim == 2 + + @pytest.mark.timeout(30) + def test_cross_edges_layer4(self, gen_graph): + """Cross edges that span L3 chunk boundaries should appear at layer 4. + The SVs must be on the touching face between L3 children: + L4 [0,0,0] has L3 children [0,0,0] (x=0,1) and [1,0,0] (x=2,3). + Touching face is at L2 x=1 and x=2.""" + cg = gen_graph(n_layers=5) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + # SV at L1 [1,0,0] - on the right boundary of L3 [0,0,0] + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + # SV at L1 [2,0,0] - on the left boundary of L3 [1,0,0] + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], n_threads=1) + + # At layer 4, chunk [0,0,0] should find cross edges at the L3 boundary + result = get_children_chunk_cross_edges( + cg, layer=4, chunk_coord=[0, 0, 0], use_threads=False + ) + result = np.array(result) + assert result.size > 0 + assert result.ndim == 2 + + +class TestGetChunkNodesCrossEdgeLayer: + """Tests for get_chunk_nodes_cross_edge_layer (lines 112-147).""" + + @pytest.mark.timeout(60) + def test_no_threads_with_cross_edges(self, gen_graph): + """use_threads=False should return dict mapping node_id to layer. + Cross edge between [0,0,0] and [2,0,0] has layer 3. + get_bounding_atomic_chunks(meta, 3, [0,0,0]) returns L2 boundary + chunks of L3 [0,0,0], which includes L2 at x=0 with AtomicCrossChunkEdge[3]. + """ + cg = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + # SV at L1 [0,0,0] with cross edge to [2,0,0] (layer-3 cross edge) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 2, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + # SV at L1 [2,0,0] + create_chunk( + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0)], + edges=[ + (to_label(cg, 1, 2, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], n_threads=1) + + result = get_chunk_nodes_cross_edge_layer( + cg, layer=3, chunk_coord=[0, 0, 0], use_threads=False + ) + assert isinstance(result, dict) + assert len(result) > 0 + for node_id, layer in result.items(): + assert layer >= 3 + + @pytest.mark.timeout(60) + def test_no_threads_empty_chunk(self, gen_graph): + """use_threads=False with out-of-bounds chunk should return empty dict.""" + cg = gen_graph(n_layers=3, atomic_chunk_bounds=np.array([1, 1, 1])) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + + # Out of bounds chunk coord + result = get_chunk_nodes_cross_edge_layer( + cg, layer=3, chunk_coord=[1, 0, 0], use_threads=False + ) + assert isinstance(result, dict) + assert len(result) == 0 + + @pytest.mark.timeout(60) + def test_no_cross_edges_returns_empty(self, gen_graph): + """When chunks have no cross edges at the relevant layers, result is empty.""" + cg = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + + result = get_chunk_nodes_cross_edge_layer( + cg, layer=3, chunk_coord=[0, 0, 0], use_threads=False + ) + assert isinstance(result, dict) + assert len(result) == 0 + + +class TestFindMinLayerExtended: + """Additional tests for _find_min_layer with edge cases.""" + + def test_single_node_multiple_batches(self): + """Same node_id across multiple batches; lowest layer wins.""" + node_layer_d = {} + node_ids_shared = [ + np.array([100], dtype=basetypes.NODE_ID), + np.array([100], dtype=basetypes.NODE_ID), + np.array([100], dtype=basetypes.NODE_ID), + ] + node_layers_shared = [ + np.array([8], dtype=np.uint8), + np.array([3], dtype=np.uint8), + np.array([5], dtype=np.uint8), + ] + + _find_min_layer(node_layer_d, node_ids_shared, node_layers_shared) + assert node_layer_d[100] == 3 + + def test_no_overlap(self): + """All unique node_ids across batches should just pass through.""" + node_layer_d = {} + node_ids_shared = [ + np.array([1, 2], dtype=basetypes.NODE_ID), + np.array([3, 4], dtype=basetypes.NODE_ID), + ] + node_layers_shared = [ + np.array([5, 6], dtype=np.uint8), + np.array([7, 8], dtype=np.uint8), + ] + + _find_min_layer(node_layer_d, node_ids_shared, node_layers_shared) + assert node_layer_d[1] == 5 + assert node_layer_d[2] == 6 + assert node_layer_d[3] == 7 + assert node_layer_d[4] == 8 diff --git a/pychunkedgraph/tests/test_ingest_manager.py b/pychunkedgraph/tests/test_ingest_manager.py new file mode 100644 index 000000000..1c2032081 --- /dev/null +++ b/pychunkedgraph/tests/test_ingest_manager.py @@ -0,0 +1,131 @@ +"""Tests for pychunkedgraph.ingest.manager""" + +import pickle +import pytest +from unittest.mock import MagicMock, patch + +from pychunkedgraph.ingest import IngestConfig +from pychunkedgraph.graph.meta import ChunkedGraphMeta, GraphConfig, DataSource + + +def _make_config_and_meta(): + config = IngestConfig() + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], ID="test") + ds = DataSource( + EDGES="gs://test/edges", + COMPONENTS="gs://test/comp", + WATERSHED="gs://test/ws", + DATA_VERSION=2, + ) + meta = ChunkedGraphMeta(gc, ds) + meta._layer_count = 4 + meta._bitmasks = {1: 10, 2: 10, 3: 1, 4: 1} + return config, meta + + +def _make_manager(): + """Create an IngestionManager with mocked redis connection.""" + config, meta = _make_config_and_meta() + with patch("pychunkedgraph.ingest.manager.get_redis_connection") as mock_redis_conn: + mock_redis = MagicMock() + mock_redis_conn.return_value = mock_redis + from pychunkedgraph.ingest.manager import IngestionManager + + manager = IngestionManager(config=config, chunkedgraph_meta=meta) + return manager, config, meta, mock_redis + + +class TestIngestionManagerSerialization: + def test_serialized_dict(self): + config, meta = _make_config_and_meta() + # Test the serialized dict path without needing Redis + params = {"config": config, "chunkedgraph_meta": meta} + assert "config" in params + assert "chunkedgraph_meta" in params + assert params["config"] == config + + def test_serialized_pickle_roundtrip(self): + config, meta = _make_config_and_meta() + params = {"config": config, "chunkedgraph_meta": meta} + serialized = pickle.dumps(params) + restored = pickle.loads(serialized) + assert restored["config"] == config + assert restored["chunkedgraph_meta"].graph_config.ID == "test" + + +class TestSerializedDict: + def test_serialized_returns_dict_with_correct_keys(self): + """serialized() returns a dict with config and chunkedgraph_meta keys.""" + manager, config, meta, _ = _make_manager() + result = manager.serialized() + assert isinstance(result, dict) + assert "config" in result + assert "chunkedgraph_meta" in result + assert result["config"] is config + assert result["chunkedgraph_meta"] is meta + + +class TestSerializedPickleRoundtrip: + def test_serialized_pickled_roundtrips(self): + """serialized(pickled=True) produces bytes that pickle-load back correctly.""" + manager, config, meta, _ = _make_manager() + pickled = manager.serialized(pickled=True) + assert isinstance(pickled, bytes) + loaded = pickle.loads(pickled) + assert isinstance(loaded, dict) + assert loaded["config"] == config + assert isinstance(loaded["chunkedgraph_meta"], ChunkedGraphMeta) + assert loaded["chunkedgraph_meta"].graph_config == meta.graph_config + assert loaded["chunkedgraph_meta"].data_source == meta.data_source + + +class TestConfigProperty: + def test_config_property_returns_injected_config(self): + """config property returns the IngestConfig passed to __init__.""" + manager, config, _, _ = _make_manager() + assert manager.config is config + + +class TestCgMetaProperty: + def test_cg_meta_property_returns_injected_meta(self): + """cg_meta property returns the ChunkedGraphMeta passed to __init__.""" + manager, _, meta, _ = _make_manager() + assert manager.cg_meta is meta + + +class TestGetTaskQueueCaching: + def test_get_task_queue_returns_cached_on_second_call(self): + """Calling get_task_queue twice with the same name returns the same cached object.""" + manager, _, _, _ = _make_manager() + with patch("pychunkedgraph.ingest.manager.get_rq_queue") as mock_get_rq: + mock_queue = MagicMock() + mock_get_rq.return_value = mock_queue + + q1 = manager.get_task_queue("test_queue") + q2 = manager.get_task_queue("test_queue") + + assert q1 is q2 + mock_get_rq.assert_called_once_with("test_queue") + + +class TestRedisPropertyCaching: + def test_redis_returns_cached_connection(self): + """redis property returns cached value on second access; get_redis_connection not called again.""" + config, meta = _make_config_and_meta() + with patch( + "pychunkedgraph.ingest.manager.get_redis_connection" + ) as mock_redis_conn: + mock_redis = MagicMock() + mock_redis_conn.return_value = mock_redis + from pychunkedgraph.ingest.manager import IngestionManager + + manager = IngestionManager(config=config, chunkedgraph_meta=meta) + call_count_after_init = mock_redis_conn.call_count + + r1 = manager.redis + r2 = manager.redis + + # No additional calls to get_redis_connection after init + assert mock_redis_conn.call_count == call_count_after_init + assert r1 is r2 + assert r1 is mock_redis diff --git a/pychunkedgraph/tests/test_ingest_parent_layer.py b/pychunkedgraph/tests/test_ingest_parent_layer.py new file mode 100644 index 000000000..2e46a5e67 --- /dev/null +++ b/pychunkedgraph/tests/test_ingest_parent_layer.py @@ -0,0 +1,63 @@ +"""Tests for pychunkedgraph.ingest.create.parent_layer""" + +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestAddParentChunk: + def test_single_thread(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + + # Should not raise + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + + # Verify parent was created + sv = to_label(graph, 1, 0, 0, 0, 0) + parent = graph.get_parent(sv) + assert parent is not None + assert graph.get_chunk_layer(parent) == 2 + + def test_multi_chunk(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + # Both SVs should share a root + root0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + root1 = graph.get_root(to_label(graph, 1, 1, 0, 0, 0)) + assert root0 == root1 diff --git a/pychunkedgraph/tests/test_ingest_ran_agglomeration.py b/pychunkedgraph/tests/test_ingest_ran_agglomeration.py new file mode 100644 index 000000000..9d02fd306 --- /dev/null +++ b/pychunkedgraph/tests/test_ingest_ran_agglomeration.py @@ -0,0 +1,1100 @@ +"""Tests for pychunkedgraph.ingest.ran_agglomeration - selected unit tests""" + +from binascii import crc32 + +import numpy as np +import pytest + +from pychunkedgraph.ingest.ran_agglomeration import ( + _crc_check, + _get_cont_chunk_coords, + define_active_edges, + get_active_edges, +) +from pychunkedgraph.graph.edges import Edges, EDGE_TYPES +from pychunkedgraph.graph.utils import basetypes + + +class TestCrcCheck: + def test_valid(self): + payload = b"test data here" + crc = np.array([crc32(payload)], dtype=np.uint32).tobytes() + full = payload + crc + _crc_check(full) # should not raise + + def test_invalid(self): + payload = b"test data here" + bad_crc = np.array([12345], dtype=np.uint32).tobytes() + full = payload + bad_crc + with pytest.raises(AssertionError): + _crc_check(full) + + +class TestDefineActiveEdges: + def test_basic(self): + edges = { + EDGE_TYPES.in_chunk: Edges( + np.array([1, 2], dtype=basetypes.NODE_ID), + np.array([3, 4], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + # Both sv1 and sv2 map to same agg ID -> active + mapping = {1: 0, 2: 0, 3: 0, 4: 0} + active, isolated = define_active_edges(edges, mapping) + assert np.all(active[EDGE_TYPES.in_chunk]) + + def test_unmapped_edges(self): + edges = { + EDGE_TYPES.in_chunk: Edges( + np.array([1], dtype=basetypes.NODE_ID), + np.array([2], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + # sv1 not in mapping -> isolated + mapping = {2: 0} + active, isolated = define_active_edges(edges, mapping) + assert not active[EDGE_TYPES.in_chunk][0] + assert 1 in isolated + + +class TestGetActiveEdges: + def test_basic(self): + edges = { + EDGE_TYPES.in_chunk: Edges( + np.array([1, 2], dtype=basetypes.NODE_ID), + np.array([3, 4], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + mapping = {1: 0, 2: 0, 3: 0, 4: 0} + chunk_edges, pseudo_isolated = get_active_edges(edges, mapping) + for et in EDGE_TYPES: + assert et in chunk_edges + assert len(pseudo_isolated) > 0 + + +class TestGetContChunkCoords: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + + class FakeIM: + cg_meta = graph.meta + + coord_a = np.array([1, 0, 0]) + coord_b = np.array([0, 0, 0]) + result = _get_cont_chunk_coords(FakeIM(), coord_a, coord_b) + assert isinstance(result, list) + + def test_returns_only_valid_coords(self, gen_graph): + """All returned coords should not be out of bounds.""" + graph = gen_graph(n_layers=4) + + class FakeIM: + cg_meta = graph.meta + + coord_a = np.array([1, 0, 0]) + coord_b = np.array([0, 0, 0]) + result = _get_cont_chunk_coords(FakeIM(), coord_a, coord_b) + for coord in result: + assert not graph.meta.is_out_of_bounds(coord) + + def test_symmetric_direction(self, gen_graph): + """Swapping coord_a and coord_b should yield the same set of neighboring coords.""" + graph = gen_graph(n_layers=4) + + class FakeIM: + cg_meta = graph.meta + + coord_a = np.array([1, 0, 0]) + coord_b = np.array([0, 0, 0]) + result_ab = _get_cont_chunk_coords(FakeIM(), coord_a, coord_b) + result_ba = _get_cont_chunk_coords(FakeIM(), coord_b, coord_a) + + # Convert to sets of tuples for comparison + set_ab = {tuple(c) for c in result_ab} + set_ba = {tuple(c) for c in result_ba} + assert set_ab == set_ba + + def test_non_adjacent_raises(self, gen_graph): + """Non-adjacent chunks (differing in more than one dim) should raise AssertionError.""" + graph = gen_graph(n_layers=4) + + class FakeIM: + cg_meta = graph.meta + + coord_a = np.array([1, 1, 0]) + coord_b = np.array([0, 0, 0]) + with pytest.raises(AssertionError): + _get_cont_chunk_coords(FakeIM(), coord_a, coord_b) + + def test_y_dim_adjacency(self, gen_graph): + """Test adjacency along y dimension.""" + graph = gen_graph(n_layers=4) + + class FakeIM: + cg_meta = graph.meta + + coord_a = np.array([0, 1, 0]) + coord_b = np.array([0, 0, 0]) + result = _get_cont_chunk_coords(FakeIM(), coord_a, coord_b) + assert isinstance(result, list) + # All returned coords should differ from chunk_coord_l along y + for coord in result: + assert not graph.meta.is_out_of_bounds(coord) + + +class TestParseEdgePayloads: + def test_empty_payloads(self): + """Empty list of payloads should return empty result.""" + from pychunkedgraph.ingest.ran_agglomeration import _parse_edge_payloads + + result = _parse_edge_payloads( + [], edge_dtype=[("sv1", np.uint64), ("sv2", np.uint64)] + ) + assert result == [] + + def test_none_content_skipped(self): + """Payloads with None content should be skipped.""" + from pychunkedgraph.ingest.ran_agglomeration import _parse_edge_payloads + + payloads = [{"content": None}] + result = _parse_edge_payloads( + payloads, edge_dtype=[("sv1", np.uint64), ("sv2", np.uint64)] + ) + assert result == [] + + def test_valid_payload(self): + """A valid payload with correct CRC should be parsed.""" + from pychunkedgraph.ingest.ran_agglomeration import _parse_edge_payloads + + dtype = [("sv1", np.uint64), ("sv2", np.uint64)] + data = np.array([(1, 2), (3, 4)], dtype=dtype) + raw = data.tobytes() + crc_val = np.array([crc32(raw)], dtype=np.uint32).tobytes() + content = raw + crc_val + + payloads = [{"content": content}] + result = _parse_edge_payloads(payloads, edge_dtype=dtype) + assert len(result) == 1 + assert len(result[0]) == 2 + assert result[0][0]["sv1"] == 1 + assert result[0][1]["sv2"] == 4 + + def test_bad_crc_raises(self): + """Payload with bad CRC should raise AssertionError.""" + from pychunkedgraph.ingest.ran_agglomeration import _parse_edge_payloads + + dtype = [("sv1", np.uint64), ("sv2", np.uint64)] + data = np.array([(1, 2)], dtype=dtype) + raw = data.tobytes() + bad_crc = np.array([99999], dtype=np.uint32).tobytes() + content = raw + bad_crc + + payloads = [{"content": content}] + with pytest.raises(AssertionError): + _parse_edge_payloads(payloads, edge_dtype=dtype) + + +class TestDefineActiveEdgesExtended: + def test_both_unmapped(self): + """When both endpoints are unmapped, edge should be inactive and both isolated.""" + edges = { + EDGE_TYPES.in_chunk: Edges( + np.array([10, 20], dtype=basetypes.NODE_ID), + np.array([30, 40], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + mapping = {} # No IDs in mapping + active, isolated = define_active_edges(edges, mapping) + # All edges should be inactive + assert not np.any(active[EDGE_TYPES.in_chunk]) + # All unmapped IDs should appear in isolated + for sv_id in [10, 20, 30, 40]: + assert sv_id in isolated + + def test_different_agg_ids(self): + """Edges where sv1 and sv2 map to different agg IDs should be inactive.""" + edges = { + EDGE_TYPES.in_chunk: Edges( + np.array([1], dtype=basetypes.NODE_ID), + np.array([2], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + mapping = {1: 100, 2: 200} # Different agg IDs + active, isolated = define_active_edges(edges, mapping) + assert not active[EDGE_TYPES.in_chunk][0] + + def test_empty_edges(self): + """Empty edge arrays should produce empty active arrays.""" + edges = { + EDGE_TYPES.in_chunk: Edges( + np.array([], dtype=basetypes.NODE_ID), + np.array([], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + mapping = {} + active, isolated = define_active_edges(edges, mapping) + assert len(active[EDGE_TYPES.in_chunk]) == 0 + + def test_between_chunk_edges_active(self): + """Between-chunk edges should also be classified.""" + edges = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges( + np.array([1, 2], dtype=basetypes.NODE_ID), + np.array([3, 4], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.cross_chunk: Edges([], []), + } + # 1->3 same agg, 2->4 different agg + mapping = {1: 0, 3: 0, 2: 1, 4: 2} + active, isolated = define_active_edges(edges, mapping) + assert active[EDGE_TYPES.between_chunk][0] # same agg + assert not active[EDGE_TYPES.between_chunk][1] # different agg + + +class TestGetActiveEdgesExtended: + def test_cross_chunk_always_active(self): + """Cross-chunk edges should always be kept active regardless of mapping.""" + edges = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges( + np.array([1, 2], dtype=basetypes.NODE_ID), + np.array([3, 4], dtype=basetypes.NODE_ID), + affinities=np.array([float("inf"), float("inf")]), + areas=np.array([1.0, 1.0]), + ), + } + mapping = {} # Empty mapping - but cross_chunk should still be active + chunk_edges, pseudo_isolated = get_active_edges(edges, mapping) + assert len(chunk_edges[EDGE_TYPES.cross_chunk].node_ids1) == 2 + + def test_pseudo_isolated_includes_all_node_ids(self): + """pseudo_isolated should include all node_ids from all edge types.""" + edges = { + EDGE_TYPES.in_chunk: Edges( + np.array([1], dtype=basetypes.NODE_ID), + np.array([2], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.between_chunk: Edges( + np.array([3], dtype=basetypes.NODE_ID), + np.array([4], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.cross_chunk: Edges( + np.array([5], dtype=basetypes.NODE_ID), + np.array([6], dtype=basetypes.NODE_ID), + affinities=np.array([float("inf")]), + areas=np.array([1.0]), + ), + } + mapping = {1: 0, 2: 0, 3: 0, 4: 0} + chunk_edges, pseudo_isolated = get_active_edges(edges, mapping) + # Should include node_ids1 from all types and node_ids2 from in_chunk + for sv_id in [1, 2, 3, 5]: + assert sv_id in pseudo_isolated + + +class TestGetIndex: + """Tests for _get_index which reads sharded index data from CloudFiles.""" + + def test_inchunk_index(self): + """Test _get_index with inchunk_or_agg=True uses single-u8 chunkid dtype.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import ( + CRC_LEN, + HEADER_LEN, + VERSION_LEN, + _get_index, + ) + + # Create fake index data with inchunk dtype + dt = np.dtype([("chunkid", "u8"), ("offset", "u8"), ("size", "u8")]) + index_entries = np.array([(100, 20, 50)], dtype=dt) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + # Build a fake header: version (4 bytes) + idx_offset (8 bytes) + idx_length (8 bytes) = 20 bytes + idx_offset = np.uint64(HEADER_LEN) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + cf = MagicMock() + # First call returns headers, second call returns index data + cf.get.side_effect = [ + [{"path": "test.data", "content": header_content}], + [{"path": "test.data", "content": index_with_crc}], + ] + + result = _get_index(cf, ["test.data"], inchunk_or_agg=True) + assert "test.data" in result + assert result["test.data"][0]["chunkid"] == 100 + assert result["test.data"][0]["offset"] == 20 + assert result["test.data"][0]["size"] == 50 + + def test_between_chunk_index(self): + """Test _get_index with inchunk_or_agg=False uses 2-u8 chunkid dtype.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import ( + CRC_LEN, + HEADER_LEN, + VERSION_LEN, + _get_index, + ) + + # Between-chunk index uses ("chunkid", "2u8") -> two uint64 values + dt = np.dtype([("chunkid", "2u8"), ("offset", "u8"), ("size", "u8")]) + index_entries = np.array([((200, 300), 40, 60)], dtype=dt) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(HEADER_LEN) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + cf = MagicMock() + cf.get.side_effect = [ + [{"path": "between.data", "content": header_content}], + [{"path": "between.data", "content": index_with_crc}], + ] + + result = _get_index(cf, ["between.data"], inchunk_or_agg=False) + assert "between.data" in result + assert result["between.data"][0]["chunkid"][0] == 200 + assert result["between.data"][0]["chunkid"][1] == 300 + assert result["between.data"][0]["offset"] == 40 + assert result["between.data"][0]["size"] == 60 + + def test_none_content_skipped(self): + """When header content is None, that file should be skipped in the index.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import _get_index + + cf = MagicMock() + # Header returns None content for the file + cf.get.side_effect = [ + [{"path": "missing.data", "content": None}], + [], # No index_infos to fetch + ] + + result = _get_index(cf, ["missing.data"], inchunk_or_agg=True) + assert result == {} + + def test_multiple_files(self): + """Test _get_index with multiple filenames, one valid and one missing.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _get_index, + ) + + dt = np.dtype([("chunkid", "u8"), ("offset", "u8"), ("size", "u8")]) + index_entries = np.array([(500, 100, 200)], dtype=dt) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(HEADER_LEN) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + cf = MagicMock() + cf.get.side_effect = [ + [ + {"path": "valid.data", "content": header_content}, + {"path": "invalid.data", "content": None}, + ], + [{"path": "valid.data", "content": index_with_crc}], + ] + + result = _get_index(cf, ["valid.data", "invalid.data"], inchunk_or_agg=True) + assert "valid.data" in result + assert "invalid.data" not in result + + def test_multiple_index_entries(self): + """Test _get_index with multiple entries in a single file index.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _get_index, + ) + + dt = np.dtype([("chunkid", "u8"), ("offset", "u8"), ("size", "u8")]) + index_entries = np.array( + [(100, 20, 50), (200, 70, 80), (300, 150, 30)], dtype=dt + ) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(HEADER_LEN) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + cf = MagicMock() + cf.get.side_effect = [ + [{"path": "multi.data", "content": header_content}], + [{"path": "multi.data", "content": index_with_crc}], + ] + + result = _get_index(cf, ["multi.data"], inchunk_or_agg=True) + assert "multi.data" in result + assert len(result["multi.data"]) == 3 + assert result["multi.data"][0]["chunkid"] == 100 + assert result["multi.data"][1]["chunkid"] == 200 + assert result["multi.data"][2]["chunkid"] == 300 + + +class TestReadInChunkFiles: + """Tests for _read_in_chunk_files which reads edge data for a specific chunk.""" + + def test_basic_read(self): + """Mock CloudFiles to test full read flow for in-chunk files.""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _read_in_chunk_files, + ) + + chunk_id = np.uint64(100) + edge_dtype = [("sv1", np.uint64), ("sv2", np.uint64)] + + # Build index: one entry for our chunk_id + dt = np.dtype([("chunkid", "u8"), ("offset", "u8"), ("size", "u8")]) + edge_data = np.array([(10, 20)], dtype=edge_dtype) + edge_bytes = edge_data.tobytes() + edge_crc = np.array([crc32(edge_bytes)], dtype=np.uint32).tobytes() + edge_payload = edge_bytes + edge_crc + + data_offset = np.uint64(HEADER_LEN) + data_size = np.uint64(len(edge_payload)) + + index_entries = np.array([(chunk_id, data_offset, data_size)], dtype=dt) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(data_offset + data_size) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + # 1st call: headers + [{"path": "in_chunk_0_0_0_0.data", "content": header_content}], + # 2nd call: index data + [{"path": "in_chunk_0_0_0_0.data", "content": index_with_crc}], + # 3rd call: edge payloads + [{"path": "in_chunk_0_0_0_0.data", "content": edge_payload}], + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_in_chunk_files( + chunk_id, "gs://fake/path", ["in_chunk_0_0_0_0.data"], edge_dtype + ) + + assert len(result) == 1 + assert result[0][0]["sv1"] == 10 + assert result[0][0]["sv2"] == 20 + + def test_no_matching_chunk(self): + """When the index has no entry matching the requested chunk_id, no payloads are fetched.""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _read_in_chunk_files, + ) + + chunk_id = np.uint64(999) + edge_dtype = [("sv1", np.uint64), ("sv2", np.uint64)] + + # Index entry for a *different* chunk_id + dt = np.dtype([("chunkid", "u8"), ("offset", "u8"), ("size", "u8")]) + index_entries = np.array([(100, 20, 50)], dtype=dt) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(HEADER_LEN) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + [{"path": "in_chunk_0_0_0_0.data", "content": header_content}], + [{"path": "in_chunk_0_0_0_0.data", "content": index_with_crc}], + [], # No payloads fetched + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_in_chunk_files( + chunk_id, "gs://fake/path", ["in_chunk_0_0_0_0.data"], edge_dtype + ) + + assert result == [] + + +class TestReadBetweenOrFakeChunkFiles: + """Tests for _read_between_or_fake_chunk_files which reads between-chunk edge data.""" + + def _make_between_index_and_header(self, entries_list): + """Helper to create between-chunk index data and header. + + entries_list: list of (chunkid0, chunkid1, offset, size) tuples + """ + from pychunkedgraph.ingest.ran_agglomeration import HEADER_LEN + + dt = np.dtype([("chunkid", "2u8"), ("offset", "u8"), ("size", "u8")]) + index_entries = np.array( + [((c0, c1), off, sz) for c0, c1, off, sz in entries_list], dtype=dt + ) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(HEADER_LEN) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + return header_content, index_with_crc + + def test_basic_between_chunk_read(self): + """Test reading between-chunk files with matching chunk pair.""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _read_between_or_fake_chunk_files, + ) + + chunk_id = np.uint64(100) + adjacent_id = np.uint64(200) + edge_dtype = [("sv1", np.uint64), ("sv2", np.uint64)] + + # Create edge payload + edge_data = np.array([(10, 20)], dtype=edge_dtype) + edge_bytes = edge_data.tobytes() + edge_crc = np.array([crc32(edge_bytes)], dtype=np.uint32).tobytes() + edge_payload = edge_bytes + edge_crc + + data_offset = np.uint64(HEADER_LEN) + data_size = np.uint64(len(edge_payload)) + + header_content, index_with_crc = self._make_between_index_and_header( + [(100, 200, int(data_offset), int(data_size))] + ) + + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + # headers + [{"path": "between.data", "content": header_content}], + # index data + [{"path": "between.data", "content": index_with_crc}], + # chunk_finfos payloads (forward direction) + [{"path": "between.data", "content": edge_payload}], + # adj_chunk_finfos payloads (reverse direction) - empty + [], + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_between_or_fake_chunk_files( + chunk_id, adjacent_id, "gs://fake/path", ["between.data"], edge_dtype + ) + + assert len(result) == 1 + assert result[0][0]["sv1"] == 10 + assert result[0][0]["sv2"] == 20 + + def test_reverse_direction(self): + """Test reading from the adjacent->chunk direction (swapped columns in result dtype).""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _read_between_or_fake_chunk_files, + ) + + chunk_id = np.uint64(100) + adjacent_id = np.uint64(200) + edge_dtype = [("sv1", np.uint64), ("sv2", np.uint64)] + + # Edge payload for the *reverse* direction (adjacent_id, chunk_id) + # When reading reverse direction, the dtype columns are swapped: (sv2, sv1) + rev_edge_dtype = [("sv2", np.uint64), ("sv1", np.uint64)] + edge_data = np.array([(30, 40)], dtype=rev_edge_dtype) + edge_bytes = edge_data.tobytes() + edge_crc = np.array([crc32(edge_bytes)], dtype=np.uint32).tobytes() + edge_payload = edge_bytes + edge_crc + + data_offset = np.uint64(HEADER_LEN) + data_size = np.uint64(len(edge_payload)) + + # Index entry: (adjacent_id, chunk_id) => reverse direction + header_content, index_with_crc = self._make_between_index_and_header( + [(200, 100, int(data_offset), int(data_size))] + ) + + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + # headers + [{"path": "between.data", "content": header_content}], + # index + [{"path": "between.data", "content": index_with_crc}], + # chunk_finfos (forward) - empty + [], + # adj_chunk_finfos (reverse) + [{"path": "between.data", "content": edge_payload}], + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_between_or_fake_chunk_files( + chunk_id, adjacent_id, "gs://fake/path", ["between.data"], edge_dtype + ) + + # Result comes from adj_result which used the swapped dtype + assert len(result) == 1 + assert result[0][0]["sv2"] == 30 + assert result[0][0]["sv1"] == 40 + + def test_no_matching_pairs(self): + """When no chunk pair matches, should return empty list.""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _read_between_or_fake_chunk_files, + ) + + chunk_id = np.uint64(100) + adjacent_id = np.uint64(200) + edge_dtype = [("sv1", np.uint64), ("sv2", np.uint64)] + + # Index entry for a totally different pair + header_content, index_with_crc = self._make_between_index_and_header( + [(999, 888, 20, 50)] + ) + + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + [{"path": "between.data", "content": header_content}], + [{"path": "between.data", "content": index_with_crc}], + [], # No forward payloads + [], # No reverse payloads + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_between_or_fake_chunk_files( + chunk_id, adjacent_id, "gs://fake/path", ["between.data"], edge_dtype + ) + + assert result == [] + + +class TestReadAggFiles: + """Tests for _read_agg_files which reads agglomeration remap data.""" + + def test_basic_agg_read(self): + """Test reading agglomeration files returns edge list.""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + CRC_LEN, + HEADER_LEN, + _read_agg_files, + ) + + chunk_id = np.uint64(42) + + # Index entry for our chunk + dt = np.dtype([("chunkid", "u8"), ("offset", "u8"), ("size", "u8")]) + + # Build edge data: pairs of node IDs + edges = np.array([[10, 20], [30, 40]], dtype=basetypes.NODE_ID) + edge_bytes = edges.tobytes() + edge_crc = np.array([crc32(edge_bytes)], dtype=np.uint32).tobytes() + edge_payload = edge_bytes + edge_crc + + data_offset = np.uint64(HEADER_LEN) + data_size = np.uint64(len(edge_payload)) + + index_entries = np.array([(chunk_id, data_offset, data_size)], dtype=dt) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(data_offset + data_size) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + # headers + [{"path": "done_0_0_0_0.data", "content": header_content}], + # index + [{"path": "done_0_0_0_0.data", "content": index_with_crc}], + # payloads + [{"path": "done_0_0_0_0.data", "content": edge_payload}], + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_agg_files( + ["done_0_0_0_0.data"], [chunk_id], "gs://fake/remap/" + ) + + # Result is a list starting with empty_2d, plus our edge data + assert len(result) >= 2 # empty_2d + our edges + # The last element should be our 2x2 edge array + combined = np.concatenate(result) + assert combined.shape[1] == 2 + assert len(combined) == 2 + + def test_missing_file_skipped(self): + """When a filename is not in files_index (KeyError), it should be skipped.""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _read_agg_files, + ) + + # No valid headers -> empty index + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + # headers: all None + [{"path": "done_0_0_0_0.data", "content": None}], + [], # empty index_infos + [], # empty payloads + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_agg_files( + ["done_0_0_0_0.data"], [np.uint64(42)], "gs://fake/remap/" + ) + + # Should only contain the initial empty_2d + assert len(result) == 1 + assert result[0].shape == (0, 2) + + def test_none_payload_skipped(self): + """When a payload content is None, it should be skipped.""" + from unittest.mock import MagicMock, patch + + from pychunkedgraph.ingest.ran_agglomeration import ( + HEADER_LEN, + _read_agg_files, + ) + + chunk_id = np.uint64(42) + dt = np.dtype([("chunkid", "u8"), ("offset", "u8"), ("size", "u8")]) + index_entries = np.array([(chunk_id, 20, 50)], dtype=dt) + index_bytes = index_entries.tobytes() + index_crc = np.array([crc32(index_bytes)], dtype=np.uint32).tobytes() + index_with_crc = index_bytes + index_crc + + idx_offset = np.uint64(HEADER_LEN) + idx_length = np.uint64(len(index_with_crc)) + version = b"SO01" + header_content = ( + version + + np.array([idx_offset], dtype=np.uint64).tobytes() + + np.array([idx_length], dtype=np.uint64).tobytes() + ) + + mock_cf = MagicMock() + mock_cf.get.side_effect = [ + [{"path": "done_0_0_0_0.data", "content": header_content}], + [{"path": "done_0_0_0_0.data", "content": index_with_crc}], + [{"path": "done_0_0_0_0.data", "content": None}], # None payload + ] + + with patch( + "pychunkedgraph.ingest.ran_agglomeration.CloudFiles", + return_value=mock_cf, + ): + result = _read_agg_files( + ["done_0_0_0_0.data"], [chunk_id], "gs://fake/remap/" + ) + + # Should only contain the initial empty_2d (None content was skipped) + assert len(result) == 1 + assert result[0].shape == (0, 2) + + +class TestReadRawEdgeData: + """Tests for read_raw_edge_data which orchestrates edge collection and writing.""" + + from unittest.mock import patch, MagicMock + + @patch("pychunkedgraph.ingest.ran_agglomeration._collect_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.postprocess_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.put_chunk_edges") + def test_basic(self, mock_put, mock_postprocess, mock_collect): + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import read_raw_edge_data + + # Setup mock return values + edge_dict = {} + for et in EDGE_TYPES: + edge_dict[et] = { + "sv1": np.array([1, 2], dtype=np.uint64), + "sv2": np.array([3, 4], dtype=np.uint64), + "aff": np.array([0.5, 0.6]), + "area": np.array([10, 20]), + } + # cross_chunk doesn't have aff/area in the read path (they get inf/ones) + edge_dict[EDGE_TYPES.cross_chunk] = { + "sv1": np.array([5], dtype=np.uint64), + "sv2": np.array([6], dtype=np.uint64), + } + mock_collect.return_value = edge_dict + mock_postprocess.return_value = edge_dict + + imanager = MagicMock() + imanager.cg_meta.data_source.EDGES = "gs://fake/edges" + + result = read_raw_edge_data(imanager, [0, 0, 0]) + assert EDGE_TYPES.in_chunk in result + assert EDGE_TYPES.between_chunk in result + assert EDGE_TYPES.cross_chunk in result + # in_chunk should have 2 edges + assert len(result[EDGE_TYPES.in_chunk].node_ids1) == 2 + # cross_chunk should have 1 edge with inf affinity + assert len(result[EDGE_TYPES.cross_chunk].node_ids1) == 1 + assert np.isinf(result[EDGE_TYPES.cross_chunk].affinities[0]) + # put_chunk_edges should have been called since there are edges + mock_put.assert_called_once() + + @patch("pychunkedgraph.ingest.ran_agglomeration._collect_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.postprocess_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.put_chunk_edges") + def test_no_edges(self, mock_put, mock_postprocess, mock_collect): + """When all edge types are empty, put_chunk_edges should not be called.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import read_raw_edge_data + + edge_dict = {et: {} for et in EDGE_TYPES} + mock_collect.return_value = edge_dict + mock_postprocess.return_value = edge_dict + + imanager = MagicMock() + imanager.cg_meta.data_source.EDGES = "gs://fake/edges" + + result = read_raw_edge_data(imanager, [0, 0, 0]) + # All edge types should be empty Edges objects + for et in EDGE_TYPES: + assert len(result[et].node_ids1) == 0 + mock_put.assert_not_called() + + @patch("pychunkedgraph.ingest.ran_agglomeration._collect_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.postprocess_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.put_chunk_edges") + def test_edges_but_no_storage_path(self, mock_put, mock_postprocess, mock_collect): + """When EDGES path is empty/falsy, put_chunk_edges should not be called.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import read_raw_edge_data + + edge_dict = {} + for et in EDGE_TYPES: + edge_dict[et] = { + "sv1": np.array([1], dtype=np.uint64), + "sv2": np.array([2], dtype=np.uint64), + "aff": np.array([0.5]), + "area": np.array([10]), + } + mock_collect.return_value = edge_dict + mock_postprocess.return_value = edge_dict + + imanager = MagicMock() + imanager.cg_meta.data_source.EDGES = "" # empty string = falsy + + result = read_raw_edge_data(imanager, [0, 0, 0]) + assert EDGE_TYPES.in_chunk in result + mock_put.assert_not_called() + + @patch("pychunkedgraph.ingest.ran_agglomeration._collect_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.postprocess_edge_data") + @patch("pychunkedgraph.ingest.ran_agglomeration.put_chunk_edges") + def test_partial_edges(self, mock_put, mock_postprocess, mock_collect): + """Only in_chunk has edges, others empty.""" + from unittest.mock import MagicMock + + from pychunkedgraph.ingest.ran_agglomeration import read_raw_edge_data + + edge_dict = { + EDGE_TYPES.in_chunk: { + "sv1": np.array([1, 2], dtype=np.uint64), + "sv2": np.array([3, 4], dtype=np.uint64), + "aff": np.array([0.5, 0.6]), + "area": np.array([10, 20]), + }, + EDGE_TYPES.between_chunk: {}, + EDGE_TYPES.cross_chunk: {}, + } + mock_collect.return_value = edge_dict + mock_postprocess.return_value = edge_dict + + imanager = MagicMock() + imanager.cg_meta.data_source.EDGES = "gs://fake/edges" + + result = read_raw_edge_data(imanager, [0, 0, 0]) + assert len(result[EDGE_TYPES.in_chunk].node_ids1) == 2 + assert len(result[EDGE_TYPES.between_chunk].node_ids1) == 0 + assert len(result[EDGE_TYPES.cross_chunk].node_ids1) == 0 + # Should still write because in_chunk has edges + mock_put.assert_called_once() + + +class TestReadRawAgglomerationData: + """Tests for read_raw_agglomeration_data which reads agg remap files.""" + + from unittest.mock import patch, MagicMock + + @patch("pychunkedgraph.ingest.ran_agglomeration._read_agg_files") + @patch("pychunkedgraph.ingest.ran_agglomeration.put_chunk_components") + def test_basic(self, mock_put_components, mock_read_agg, gen_graph): + from pychunkedgraph.ingest.ran_agglomeration import read_raw_agglomeration_data + from unittest.mock import MagicMock + + graph = gen_graph(n_layers=4) + imanager = MagicMock() + imanager.cg_meta = graph.meta + imanager.config.AGGLOMERATION = "gs://fake/agg" + + # Return edge pairs that form connected components + mock_read_agg.return_value = [np.array([[1, 2], [2, 3]], dtype=np.uint64)] + + mapping = read_raw_agglomeration_data(imanager, np.array([0, 0, 0])) + assert isinstance(mapping, dict) + # 1, 2, 3 should all map to the same component + assert mapping[1] == mapping[2] == mapping[3] + # put_chunk_components should have been called + mock_put_components.assert_called_once() + + @patch("pychunkedgraph.ingest.ran_agglomeration._read_agg_files") + @patch("pychunkedgraph.ingest.ran_agglomeration.put_chunk_components") + def test_multiple_components(self, mock_put_components, mock_read_agg, gen_graph): + from pychunkedgraph.ingest.ran_agglomeration import read_raw_agglomeration_data + from unittest.mock import MagicMock + + graph = gen_graph(n_layers=4) + imanager = MagicMock() + imanager.cg_meta = graph.meta + imanager.config.AGGLOMERATION = "gs://fake/agg" + + # Two separate components: {1,2} and {3,4} + mock_read_agg.return_value = [np.array([[1, 2], [3, 4]], dtype=np.uint64)] + + mapping = read_raw_agglomeration_data(imanager, np.array([0, 0, 0])) + assert isinstance(mapping, dict) + assert mapping[1] == mapping[2] + assert mapping[3] == mapping[4] + # The two components should have different IDs + assert mapping[1] != mapping[3] + + @patch("pychunkedgraph.ingest.ran_agglomeration._read_agg_files") + @patch("pychunkedgraph.ingest.ran_agglomeration.put_chunk_components") + def test_no_components_path(self, mock_put_components, mock_read_agg, gen_graph): + """When COMPONENTS is None (falsy), put_chunk_components should not be called.""" + from pychunkedgraph.ingest.ran_agglomeration import read_raw_agglomeration_data + from unittest.mock import MagicMock + + graph = gen_graph(n_layers=4) + # Replace the data_source with one that has COMPONENTS=None + original_ds = graph.meta.data_source + graph.meta._data_source = original_ds._replace(COMPONENTS=None) + + imanager = MagicMock() + imanager.cg_meta = graph.meta + imanager.config.AGGLOMERATION = "gs://fake/agg" + + mock_read_agg.return_value = [np.array([[1, 2]], dtype=np.uint64)] + + mapping = read_raw_agglomeration_data(imanager, np.array([0, 0, 0])) + assert isinstance(mapping, dict) + mock_put_components.assert_not_called() + + # Restore original data_source + graph.meta._data_source = original_ds diff --git a/pychunkedgraph/tests/test_ingest_utils.py b/pychunkedgraph/tests/test_ingest_utils.py new file mode 100644 index 000000000..4c5bdf0af --- /dev/null +++ b/pychunkedgraph/tests/test_ingest_utils.py @@ -0,0 +1,492 @@ +"""Tests for pychunkedgraph.ingest.utils""" + +import io +import sys +import numpy as np +import pytest +from unittest.mock import MagicMock, patch + +from pychunkedgraph.ingest.utils import ( + bootstrap, + chunk_id_str, + get_chunks_not_done, + job_type_guard, + move_up, + postprocess_edge_data, + randomize_grid_points, +) + + +class TestBootstrap: + def test_from_config(self): + from google.auth import credentials + + config = { + "data_source": { + "EDGES": "gs://test/edges", + "COMPONENTS": "gs://test/components", + "WATERSHED": "gs://test/ws", + }, + "graph_config": { + "CHUNK_SIZE": [64, 64, 64], + "FANOUT": 2, + "SPATIAL_BITS": 10, + }, + "backend_client": { + "TYPE": "bigtable", + "CONFIG": { + "ADMIN": True, + "READ_ONLY": False, + "PROJECT": "test-project", + "INSTANCE": "test-instance", + "CREDENTIALS": credentials.AnonymousCredentials(), + }, + }, + "ingest_config": {}, + } + meta, ingest_config, client_info = bootstrap("test_graph", config=config) + assert meta.graph_config.ID == "test_graph" + assert meta.graph_config.FANOUT == 2 + assert ingest_config.USE_RAW_EDGES is False + + +class TestPostprocessEdgeData: + def test_v2_passthrough(self): + class FakeMeta: + class data_source: + DATA_VERSION = 2 + + resolution = np.array([1, 1, 1]) + + class FakeIM: + cg_meta = FakeMeta() + + edge_dict = {"test": {"sv1": [1], "sv2": [2], "aff": [0.5], "area": [10]}} + result = postprocess_edge_data(FakeIM(), edge_dict) + assert result == edge_dict + + def test_v3(self): + class FakeMeta: + class data_source: + DATA_VERSION = 3 + + resolution = np.array([4, 4, 40]) + + class FakeIM: + cg_meta = FakeMeta() + + edge_dict = { + "test": { + "sv1": np.array([1]), + "sv2": np.array([2]), + "aff_x": np.array([0.1]), + "aff_y": np.array([0.2]), + "aff_z": np.array([0.3]), + "area_x": np.array([10]), + "area_y": np.array([20]), + "area_z": np.array([30]), + } + } + result = postprocess_edge_data(FakeIM(), edge_dict) + assert "aff" in result["test"] + assert "area" in result["test"] + # aff = 0.1*4 + 0.2*4 + 0.3*40 = 0.4 + 0.8 + 12 = 13.2 + np.testing.assert_almost_equal(result["test"]["aff"][0], 13.2) + + def test_empty_data(self): + class FakeMeta: + class data_source: + DATA_VERSION = 3 + + resolution = np.array([1, 1, 1]) + + class FakeIM: + cg_meta = FakeMeta() + + edge_dict = {"test": {}} + result = postprocess_edge_data(FakeIM(), edge_dict) + assert result["test"] == {} + + +class TestRandomizeGridPoints: + def test_basic(self): + points = list(randomize_grid_points(2, 2, 2)) + assert len(points) == 8 + # All coordinates should be valid + for x, y, z in points: + assert 0 <= x < 2 + assert 0 <= y < 2 + assert 0 <= z < 2 + + def test_covers_all(self): + points = list(randomize_grid_points(3, 2, 1)) + assert len(points) == 6 + coords = {(x, y, z) for x, y, z in points} + assert len(coords) == 6 + + +class TestPostprocessEdgeDataUnknownVersion: + def test_version5_raises(self): + """Version 5 is not supported and should raise ValueError.""" + + class FakeMeta: + class data_source: + DATA_VERSION = 5 + + resolution = np.array([1, 1, 1]) + + class FakeIM: + cg_meta = FakeMeta() + + edge_dict = {"test": {"sv1": [1], "sv2": [2]}} + with pytest.raises(ValueError, match="Unknown data_version"): + postprocess_edge_data(FakeIM(), edge_dict) + + +class TestPostprocessEdgeDataV4SameAsV3: + def test_v4_same_code_path(self): + """Version 4 should use the same processing logic as v3 (combine xyz components).""" + + class FakeMetaV3: + class data_source: + DATA_VERSION = 3 + + resolution = np.array([2, 2, 20]) + + class FakeMetaV4: + class data_source: + DATA_VERSION = 4 + + resolution = np.array([2, 2, 20]) + + class FakeIMv3: + cg_meta = FakeMetaV3() + + class FakeIMv4: + cg_meta = FakeMetaV4() + + edge_dict_v3 = { + "test": { + "sv1": np.array([10]), + "sv2": np.array([20]), + "aff_x": np.array([0.1]), + "aff_y": np.array([0.2]), + "aff_z": np.array([0.3]), + "area_x": np.array([5]), + "area_y": np.array([6]), + "area_z": np.array([7]), + } + } + edge_dict_v4 = { + "test": { + "sv1": np.array([10]), + "sv2": np.array([20]), + "aff_x": np.array([0.1]), + "aff_y": np.array([0.2]), + "aff_z": np.array([0.3]), + "area_x": np.array([5]), + "area_y": np.array([6]), + "area_z": np.array([7]), + } + } + + result_v3 = postprocess_edge_data(FakeIMv3(), edge_dict_v3) + result_v4 = postprocess_edge_data(FakeIMv4(), edge_dict_v4) + + # Both versions should produce the same combined aff and area values + np.testing.assert_array_almost_equal( + result_v3["test"]["aff"], result_v4["test"]["aff"] + ) + np.testing.assert_array_almost_equal( + result_v3["test"]["area"], result_v4["test"]["area"] + ) + np.testing.assert_array_equal( + result_v3["test"]["sv1"], result_v4["test"]["sv1"] + ) + np.testing.assert_array_equal( + result_v3["test"]["sv2"], result_v4["test"]["sv2"] + ) + + +class TestChunkIdStr: + def test_basic(self): + result = chunk_id_str(3, [1, 2, 3]) + assert result == "3_1_2_3" + + def test_layer_zero(self): + result = chunk_id_str(0, [0, 0, 0]) + assert result == "0_0_0_0" + + def test_tuple_coords(self): + result = chunk_id_str(5, (10, 20, 30)) + assert result == "5_10_20_30" + + def test_single_coord(self): + result = chunk_id_str(2, [7]) + assert result == "2_7" + + +class TestMoveUp: + def test_writes_escape_code_to_stdout(self): + """move_up() writes the ANSI escape code for cursor-up to stdout.""" + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + move_up(3) + finally: + sys.stdout = old_stdout + assert captured.getvalue() == "\033[3A" + + def test_default_one_line(self): + """move_up() with no argument moves up 1 line.""" + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + move_up() + finally: + sys.stdout = old_stdout + assert captured.getvalue() == "\033[1A" + + +class TestGetChunksNotDone: + def _make_mock_imanager(self): + imanager = MagicMock() + imanager.redis = MagicMock() + return imanager + + def test_all_completed_returns_empty(self): + """When all coords are completed in redis, returns empty list.""" + imanager = self._make_mock_imanager() + coords = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + # All marked as completed (1 = member of the set) + imanager.redis.smismember.return_value = [1, 1, 1] + result = get_chunks_not_done(imanager, layer=2, coords=coords) + assert result == [] + + def test_some_not_completed_returns_those(self): + """When some coords are not completed, returns those coords.""" + imanager = self._make_mock_imanager() + coords = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + # First is completed, second and third are not + imanager.redis.smismember.return_value = [1, 0, 0] + result = get_chunks_not_done(imanager, layer=2, coords=coords) + assert result == [[1, 0, 0], [0, 1, 0]] + + def test_redis_exception_returns_all_coords(self): + """When redis raises an exception, returns all coords as fallback.""" + imanager = self._make_mock_imanager() + coords = [[0, 0, 0], [1, 0, 0]] + imanager.redis.smismember.side_effect = Exception("Redis down") + result = get_chunks_not_done(imanager, layer=2, coords=coords) + assert result == coords + + +class TestJobTypeGuard: + @patch("pychunkedgraph.ingest.utils.get_redis_connection") + def test_same_job_type_runs_normally(self, mock_get_redis): + """When current job_type matches, decorated function runs normally.""" + mock_redis = MagicMock() + mock_redis.get.return_value = b"ingest" + mock_get_redis.return_value = mock_redis + + @job_type_guard("ingest") + def my_func(): + return "success" + + assert my_func() == "success" + + @patch("pychunkedgraph.ingest.utils.get_redis_connection") + def test_different_job_type_calls_exit(self, mock_get_redis): + """When current job_type differs, exit(1) is called.""" + mock_redis = MagicMock() + mock_redis.get.return_value = b"upgrade" + mock_get_redis.return_value = mock_redis + + @job_type_guard("ingest") + def my_func(): + return "success" + + with pytest.raises(SystemExit) as exc_info: + my_func() + assert exc_info.value.code == 1 + + @patch("pychunkedgraph.ingest.utils.get_redis_connection") + def test_no_current_type_runs_normally(self, mock_get_redis): + """When no current job_type is set in redis, decorated function runs normally.""" + mock_redis = MagicMock() + mock_redis.get.return_value = None + mock_get_redis.return_value = mock_redis + + @job_type_guard("ingest") + def my_func(): + return "success" + + assert my_func() == "success" + + +# ===================================================================== +# Additional pure unit tests +# ===================================================================== +from pychunkedgraph.ingest.utils import start_ocdbt_server + + +class TestGetChunksNotDoneWithSplits: + """Test get_chunks_not_done with splits > 0.""" + + def _make_mock_imanager(self): + imanager = MagicMock() + imanager.redis = MagicMock() + return imanager + + def test_get_chunks_not_done_with_splits(self): + """When splits > 0, should expand coords with split indices.""" + imanager = self._make_mock_imanager() + coords = [[0, 0, 0], [1, 0, 0]] + splits = 2 + # With 2 coords and 2 splits, we get 4 entries: + # (0,0,0) split 0, (0,0,0) split 1, (1,0,0) split 0, (1,0,0) split 1 + # All completed + imanager.redis.smismember.return_value = [1, 1, 1, 1] + result = get_chunks_not_done(imanager, layer=2, coords=coords, splits=splits) + assert result == [] + + def test_get_chunks_not_done_with_splits_some_incomplete(self): + """When splits > 0 and some are not done, return the incomplete (coord, split) tuples.""" + imanager = self._make_mock_imanager() + coords = [[0, 0, 0], [1, 0, 0]] + splits = 2 + # 4 entries, only first is completed + imanager.redis.smismember.return_value = [1, 0, 1, 0] + result = get_chunks_not_done(imanager, layer=2, coords=coords, splits=splits) + # Should return the (coord, split) tuples that are not done + assert len(result) == 2 + assert result[0] == ([0, 0, 0], 1) + assert result[1] == ([1, 0, 0], 1) + + def test_get_chunks_not_done_splits_redis_error(self): + """When redis raises with splits > 0, should return split_coords as fallback.""" + imanager = self._make_mock_imanager() + coords = [[0, 0, 0]] + splits = 2 + imanager.redis.smismember.side_effect = Exception("Redis down") + result = get_chunks_not_done(imanager, layer=2, coords=coords, splits=splits) + # Should return all (coord, split) tuples + assert len(result) == 2 + assert result[0] == ([0, 0, 0], 0) + assert result[1] == ([0, 0, 0], 1) + + def test_get_chunks_not_done_splits_coord_str_format(self): + """With splits, redis keys should include the split index.""" + imanager = self._make_mock_imanager() + coords = [[2, 3, 4]] + splits = 1 + imanager.redis.smismember.return_value = [0] + get_chunks_not_done(imanager, layer=3, coords=coords, splits=splits) + # Check the coords_strs passed to smismember + call_args = imanager.redis.smismember.call_args + assert call_args[0][0] == "3c" + assert call_args[0][1] == ["2_3_4_0"] + + +class TestStartOcdbtServer: + """Test start_ocdbt_server function.""" + + @patch("pychunkedgraph.ingest.utils.ts") + @patch.dict("os.environ", {"MY_POD_IP": "10.0.0.1"}) + def test_start_ocdbt_server(self, mock_ts): + """start_ocdbt_server should open a KvStore and set redis keys.""" + imanager = MagicMock() + imanager.cg.meta.data_source.EDGES = "gs://bucket/edges" + mock_redis = MagicMock() + imanager.redis = mock_redis + + server = MagicMock() + server.port = 12345 + + mock_kv_future = MagicMock() + mock_ts.KvStore.open.return_value = mock_kv_future + + start_ocdbt_server(imanager, server) + + # Verify tensorstore was called with the right spec + call_args = mock_ts.KvStore.open.call_args[0][0] + assert call_args["driver"] == "ocdbt" + assert "gs://bucket/edges/ocdbt" in call_args["base"] + assert call_args["coordinator"]["address"] == "localhost:12345" + mock_kv_future.result.assert_called_once() + + # Verify redis keys were set + mock_redis.set.assert_any_call("OCDBT_COORDINATOR_PORT", "12345") + mock_redis.set.assert_any_call("OCDBT_COORDINATOR_HOST", "10.0.0.1") + + @patch("pychunkedgraph.ingest.utils.ts") + @patch.dict("os.environ", {}, clear=True) + def test_start_ocdbt_server_default_host(self, mock_ts): + """When MY_POD_IP is not set, should default to localhost.""" + imanager = MagicMock() + imanager.cg.meta.data_source.EDGES = "gs://bucket/edges" + mock_redis = MagicMock() + imanager.redis = mock_redis + + server = MagicMock() + server.port = 9999 + + mock_kv_future = MagicMock() + mock_ts.KvStore.open.return_value = mock_kv_future + + start_ocdbt_server(imanager, server) + + mock_redis.set.assert_any_call("OCDBT_COORDINATOR_HOST", "localhost") + + +class TestPostprocessEdgeDataNoneValues: + """Test postprocess_edge_data when edge_dict values are None.""" + + def test_postprocess_edge_data_none_values(self): + """When edge_dict[k] is None, the key should be in result with empty dict.""" + + class FakeMeta: + class data_source: + DATA_VERSION = 3 + + resolution = np.array([4, 4, 40]) + + class FakeIM: + cg_meta = FakeMeta() + + edge_dict = {"test_key": None} + result = postprocess_edge_data(FakeIM(), edge_dict) + assert "test_key" in result + assert result["test_key"] == {} + + def test_postprocess_edge_data_v4_none_values(self): + """Version 4 with None values should also produce empty dict.""" + + class FakeMeta: + class data_source: + DATA_VERSION = 4 + + resolution = np.array([4, 4, 40]) + + class FakeIM: + cg_meta = FakeMeta() + + edge_dict = { + "a": None, + "b": { + "sv1": np.array([1]), + "sv2": np.array([2]), + "aff_x": np.array([0.1]), + "aff_y": np.array([0.2]), + "aff_z": np.array([0.3]), + "area_x": np.array([10]), + "area_y": np.array([20]), + "area_z": np.array([30]), + }, + } + result = postprocess_edge_data(FakeIM(), edge_dict) + assert result["a"] == {} + assert "aff" in result["b"] + assert "area" in result["b"] diff --git a/pychunkedgraph/tests/test_io_components.py b/pychunkedgraph/tests/test_io_components.py new file mode 100644 index 000000000..63ac5abaa --- /dev/null +++ b/pychunkedgraph/tests/test_io_components.py @@ -0,0 +1,57 @@ +"""Tests for pychunkedgraph.io.components using file:// protocol""" + +import numpy as np +import pytest + +from pychunkedgraph.io.components import ( + serialize, + deserialize, + put_chunk_components, + get_chunk_components, +) +from pychunkedgraph.graph.utils import basetypes + + +class TestSerializeDeserialize: + def test_roundtrip(self): + components = [ + {np.uint64(1), np.uint64(2), np.uint64(3)}, + {np.uint64(4), np.uint64(5)}, + ] + proto = serialize(components) + result = deserialize(proto) + # Each supervoxel should map to its component index + assert result[np.uint64(1)] == result[np.uint64(2)] == result[np.uint64(3)] + assert result[np.uint64(4)] == result[np.uint64(5)] + assert result[np.uint64(1)] != result[np.uint64(4)] + + def test_empty_components(self): + # serialize([]) raises ValueError because np.concatenate + # is called on an empty list; this matches production behavior + # where empty components are never serialized + with pytest.raises(ValueError): + serialize([]) + + +class TestPutGetChunkComponents: + def test_roundtrip_via_filesystem(self, tmp_path): + components_dir = f"file://{tmp_path}" + chunk_coord = np.array([1, 2, 3]) + + components = [ + {np.uint64(10), np.uint64(20)}, + {np.uint64(30)}, + ] + put_chunk_components(components_dir, components, chunk_coord) + result = get_chunk_components(components_dir, chunk_coord) + + assert np.uint64(10) in result + assert np.uint64(20) in result + assert np.uint64(30) in result + assert result[np.uint64(10)] == result[np.uint64(20)] + assert result[np.uint64(10)] != result[np.uint64(30)] + + def test_missing_file_returns_empty(self, tmp_path): + components_dir = f"file://{tmp_path}" + result = get_chunk_components(components_dir, np.array([99, 99, 99])) + assert result == {} diff --git a/pychunkedgraph/tests/test_io_edges.py b/pychunkedgraph/tests/test_io_edges.py new file mode 100644 index 000000000..2111bbc6b --- /dev/null +++ b/pychunkedgraph/tests/test_io_edges.py @@ -0,0 +1,79 @@ +"""Tests for pychunkedgraph.io.edges using file:// protocol""" + +import numpy as np +import pytest + +from pychunkedgraph.io.edges import ( + serialize, + deserialize, + get_chunk_edges, + put_chunk_edges, + _parse_edges, +) +from pychunkedgraph.graph.edges import Edges, EDGE_TYPES +from pychunkedgraph.graph.utils import basetypes + + +class TestSerializeDeserialize: + def test_roundtrip(self): + ids1 = np.array([1, 2, 3], dtype=basetypes.NODE_ID) + ids2 = np.array([4, 5, 6], dtype=basetypes.NODE_ID) + affs = np.array([0.5, 0.6, 0.7], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([10, 20, 30], dtype=basetypes.EDGE_AREA) + edges = Edges(ids1, ids2, affinities=affs, areas=areas) + + proto = serialize(edges) + result = deserialize(proto) + np.testing.assert_array_equal(result.node_ids1, ids1) + np.testing.assert_array_equal(result.node_ids2, ids2) + np.testing.assert_array_almost_equal(result.affinities, affs) + np.testing.assert_array_almost_equal(result.areas, areas) + + def test_empty_edges(self): + edges = Edges([], []) + proto = serialize(edges) + result = deserialize(proto) + assert len(result) == 0 + + +class TestParseEdges: + def test_empty_list(self): + result = _parse_edges([]) + assert result == [] + + +class TestPutGetChunkEdges: + def test_roundtrip_via_filesystem(self, tmp_path): + edges_dir = f"file://{tmp_path}" + chunk_coord = np.array([0, 0, 0]) + + edges_d = { + EDGE_TYPES.in_chunk: Edges( + [1, 2], + [3, 4], + affinities=[0.5, 0.6], + areas=[10, 20], + ), + EDGE_TYPES.between_chunk: Edges( + [5], + [6], + affinities=[0.7], + areas=[30], + ), + EDGE_TYPES.cross_chunk: Edges([], []), + } + + put_chunk_edges(edges_dir, chunk_coord, edges_d, compression_level=3) + result = get_chunk_edges(edges_dir, [chunk_coord]) + + assert EDGE_TYPES.in_chunk in result + assert EDGE_TYPES.between_chunk in result + assert EDGE_TYPES.cross_chunk in result + assert len(result[EDGE_TYPES.in_chunk]) == 2 + assert len(result[EDGE_TYPES.between_chunk]) == 1 + + def test_missing_file_returns_empty(self, tmp_path): + edges_dir = f"file://{tmp_path}" + result = get_chunk_edges(edges_dir, [np.array([99, 99, 99])]) + for edge_type in EDGE_TYPES: + assert len(result[edge_type]) == 0 diff --git a/pychunkedgraph/tests/test_lineage.py b/pychunkedgraph/tests/test_lineage.py new file mode 100644 index 000000000..118393e8e --- /dev/null +++ b/pychunkedgraph/tests/test_lineage.py @@ -0,0 +1,458 @@ +"""Tests for pychunkedgraph.graph.lineage""" + +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest +from networkx import DiGraph + +from pychunkedgraph.graph.lineage import ( + get_latest_root_id, + get_future_root_ids, + get_past_root_ids, + get_root_id_history, + lineage_graph, + get_previous_root_ids, + _get_node_properties, +) +from pychunkedgraph.graph import attributes + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestLineage: + def _build_and_merge(self, gen_graph): + """Build a graph with 2 isolated SVs, then merge them.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + + old_root_0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + old_root_1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + + # Merge + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, old_root_0, old_root_1, new_root + + def test_get_latest_root_id_current(self, gen_graph): + graph, _, _, new_root = self._build_and_merge(gen_graph) + latest = get_latest_root_id(graph, new_root) + assert new_root in latest + + def test_get_latest_root_id_after_edit(self, gen_graph): + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + latest = get_latest_root_id(graph, old_root_0) + assert new_root in latest + + def test_get_future_root_ids(self, gen_graph): + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + future = get_future_root_ids(graph, old_root_0) + assert new_root in future + + def test_get_past_root_ids(self, gen_graph): + graph, old_root_0, old_root_1, new_root = self._build_and_merge(gen_graph) + past = get_past_root_ids(graph, new_root) + assert old_root_0 in past or old_root_1 in past + + def test_get_root_id_history(self, gen_graph): + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + history = get_root_id_history(graph, old_root_0) + assert len(history) >= 2 + assert old_root_0 in history + assert new_root in history + + def test_lineage_graph(self, gen_graph): + """lineage_graph should return a DiGraph with nodes for old and new roots.""" + graph, old_root_0, old_root_1, new_root = self._build_and_merge(gen_graph) + lg = lineage_graph(graph, [new_root]) + assert isinstance(lg, DiGraph) + # The lineage graph should contain the new root + assert new_root in lg.nodes + # Should have at least 2 nodes (old root(s) + new root) + assert len(lg.nodes) >= 2 + # Should have edges connecting old roots to the new root + assert lg.number_of_edges() > 0 + + def test_lineage_graph_with_timestamps(self, gen_graph): + """lineage_graph should respect timestamp boundaries.""" + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + # Build lineage graph with a past timestamp that includes the merge + past = datetime.now(UTC) - timedelta(days=20) + future = datetime.now(UTC) + timedelta(days=1) + lg = lineage_graph( + graph, [new_root], timestamp_past=past, timestamp_future=future + ) + assert isinstance(lg, DiGraph) + assert new_root in lg.nodes + + def test_lineage_graph_single_node_id(self, gen_graph): + """lineage_graph should accept a single integer node_id.""" + graph, _, _, new_root = self._build_and_merge(gen_graph) + lg = lineage_graph(graph, int(new_root)) + assert isinstance(lg, DiGraph) + assert new_root in lg.nodes + + def test_get_previous_root_ids(self, gen_graph): + """After a merge, get_previous_root_ids of the new root should include the old roots.""" + graph, old_root_0, old_root_1, new_root = self._build_and_merge(gen_graph) + result = get_previous_root_ids(graph, [new_root]) + assert isinstance(result, dict) + assert new_root in result + previous = result[new_root] + # The previous roots of the merged node should include the old roots + assert old_root_0 in previous or old_root_1 in previous + + def test_get_node_properties(self, gen_graph): + """_get_node_properties should extract timestamp and operation_id from a node entry.""" + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + # Read the new root node with all properties + node_entry = graph.client.read_node(new_root) + assert node_entry is not None + + # _get_node_properties expects a dict with at least Hierarchy.Child + props = _get_node_properties(node_entry) + assert isinstance(props, dict) + # Should have a 'timestamp' key with a float value (epoch seconds) + assert "timestamp" in props + assert isinstance(props["timestamp"], float) + assert props["timestamp"] > 0 + + def test_get_node_properties_with_operation_id(self, gen_graph): + """Nodes created by edits should have an operation_id in their properties.""" + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + # The old root should have NewParent and OperationID set after the merge + node_entry = graph.client.read_node(old_root_0) + props = _get_node_properties(node_entry) + assert "timestamp" in props + # Old roots involved in an edit should have operation_id + if attributes.OperationLogs.OperationID in node_entry: + assert "operation_id" in props + + +class TestGetFutureRootIdsLatest: + """Test get_future_root_ids with different time_stamp values.""" + + def _build_graph_with_two_merges(self, gen_graph): + """Build a graph with 3 SVs, do 2 merges: + First merge SV0+SV1 -> root_A + Then merge root_A+SV2 -> root_B + """ + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + from .helpers import create_chunk, to_label + + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + to_label(graph, 1, 0, 0, 0, 2), + ], + edges=[], + timestamp=fake_ts, + ) + + old_root_0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + old_root_1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + old_root_2 = graph.get_root(to_label(graph, 1, 0, 0, 0, 2)) + + # First merge: SV0 + SV1 + result1 = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + mid_root = result1.new_root_ids[0] + + # Second merge: merged root + SV2 + result2 = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 2)], + affinities=[0.3], + ) + final_root = result2.new_root_ids[0] + + return graph, old_root_0, old_root_1, old_root_2, mid_root, final_root + + def test_future_root_ids_finds_chain(self, gen_graph): + """get_future_root_ids from original root should find mid and final roots.""" + graph, old_root_0, _, _, mid_root, final_root = ( + self._build_graph_with_two_merges(gen_graph) + ) + future = get_future_root_ids(graph, old_root_0) + # Should find at least the mid root and final root + assert len(future) >= 1 + # The final root should be reachable + assert mid_root in future or final_root in future + + def test_future_root_ids_with_past_timestamp(self, gen_graph): + """Using a very old timestamp should find nothing (no future roots before that time).""" + graph, old_root_0, _, _, _, _ = self._build_graph_with_two_merges(gen_graph) + very_old = datetime.now(UTC) - timedelta(days=20) + future = get_future_root_ids(graph, old_root_0, time_stamp=very_old) + # With a very old timestamp, no future roots should be found since + # all edits happened after that time + assert len(future) == 0 + + def test_future_root_ids_current_root_returns_empty(self, gen_graph): + """For the latest root, get_future_root_ids should return empty.""" + graph, _, _, _, _, final_root = self._build_graph_with_two_merges(gen_graph) + future = get_future_root_ids(graph, final_root) + assert len(future) == 0 + + +class TestGetPastRootIdsTimestamps: + """Test get_past_root_ids with different time_stamp values.""" + + def _build_and_merge(self, gen_graph): + """Build a graph with 2 isolated SVs, then merge them.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + from .helpers import create_chunk, to_label + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + + old_root_0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + old_root_1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, old_root_0, old_root_1, new_root + + def test_past_root_ids_of_merged_root(self, gen_graph): + """get_past_root_ids of the merged root should find old roots.""" + graph, old_root_0, old_root_1, new_root = self._build_and_merge(gen_graph) + past = get_past_root_ids(graph, new_root) + assert old_root_0 in past or old_root_1 in past + + def test_past_root_ids_with_future_timestamp(self, gen_graph): + """Using a far-future timestamp should find nothing (no past roots after that time).""" + graph, _, _, new_root = self._build_and_merge(gen_graph) + far_future = datetime.now(UTC) + timedelta(days=365) + past = get_past_root_ids(graph, new_root, time_stamp=far_future) + # With a far-future timestamp, the condition row_time_stamp > time_stamp + # will be False, so no past roots should be found + assert len(past) == 0 + + def test_past_root_ids_original_root_empty(self, gen_graph): + """An original root with no prior edits should have no past root ids.""" + graph, old_root_0, _, _ = self._build_and_merge(gen_graph) + past = get_past_root_ids(graph, old_root_0) + # The original root has no former parents, so past should be empty + assert len(past) == 0 + + +class TestGetRootIdHistory: + """Test get_root_id_history returns full history.""" + + def _build_and_merge(self, gen_graph): + """Build a graph with 2 isolated SVs, then merge them.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + from .helpers import create_chunk, to_label + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + + old_root_0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + old_root_1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, old_root_0, old_root_1, new_root + + def test_history_after_merge(self, gen_graph): + """After merge, get_root_id_history should contain past and current root.""" + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + history = get_root_id_history(graph, old_root_0) + assert isinstance(history, np.ndarray) + # Should contain the queried root itself + assert old_root_0 in history + # Should contain the new root + assert new_root in history + assert len(history) >= 2 + + def test_history_from_new_root(self, gen_graph): + """get_root_id_history from the new root should include old roots.""" + graph, old_root_0, old_root_1, new_root = self._build_and_merge(gen_graph) + history = get_root_id_history(graph, new_root) + assert isinstance(history, np.ndarray) + assert new_root in history + # At least one old root should appear in the history + assert old_root_0 in history or old_root_1 in history + + def test_history_with_timestamps(self, gen_graph): + """get_root_id_history with restrictive timestamps may limit results.""" + graph, old_root_0, _, new_root = self._build_and_merge(gen_graph) + # Very narrow time window: only current root + far_future = datetime.now(UTC) + timedelta(days=365) + very_old = datetime.now(UTC) - timedelta(days=365) + history = get_root_id_history( + graph, + new_root, + time_stamp_past=far_future, + time_stamp_future=very_old, + ) + assert isinstance(history, np.ndarray) + # At minimum, the queried root itself should be in the history + assert new_root in history + + +class TestGetRootIdHistoryDetailed: + """Detailed tests for get_root_id_history covering all branches.""" + + def _build_graph_with_two_merges(self, gen_graph): + """Build a graph with 3 SVs, do 2 merges: + First merge SV0+SV1 -> root_A + Then merge root_A+SV2 -> root_B + """ + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[ + to_label(graph, 1, 0, 0, 0, 0), + to_label(graph, 1, 0, 0, 0, 1), + to_label(graph, 1, 0, 0, 0, 2), + ], + edges=[], + timestamp=fake_ts, + ) + + old_root_0 = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + old_root_1 = graph.get_root(to_label(graph, 1, 0, 0, 0, 1)) + old_root_2 = graph.get_root(to_label(graph, 1, 0, 0, 0, 2)) + + # First merge: SV0 + SV1 + result1 = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + mid_root = result1.new_root_ids[0] + + # Second merge: merged root + SV2 + result2 = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 2)], + affinities=[0.3], + ) + final_root = result2.new_root_ids[0] + + return graph, old_root_0, old_root_1, old_root_2, mid_root, final_root + + def test_history_contains_all_roots_from_old(self, gen_graph): + """get_root_id_history from original root should contain all related roots.""" + graph, old_root_0, _, _, mid_root, final_root = ( + self._build_graph_with_two_merges(gen_graph) + ) + history = get_root_id_history(graph, old_root_0) + assert isinstance(history, np.ndarray) + # Should contain the queried root itself + assert old_root_0 in history + # Should contain mid_root (first merge) + assert mid_root in history + # Should contain final_root (second merge) + assert final_root in history + + def test_history_from_mid_root(self, gen_graph): + """get_root_id_history from mid root should include both past and future.""" + graph, old_root_0, old_root_1, _, mid_root, final_root = ( + self._build_graph_with_two_merges(gen_graph) + ) + history = get_root_id_history(graph, mid_root) + assert isinstance(history, np.ndarray) + assert mid_root in history + # Should include past roots + assert old_root_0 in history or old_root_1 in history + # Should include future root + assert final_root in history + + def test_history_from_final_root(self, gen_graph): + """get_root_id_history from final root should include all past roots.""" + graph, old_root_0, old_root_1, old_root_2, mid_root, final_root = ( + self._build_graph_with_two_merges(gen_graph) + ) + history = get_root_id_history(graph, final_root) + assert isinstance(history, np.ndarray) + assert final_root in history + # Should include the mid root + assert mid_root in history + # Should include at least one of the original roots + assert old_root_0 in history or old_root_1 in history or old_root_2 in history + + def test_history_with_narrow_past_timestamp(self, gen_graph): + """get_root_id_history with a very recent past timestamp excludes old roots.""" + graph, old_root_0, _, _, mid_root, final_root = ( + self._build_graph_with_two_merges(gen_graph) + ) + # Use a very recent past timestamp to exclude past roots + recent = datetime.now(UTC) + timedelta(days=365) + history = get_root_id_history( + graph, + mid_root, + time_stamp_past=recent, + ) + assert isinstance(history, np.ndarray) + # Should contain the root itself + assert mid_root in history + # Should still contain future roots (timestamp_future defaults to max) + assert final_root in history + + def test_history_with_narrow_future_timestamp(self, gen_graph): + """get_root_id_history with a very old future timestamp excludes future roots.""" + graph, old_root_0, _, _, mid_root, final_root = ( + self._build_graph_with_two_merges(gen_graph) + ) + # Use a very old future timestamp to exclude future roots + very_old = datetime.now(UTC) - timedelta(days=365) + history = get_root_id_history( + graph, + mid_root, + time_stamp_future=very_old, + ) + assert isinstance(history, np.ndarray) + # Should contain the root itself + assert mid_root in history + # Should contain past roots (timestamp_past defaults to min) + assert old_root_0 in history diff --git a/pychunkedgraph/tests/test_locks.py b/pychunkedgraph/tests/test_locks.py index 41b59163b..a0f7161cd 100644 --- a/pychunkedgraph/tests/test_locks.py +++ b/pychunkedgraph/tests/test_locks.py @@ -413,3 +413,340 @@ def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): operation_id=operation_id_2, future_root_ids_d=future_root_ids_d, )[0] + + +# ===================================================================== +# Pure unit tests (no BigTable emulator needed) +# ===================================================================== +from unittest.mock import MagicMock, patch +from collections import defaultdict +import networkx as nx + +from ..graph.locks import RootLock, IndefiniteRootLock +from ..graph.exceptions import LockingError + + +def _make_mock_cg(): + """Create a mock ChunkedGraph object with the methods needed by locks.""" + cg = MagicMock() + cg.id_client.create_operation_id.return_value = np.uint64(42) + cg.client.lock_roots.return_value = (True, [np.uint64(100)]) + cg.client.unlock_root.return_value = None + cg.client.renew_locks.return_value = True + cg.client.lock_roots_indefinitely.return_value = ( + True, + [np.uint64(100)], + [], + ) + cg.client.unlock_indefinitely_locked_root.return_value = None + cg.get_node_timestamps.return_value = [MagicMock()] + return cg + + +class TestRootLockPrivilegedMode: + def test_rootlock_privileged_mode(self): + """privileged_mode=True should skip locking entirely and return self.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + op_id = np.uint64(999) + + lock = RootLock(cg, root_ids, operation_id=op_id, privileged_mode=True) + result = lock.__enter__() + + assert result is lock + assert lock.lock_acquired is False + cg.client.lock_roots.assert_not_called() + + def test_rootlock_privileged_mode_exit_no_unlock(self): + """When privileged and lock was never acquired, __exit__ should not unlock.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + op_id = np.uint64(999) + + lock = RootLock(cg, root_ids, operation_id=op_id, privileged_mode=True) + lock.__enter__() + lock.__exit__(None, None, None) + + cg.client.unlock_root.assert_not_called() + + +class TestRootLockCreatesOperationId: + def test_rootlock_creates_operation_id(self): + """When operation_id is None, __enter__ should create one via cg.id_client.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + + mock_graph = nx.DiGraph() + mock_graph.add_node(np.uint64(100)) + + with patch("pychunkedgraph.graph.locks.lineage_graph", return_value=mock_graph): + lock = RootLock(cg, root_ids, operation_id=None) + lock.__enter__() + + cg.id_client.create_operation_id.assert_called_once() + assert lock.operation_id == np.uint64(42) + + +class TestRootLockAcquired: + def test_rootlock_lock_acquired(self): + """When lock_roots returns (True, [...]), lock_acquired should be True.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + locked = [np.uint64(100), np.uint64(101)] + cg.client.lock_roots.return_value = (True, locked) + + mock_graph = nx.DiGraph() + mock_graph.add_node(np.uint64(100)) + + with patch("pychunkedgraph.graph.locks.lineage_graph", return_value=mock_graph): + lock = RootLock(cg, root_ids, operation_id=np.uint64(10)) + result = lock.__enter__() + + assert lock.lock_acquired is True + assert lock.locked_root_ids == locked + assert result is lock + + +class TestRootLockFailed: + def test_rootlock_lock_failed(self): + """When lock_roots returns (False, []), should raise LockingError.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + cg.client.lock_roots.return_value = (False, []) + + mock_graph = nx.DiGraph() + mock_graph.add_node(np.uint64(100)) + + with patch("pychunkedgraph.graph.locks.lineage_graph", return_value=mock_graph): + lock = RootLock(cg, root_ids, operation_id=np.uint64(10)) + with pytest.raises(LockingError, match="Could not acquire root lock"): + lock.__enter__() + + +class TestRootLockExitUnlocks: + def test_rootlock_exit_unlocks(self): + """When lock_acquired=True, __exit__ should call unlock_root for each locked_root_id.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + locked = [np.uint64(100), np.uint64(101)] + cg.client.lock_roots.return_value = (True, locked) + + mock_graph = nx.DiGraph() + mock_graph.add_node(np.uint64(100)) + + with patch("pychunkedgraph.graph.locks.lineage_graph", return_value=mock_graph): + lock = RootLock(cg, root_ids, operation_id=np.uint64(10)) + lock.__enter__() + + lock.__exit__(None, None, None) + + assert cg.client.unlock_root.call_count == 2 + actual_calls = cg.client.unlock_root.call_args_list + called_root_ids = {c[0][0] for c in actual_calls} + assert called_root_ids == {np.uint64(100), np.uint64(101)} + for c in actual_calls: + assert c[0][1] == np.uint64(10) + + def test_rootlock_exit_no_unlock_when_not_acquired(self): + """When lock_acquired=False, __exit__ should not call unlock_root.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + + lock = RootLock(cg, root_ids, operation_id=np.uint64(10)) + lock.__exit__(None, None, None) + + cg.client.unlock_root.assert_not_called() + + def test_rootlock_exit_handles_unlock_exception(self): + """When unlock_root raises, __exit__ should log warning and not re-raise.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + locked = [np.uint64(100)] + cg.client.lock_roots.return_value = (True, locked) + cg.client.unlock_root.side_effect = RuntimeError("unlock failed") + + mock_graph = nx.DiGraph() + mock_graph.add_node(np.uint64(100)) + + with patch("pychunkedgraph.graph.locks.lineage_graph", return_value=mock_graph): + lock = RootLock(cg, root_ids, operation_id=np.uint64(10)) + lock.__enter__() + + # Should not raise even though unlock_root raises + lock.__exit__(None, None, None) + + +class TestIndefiniteRootLockPrivilegedMode: + def test_indefiniterootlock_privileged_mode(self): + """privileged_mode=True should skip locking and return self.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + op_id = np.uint64(999) + + lock = IndefiniteRootLock(cg, op_id, root_ids, privileged_mode=True) + result = lock.__enter__() + + assert result is lock + assert lock.acquired is False + cg.client.renew_locks.assert_not_called() + cg.client.lock_roots_indefinitely.assert_not_called() + + +class TestIndefiniteRootLockRenewFails: + def test_indefiniterootlock_renew_fails(self): + """When renew_locks returns False, should raise LockingError.""" + cg = _make_mock_cg() + cg.client.renew_locks.return_value = False + root_ids = np.array([np.uint64(100)]) + op_id = np.uint64(10) + + lock = IndefiniteRootLock( + cg, op_id, root_ids, future_root_ids_d=defaultdict(list) + ) + with pytest.raises(LockingError, match="Could not renew locks"): + lock.__enter__() + + +class TestIndefiniteRootLockSuccess: + def test_indefiniterootlock_lock_success(self): + """When lock_roots_indefinitely returns (True, [...], []), acquired should be True.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + locked = [np.uint64(100)] + cg.client.lock_roots_indefinitely.return_value = (True, locked, []) + + lock = IndefiniteRootLock( + cg, + np.uint64(10), + root_ids, + future_root_ids_d=defaultdict(list), + ) + result = lock.__enter__() + + assert lock.acquired is True + assert result is lock + assert list(lock.root_ids) == locked + + +class TestIndefiniteRootLockFail: + def test_indefiniterootlock_lock_fail(self): + """When lock_roots_indefinitely returns (False, [], [...]), should raise LockingError.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + failed = [np.uint64(100)] + cg.client.lock_roots_indefinitely.return_value = (False, [], failed) + + lock = IndefiniteRootLock( + cg, + np.uint64(10), + root_ids, + future_root_ids_d=defaultdict(list), + ) + with pytest.raises(LockingError, match="have been locked indefinitely"): + lock.__enter__() + + +class TestIndefiniteRootLockExitUnlocks: + def test_indefiniterootlock_exit_unlocks(self): + """When acquired=True, __exit__ should call unlock_indefinitely_locked_root.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100), np.uint64(101)]) + cg.client.lock_roots_indefinitely.return_value = ( + True, + [np.uint64(100), np.uint64(101)], + [], + ) + + lock = IndefiniteRootLock( + cg, + np.uint64(10), + root_ids, + future_root_ids_d=defaultdict(list), + ) + lock.__enter__() + lock.__exit__(None, None, None) + + assert cg.client.unlock_indefinitely_locked_root.call_count == 2 + actual_calls = cg.client.unlock_indefinitely_locked_root.call_args_list + called_root_ids = {c[0][0] for c in actual_calls} + assert called_root_ids == {np.uint64(100), np.uint64(101)} + for c in actual_calls: + assert c[0][1] == np.uint64(10) + + def test_indefiniterootlock_exit_no_unlock_when_not_acquired(self): + """When acquired=False, __exit__ should not unlock.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + lock = IndefiniteRootLock(cg, np.uint64(10), root_ids) + lock.__exit__(None, None, None) + cg.client.unlock_indefinitely_locked_root.assert_not_called() + + def test_indefiniterootlock_exit_handles_exception(self): + """When unlock_indefinitely_locked_root raises, should not re-raise.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + cg.client.lock_roots_indefinitely.return_value = ( + True, + [np.uint64(100)], + [], + ) + cg.client.unlock_indefinitely_locked_root.side_effect = RuntimeError("fail") + + lock = IndefiniteRootLock( + cg, + np.uint64(10), + root_ids, + future_root_ids_d=defaultdict(list), + ) + lock.__enter__() + # Should not raise + lock.__exit__(None, None, None) + + +class TestIndefiniteRootLockComputesFutureRootIds: + def test_indefiniterootlock_computes_future_root_ids(self): + """When future_root_ids_d is None, should compute from lineage_graph.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + cg.client.lock_roots_indefinitely.return_value = ( + True, + [np.uint64(100)], + [], + ) + + mock_lgraph = nx.DiGraph() + mock_lgraph.add_edge(np.uint64(100), np.uint64(200)) + mock_lgraph.add_edge(np.uint64(100), np.uint64(300)) + + with patch( + "pychunkedgraph.graph.locks.lineage_graph", return_value=mock_lgraph + ): + lock = IndefiniteRootLock( + cg, + np.uint64(10), + root_ids, + future_root_ids_d=None, + ) + lock.__enter__() + + assert lock.future_root_ids_d is not None + descendants = lock.future_root_ids_d[np.uint64(100)] + assert set(descendants) == {np.uint64(200), np.uint64(300)} + + +class TestRootLockContextManager: + def test_rootlock_as_context_manager(self): + """Test using RootLock with the `with` statement.""" + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100)]) + locked = [np.uint64(100)] + cg.client.lock_roots.return_value = (True, locked) + + mock_graph = nx.DiGraph() + mock_graph.add_node(np.uint64(100)) + + with patch("pychunkedgraph.graph.locks.lineage_graph", return_value=mock_graph): + with RootLock(cg, root_ids, operation_id=np.uint64(10)) as lock: + assert lock.lock_acquired is True + + cg.client.unlock_root.assert_called_once() diff --git a/pychunkedgraph/tests/test_meta.py b/pychunkedgraph/tests/test_meta.py new file mode 100644 index 000000000..f94b7d792 --- /dev/null +++ b/pychunkedgraph/tests/test_meta.py @@ -0,0 +1,609 @@ +"""Tests for pychunkedgraph.graph.meta""" + +import pickle + +import numpy as np +import pytest + +from pychunkedgraph.graph.meta import ChunkedGraphMeta, GraphConfig, DataSource + + +class TestChunkedGraphMeta: + def test_init(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + assert meta.graph_config is not None + assert meta.data_source is not None + + def test_graph_config_properties(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + assert meta.graph_config.FANOUT == 2 + assert meta.graph_config.SPATIAL_BITS == 10 + assert meta.graph_config.LAYER_ID_BITS == 8 + + def test_layer_count_setter(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + meta.layer_count = 6 + assert meta.layer_count == 6 + assert meta.bitmasks is not None + assert 1 in meta.bitmasks + + def test_bitmasks(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + bm = meta.bitmasks + assert isinstance(bm, dict) + assert 1 in bm + assert 2 in bm + + def test_read_only_default(self, gen_graph): + graph = gen_graph(n_layers=4) + assert graph.meta.READ_ONLY is False + + def test_is_out_of_bounds(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + assert meta.is_out_of_bounds(np.array([-1, 0, 0])) + assert not meta.is_out_of_bounds(np.array([0, 0, 0])) + + def test_pickle_roundtrip(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + state = meta.__getstate__() + new_meta = ChunkedGraphMeta.__new__(ChunkedGraphMeta) + new_meta.__setstate__(state) + assert new_meta.graph_config == meta.graph_config + assert new_meta.data_source == meta.data_source + + def test_split_bounding_offset_default(self, gen_graph): + graph = gen_graph(n_layers=4) + assert graph.meta.split_bounding_offset == (240, 240, 24) + + +class TestEdgeDtype: + def test_v2(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=2) + meta = ChunkedGraphMeta(gc, ds) + # Manually set bitmasks/layer_count to avoid CloudVolume access + meta._layer_count = 4 + meta._bitmasks = {1: 10, 2: 10, 3: 1, 4: 1} + dt = meta.edge_dtype + names = [d[0] for d in dt] + assert "sv1" in names + assert "aff" in names + assert "area" in names + + def test_v3(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=3) + meta = ChunkedGraphMeta(gc, ds) + meta._layer_count = 4 + meta._bitmasks = {1: 10, 2: 10, 3: 1, 4: 1} + dt = meta.edge_dtype + names = [d[0] for d in dt] + assert "aff_x" in names + assert "area_x" in names + + def test_v4(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + meta._layer_count = 4 + meta._bitmasks = {1: 10, 2: 10, 3: 1, 4: 1} + dt = meta.edge_dtype + # v4 uses float32 for affinities + for name, dtype in dt: + if name.startswith("aff"): + assert dtype == np.float32 + + +class TestDataSourceDefaults: + def test_defaults(self): + ds = DataSource() + assert ds.EDGES is None + assert ds.COMPONENTS is None + assert ds.WATERSHED is None + assert ds.DATA_VERSION is None + assert ds.CV_MIP == 0 + + +class TestGraphConfigDefaults: + def test_defaults(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + assert gc.FANOUT == 2 + assert gc.LAYER_ID_BITS == 8 + assert gc.SPATIAL_BITS == 10 + assert gc.OVERWRITE is False + assert gc.ROOT_COUNTERS == 8 + + +class TestResolutionProperty: + def test_resolution_returns_numpy_array(self, gen_graph): + """meta.resolution should delegate to ws_cv.resolution and return a numpy array.""" + graph = gen_graph(n_layers=4) + meta = graph.meta + res = meta.resolution + assert isinstance(res, np.ndarray) + # The mock CloudVolumeMock sets resolution to [1, 1, 1] + np.testing.assert_array_equal(res, np.array([1, 1, 1])) + + def test_resolution_dtype(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + res = meta.resolution + # Should be numeric + assert np.issubdtype(res.dtype, np.integer) or np.issubdtype( + res.dtype, np.floating + ) + + +class TestLayerChunkCounts: + def test_layer_chunk_counts_length(self, gen_graph): + """layer_chunk_counts should return a list with one entry per layer from 2..layer_count-1, plus [1] for root.""" + graph = gen_graph(n_layers=4) + meta = graph.meta + counts = meta.layer_chunk_counts + # layers 2, 3 contribute entries, plus the trailing [1] for root + # layer_count=4, so range(2, 4) => layers 2, 3 => 2 entries + [1] = 3 + assert isinstance(counts, list) + assert ( + len(counts) == meta.layer_count - 2 + 1 + ) # -2 for range start, +1 for root + # The last entry should always be 1 (root layer) + assert counts[-1] == 1 + + def test_layer_chunk_counts_values(self, gen_graph): + """Each count should be the product of chunk bounds for that layer.""" + graph = gen_graph(n_layers=4) + meta = graph.meta + counts = meta.layer_chunk_counts + for i, layer in enumerate(range(2, meta.layer_count)): + expected = np.prod(meta.layer_chunk_bounds[layer]) + assert counts[i] == expected + + def test_layer_chunk_counts_n_layers_5(self, gen_graph): + graph = gen_graph(n_layers=5) + meta = graph.meta + counts = meta.layer_chunk_counts + # n_layers=5 => layers 2,3,4 + root => 4 entries + assert len(counts) == 4 + assert counts[-1] == 1 + + +class TestLayerChunkBoundsSetter: + def test_setter_overrides_bounds(self, gen_graph): + """Setting layer_chunk_bounds should override the computed value.""" + graph = gen_graph(n_layers=4) + meta = graph.meta + + custom_bounds = { + 2: np.array([10, 10, 10]), + 3: np.array([5, 5, 5]), + } + meta.layer_chunk_bounds = custom_bounds + assert meta.layer_chunk_bounds is custom_bounds + np.testing.assert_array_equal( + meta.layer_chunk_bounds[2], np.array([10, 10, 10]) + ) + np.testing.assert_array_equal(meta.layer_chunk_bounds[3], np.array([5, 5, 5])) + + def test_setter_with_none_clears(self, gen_graph): + """Setting layer_chunk_bounds to None should clear cached value (next access recomputes).""" + graph = gen_graph(n_layers=4) + meta = graph.meta + # Access to populate the cache + _ = meta.layer_chunk_bounds + meta.layer_chunk_bounds = None + # After clearing, the internal _layer_bounds_d is None + assert meta._layer_bounds_d is None + + +class TestEdgeDtypeUnknownVersion: + """Test that an unknown DATA_VERSION raises Exception in edge_dtype.""" + + def test_unknown_version_raises(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=999) + meta = ChunkedGraphMeta(gc, ds) + meta._layer_count = 4 + meta._bitmasks = {1: 10, 2: 10, 3: 1, 4: 1} + with pytest.raises(Exception): + _ = meta.edge_dtype + + def test_none_version_raises(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=None) + meta = ChunkedGraphMeta(gc, ds) + meta._layer_count = 4 + meta._bitmasks = {1: 10, 2: 10, 3: 1, 4: 1} + with pytest.raises(Exception): + _ = meta.edge_dtype + + def test_version_1_raises(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=1) + meta = ChunkedGraphMeta(gc, ds) + meta._layer_count = 4 + meta._bitmasks = {1: 10, 2: 10, 3: 1, 4: 1} + with pytest.raises(Exception): + _ = meta.edge_dtype + + +class TestGetNewArgs: + """Test __getnewargs__ returns (graph_config, data_source).""" + + def test_getnewargs_returns_tuple(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + result = meta.__getnewargs__() + assert isinstance(result, tuple) + assert len(result) == 2 + + def test_getnewargs_contains_config_and_source(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], FANOUT=2) + ds = DataSource(DATA_VERSION=3, CV_MIP=1) + meta = ChunkedGraphMeta(gc, ds) + result = meta.__getnewargs__() + assert result[0] is gc + assert result[1] is ds + assert result[0].CHUNK_SIZE == [64, 64, 64] + assert result[1].DATA_VERSION == 3 + + def test_getnewargs_with_gen_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + result = meta.__getnewargs__() + assert result[0] == meta.graph_config + assert result[1] == meta.data_source + + +class TestCustomData: + """Test custom_data including READ_ONLY=True and mesh dir.""" + + def test_read_only_true(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"READ_ONLY": True}) + assert meta.READ_ONLY is True + + def test_read_only_false_explicit(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"READ_ONLY": False}) + assert meta.READ_ONLY is False + + def test_read_only_default_no_custom_data(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + assert meta.READ_ONLY is False + + def test_mesh_dir_in_custom_data(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta( + gc, ds, custom_data={"mesh": {"dir": "gs://bucket/mesh"}} + ) + assert meta.custom_data["mesh"]["dir"] == "gs://bucket/mesh" + + def test_split_bounding_offset_custom(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta( + gc, ds, custom_data={"split_bounding_offset": (100, 100, 10)} + ) + assert meta.split_bounding_offset == (100, 100, 10) + + def test_custom_data_preserved_through_getstate(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + custom = {"READ_ONLY": True, "mesh": {"dir": "gs://bucket/mesh"}} + meta = ChunkedGraphMeta(gc, ds, custom_data=custom) + state = meta.__getstate__() + assert state["custom_data"] == custom + + +class TestCvAlias: + """Test that cv property returns the same object as ws_cv.""" + + def test_cv_returns_same_as_ws_cv(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + assert meta.cv is meta.ws_cv + + def test_cv_is_not_none(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + assert meta.cv is not None + + +class TestStr: + """Test __str__ returns a non-empty string with expected sections.""" + + def _add_info_to_mock(self, meta): + """Add an info dict to the CloudVolumeMock so dataset_info works.""" + meta._ws_cv.info = {"scales": [{"resolution": [1, 1, 1]}]} + + def test_str_not_empty(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + result = str(meta) + assert len(result) > 0 + + def test_str_contains_sections(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + result = str(meta) + assert "GRAPH_CONFIG" in result + assert "DATA_SOURCE" in result + assert "CUSTOM_DATA" in result + assert "BITMASKS" in result + assert "VOXEL_BOUNDS" in result + assert "VOXEL_COUNTS" in result + assert "LAYER_CHUNK_BOUNDS" in result + assert "LAYER_CHUNK_COUNTS" in result + assert "DATASET_INFO" in result + + def test_str_is_string_type(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + result = str(meta) + assert isinstance(result, str) + + +class TestDatasetInfo: + """Test dataset_info returns dict with expected keys.""" + + def _add_info_to_mock(self, meta): + """Add an info dict to the CloudVolumeMock so dataset_info works.""" + meta._ws_cv.info = {"scales": [{"resolution": [1, 1, 1]}]} + + def test_dataset_info_is_dict(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + info = meta.dataset_info + assert isinstance(info, dict) + + def test_dataset_info_has_expected_keys(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + info = meta.dataset_info + assert "chunks_start_at_voxel_offset" in info + assert info["chunks_start_at_voxel_offset"] is True + assert "data_dir" in info + assert "graph" in info + + def test_dataset_info_graph_section(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + info = meta.dataset_info + graph_info = info["graph"] + assert "chunk_size" in graph_info + assert "n_bits_for_layer_id" in graph_info + assert "cv_mip" in graph_info + assert "n_layers" in graph_info + assert "spatial_bit_masks" in graph_info + assert graph_info["n_layers"] == meta.layer_count + + def test_dataset_info_with_mesh_dir(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + meta._custom_data = {"mesh": {"dir": "gs://bucket/mesh"}} + info = meta.dataset_info + assert "mesh" in info + assert info["mesh"] == "gs://bucket/mesh" + + def test_dataset_info_without_mesh_dir(self, gen_graph): + graph = gen_graph(n_layers=4) + meta = graph.meta + self._add_info_to_mock(meta) + meta._custom_data = {} + info = meta.dataset_info + assert "mesh" not in info + + +# ===================================================================== +# Pure unit tests (no BigTable emulator needed) - mock CloudVolume & Redis +# ===================================================================== +import json +from unittest.mock import MagicMock, patch, PropertyMock + + +class TestWsCvRedisCached: + """Test ws_cv property with Redis caching.""" + + @patch("pychunkedgraph.graph.meta.CloudVolume") + @patch("pychunkedgraph.graph.meta.get_redis_connection") + def test_ws_cv_redis_cached(self, mock_get_redis, mock_cv_cls): + """When redis has cached info, ws_cv uses cached CloudVolume.""" + gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + cached_info = {"scales": [{"resolution": [8, 8, 40]}]} + mock_redis = MagicMock() + mock_redis.get.return_value = json.dumps(cached_info) + mock_get_redis.return_value = mock_redis + + mock_cv_instance = MagicMock() + mock_cv_cls.return_value = mock_cv_instance + + result = meta.ws_cv + + assert result is mock_cv_instance + mock_cv_cls.assert_called_once_with("gs://bucket/ws", info=cached_info) + + @patch("pychunkedgraph.graph.meta.CloudVolume") + @patch("pychunkedgraph.graph.meta.get_redis_connection") + def test_ws_cv_redis_failure_fallback(self, mock_get_redis, mock_cv_cls): + """When redis raises, ws_cv falls back to direct CloudVolume.""" + gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + mock_get_redis.side_effect = Exception("Redis connection failed") + + mock_cv_instance = MagicMock() + mock_cv_instance.info = {"scales": []} + mock_cv_cls.return_value = mock_cv_instance + + result = meta.ws_cv + + assert result is mock_cv_instance + # Should have been called without info kwarg (fallback) + mock_cv_cls.assert_called_with("gs://bucket/ws") + + @patch("pychunkedgraph.graph.meta.CloudVolume") + @patch("pychunkedgraph.graph.meta.get_redis_connection") + def test_ws_cv_caches_to_redis(self, mock_get_redis, mock_cv_cls): + """When redis is available but cache miss, ws_cv caches info to redis.""" + gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + mock_redis = MagicMock() + # Make redis.get raise to simulate cache miss on json.loads + mock_redis.get.return_value = None # This will make json.loads fail + mock_get_redis.return_value = mock_redis + + mock_cv_instance = MagicMock() + mock_cv_instance.info = {"scales": [{"resolution": [8, 8, 40]}]} + mock_cv_cls.return_value = mock_cv_instance + + result = meta.ws_cv + + assert result is mock_cv_instance + # The fallback CloudVolume call (no info= kwarg) + mock_cv_cls.assert_called_with("gs://bucket/ws") + # Should try to cache in redis + mock_redis.set.assert_called_once() + + @patch("pychunkedgraph.graph.meta.CloudVolume") + @patch("pychunkedgraph.graph.meta.get_redis_connection") + def test_ws_cv_returns_cached_instance(self, mock_get_redis, mock_cv_cls): + """Once ws_cv has been set, subsequent calls return the cached instance.""" + gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + # Pre-set the cached ws_cv + mock_cv = MagicMock() + meta._ws_cv = mock_cv + + result = meta.ws_cv + assert result is mock_cv + # Should not try to create a new CloudVolume + mock_cv_cls.assert_not_called() + + +class TestLayerCountComputed: + """Test layer_count property computation from CloudVolume bounds.""" + + def test_layer_count_computed_from_cv(self): + """layer_count should be computed from ws_cv.bounds.""" + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], FANOUT=2, SPATIAL_BITS=10) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + # Create a mock ws_cv with bounds + mock_cv = MagicMock() + # bounds.to_list() returns [x_min, y_min, z_min, x_max, y_max, z_max] + # With a 256x256x256 volume and 64x64x64 chunks: 4 chunks per dim + # log_2(4) = 2, +2 = 4 layers + mock_cv.bounds.to_list.return_value = [0, 0, 0, 256, 256, 256] + meta._ws_cv = mock_cv + + count = meta.layer_count + assert isinstance(count, int) + assert count >= 3 # at least 3 layers for any reasonable volume + + def test_layer_count_cached_after_first_access(self): + """After layer_count is computed, it should be cached.""" + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], FANOUT=2, SPATIAL_BITS=10) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + meta._layer_count = 5 + + assert meta.layer_count == 5 + + +class TestBitmasksLazy: + """Test bitmasks property lazy computation.""" + + def test_bitmasks_lazy_computed(self): + """bitmasks should be computed lazily from layer_count.""" + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], FANOUT=2, SPATIAL_BITS=10) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + # Set layer_count directly to avoid needing ws_cv for layer_count + meta._layer_count = 5 + + bm = meta.bitmasks + assert isinstance(bm, dict) + assert 1 in bm + assert 2 in bm + + def test_bitmasks_cached_after_first_access(self): + """Once computed, bitmasks should be cached.""" + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], FANOUT=2, SPATIAL_BITS=10) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + meta._layer_count = 5 + + bm1 = meta.bitmasks + bm2 = meta.bitmasks + assert bm1 is bm2 + + +class TestLayerChunkBoundsComputed: + """Test layer_chunk_bounds property computation.""" + + def test_layer_chunk_bounds_computed(self): + """layer_chunk_bounds should be computed from voxel_counts and chunk_size.""" + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], FANOUT=2, SPATIAL_BITS=10) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + mock_cv = MagicMock() + mock_cv.bounds.to_list.return_value = [0, 0, 0, 256, 256, 256] + meta._ws_cv = mock_cv + # layer_count needs to be set to avoid recursive calls + meta._layer_count = 4 + + bounds = meta.layer_chunk_bounds + assert isinstance(bounds, dict) + # For layer_count=4, should have entries for layers 2 and 3 + assert 2 in bounds + assert 3 in bounds + # With 256/64=4 chunks, layer 2 should have 4 chunks per dim + np.testing.assert_array_equal(bounds[2], np.array([4, 4, 4])) + # layer 3: 4/2 = 2 chunks per dim + np.testing.assert_array_equal(bounds[3], np.array([2, 2, 2])) + + def test_layer_chunk_bounds_cached(self): + """After first access, layer_chunk_bounds should be cached.""" + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64], FANOUT=2, SPATIAL_BITS=10) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + + mock_cv = MagicMock() + mock_cv.bounds.to_list.return_value = [0, 0, 0, 256, 256, 256] + meta._ws_cv = mock_cv + meta._layer_count = 4 + + bounds1 = meta.layer_chunk_bounds + bounds2 = meta.layer_chunk_bounds + assert bounds1 is bounds2 diff --git a/pychunkedgraph/tests/test_misc.py b/pychunkedgraph/tests/test_misc.py new file mode 100644 index 000000000..0181934c2 --- /dev/null +++ b/pychunkedgraph/tests/test_misc.py @@ -0,0 +1,293 @@ +"""Tests for pychunkedgraph.graph.misc""" + +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from pychunkedgraph.graph.misc import ( + get_latest_roots, + get_delta_roots, + get_proofread_root_ids, + get_agglomerations, + get_activated_edges, +) +from pychunkedgraph.graph.edges import Edges +from pychunkedgraph.graph.types import Agglomeration + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestGetLatestRoots: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + roots = get_latest_roots(graph) + assert len(roots) >= 1 + + def test_with_timestamp(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + roots_before = get_latest_roots(graph, fake_ts - timedelta(days=1)) + roots_after = get_latest_roots(graph) + # Before creation, there should be no roots + assert len(roots_before) == 0 + assert len(roots_after) >= 1 + + +class TestGetDeltaRoots: + def test_basic(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + + before_merge = datetime.now(UTC) + + graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + + new_roots, expired_roots = get_delta_roots(graph, before_merge) + assert len(new_roots) >= 1 + + +class TestGetProofreadRootIds: + def test_after_merge(self, gen_graph): + """After a merge, get_proofread_root_ids should return old and new root IDs.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + + create_chunk( + graph, + vertices=[sv0, sv1], + edges=[], + timestamp=fake_ts, + ) + + before_merge = datetime.now(UTC) + + # Both SVs should have separate roots before merge + old_root0 = graph.get_root(sv0) + old_root1 = graph.get_root(sv1) + assert old_root0 != old_root1 + + # Perform a merge + graph.add_edges( + "TestUser", + [sv0, sv1], + affinities=[0.3], + ) + + # After merge, the two SVs share a new root + new_root = graph.get_root(sv0) + assert new_root == graph.get_root(sv1) + + old_roots, new_roots = get_proofread_root_ids(graph, start_time=before_merge) + + # The new root from the merge should appear in new_roots + assert new_root in new_roots + # The old roots that were merged should appear in old_roots + old_roots_set = set(old_roots.tolist()) + assert old_root0 in old_roots_set or old_root1 in old_roots_set + + def test_empty_when_no_operations(self, gen_graph): + """When no operations occurred, both arrays should be empty.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + + # Query a time range in the future where no operations exist + future = datetime.now(UTC) + timedelta(days=1) + old_roots, new_roots = get_proofread_root_ids(graph, start_time=future) + + assert len(old_roots) == 0 + assert len(new_roots) == 0 + + +class TestGetAgglomerations: + def test_single_l2id(self): + """Test get_agglomerations with a single L2 ID and its supervoxels.""" + l2id = np.uint64(100) + sv1 = np.uint64(1) + sv2 = np.uint64(2) + sv3 = np.uint64(3) + + l2id_children_d = {l2id: np.array([sv1, sv2, sv3], dtype=np.uint64)} + + # sv_parent_d maps supervoxel -> parent l2id + sv_parent_d = {sv1: l2id, sv2: l2id, sv3: l2id} + + # in_edges: edges within the agglomeration (sv1-sv2, sv2-sv3) + in_edges = Edges( + np.array([sv1, sv2], dtype=np.uint64), + np.array([sv2, sv3], dtype=np.uint64), + ) + + # ot_edges: edges to other agglomerations (empty here) + ot_edges = Edges(np.array([], dtype=np.uint64), np.array([], dtype=np.uint64)) + + # cx_edges: cross-chunk edges (empty here) + cx_edges = Edges(np.array([], dtype=np.uint64), np.array([], dtype=np.uint64)) + + result = get_agglomerations( + l2id_children_d, in_edges, ot_edges, cx_edges, sv_parent_d + ) + + assert l2id in result + agg = result[l2id] + assert isinstance(agg, Agglomeration) + assert agg.node_id == l2id + np.testing.assert_array_equal( + agg.supervoxels, np.array([sv1, sv2, sv3], dtype=np.uint64) + ) + # The in_edges should contain both edges (sv1-sv2, sv2-sv3) since both have node_ids1 mapping to l2id + assert len(agg.in_edges) == 2 + assert len(agg.out_edges) == 0 + assert len(agg.cross_edges) == 0 + + def test_multiple_l2ids(self): + """Test get_agglomerations partitions edges correctly across multiple L2 IDs.""" + l2id_a = np.uint64(100) + l2id_b = np.uint64(200) + + sv_a1 = np.uint64(1) + sv_a2 = np.uint64(2) + sv_b1 = np.uint64(3) + sv_b2 = np.uint64(4) + + l2id_children_d = { + l2id_a: np.array([sv_a1, sv_a2], dtype=np.uint64), + l2id_b: np.array([sv_b1, sv_b2], dtype=np.uint64), + } + + sv_parent_d = {sv_a1: l2id_a, sv_a2: l2id_a, sv_b1: l2id_b, sv_b2: l2id_b} + + # in_edges: internal edges for each agglomeration + in_edges = Edges( + np.array([sv_a1, sv_b1], dtype=np.uint64), + np.array([sv_a2, sv_b2], dtype=np.uint64), + ) + + # ot_edges: edge from sv_a2 to sv_b1 (between agglomerations) + ot_edges = Edges( + np.array([sv_a2, sv_b1], dtype=np.uint64), + np.array([sv_b1, sv_a2], dtype=np.uint64), + ) + + # cx_edges: empty + cx_edges = Edges(np.array([], dtype=np.uint64), np.array([], dtype=np.uint64)) + + result = get_agglomerations( + l2id_children_d, in_edges, ot_edges, cx_edges, sv_parent_d + ) + + assert len(result) == 2 + assert l2id_a in result + assert l2id_b in result + + agg_a = result[l2id_a] + agg_b = result[l2id_b] + + # Each agglomeration should have exactly 1 internal edge + assert len(agg_a.in_edges) == 1 + assert len(agg_b.in_edges) == 1 + + # Each agglomeration should have exactly 1 out_edge + assert len(agg_a.out_edges) == 1 + assert len(agg_b.out_edges) == 1 + + def test_empty_edges(self): + """Test get_agglomerations with an L2 ID that has no edges at all.""" + l2id = np.uint64(50) + sv = np.uint64(10) + + l2id_children_d = {l2id: np.array([sv], dtype=np.uint64)} + sv_parent_d = {sv: l2id} + + in_edges = Edges(np.array([], dtype=np.uint64), np.array([], dtype=np.uint64)) + ot_edges = Edges(np.array([], dtype=np.uint64), np.array([], dtype=np.uint64)) + cx_edges = Edges(np.array([], dtype=np.uint64), np.array([], dtype=np.uint64)) + + result = get_agglomerations( + l2id_children_d, in_edges, ot_edges, cx_edges, sv_parent_d + ) + + assert l2id in result + agg = result[l2id] + assert agg.node_id == l2id + assert len(agg.in_edges) == 0 + assert len(agg.out_edges) == 0 + assert len(agg.cross_edges) == 0 + + +class TestGetActivatedEdges: + @pytest.mark.timeout(30) + def test_returns_numpy_array_after_merge(self, gen_graph): + """After merging two isolated SVs, get_activated_edges returns a numpy array.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + sv0 = to_label(graph, 1, 0, 0, 0, 0) + sv1 = to_label(graph, 1, 0, 0, 0, 1) + + create_chunk( + graph, + vertices=[sv0, sv1], + edges=[], + timestamp=fake_ts, + ) + + # Merge the two isolated supervoxels + result = graph.add_edges( + "TestUser", + [sv0, sv1], + affinities=[0.3], + ) + + activated = get_activated_edges(graph, result.operation_id) + assert isinstance(activated, np.ndarray) diff --git a/pychunkedgraph/tests/test_operation.py b/pychunkedgraph/tests/test_operation.py index e9d81999e..626efbf7e 100644 --- a/pychunkedgraph/tests/test_operation.py +++ b/pychunkedgraph/tests/test_operation.py @@ -1,11 +1,12 @@ """Integration tests for GraphEditOperation and its subclasses. Tests operation type identification from log records, operation inversion, -and undo/redo chain resolution — all using real graph operations through -the BigTable emulator. +undo/redo chain resolution, ID validation, and execute error handling +-- all using real graph operations through the BigTable emulator. """ from datetime import datetime, timedelta, UTC +from math import inf import numpy as np import pytest @@ -15,13 +16,73 @@ from ..graph.operation import ( GraphEditOperation, MergeOperation, + MulticutOperation, SplitOperation, RedoOperation, UndoOperation, ) +from ..graph.exceptions import PreconditionError, PostconditionError from ..ingest.create.parent_layer import add_parent_chunk +def _build_two_sv_disconnected(gen_graph): + """2-layer graph, two disconnected SVs in the same chunk.""" + cg = gen_graph(n_layers=2, atomic_chunk_bounds=np.array([1, 1, 1])) + ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[], + timestamp=ts, + ) + return cg, ts + + +def _build_two_sv_connected(gen_graph): + """2-layer graph, two connected SVs in the same chunk.""" + cg = gen_graph(n_layers=2, atomic_chunk_bounds=np.array([1, 1, 1])) + ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1)], + edges=[ + (to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5), + ], + timestamp=ts, + ) + return cg, ts + + +def _build_cross_chunk(gen_graph): + """4-layer graph with cross-chunk edges suitable for MulticutOperation.""" + cg = gen_graph(n_layers=4) + ts = datetime.now(UTC) - timedelta(days=10) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + create_chunk( + cg, + vertices=[sv0, sv1], + edges=[ + (sv0, sv1, 0.5), + (sv0, to_label(cg, 1, 1, 0, 0, 0), inf), + ], + timestamp=ts, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), sv0, inf)], + timestamp=ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(cg, 3, [1, 0, 0], n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], n_threads=1) + return cg, ts, sv0, sv1 + + +# =========================================================================== +# Existing tests (log record types, inversion, undo/redo chain) +# =========================================================================== class TestOperationFromLogRecord: """Test that GraphEditOperation.from_log_record correctly identifies operation types.""" @@ -108,7 +169,7 @@ class TestOperationInversion: @pytest.fixture() def split_and_merge_ops(self, gen_graph): - """Build graph, split, merge — return (cg, merge_op_id, split_op_id).""" + """Build graph, split, merge -- return (cg, merge_op_id, split_op_id).""" cg = gen_graph(n_layers=3) fake_timestamp = datetime.now(UTC) - timedelta(days=10) @@ -166,7 +227,7 @@ class TestUndoRedoChainResolution: @pytest.fixture() def graph_with_undo(self, gen_graph): - """Build graph, perform split, then undo — return (cg, split_op_id, undo_result).""" + """Build graph, perform split, then undo -- return (cg, split_op_id, undo_result).""" cg = gen_graph(n_layers=3) fake_timestamp = datetime.now(UTC) - timedelta(days=10) @@ -246,3 +307,654 @@ def test_undo_redo_chain_prevention(self, graph_with_undo): superseded_operation_id=undo_result.operation_id, multicut_as_split=True, ) + + +# =========================================================================== +# NEW: Multicut log record type identification (lines 151-153) +# =========================================================================== +class TestGetLogRecordTypeMulticut: + """Synthetic tests for MulticutOperation identification in get_log_record_type.""" + + def test_bbox_only_is_multicut(self): + """BoundingBoxOffset with no RemovedEdge -> MulticutOperation (line 152-153).""" + log = {attributes.OperationLogs.BoundingBoxOffset: np.array([10, 10, 10])} + assert GraphEditOperation.get_log_record_type(log) is MulticutOperation + + def test_removed_edge_with_bbox_multicut_as_split_true(self): + """RemovedEdge + BoundingBoxOffset + multicut_as_split=True -> SplitOperation (line 150).""" + log = { + attributes.OperationLogs.RemovedEdge: np.array([[1, 2]], dtype=np.uint64), + attributes.OperationLogs.BoundingBoxOffset: np.array([10, 10, 10]), + } + assert ( + GraphEditOperation.get_log_record_type(log, multicut_as_split=True) + is SplitOperation + ) + + def test_removed_edge_with_bbox_multicut_as_split_false(self): + """RemovedEdge + BoundingBoxOffset + multicut_as_split=False -> MulticutOperation (line 151).""" + log = { + attributes.OperationLogs.RemovedEdge: np.array([[1, 2]], dtype=np.uint64), + attributes.OperationLogs.BoundingBoxOffset: np.array([10, 10, 10]), + } + assert ( + GraphEditOperation.get_log_record_type(log, multicut_as_split=False) + is MulticutOperation + ) + + def test_undo_log_record(self): + """UndoOperationID in log -> UndoOperation.""" + log = {attributes.OperationLogs.UndoOperationID: np.uint64(42)} + assert GraphEditOperation.get_log_record_type(log) is UndoOperation + + def test_redo_log_record(self): + """RedoOperationID in log -> RedoOperation.""" + log = {attributes.OperationLogs.RedoOperationID: np.uint64(42)} + assert GraphEditOperation.get_log_record_type(log) is RedoOperation + + def test_empty_log_raises_type_error(self): + """Empty log record should raise TypeError (line 154).""" + with pytest.raises(TypeError, match="Could not determine"): + GraphEditOperation.get_log_record_type({}) + + +# =========================================================================== +# NEW: from_log_record MulticutOperation path (lines 235-251) +# =========================================================================== +class TestFromLogRecordMulticutPath: + """Test from_log_record for the MulticutOperation path with multicut_as_split=False.""" + + @pytest.mark.timeout(60) + def test_multicut_from_log_record(self, gen_graph): + """A multicut operation's log, read back with multicut_as_split=False, + should be reconstructed as MulticutOperation (lines 235-249).""" + cg, _, sv0, sv1 = _build_cross_chunk(gen_graph) + source_coords = [[0, 0, 0]] + sink_coords = [[512, 0, 0]] + try: + mc_result = cg.remove_edges( + "test_user", + source_ids=sv0, + sink_ids=sv1, + source_coords=source_coords, + sink_coords=sink_coords, + mincut=True, + ) + except (PreconditionError, PostconditionError): + pytest.skip("Multicut not feasible in this small test graph") + + log, _ = cg.client.read_log_entry(mc_result.operation_id) + op = GraphEditOperation.from_log_record(cg, log, multicut_as_split=False) + assert isinstance(op, MulticutOperation) + + # With default multicut_as_split=True -> SplitOperation + op2 = GraphEditOperation.from_log_record(cg, log, multicut_as_split=True) + assert isinstance(op2, SplitOperation) + + +# =========================================================================== +# NEW: from_operation_id (lines 278-281) +# =========================================================================== +class TestFromOperationId: + """Test GraphEditOperation.from_operation_id round-trip.""" + + @pytest.mark.timeout(30) + def test_from_operation_id_merge(self, gen_graph): + """from_operation_id should reconstruct a MergeOperation.""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + result = cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + op = GraphEditOperation.from_operation_id(cg, result.operation_id) + assert isinstance(op, MergeOperation) + # privileged_mode defaults to False + assert op.privileged_mode is False + + @pytest.mark.timeout(30) + def test_from_operation_id_privileged(self, gen_graph): + """from_operation_id with privileged_mode=True should propagate the flag (line 280).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + result = cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + op = GraphEditOperation.from_operation_id( + cg, result.operation_id, privileged_mode=True + ) + assert op.privileged_mode is True + + @pytest.mark.timeout(30) + def test_from_operation_id_split(self, gen_graph): + """from_operation_id should reconstruct a SplitOperation.""" + cg, _ = _build_two_sv_connected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + op = GraphEditOperation.from_operation_id(cg, result.operation_id) + assert isinstance(op, SplitOperation) + + +# =========================================================================== +# NEW: MulticutOperation.invert() (line 974-981) +# =========================================================================== +class TestMulticutInversion: + """Test MulticutOperation.invert() returns a MergeOperation.""" + + @pytest.mark.timeout(30) + def test_multicut_invert(self, gen_graph): + """MulticutOperation.invert() -> MergeOperation with removed_edges as added_edges.""" + cg, _, sv0, sv1 = _build_cross_chunk(gen_graph) + mc_op = MulticutOperation( + cg, + user_id="test_user", + source_ids=[sv0], + sink_ids=[sv1], + source_coords=[[0, 0, 0]], + sink_coords=[[512, 0, 0]], + bbox_offset=[240, 240, 24], + removed_edges=np.array([[sv0, sv1]], dtype=np.uint64), + ) + inverted = mc_op.invert() + assert isinstance(inverted, MergeOperation) + np.testing.assert_array_equal(inverted.added_edges, mc_op.removed_edges) + + +# =========================================================================== +# NEW: ID validation -- self-loops and overlapping IDs (lines 593-596, 732-733, 871-875) +# =========================================================================== +class TestIDValidation: + """Test PreconditionError on self-loops and overlapping IDs.""" + + @pytest.mark.timeout(30) + def test_merge_self_loop_raises(self, gen_graph): + """added_edges where source == sink should raise PreconditionError (line 596).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + with pytest.raises(PreconditionError, match="self-loop"): + MergeOperation( + cg, + user_id="test_user", + added_edges=[[sv0, sv0]], + source_coords=None, + sink_coords=None, + ) + + @pytest.mark.timeout(30) + def test_split_self_loop_raises(self, gen_graph): + """removed_edges where source == sink should raise PreconditionError (line 733).""" + cg, _ = _build_two_sv_connected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + with pytest.raises(PreconditionError, match="self-loop"): + SplitOperation( + cg, + user_id="test_user", + removed_edges=[[sv0, sv0]], + source_coords=None, + sink_coords=None, + ) + + @pytest.mark.timeout(30) + def test_multicut_overlapping_ids_raises(self, gen_graph): + """source_ids overlapping sink_ids should raise PreconditionError (line 872).""" + cg, _, sv0, sv1 = _build_cross_chunk(gen_graph) + with pytest.raises(PreconditionError, match="both sink and source"): + MulticutOperation( + cg, + user_id="test_user", + source_ids=[sv0, sv1], + sink_ids=[sv1], + source_coords=[[0, 0, 0], [1, 0, 0]], + sink_coords=[[1, 0, 0]], + bbox_offset=[240, 240, 24], + ) + + +# =========================================================================== +# NEW: Empty coords / affinities normalization (lines 82, 86, 593) +# =========================================================================== +class TestEmptyCoordsAffinities: + """Empty source/sink coords and affinities should be normalized to None.""" + + @pytest.mark.timeout(30) + def test_empty_source_coords_becomes_none(self, gen_graph): + """source_coords with size 0 should be stored as None (line 82).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + op = MergeOperation( + cg, + user_id="test_user", + added_edges=[[sv0, sv1]], + source_coords=np.array([], dtype=np.int64).reshape(0, 3), + sink_coords=np.array([], dtype=np.int64).reshape(0, 3), + ) + assert op.source_coords is None + assert op.sink_coords is None + + @pytest.mark.timeout(30) + def test_empty_affinities_becomes_none(self, gen_graph): + """affinities with size 0 should be stored as None (line 593).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + op = MergeOperation( + cg, + user_id="test_user", + added_edges=[[sv0, sv1]], + source_coords=None, + sink_coords=None, + affinities=np.array([], dtype=np.float32), + ) + assert op.affinities is None + + +# =========================================================================== +# NEW: Merge / Split preconditions via execute (lines 618, 765) +# =========================================================================== +class TestEditPreconditions: + """Test precondition errors raised during _apply.""" + + @pytest.mark.timeout(30) + def test_merge_same_segment_raises(self, gen_graph): + """Merging SVs already in the same root raises PreconditionError (line 618).""" + cg, _ = _build_two_sv_connected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + with pytest.raises(PreconditionError, match="different objects"): + cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + @pytest.mark.timeout(30) + def test_split_different_roots_raises(self, gen_graph): + """Splitting SVs from different roots raises PreconditionError (line 765).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + with pytest.raises(PreconditionError, match="same object"): + cg.remove_edges("test_user", source_ids=sv0, sink_ids=sv1, mincut=False) + + +# =========================================================================== +# NEW: Undo / Redo via actual operations (lines 1160-1175, 1245-1259, etc.) +# =========================================================================== +class TestUndoRedoExecute: + """End-to-end undo/redo tests that verify graph state after execute.""" + + def _build_connected_cross_chunk(self, gen_graph): + """Build a 3-layer graph with between-chunk edge -- suitable for split+undo.""" + cg = gen_graph(n_layers=3) + ts = datetime.now(UTC) - timedelta(days=10) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 1, 0, 0, 0) + create_chunk( + cg, + vertices=[sv0], + edges=[(sv0, sv1, 0.5)], + timestamp=ts, + ) + create_chunk( + cg, + vertices=[sv1], + edges=[(sv1, sv0, 0.5)], + timestamp=ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=ts, n_threads=1) + return cg, sv0, sv1 + + @pytest.mark.timeout(60) + def test_undo_split_restores_root(self, gen_graph): + """After split + undo, the SVs should share a root again (lines 1160-1175).""" + cg, sv0, sv1 = self._build_connected_cross_chunk(gen_graph) + assert cg.get_root(sv0) == cg.get_root(sv1) + + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + assert cg.get_root(sv0) != cg.get_root(sv1) + + undo_result = cg.undo_operation("test_user", split_result.operation_id) + assert cg.get_root(sv0) == cg.get_root(sv1) + + @pytest.mark.timeout(60) + def test_redo_split_after_undo(self, gen_graph): + """After split + undo, redo the split directly (lines 1036-1043, 1094-1106).""" + cg, sv0, sv1 = self._build_connected_cross_chunk(gen_graph) + + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + assert cg.get_root(sv0) != cg.get_root(sv1) + + undo_result = cg.undo_operation("test_user", split_result.operation_id) + assert cg.get_root(sv0) == cg.get_root(sv1) + + # Redo the original split directly + redo_result = cg.redo_operation("test_user", split_result.operation_id) + # The redo should succeed and re-apply the split + assert redo_result.operation_id is not None + + @pytest.mark.timeout(60) + def test_undo_of_undo_resolves_to_redo(self, gen_graph): + """Undoing an undo should resolve to a RedoOperation (lines 102-108).""" + cg, sv0, sv1 = self._build_connected_cross_chunk(gen_graph) + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + undo_result = cg.undo_operation("test_user", split_result.operation_id) + + op = GraphEditOperation.undo_operation( + cg, user_id="test_user", operation_id=undo_result.operation_id + ) + assert isinstance(op, RedoOperation) + + +# =========================================================================== +# NEW: UndoOperation / RedoOperation .invert() (lines 1087, 1228) +# =========================================================================== +class TestUndoRedoInvert: + """Test invert() on UndoOperation and RedoOperation.""" + + @pytest.mark.timeout(60) + def test_undo_invert_is_redo(self, gen_graph): + """UndoOperation.invert() -> RedoOperation (line 1228).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + merge_result = cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + undo_op = GraphEditOperation.undo_operation( + cg, user_id="test_user", operation_id=merge_result.operation_id + ) + assert isinstance(undo_op, UndoOperation) + inverted = undo_op.invert() + assert isinstance(inverted, RedoOperation) + assert inverted.superseded_operation_id == undo_op.superseded_operation_id + + @pytest.mark.timeout(60) + def test_redo_invert_is_undo(self, gen_graph): + """RedoOperation.invert() -> UndoOperation (line 1087).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + merge_result = cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + redo_op = GraphEditOperation.redo_operation( + cg, user_id="test_user", operation_id=merge_result.operation_id + ) + assert isinstance(redo_op, RedoOperation) + inverted = redo_op.invert() + assert isinstance(inverted, UndoOperation) + assert inverted.superseded_operation_id == redo_op.superseded_operation_id + + +# =========================================================================== +# NEW: UndoOperation / RedoOperation edge attributes (lines 1040-1043, 1172-1175) +# =========================================================================== +class TestUndoRedoEdgeAttributes: + """Verify that undo/redo operations carry the correct edge attributes.""" + + @pytest.mark.timeout(60) + def test_undo_merge_has_removed_edges(self, gen_graph): + """Undoing a merge -> inverse is SplitOp -> undo should have removed_edges (line 1175).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + merge_result = cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + undo_op = GraphEditOperation.undo_operation( + cg, user_id="test_user", operation_id=merge_result.operation_id + ) + assert hasattr(undo_op, "removed_edges") + assert undo_op.removed_edges.shape[1] == 2 + + @pytest.mark.timeout(60) + def test_undo_split_has_added_edges(self, gen_graph): + """Undoing a split -> inverse is MergeOp -> undo should have added_edges (line 1173).""" + cg, _ = _build_two_sv_connected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + + undo_op = GraphEditOperation.undo_operation( + cg, user_id="test_user", operation_id=split_result.operation_id + ) + assert hasattr(undo_op, "added_edges") + assert undo_op.added_edges.shape[1] == 2 + + @pytest.mark.timeout(60) + def test_redo_merge_has_added_edges(self, gen_graph): + """RedoOperation for a merge should have added_edges (line 1040-1041).""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + merge_result = cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + redo_op = GraphEditOperation.redo_operation( + cg, user_id="test_user", operation_id=merge_result.operation_id + ) + assert isinstance(redo_op, RedoOperation) + assert hasattr(redo_op, "added_edges") + assert redo_op.added_edges.shape[1] == 2 + + @pytest.mark.timeout(60) + def test_redo_split_has_removed_edges(self, gen_graph): + """RedoOperation for a split should have removed_edges (line 1042-1043).""" + cg, _ = _build_two_sv_connected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + + redo_op = GraphEditOperation.redo_operation( + cg, user_id="test_user", operation_id=split_result.operation_id + ) + assert isinstance(redo_op, RedoOperation) + assert hasattr(redo_op, "removed_edges") + assert redo_op.removed_edges.shape[1] == 2 + + +# =========================================================================== +# NEW: Undo / Redo log record type from actual operations +# =========================================================================== +class TestUndoRedoLogRecordTypes: + """Verify that actual undo/redo operations produce correct log record types.""" + + def _build_and_split(self, gen_graph): + """Build a cross-chunk graph and split it -- suitable for undo/redo.""" + cg = gen_graph(n_layers=3) + ts = datetime.now(UTC) - timedelta(days=10) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 1, 0, 0, 0) + create_chunk( + cg, + vertices=[sv0], + edges=[(sv0, sv1, 0.5)], + timestamp=ts, + ) + create_chunk( + cg, + vertices=[sv1], + edges=[(sv1, sv0, 0.5)], + timestamp=ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=ts, n_threads=1) + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + return cg, sv0, sv1, split_result + + @pytest.mark.timeout(60) + def test_undo_log_type(self, gen_graph): + """Undo operation log should be identified as UndoOperation.""" + cg, sv0, sv1, split_result = self._build_and_split(gen_graph) + undo_result = cg.undo_operation("test_user", split_result.operation_id) + + log, _ = cg.client.read_log_entry(undo_result.operation_id) + assert GraphEditOperation.get_log_record_type(log) is UndoOperation + + @pytest.mark.timeout(60) + def test_redo_log_type(self, gen_graph): + """Redo operation log should be identified as RedoOperation.""" + cg, sv0, sv1, split_result = self._build_and_split(gen_graph) + undo_result = cg.undo_operation("test_user", split_result.operation_id) + + # Redo the split that was just undone + redo_result = cg.redo_operation("test_user", split_result.operation_id) + assert redo_result.operation_id is not None + + log, _ = cg.client.read_log_entry(redo_result.operation_id) + assert GraphEditOperation.get_log_record_type(log) is RedoOperation + + +# =========================================================================== +# NEW: execute() error handling -- PreconditionError clears cache (lines 436, 460-462) +# =========================================================================== +class TestExecuteErrorHandling: + """Test that execute() clears cache on PreconditionError/PostconditionError.""" + + @pytest.mark.timeout(30) + def test_execute_precondition_error_clears_cache(self, gen_graph): + """Trigger PreconditionError during merge (same-segment merge) and verify cache is cleared.""" + cg, _ = _build_two_sv_connected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + + # Merging already-connected SVs raises PreconditionError + with pytest.raises(PreconditionError, match="different objects"): + cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + # After the error, the graph cache should have been cleared (set to None) + assert cg.cache is None + + @pytest.mark.timeout(30) + def test_execute_postcondition_error_clears_cache(self, gen_graph): + """PostconditionError during execute should also clear cache (lines 463-465).""" + from unittest.mock import patch + + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + + # Mock _apply to raise PostconditionError + with patch.object( + MergeOperation, + "_apply", + side_effect=PostconditionError("test postcondition error"), + ): + with pytest.raises(PostconditionError, match="test postcondition error"): + cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + # Cache should have been cleared + assert cg.cache is None + + @pytest.mark.timeout(30) + def test_execute_assertion_error_clears_cache(self, gen_graph): + """AssertionError/RuntimeError during execute should also clear cache (lines 466-468).""" + from unittest.mock import patch + + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + + # Mock _apply to raise RuntimeError + with patch.object( + MergeOperation, "_apply", side_effect=RuntimeError("test runtime error") + ): + with pytest.raises(RuntimeError, match="test runtime error"): + cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + + assert cg.cache is None + + +# =========================================================================== +# NEW: UndoOperation.execute() edge validation (lines 1245-1267) +# =========================================================================== +class TestUndoEdgeValidation: + """Test UndoOperation.execute() edge validation logic.""" + + def _build_connected_cross_chunk(self, gen_graph): + """Build a 3-layer graph with between-chunk edge suitable for split+undo.""" + cg = gen_graph(n_layers=3) + ts = datetime.now(UTC) - timedelta(days=10) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 1, 0, 0, 0) + create_chunk( + cg, + vertices=[sv0], + edges=[(sv0, sv1, 0.5)], + timestamp=ts, + ) + create_chunk( + cg, + vertices=[sv1], + edges=[(sv1, sv0, 0.5)], + timestamp=ts, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=ts, n_threads=1) + return cg, sv0, sv1 + + @pytest.mark.timeout(60) + def test_undo_split_restores_edges(self, gen_graph): + """After undo of a split, edges should be active again.""" + cg, sv0, sv1 = self._build_connected_cross_chunk(gen_graph) + + # Verify initially connected + assert cg.get_root(sv0) == cg.get_root(sv1) + + # Split + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + assert cg.get_root(sv0) != cg.get_root(sv1) + + # Undo the split + undo_result = cg.undo_operation("test_user", split_result.operation_id) + assert undo_result.operation_id is not None + + # Edges should be active again -- the SVs share a root + assert cg.get_root(sv0) == cg.get_root(sv1) + + @pytest.mark.timeout(60) + def test_undo_merge_via_undo_operation_class(self, gen_graph): + """UndoOperation on a merge constructs with inverse being SplitOperation.""" + cg, _ = _build_two_sv_disconnected(gen_graph) + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + + # Merge + merge_result = cg.add_edges("test_user", [sv0, sv1], affinities=[0.3]) + assert cg.get_root(sv0) == cg.get_root(sv1) + + # Build the UndoOperation manually to inspect its structure + undo_op = GraphEditOperation.undo_operation( + cg, user_id="test_user", operation_id=merge_result.operation_id + ) + assert isinstance(undo_op, UndoOperation) + # The inverse of a merge is a split, so removed_edges should be set + assert hasattr(undo_op, "removed_edges") + assert undo_op.removed_edges.shape[1] == 2 + + @pytest.mark.timeout(60) + def test_undo_noop_when_split_already_undone(self, gen_graph): + """UndoOperation.execute() with edges already active returns early (lines 1253-1258).""" + cg, sv0, sv1 = self._build_connected_cross_chunk(gen_graph) + + # Split + split_result = cg.remove_edges( + "test_user", source_ids=sv0, sink_ids=sv1, mincut=False + ) + + # First undo + undo_result1 = cg.undo_operation("test_user", split_result.operation_id) + assert cg.get_root(sv0) == cg.get_root(sv1) + + # Second undo of the same split -- the inverse is a MergeOp + # and since those edges are already active, it should return early + # (lines 1253-1258: if np.all(a): early return with empty Result) + undo_result2 = cg.undo_operation("test_user", split_result.operation_id) + # The early return path returns a Result with operation_id=None and empty arrays + assert undo_result2.operation_id is None + assert len(undo_result2.new_root_ids) == 0 diff --git a/pychunkedgraph/tests/test_segmenthistory.py b/pychunkedgraph/tests/test_segmenthistory.py new file mode 100644 index 000000000..0ccb2ab55 --- /dev/null +++ b/pychunkedgraph/tests/test_segmenthistory.py @@ -0,0 +1,627 @@ +"""Tests for pychunkedgraph.graph.segmenthistory""" + +from datetime import datetime, timedelta, UTC + +import numpy as np +import pytest +from pandas import DataFrame + +from pychunkedgraph.graph.segmenthistory import ( + SegmentHistory, + LogEntry, + get_all_log_entries, +) + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestSegmentHistory: + def _build_and_merge(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, new_root + + def test_init(self, gen_graph): + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + assert len(sh.root_ids) == 1 + + def test_lineage_graph(self, gen_graph): + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + lg = sh.lineage_graph + assert len(lg.nodes) > 0 + + def test_operation_ids(self, gen_graph): + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + ops = sh.operation_ids + assert len(ops) > 0 + + def test_past_operation_ids(self, gen_graph): + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + past_ops = sh.past_operation_ids(root_id=new_root) + assert isinstance(past_ops, np.ndarray) + + def test_collect_edited_sv_ids(self, gen_graph): + """After a merge, collect_edited_sv_ids should return supervoxel IDs.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + sv_ids = sh.collect_edited_sv_ids() + assert isinstance(sv_ids, np.ndarray) + assert sv_ids.dtype == np.uint64 + # The merge involved 2 supervoxels, so at least some IDs should appear + assert len(sv_ids) > 0 + + def test_collect_edited_sv_ids_with_root(self, gen_graph): + """collect_edited_sv_ids with an explicit root_id should also work.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + sv_ids = sh.collect_edited_sv_ids(root_id=new_root) + assert isinstance(sv_ids, np.ndarray) + assert len(sv_ids) > 0 + + def test_root_id_operation_id_dict(self, gen_graph): + """root_id_operation_id_dict maps each root_id in the lineage to its operation_id.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + d = sh.root_id_operation_id_dict + assert isinstance(d, dict) + # Should contain at least the new root + assert new_root in d + # Values should be integer operation IDs (including 0 for non-edit nodes) + for root_id, op_id in d.items(): + assert isinstance(root_id, (int, np.integer)) + assert isinstance(op_id, (int, np.integer)) + + def test_root_id_timestamp_dict(self, gen_graph): + """root_id_timestamp_dict maps each root_id to a timestamp.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + d = sh.root_id_timestamp_dict + assert isinstance(d, dict) + assert new_root in d + # Timestamps should be numeric (epoch seconds) or 0 for defaults + for root_id, ts in d.items(): + assert isinstance(ts, (int, float, np.integer, np.floating)) + + def test_last_edit_timestamp(self, gen_graph): + """last_edit_timestamp should return the timestamp for the given root.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + ts = sh.last_edit_timestamp(root_id=new_root) + # Should be a numeric timestamp (float epoch) or default value + assert isinstance(ts, (int, float, np.integer, np.floating)) + + def test_log_entry_api(self, gen_graph): + """After a merge, retrieve a log entry and verify its properties.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + op_ids = sh.operation_ids + # Filter out operation_id 0 (default for nodes without operations) + op_ids = op_ids[op_ids != 0] + assert len(op_ids) > 0, "Expected at least one real operation ID" + + entry = sh.log_entry(op_ids[0]) + assert isinstance(entry, LogEntry) + + # is_merge should be True since we performed a merge + assert entry.is_merge is True + + # user_id should be the user we passed to add_edges + assert entry.user_id == "TestUser" + + # log_type should be "merge" + assert entry.log_type == "merge" + + # edges_failsafe should return an array of SV IDs + ef = entry.edges_failsafe + assert isinstance(ef, np.ndarray) + assert len(ef) > 0 + + # __str__ should return a non-empty string + s = str(entry) + assert isinstance(s, str) + assert len(s) > 0 + + # __iter__ should yield attributes (user_id, log_type, root_ids, timestamp) + items = list(entry) + assert len(items) == 4 + + def test_tabular_changelogs(self, gen_graph): + """After a merge, tabular_changelogs should produce a DataFrame per root.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + changelogs = sh.tabular_changelogs + assert isinstance(changelogs, dict) + assert new_root in changelogs + + df = changelogs[new_root] + assert isinstance(df, DataFrame) + + # Verify expected columns are present + expected_columns = { + "operation_id", + "timestamp", + "user_id", + "before_root_ids", + "after_root_ids", + "is_merge", + "in_neuron", + "is_relevant", + } + assert expected_columns.issubset(set(df.columns)) + + # Should have at least one row (the merge we performed) + assert len(df) > 0 + + def test_tabular_changelog_single_root(self, gen_graph): + """tabular_changelog() with a single root should return the DataFrame directly.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + df = sh.tabular_changelog() + assert isinstance(df, DataFrame) + assert len(df) > 0 + + def test_operation_id_root_id_dict(self, gen_graph): + """operation_id_root_id_dict should be the inverse of root_id_operation_id_dict.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + d = sh.operation_id_root_id_dict + assert isinstance(d, dict) + # Each value should be a list of root IDs + for op_id, root_ids in d.items(): + assert isinstance(root_ids, list) + assert len(root_ids) > 0 + + def test_tabular_changelogs_filtered(self, gen_graph): + """After merge, tabular_changelogs_filtered returns dict with DataFrames + that have 'in_neuron' and 'is_relevant' columns.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + filtered = sh.tabular_changelogs_filtered + assert isinstance(filtered, dict) + assert new_root in filtered + df = filtered[new_root] + assert isinstance(df, DataFrame) + # The filtered method calls tabular_changelog(filtered=True) which + # drops "in_neuron" and "is_relevant" columns after filtering + assert "in_neuron" not in df.columns + assert "is_relevant" not in df.columns + + def test_tabular_changelog_with_explicit_root(self, gen_graph): + """tabular_changelog(root_id=new_root) should work same as without.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + df_implicit = sh.tabular_changelog() + df_explicit = sh.tabular_changelog(root_id=new_root) + assert isinstance(df_explicit, DataFrame) + assert len(df_explicit) == len(df_implicit) + # Same columns + assert set(df_explicit.columns) == set(df_implicit.columns) + + def test_change_log_summary(self, gen_graph): + """change_log_summary should return n_splits, n_mergers, user_info, etc.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + summary = sh.change_log_summary(root_id=new_root) + assert isinstance(summary, dict) + assert "n_splits" in summary + assert "n_mergers" in summary + assert "user_info" in summary + assert "operations_ids" in summary + assert "past_ids" in summary + assert summary["n_mergers"] >= 1 + + def test_past_future_id_mapping(self, gen_graph): + """past_future_id_mapping should return two dicts mapping past<->future root IDs.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + past_map, future_map = sh.past_future_id_mapping(root_id=new_root) + assert isinstance(past_map, dict) + assert isinstance(future_map, dict) + # The new_root should appear in past_map + assert int(new_root) in past_map + + +class TestLogEntryUnit: + """Pure unit tests for LogEntry class (no emulator needed).""" + + def test_merge_entry(self): + from pychunkedgraph.graph.attributes import OperationLogs + + row = { + OperationLogs.AddedEdge: np.array([[1, 2]], dtype=np.uint64), + OperationLogs.UserID: "alice", + OperationLogs.RootID: np.array([100], dtype=np.uint64), + OperationLogs.SourceID: np.array([1], dtype=np.uint64), + OperationLogs.SinkID: np.array([2], dtype=np.uint64), + OperationLogs.SourceCoordinate: np.array([0, 0, 0]), + OperationLogs.SinkCoordinate: np.array([1, 1, 1]), + } + ts = datetime.now(UTC) + entry = LogEntry(row, timestamp=ts) + assert entry.is_merge is True + assert entry.log_type == "merge" + assert entry.user_id == "alice" + assert entry.timestamp == ts + np.testing.assert_array_equal(entry.root_ids, np.array([100], dtype=np.uint64)) + np.testing.assert_array_equal( + entry.added_edges, np.array([[1, 2]], dtype=np.uint64) + ) + coords = entry.coordinates + assert coords.shape == (2, 3) + ef = entry.edges_failsafe + assert len(ef) > 0 + + def test_split_entry(self): + from pychunkedgraph.graph.attributes import OperationLogs + + row = { + OperationLogs.RemovedEdge: np.array([[3, 4]], dtype=np.uint64), + OperationLogs.UserID: "bob", + OperationLogs.RootID: np.array([200, 201], dtype=np.uint64), + OperationLogs.SourceID: np.array([3], dtype=np.uint64), + OperationLogs.SinkID: np.array([4], dtype=np.uint64), + OperationLogs.SourceCoordinate: np.array([0, 0, 0]), + OperationLogs.SinkCoordinate: np.array([1, 1, 1]), + } + ts = datetime.now(UTC) + entry = LogEntry(row, timestamp=ts) + assert entry.is_merge is False + assert entry.log_type == "split" + assert entry.user_id == "bob" + np.testing.assert_array_equal( + entry.removed_edges, np.array([[3, 4]], dtype=np.uint64) + ) + assert len(str(entry)) > 0 + assert len(list(entry)) == 4 + + def test_added_edges_on_split_raises(self): + from pychunkedgraph.graph.attributes import OperationLogs + + row = { + OperationLogs.RemovedEdge: np.array([[3, 4]], dtype=np.uint64), + OperationLogs.UserID: "bob", + OperationLogs.RootID: np.array([200], dtype=np.uint64), + } + entry = LogEntry(row, timestamp=datetime.now(UTC)) + with pytest.raises(AssertionError, match="Not a merge"): + entry.added_edges + + def test_removed_edges_on_merge_raises(self): + from pychunkedgraph.graph.attributes import OperationLogs + + row = { + OperationLogs.AddedEdge: np.array([[1, 2]], dtype=np.uint64), + OperationLogs.UserID: "alice", + OperationLogs.RootID: np.array([100], dtype=np.uint64), + } + entry = LogEntry(row, timestamp=datetime.now(UTC)) + with pytest.raises(AssertionError, match="Not a split"): + entry.removed_edges + + +class TestGetAllLogEntries: + def test_empty_graph(self, gen_graph): + """Create graph with no operations. get_all_log_entries should return empty list.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + # Create a chunk with vertices but perform no edits + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + entries = get_all_log_entries(graph) + assert isinstance(entries, list) + assert len(entries) == 0 + + def test_basic(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + # get_all_log_entries iterates range(get_max_operation_id()) which + # may not include the actual operation ID; verify it doesn't crash + entries = get_all_log_entries(graph) + assert isinstance(entries, list) + # If entries exist, verify LogEntry API works + for entry in entries: + assert entry.log_type in ("merge", "split") + assert str(entry) + for _ in entry: + pass + + +class TestMergeLog: + """Tests for SegmentHistory.merge_log() method (lines 245-268).""" + + def _build_and_merge(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + source_coords=[0, 0, 0], + sink_coords=[1, 1, 1], + ) + new_root = result.new_root_ids[0] + return graph, new_root + + def test_merge_log_with_root(self, gen_graph): + """merge_log(root_id=...) should return merge_edges and merge_edge_coords.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + result = sh.merge_log(root_id=new_root) + assert isinstance(result, dict) + assert "merge_edges" in result + assert "merge_edge_coords" in result + # We performed one merge, so there should be one entry + assert len(result["merge_edges"]) >= 1 + assert len(result["merge_edge_coords"]) >= 1 + + def test_merge_log_without_root(self, gen_graph): + """merge_log() without root_id should iterate over all root_ids.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + result = sh.merge_log() + assert isinstance(result, dict) + assert "merge_edges" in result + assert "merge_edge_coords" in result + + def test_merge_log_correct_for_wrong_coord_type_false(self, gen_graph): + """merge_log with correct_for_wrong_coord_type=False should skip coord hack.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + result = sh.merge_log(root_id=new_root, correct_for_wrong_coord_type=False) + assert isinstance(result, dict) + assert "merge_edges" in result + assert len(result["merge_edges"]) >= 1 + + +class TestPastOperationIdsExtended: + """Tests for SegmentHistory.past_operation_ids() (lines 270-292).""" + + def _build_and_merge(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, new_root + + def test_past_operation_ids_without_root(self, gen_graph): + """past_operation_ids() without root_id iterates all root_ids.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + ops = sh.past_operation_ids() + assert isinstance(ops, np.ndarray) + # Should have at least the merge operation + assert len(ops) >= 1 + # 0 should not appear in result + assert 0 not in ops + + def test_past_operation_ids_with_root(self, gen_graph): + """past_operation_ids(root_id=...) should return operations for that root.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + ops = sh.past_operation_ids(root_id=new_root) + assert isinstance(ops, np.ndarray) + assert len(ops) >= 1 + + +class TestPastFutureIdMappingExtended: + """More thorough tests for past_future_id_mapping (lines 315-368).""" + + def _build_and_merge(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + ) + new_root = result.new_root_ids[0] + return graph, new_root + + def test_past_future_id_mapping_without_root(self, gen_graph): + """past_future_id_mapping() without root_id iterates all root_ids.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + past_map, future_map = sh.past_future_id_mapping() + assert isinstance(past_map, dict) + assert isinstance(future_map, dict) + assert int(new_root) in past_map + + def test_past_future_id_mapping_values(self, gen_graph): + """Verify past_map values are arrays of past root IDs.""" + graph, new_root = self._build_and_merge(gen_graph) + sh = SegmentHistory(graph, new_root) + past_map, future_map = sh.past_future_id_mapping(root_id=new_root) + # past_map[int(new_root)] should point back to the original roots + past_ids = past_map[int(new_root)] + assert len(past_ids) >= 1 + # future_map should have entries for the past IDs + for past_id in past_ids: + if past_id in future_map: + assert future_map[past_id] is not None + + +class TestMergeSplitHistory: + """Tests involving merge followed by split to cover more branches.""" + + def _build_merge_and_split(self, gen_graph): + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[], + timestamp=fake_ts, + ) + # Merge + merge_result = graph.add_edges( + "TestUser", + [to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + affinities=[0.3], + source_coords=[0, 0, 0], + sink_coords=[1, 1, 1], + ) + merge_root = merge_result.new_root_ids[0] + + # Split + split_result = graph.remove_edges( + "TestUser", + source_ids=to_label(graph, 1, 0, 0, 0, 0), + sink_ids=to_label(graph, 1, 0, 0, 0, 1), + mincut=False, + ) + split_roots = split_result.new_root_ids + return graph, merge_root, split_roots + + def test_change_log_summary_with_split(self, gen_graph): + """change_log_summary after merge+split should show both operations.""" + graph, merge_root, split_roots = self._build_merge_and_split(gen_graph) + # Use the first split root as the segment history root + root = split_roots[0] + sh = SegmentHistory(graph, root) + summary = sh.change_log_summary(root_id=root) + assert isinstance(summary, dict) + assert "n_splits" in summary + assert "n_mergers" in summary + # There was at least a merge and a split in the history + total_ops = summary["n_splits"] + summary["n_mergers"] + assert total_ops >= 1 + + def test_past_operation_ids_after_split(self, gen_graph): + """past_operation_ids should include both merge and split operations.""" + graph, merge_root, split_roots = self._build_merge_and_split(gen_graph) + root = split_roots[0] + sh = SegmentHistory(graph, root) + ops = sh.past_operation_ids(root_id=root) + assert isinstance(ops, np.ndarray) + # Should include at least 2 operations (merge + split) + assert len(ops) >= 2 + + def test_merge_log_after_split(self, gen_graph): + """merge_log after split should still find the original merge.""" + graph, merge_root, split_roots = self._build_merge_and_split(gen_graph) + root = split_roots[0] + sh = SegmentHistory(graph, root) + result = sh.merge_log(root_id=root) + assert isinstance(result, dict) + # The original merge should still be in the history + assert len(result["merge_edges"]) >= 1 + + def test_tabular_changelog_after_split(self, gen_graph): + """tabular_changelog after merge+split should have multiple rows.""" + graph, merge_root, split_roots = self._build_merge_and_split(gen_graph) + root = split_roots[0] + sh = SegmentHistory(graph, root) + df = sh.tabular_changelog(root_id=root) + assert isinstance(df, DataFrame) + # Should have at least 2 rows (merge + split) + assert len(df) >= 2 + + def test_past_future_id_mapping_after_split(self, gen_graph): + """past_future_id_mapping after merge+split should track the lineage.""" + graph, merge_root, split_roots = self._build_merge_and_split(gen_graph) + root = split_roots[0] + sh = SegmentHistory(graph, root) + past_map, future_map = sh.past_future_id_mapping(root_id=root) + assert isinstance(past_map, dict) + assert isinstance(future_map, dict) + + def test_collect_edited_sv_ids_no_edits(self, gen_graph): + """collect_edited_sv_ids returns empty array when no edits exist for a root.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + sh = SegmentHistory(graph, root) + sv_ids = sh.collect_edited_sv_ids(root_id=root) + assert isinstance(sv_ids, np.ndarray) + assert sv_ids.dtype == np.uint64 + assert len(sv_ids) == 0 + + def test_change_log_summary_no_operations(self, gen_graph): + """change_log_summary with no operations should show zero splits/merges.""" + atomic_chunk_bounds = np.array([1, 1, 1]) + graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + sh = SegmentHistory(graph, root) + summary = sh.change_log_summary(root_id=root) + assert isinstance(summary, dict) + assert summary["n_splits"] == 0 + assert summary["n_mergers"] == 0 + assert len(summary["past_ids"]) == 0 diff --git a/pychunkedgraph/tests/test_serializers.py b/pychunkedgraph/tests/test_serializers.py new file mode 100644 index 000000000..59f1ed8c3 --- /dev/null +++ b/pychunkedgraph/tests/test_serializers.py @@ -0,0 +1,143 @@ +"""Tests for pychunkedgraph.graph.utils.serializers""" + +import numpy as np + +from pychunkedgraph.graph.utils.serializers import ( + _Serializer, + NumPyArray, + NumPyValue, + String, + JSON, + Pickle, + UInt64String, + pad_node_id, + serialize_uint64, + deserialize_uint64, + serialize_uint64s_to_regex, + serialize_key, + deserialize_key, +) +from pychunkedgraph.graph.utils import basetypes + + +class TestNumPyArray: + def test_roundtrip(self): + s = NumPyArray(dtype=basetypes.NODE_ID) + arr = np.array([1, 2, 3], dtype=basetypes.NODE_ID) + data = s.serialize(arr) + result = s.deserialize(data) + np.testing.assert_array_equal(result, arr) + + def test_with_shape(self): + s = NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)) + arr = np.array([[1, 2], [3, 4]], dtype=basetypes.NODE_ID) + data = s.serialize(arr) + result = s.deserialize(data) + assert result.shape == (2, 2) + np.testing.assert_array_equal(result, arr) + + def test_with_compression(self): + s = NumPyArray(dtype=basetypes.NODE_ID, compression_level=3) + arr = np.array([1, 2, 3, 4, 5], dtype=basetypes.NODE_ID) + data = s.serialize(arr) + result = s.deserialize(data) + np.testing.assert_array_equal(result, arr) + + def test_basetype(self): + s = NumPyArray(dtype=basetypes.NODE_ID) + assert s.basetype == basetypes.NODE_ID.type + + +class TestNumPyValue: + def test_roundtrip(self): + s = NumPyValue(dtype=basetypes.NODE_ID) + val = np.uint64(42) + data = s.serialize(val) + result = s.deserialize(data) + assert result == val + + +class TestString: + def test_roundtrip(self): + s = String() + data = s.serialize("hello") + assert s.deserialize(data) == "hello" + + +class TestJSON: + def test_roundtrip(self): + s = JSON() + obj = {"key": "value", "nested": [1, 2, 3]} + data = s.serialize(obj) + assert s.deserialize(data) == obj + + +class TestPickle: + def test_roundtrip(self): + s = Pickle() + obj = {"complex": [1, 2], "nested": {"a": True}} + data = s.serialize(obj) + assert s.deserialize(data) == obj + + +class TestUInt64String: + def test_roundtrip(self): + s = UInt64String() + val = np.uint64(12345) + data = s.serialize(val) + result = s.deserialize(data) + assert result == val + + +class TestPadNodeId: + def test_padding(self): + result = pad_node_id(np.uint64(42)) + assert len(result) == 20 + assert result == "00000000000000000042" + + def test_large_id(self): + result = pad_node_id(np.uint64(12345678901234567890)) + assert len(result) == 20 + + +class TestSerializeUint64: + def test_default(self): + result = serialize_uint64(np.uint64(42)) + assert isinstance(result, bytes) + assert b"00000000000000000042" in result + + def test_counter(self): + result = serialize_uint64(np.uint64(42), counter=True) + assert result.startswith(b"i") + + def test_fake_edges(self): + result = serialize_uint64(np.uint64(42), fake_edges=True) + assert result.startswith(b"f") + + +class TestDeserializeUint64: + def test_default(self): + serialized = serialize_uint64(np.uint64(42)) + result = deserialize_uint64(serialized) + assert result == np.uint64(42) + + def test_fake_edges(self): + serialized = serialize_uint64(np.uint64(42), fake_edges=True) + result = deserialize_uint64(serialized, fake_edges=True) + assert result == np.uint64(42) + + +class TestSerializeUint64sToRegex: + def test_multiple_ids(self): + ids = [np.uint64(1), np.uint64(2)] + result = serialize_uint64s_to_regex(ids) + assert isinstance(result, bytes) + assert b"|" in result + + +class TestSerializeKey: + def test_roundtrip(self): + key = "test_key_123" + serialized = serialize_key(key) + assert isinstance(serialized, bytes) + assert deserialize_key(serialized) == key diff --git a/pychunkedgraph/tests/test_stale_edges.py b/pychunkedgraph/tests/test_stale_edges.py index 344ef8772..bf160bdcc 100644 --- a/pychunkedgraph/tests/test_stale_edges.py +++ b/pychunkedgraph/tests/test_stale_edges.py @@ -200,3 +200,240 @@ def test_no_stale_nodes_in_unaffected_region(self, gen_graph): # The isolated root should not be stale — it was unaffected stale = get_stale_nodes(cg, [isolated_root]) assert isolated_root not in stale + + @pytest.mark.timeout(30) + def test_get_new_nodes_returns_self_for_non_stale(self, gen_graph): + """ + For freshly created nodes with no edits, get_new_nodes should return + the nodes themselves (identity mapping). + + ┌─────┐ + │ A¹ │ + │ 1 │ + │ │ + └─────┘ + """ + cg = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + sv = to_label(cg, 1, 0, 0, 0, 0) + l2_parent = cg.get_parent(sv) + + # get_new_nodes at layer 2 should return the same L2 parent + result = get_new_nodes(cg, np.array([sv], dtype=np.uint64), layer=2) + assert result[0] == l2_parent + + @pytest.mark.timeout(30) + def test_get_stale_nodes_empty_for_fresh_graph(self, gen_graph): + """ + In a freshly built graph with no edits, no nodes should be stale. + + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + root = cg.get_root(to_label(cg, 1, 0, 0, 0, 0)) + l2_0 = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) + l2_1 = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) + + # No edits have been performed, so all nodes should be non-stale + stale = get_stale_nodes(cg, [root, l2_0, l2_1]) + assert len(stale) == 0 + + @pytest.mark.timeout(30) + def test_get_new_nodes_multiple_svs(self, gen_graph): + """ + get_new_nodes with multiple supervoxels should return an array + of the same length, each mapped to its current L2 parent. + + ┌─────┬─────┐ + │ A¹ │ B¹ │ + │ 1━━┿━━2 │ + │ │ │ + └─────┴─────┘ + """ + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 1, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + create_chunk( + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[(to_label(cg, 1, 1, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0), 0.5)], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + sv1 = to_label(cg, 1, 0, 0, 0, 0) + sv2 = to_label(cg, 1, 1, 0, 0, 0) + svs = np.array([sv1, sv2], dtype=np.uint64) + + result = get_new_nodes(cg, svs, layer=2) + assert result.shape == (2,) + # Each SV should map to its L2 parent + assert result[0] == cg.get_parent(sv1) + assert result[1] == cg.get_parent(sv2) + + @pytest.mark.timeout(30) + def test_get_new_nodes_with_duplicate_svs(self, gen_graph): + """ + get_new_nodes should handle duplicate SVs correctly, + returning the same result for duplicate inputs. + + ┌─────┐ + │ A¹ │ + │ 1 │ + │ │ + └─────┘ + """ + cg = gen_graph(n_layers=3) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + sv = to_label(cg, 1, 0, 0, 0, 0) + svs = np.array([sv, sv, sv], dtype=np.uint64) + + result = get_new_nodes(cg, svs, layer=2) + assert result.shape == (3,) + # All should map to the same L2 parent + expected = cg.get_parent(sv) + assert np.all(result == expected) + + @pytest.mark.timeout(30) + def test_get_stale_nodes_with_l2_ids_after_merge(self, gen_graph): + """ + After a merge, the old L2 IDs should become stale. + + ┌─────┐ + │ A¹ │ + │ 1 2 │ (isolated, then merged) + │ │ + └─────┘ + """ + atomic_chunk_bounds = np.array([1, 1, 1]) + cg = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + sv0 = to_label(cg, 1, 0, 0, 0, 0) + sv1 = to_label(cg, 1, 0, 0, 0, 1) + + create_chunk( + cg, + vertices=[sv0, sv1], + edges=[], + timestamp=fake_timestamp, + ) + + # Get L2 parents before merge (each SV has its own L2 parent) + old_l2_0 = cg.get_parent(sv0) + old_l2_1 = cg.get_parent(sv1) + + # Merge + cg.add_edges( + "test_user", + [sv0, sv1], + affinities=[0.3], + ) + + # Old L2 parents should now be stale + stale = get_stale_nodes(cg, [old_l2_0, old_l2_1]) + assert old_l2_0 in stale or old_l2_1 in stale + + @pytest.mark.timeout(30) + def test_get_stale_nodes_returns_numpy_array(self, gen_graph): + """ + get_stale_nodes should always return a numpy ndarray, even when + no nodes are stale. + + ┌─────┐ + │ A¹ │ + │ 1 │ + │ │ + └─────┘ + """ + atomic_chunk_bounds = np.array([1, 1, 1]) + cg = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + sv0 = to_label(cg, 1, 0, 0, 0, 0) + create_chunk( + cg, + vertices=[sv0], + edges=[], + timestamp=fake_timestamp, + ) + + root = cg.get_root(sv0) + stale = get_stale_nodes(cg, [root]) + assert isinstance(stale, np.ndarray) + + @pytest.mark.timeout(30) + def test_get_new_nodes_at_root_layer(self, gen_graph): + """ + get_new_nodes called with layer=root_layer should return the root node. + + ┌─────┐ + │ A¹ │ + │ 1 │ + │ │ + └─────┘ + """ + cg = gen_graph(n_layers=4) + fake_timestamp = datetime.now(UTC) - timedelta(days=10) + + sv = to_label(cg, 1, 0, 0, 0, 0) + create_chunk( + cg, + vertices=[sv], + edges=[], + timestamp=fake_timestamp, + ) + add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + add_parent_chunk(cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) + + root = cg.get_root(sv) + root_layer = cg.get_chunk_layer(root) + + result = get_new_nodes(cg, np.array([sv], dtype=np.uint64), layer=root_layer) + assert result.shape == (1,) + assert result[0] == root diff --git a/pychunkedgraph/tests/test_subgraph.py b/pychunkedgraph/tests/test_subgraph.py new file mode 100644 index 000000000..e9ca7cd66 --- /dev/null +++ b/pychunkedgraph/tests/test_subgraph.py @@ -0,0 +1,112 @@ +"""Tests for pychunkedgraph.graph.subgraph""" + +from datetime import datetime, timedelta, UTC +from math import inf + +import numpy as np +import pytest + +from pychunkedgraph.graph.subgraph import SubgraphProgress, get_subgraph_nodes + +from .helpers import create_chunk, to_label +from ..ingest.create.parent_layer import add_parent_chunk + + +class TestSubgraphProgress: + def test_init(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + progress = SubgraphProgress( + graph.meta, + node_ids=[root], + return_layers=[2], + serializable=False, + ) + assert not progress.done_processing() + + def test_serializable_keys(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + progress = SubgraphProgress( + graph.meta, + node_ids=[root], + return_layers=[2], + serializable=True, + ) + # Keys should be strings when serializable=True + key = progress.get_dict_key(root) + assert isinstance(key, str) + + +class TestGetSubgraphNodes: + def _build_graph(self, gen_graph): + graph = gen_graph(n_layers=4) + fake_ts = datetime.now(UTC) - timedelta(days=10) + + create_chunk( + graph, + vertices=[to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1)], + edges=[ + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 1), 0.5), + (to_label(graph, 1, 0, 0, 0, 0), to_label(graph, 1, 1, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + create_chunk( + graph, + vertices=[to_label(graph, 1, 1, 0, 0, 0)], + edges=[ + (to_label(graph, 1, 1, 0, 0, 0), to_label(graph, 1, 0, 0, 0, 0), inf), + ], + timestamp=fake_ts, + ) + add_parent_chunk(graph, 3, [0, 0, 0], n_threads=1) + add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) + return graph + + def test_single_node(self, gen_graph): + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = get_subgraph_nodes(graph, root) + assert isinstance(result, dict) + assert 2 in result + + def test_return_flattened(self, gen_graph): + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = get_subgraph_nodes(graph, root, return_flattened=True) + assert isinstance(result, np.ndarray) + assert len(result) > 0 + + def test_multiple_nodes(self, gen_graph): + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = get_subgraph_nodes(graph, [root]) + assert root in result + + def test_serializable(self, gen_graph): + graph = self._build_graph(gen_graph) + root = graph.get_root(to_label(graph, 1, 0, 0, 0, 0)) + result = get_subgraph_nodes(graph, root, serializable=True) + # Keys should be layer ints, values should be arrays + assert isinstance(result, dict) diff --git a/pychunkedgraph/tests/test_types.py b/pychunkedgraph/tests/test_types.py new file mode 100644 index 000000000..ed6f5212b --- /dev/null +++ b/pychunkedgraph/tests/test_types.py @@ -0,0 +1,33 @@ +"""Tests for pychunkedgraph.graph.types""" + +import numpy as np + +from pychunkedgraph.graph.types import empty_1d, empty_2d, Agglomeration +from pychunkedgraph.graph.utils import basetypes + + +class TestEmptyArrays: + def test_empty_1d_shape_and_dtype(self): + assert empty_1d.shape == (0,) + assert empty_1d.dtype == basetypes.NODE_ID + + def test_empty_2d_shape_and_dtype(self): + assert empty_2d.shape == (0, 2) + assert empty_2d.dtype == basetypes.NODE_ID + + +class TestAgglomeration: + def test_defaults(self): + agg = Agglomeration(node_id=np.uint64(1)) + assert agg.node_id == np.uint64(1) + assert agg.supervoxels.shape == (0,) + assert agg.in_edges.shape == (0, 2) + assert agg.out_edges.shape == (0, 2) + assert agg.cross_edges.shape == (0, 2) + assert agg.cross_edges_d == {} + + def test_custom_fields(self): + svs = np.array([10, 20], dtype=basetypes.NODE_ID) + agg = Agglomeration(node_id=np.uint64(5), supervoxels=svs) + assert agg.node_id == np.uint64(5) + np.testing.assert_array_equal(agg.supervoxels, svs) diff --git a/pychunkedgraph/tests/test_utils_flatgraph.py b/pychunkedgraph/tests/test_utils_flatgraph.py new file mode 100644 index 000000000..a46ebe3c2 --- /dev/null +++ b/pychunkedgraph/tests/test_utils_flatgraph.py @@ -0,0 +1,260 @@ +"""Tests for pychunkedgraph.graph.utils.flatgraph""" + +import numpy as np + +from pychunkedgraph.graph.utils.flatgraph import ( + build_gt_graph, + connected_components, + remap_ids_from_graph, + neighboring_edges, + harmonic_mean_paths, + remove_overlapping_edges, + check_connectedness, + adjust_affinities, + flatten_edge_list, + team_paths_all_to_all, +) + + +class TestBuildGtGraph: + def test_directed(self): + edges = np.array([[0, 1], [1, 2]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=True) + assert graph.is_directed() + assert graph.num_vertices() == 3 + assert graph.num_edges() == 2 + assert cap is None + + def test_undirected(self): + edges = np.array([[0, 1], [1, 2]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=False) + assert not graph.is_directed() + assert graph.num_vertices() == 3 + + def test_with_weights(self): + edges = np.array([[0, 1], [1, 2]], dtype=np.uint64) + weights = np.array([0.5, 0.9]) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, weights=weights) + assert cap is not None + + def test_make_directed(self): + edges = np.array([[0, 1]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, make_directed=True) + assert graph.is_directed() + # make_directed doubles edges (forward + reverse) + assert graph.num_edges() == 2 + + def test_unique_ids_remapping(self): + # Non-contiguous node IDs + edges = np.array([[100, 200], [200, 300]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges) + np.testing.assert_array_equal(unique_ids, [100, 200, 300]) + + +class TestConnectedComponents: + def test_two_components(self): + edges = np.array([[0, 1], [2, 3]], dtype=np.uint64) + graph, _, _, _ = build_gt_graph(edges, is_directed=False) + ccs = connected_components(graph) + assert len(ccs) == 2 + + def test_single_component(self): + edges = np.array([[0, 1], [1, 2]], dtype=np.uint64) + graph, _, _, _ = build_gt_graph(edges, is_directed=False) + ccs = connected_components(graph) + assert len(ccs) == 1 + + +class TestRemapIdsFromGraph: + def test_basic(self): + unique_ids = np.array([100, 200, 300], dtype=np.uint64) + graph_ids = np.array([0, 2]) + result = remap_ids_from_graph(graph_ids, unique_ids) + np.testing.assert_array_equal(result, [100, 300]) + + +class TestNeighboringEdges: + def test_basic(self): + """Build graph 0-1-2 (undirected), neighboring_edges(graph, 1) returns neighbors of vertex 1.""" + edges = np.array([[0, 1], [1, 2]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=False) + add_v, add_e, weights = neighboring_edges(graph, 1) + # Should return one list of vertices and one list of edges + assert len(add_v) == 1 + assert len(add_e) == 1 + # Vertex 1 has two neighbors (0 and 2) in undirected graph + neighbor_ids = sorted([int(v) for v in add_v[0]]) + assert len(neighbor_ids) == 2 + assert 0 in neighbor_ids + assert 2 in neighbor_ids + # Should return edges corresponding to those neighbors + assert len(add_e[0]) == 2 + # Weights is always [1] + assert weights == [1] + + def test_isolated_vertex(self): + """A vertex with no out-neighbors returns empty lists.""" + # Build a directed graph: 0->1. Vertex 1 has no out-neighbors. + edges = np.array([[0, 1]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=True) + add_v, add_e, weights = neighboring_edges(graph, 1) + assert len(add_v) == 1 + assert len(add_v[0]) == 0 + assert len(add_e) == 1 + assert len(add_e[0]) == 0 + + +class TestHarmonicMeanPaths: + def test_two_values(self): + """harmonic_mean_paths([4, 16]) should return geometric mean = 8.0""" + result = harmonic_mean_paths([4, 16]) + assert result == 8.0 + + def test_single_value(self): + """harmonic_mean_paths([9]) should return 9.0""" + result = harmonic_mean_paths([9]) + assert result == 9.0 + + +class TestRemoveOverlappingEdges: + def test_no_overlap(self): + """Two path sets with no shared vertices return the same edges, do_check=False.""" + # Build two separate graphs: 0-1 and 2-3 + edges = np.array([[0, 1], [2, 3]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=False) + # Paths for "team s": vertex 0 and vertex 1 with edge 0-1 + v0 = graph.vertex(0) + v1 = graph.vertex(1) + e01 = graph.edge(0, 1) + paths_v_s = [[v0, v1]] + paths_e_s = [[e01]] + + # Paths for "team y": vertex 2 and vertex 3 with edge 2-3 + v2 = graph.vertex(2) + v3 = graph.vertex(3) + e23 = graph.edge(2, 3) + paths_v_y = [[v2, v3]] + paths_e_y = [[e23]] + + out_s, out_y, do_check = remove_overlapping_edges( + paths_v_s, paths_e_s, paths_v_y, paths_e_y + ) + # No overlap, so do_check is False + assert do_check is False + # Original edges returned unchanged + assert out_s == paths_e_s + assert out_y == paths_e_y + + def test_with_overlap(self): + """Paths sharing some vertices cause overlapping edges to be removed, do_check=True.""" + # Build a linear graph: 0-1-2-3 (undirected) + edges = np.array([[0, 1], [1, 2], [2, 3]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=False) + # Team s path: 0-1-2 (shares vertex 1 and 2) + v0 = graph.vertex(0) + v1 = graph.vertex(1) + v2 = graph.vertex(2) + v3 = graph.vertex(3) + e01 = graph.edge(0, 1) + e12 = graph.edge(1, 2) + e23 = graph.edge(2, 3) + + paths_v_s = [[v0, v1, v2]] + paths_e_s = [[e01, e12]] + + # Team y path: 1-2-3 (shares vertex 1 and 2) + paths_v_y = [[v1, v2, v3]] + paths_e_y = [[e12, e23]] + + out_s, out_y, do_check = remove_overlapping_edges( + paths_v_s, paths_e_s, paths_v_y, paths_e_y + ) + assert do_check is True + # Overlapping vertices are 1 and 2 + # Edges touching vertices 1 or 2 should be removed + # All edges in both paths touch vertex 1 or 2, so both should be empty + assert len(out_s[0]) == 0 + assert len(out_y[0]) == 0 + + +class TestCheckConnectedness: + def test_connected(self): + """A connected set of edges returns True.""" + # Build a connected graph: 0-1-2 + edges = np.array([[0, 1], [1, 2]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=False) + v0 = graph.vertex(0) + v1 = graph.vertex(1) + v2 = graph.vertex(2) + e01 = graph.edge(0, 1) + e12 = graph.edge(1, 2) + + vertices = [[v0, v1, v2]] + edge_list = [[e01, e12]] + + assert check_connectedness(vertices, edge_list, expected_number=1) is True + + def test_disconnected(self): + """A disconnected set returns False (more than expected_number components).""" + # Build a graph with two disconnected components: 0-1 and 2-3 + edges = np.array([[0, 1], [2, 3]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=False) + v0 = graph.vertex(0) + v1 = graph.vertex(1) + v2 = graph.vertex(2) + v3 = graph.vertex(3) + e01 = graph.edge(0, 1) + e23 = graph.edge(2, 3) + + # Include all vertices but edges that form two components + vertices = [[v0, v1, v2, v3]] + edge_list = [[e01, e23]] + + # Expecting 1 component but there are 2, so should return False + assert check_connectedness(vertices, edge_list, expected_number=1) is False + + +class TestAdjustAffinities: + def test_basic(self): + """Build a graph with known capacities, adjust a subset, verify capacities changed.""" + edges = np.array([[0, 1], [1, 2]], dtype=np.uint64) + weights = np.array([0.5, 0.8]) + graph, cap, g_edges, unique_ids = build_gt_graph( + edges, weights=weights, make_directed=True + ) + assert cap is not None + + # Get the edge 0->1 and adjust its affinity + e01 = graph.edge(0, 1) + original_cap_01 = cap[e01] + assert original_cap_01 == 0.5 + + paths_e = [[e01]] + new_cap = adjust_affinities(graph, cap, paths_e, value=999.0) + + # The original capacity should be unchanged (adjust_affinities copies) + assert cap[e01] == 0.5 + # The new capacity for the adjusted edge should be 999.0 + assert new_cap[e01] == 999.0 + # The reverse edge should also be adjusted + e10 = graph.edge(1, 0) + assert new_cap[e10] == 999.0 + # Edge 1->2 should be unchanged + e12 = graph.edge(1, 2) + assert new_cap[e12] == 0.8 + + +class TestFlattenEdgeList: + def test_basic(self): + """Flatten a list of graph-tool edges to unique vertex indices.""" + edges = np.array([[0, 1], [1, 2], [2, 3]], dtype=np.uint64) + graph, cap, g_edges, unique_ids = build_gt_graph(edges, is_directed=False) + e01 = graph.edge(0, 1) + e12 = graph.edge(1, 2) + e23 = graph.edge(2, 3) + + paths_e = [[e01, e12], [e23]] + result = flatten_edge_list(paths_e) + # Should contain unique vertex indices from all edges + assert isinstance(result, np.ndarray) + assert set(result.tolist()) == {0, 1, 2, 3} diff --git a/pychunkedgraph/tests/test_utils_generic.py b/pychunkedgraph/tests/test_utils_generic.py new file mode 100644 index 000000000..b6c51ea31 --- /dev/null +++ b/pychunkedgraph/tests/test_utils_generic.py @@ -0,0 +1,175 @@ +"""Tests for pychunkedgraph.graph.utils.generic""" + +import datetime + +import numpy as np +import pytz +import pytest + +from pychunkedgraph.graph.utils.generic import ( + compute_indices_pandas, + log_n, + compute_bitmasks, + get_max_time, + get_min_time, + time_min, + get_valid_timestamp, + get_bounding_box, + filter_failed_node_ids, + _get_google_compatible_time_stamp, + mask_nodes_by_bounding_box, + get_parents_at_timestamp, +) + + +class TestLogN: + def test_base2(self): + assert log_n(8, 2) == pytest.approx(3.0) + + def test_base10(self): + assert log_n(1000, 10) == pytest.approx(3.0) + + def test_other_base(self): + assert log_n(27, 3) == pytest.approx(3.0) + + def test_array_input(self): + result = log_n(np.array([4, 8, 16]), 2) + np.testing.assert_array_almost_equal(result, [2.0, 3.0, 4.0]) + + +class TestComputeBitmasks: + def test_basic(self): + bm = compute_bitmasks(4) + assert 1 in bm + assert 2 in bm + assert 3 in bm + assert 4 in bm + + def test_layer_1_equals_layer_2(self): + bm = compute_bitmasks(5) + assert bm[1] == bm[2] + + def test_insufficient_bits_raises(self): + with pytest.raises(ValueError, match="not enough"): + compute_bitmasks(4, s_bits_atomic_layer=0) + + +class TestTimeFunctions: + def test_get_max_time(self): + t = get_max_time() + assert isinstance(t, datetime.datetime) + assert t.year == 9999 + + def test_get_min_time(self): + t = get_min_time() + assert isinstance(t, datetime.datetime) + assert t.year == 2000 + + def test_time_min(self): + assert time_min() == get_min_time() + + +class TestGetValidTimestamp: + def test_none_returns_utc_now(self): + before = datetime.datetime.now(datetime.timezone.utc) + result = get_valid_timestamp(None) + after = datetime.datetime.now(datetime.timezone.utc) + assert result.tzinfo is not None + # get_valid_timestamp rounds down to millisecond precision, + # so result may be slightly before `before` + tolerance = datetime.timedelta(milliseconds=1) + assert before - tolerance <= result <= after + + def test_naive_gets_localized(self): + naive = datetime.datetime(2023, 6, 15, 12, 0, 0) + result = get_valid_timestamp(naive) + assert result.tzinfo is not None + + def test_aware_passthrough(self): + aware = datetime.datetime(2023, 6, 15, 12, 0, 0, tzinfo=pytz.UTC) + result = get_valid_timestamp(aware) + assert result.tzinfo is not None + + +class TestGoogleCompatibleTimestamp: + def test_round_down(self): + ts = datetime.datetime(2023, 6, 15, 12, 0, 0, 1500) + result = _get_google_compatible_time_stamp(ts, round_up=False) + assert result.microsecond % 1000 == 0 + assert result.microsecond == 1000 + + def test_round_up(self): + ts = datetime.datetime(2023, 6, 15, 12, 0, 0, 1500) + result = _get_google_compatible_time_stamp(ts, round_up=True) + assert result.microsecond % 1000 == 0 + assert result.microsecond == 2000 + + def test_exact_no_change(self): + ts = datetime.datetime(2023, 6, 15, 12, 0, 0, 3000) + result = _get_google_compatible_time_stamp(ts) + assert result == ts + + +class TestGetBoundingBox: + def test_normal(self): + source = np.array([[10, 20, 30]]) + sink = np.array([[50, 60, 70]]) + bbox = get_bounding_box(source, sink, bb_offset=(5, 5, 5)) + np.testing.assert_array_equal(bbox[0], [5, 15, 25]) + np.testing.assert_array_equal(bbox[1], [55, 65, 75]) + + def test_none_coords(self): + assert get_bounding_box(None, [[1, 2, 3]]) is None + assert get_bounding_box([[1, 2, 3]], None) is None + + +class TestFilterFailedNodeIds: + def test_basic(self): + row_ids = np.array([10, 20, 30, 40], dtype=np.uint64) + segment_ids = np.array([4, 3, 2, 1], dtype=np.uint64) + max_children_ids = np.array([100, 100, 200, 200]) + result = filter_failed_node_ids(row_ids, segment_ids, max_children_ids) + # Only the first occurrence of each max_children_id (by descending segment_id) survives + assert len(result) == 2 + + +class TestMaskNodesByBoundingBox: + def test_none_bbox(self): + nodes = np.array([1, 2, 3], dtype=np.uint64) + result = mask_nodes_by_bounding_box(None, nodes, bounding_box=None) + assert np.all(result) + + +class TestGetParentsAtTimestamp: + def test_normal_lookup(self): + ts1 = datetime.datetime(2023, 1, 1) + ts2 = datetime.datetime(2023, 6, 1) + ts_map = { + 10: {ts2: 100, ts1: 50}, + } + parents, skipped = get_parents_at_timestamp([10], ts_map, ts2) + assert 100 in parents + assert len(skipped) == 0 + + def test_missing_key(self): + parents, skipped = get_parents_at_timestamp([99], {}, datetime.datetime.now()) + assert len(parents) == 0 + assert 99 in skipped + + def test_unique(self): + ts = datetime.datetime(2023, 6, 1) + ts_map = { + 10: {ts: 100}, + 20: {ts: 100}, + } + parents, _ = get_parents_at_timestamp([10, 20], ts_map, ts, unique=True) + assert len(parents) == 1 + + +class TestComputeIndicesPandas: + def test_basic(self): + data = np.array([1, 2, 1, 2, 3]) + result = compute_indices_pandas(data) + assert 1 in result.index + assert 2 in result.index + assert 3 in result.index diff --git a/pychunkedgraph/tests/test_utils_id_helpers.py b/pychunkedgraph/tests/test_utils_id_helpers.py new file mode 100644 index 000000000..df8349962 --- /dev/null +++ b/pychunkedgraph/tests/test_utils_id_helpers.py @@ -0,0 +1,232 @@ +"""Tests for pychunkedgraph.graph.utils.id_helpers""" + +from unittest.mock import MagicMock + +import numpy as np + +from pychunkedgraph.graph.utils import id_helpers +from pychunkedgraph.graph.chunks import utils as chunk_utils + +from .helpers import to_label + + +class TestGetSegmentIdLimit: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + node_id = to_label(graph, 1, 0, 0, 0, 1) + limit = id_helpers.get_segment_id_limit(graph.meta, node_id) + assert limit > 0 + assert isinstance(limit, np.uint64) + + +class TestGetSegmentId: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=4) + node_id = to_label(graph, 1, 0, 0, 0, 42) + seg_id = id_helpers.get_segment_id(graph.meta, node_id) + assert seg_id == 42 + + +class TestGetNodeId: + def test_from_chunk_id(self, gen_graph): + graph = gen_graph(n_layers=4) + chunk_id = chunk_utils.get_chunk_id(graph.meta, layer=1, x=0, y=0, z=0) + node_id = id_helpers.get_node_id( + graph.meta, segment_id=np.uint64(5), chunk_id=chunk_id + ) + assert id_helpers.get_segment_id(graph.meta, node_id) == 5 + assert chunk_utils.get_chunk_layer(graph.meta, node_id) == 1 + + def test_from_components(self, gen_graph): + graph = gen_graph(n_layers=4) + node_id = id_helpers.get_node_id( + graph.meta, segment_id=np.uint64(7), layer=2, x=1, y=2, z=3 + ) + assert id_helpers.get_segment_id(graph.meta, node_id) == 7 + assert chunk_utils.get_chunk_layer(graph.meta, node_id) == 2 + coords = chunk_utils.get_chunk_coordinates(graph.meta, node_id) + np.testing.assert_array_equal(coords, [1, 2, 3]) + + +class TestGetAtomicIdFromCoord: + def test_exact_hit(self): + """When the voxel at (x, y, z) contains an atomic ID whose root matches, return it.""" + meta = MagicMock() + meta.data_source.CV_MIP = 0 + # meta.cv[x_l:x_h, y_l:y_h, z_l:z_h] returns an array block. + # For i_try=0: x_l = x - (-1)^2 = x-1, but clamped to 0 if negative; + # x_h = x + 1 + (-1)^2 = x+2. With x=0: x_l=0, x_h=2, etc. + # Simplest: put target atomic_id=42 everywhere in a small block. + meta.cv.__getitem__ = MagicMock(return_value=np.array([[[42]]])) + + root_id = np.uint64(100) + + def fake_get_root(node_id, time_stamp=None): + if node_id == 42: + return root_id + return root_id # same root for all + + result = id_helpers.get_atomic_id_from_coord( + meta, fake_get_root, 0, 0, 0, np.uint64(42), n_tries=1 + ) + assert result == np.uint64(42) + + def test_returns_none_when_no_match(self): + """When no candidate atomic ID shares the same root, return None.""" + meta = MagicMock() + meta.data_source.CV_MIP = 0 + # Return only zeros (background) from cloudvolume + meta.cv.__getitem__ = MagicMock(return_value=np.array([[[0]]])) + + root_id = np.uint64(100) + + def fake_get_root(node_id, time_stamp=None): + return root_id + + result = id_helpers.get_atomic_id_from_coord( + meta, fake_get_root, 5, 5, 5, np.uint64(999), n_tries=1 + ) + # Only candidate is 0, which is skipped, so result should be None + assert result is None + + def test_mip_scaling(self): + """Coordinates should be scaled by CV_MIP for x and y but not z.""" + meta = MagicMock() + meta.data_source.CV_MIP = 2 # scale factor of 4 for x,y + + call_args = [] + + def capture_getitem(self_mock, key): + call_args.append(key) + return np.array([[[7]]]) + + meta.cv.__getitem__ = capture_getitem + + root_id = np.uint64(200) + + def fake_get_root(node_id, time_stamp=None): + return root_id + + result = id_helpers.get_atomic_id_from_coord( + meta, fake_get_root, 8, 12, 3, np.uint64(7), n_tries=1 + ) + assert result == np.uint64(7) + # Verify that the function was called (coordinates are scaled) + assert len(call_args) >= 1 + + def test_retry_expands_search(self): + """With multiple tries, the search area should expand to find a matching ID.""" + meta = MagicMock() + meta.data_source.CV_MIP = 0 + + target_root = np.uint64(500) + wrong_root = np.uint64(999) + call_count = [0] + + def expanding_getitem(self_mock, key): + call_count[0] += 1 + if call_count[0] == 1: + # First try returns a non-matching ID + return np.array([[[10]]]) + else: + # Second try returns the matching ID + return np.array([[[10, 42]], [[10, 42]]]) + + meta.cv.__getitem__ = expanding_getitem + + def fake_get_root(node_id, time_stamp=None): + if node_id == 42: + return target_root + return wrong_root + + # parent_id=42 -> root=500; candidates: try1 has only 10 (root=999), try2 has 42 (root=500) + result = id_helpers.get_atomic_id_from_coord( + meta, fake_get_root, 5, 5, 5, np.uint64(42), n_tries=3 + ) + assert result == np.uint64(42) + assert call_count[0] >= 2 + + +class TestGetAtomicIdsFromCoords: + def test_layer1_returns_parent_id(self): + """When parent_id is already layer 1, return parent_id for all coordinates.""" + meta = MagicMock() + meta.data_source.CV_MIP = 0 + meta.resolution = np.array([1, 1, 1]) + + parent_id = np.uint64(42) + coordinates = np.array([[10, 20, 30], [40, 50, 60]]) + + def fake_get_roots( + node_ids, time_stamp=None, stop_layer=None, fail_to_zero=False + ): + return np.array([parent_id] * len(node_ids), dtype=np.uint64) + + result = id_helpers.get_atomic_ids_from_coords( + meta, + coordinates=coordinates, + parent_id=parent_id, + parent_id_layer=1, + parent_ts=None, + get_roots=fake_get_roots, + ) + + np.testing.assert_array_equal(result, [parent_id, parent_id]) + + def test_higher_layer_with_mock_cv(self): + """Test with a mocked CloudVolume that returns a known segmentation block.""" + meta = MagicMock() + meta.data_source.CV_MIP = 0 + meta.resolution = np.array([8, 8, 40]) + + parent_id = np.uint64(100) + sv1 = np.uint64(10) + sv2 = np.uint64(20) + + # Create a small segmentation volume (the CV mock) + # Coordinates: two points at [5, 5, 5] and [6, 5, 5] + coordinates = np.array([[5, 5, 5], [6, 5, 5]]) + max_dist_nm = 150 + max_dist_vx = np.ceil(max_dist_nm / np.array([8, 8, 40])).astype(np.int32) + + # Build a segmentation block big enough for the bounding box + bbox_min = np.min(coordinates, axis=0) - max_dist_vx + bbox_max = np.max(coordinates, axis=0) + max_dist_vx + 1 + shape = bbox_max - bbox_min + + seg_block = np.zeros(tuple(shape), dtype=np.uint64) + # Place sv1 at relative position of coordinate [5,5,5] + rel1 = coordinates[0] - bbox_min + seg_block[rel1[0], rel1[1], rel1[2]] = sv1 + # Place sv2 at relative position of coordinate [6,5,5] + rel2 = coordinates[1] - bbox_min + seg_block[rel2[0], rel2[1], rel2[2]] = sv2 + + meta.cv.__getitem__ = MagicMock(return_value=seg_block) + + def fake_get_roots( + node_ids, time_stamp=None, stop_layer=None, fail_to_zero=False + ): + # Map sv1 and sv2 to parent_id, everything else to 0 + result = [] + for nid in node_ids: + if nid == sv1 or nid == sv2: + result.append(parent_id) + else: + result.append(np.uint64(0)) + return np.array(result, dtype=np.uint64) + + result = id_helpers.get_atomic_ids_from_coords( + meta, + coordinates=coordinates, + parent_id=parent_id, + parent_id_layer=2, + parent_ts=None, + get_roots=fake_get_roots, + ) + + assert result is not None + assert len(result) == 2 + # Each coordinate should map to one of our supervoxels + assert np.uint64(result[0]) == sv1 + assert np.uint64(result[1]) == sv2 diff --git a/requirements-dev.txt b/requirements-dev.txt index cde620b6a..1b24f9ecb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ pylint black +pre-commit pyopenssl jupyter ipython From 03a042251e5ab3365f80b3af1f2bce0eaacc253d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 25 Feb 2026 16:41:52 +0000 Subject: [PATCH 160/367] feat: add stitching changes --- codecov.yml | 2 +- pychunkedgraph/graph/chunkedgraph.py | 2 + pychunkedgraph/graph/edges/utils.py | 6 ++- pychunkedgraph/graph/operation.py | 45 ++++++++++++-------- pychunkedgraph/graph/segmenthistory.py | 8 +++- pychunkedgraph/ingest/create/atomic_layer.py | 17 +++++--- pychunkedgraph/ingest/create/parent_layer.py | 4 +- pychunkedgraph/ingest/ran_agglomeration.py | 6 ++- pychunkedgraph/meshing/meshgen_utils.py | 10 ++++- requirements.in | 2 +- 10 files changed, 67 insertions(+), 35 deletions(-) diff --git a/codecov.yml b/codecov.yml index 31a2abfee..92e9570d2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: threshold: 1% patch: default: - target: 80% + target: 1% comment: layout: "reach,diff,flags,files" diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 38a408e92..940df2675 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -810,6 +810,7 @@ def add_edges( sink_coords: typing.Sequence[int] = None, allow_same_segment_merge: typing.Optional[bool] = False, do_sanity_check: typing.Optional[bool] = True, + stitch_mode: typing.Optional[bool] = False, ) -> operation.GraphEditOperation.Result: """ Adds an edge to the chunkedgraph @@ -827,6 +828,7 @@ def add_edges( sink_coords=sink_coords, allow_same_segment_merge=allow_same_segment_merge, do_sanity_check=do_sanity_check, + stitch_mode=stitch_mode, ).execute() def remove_edges( diff --git a/pychunkedgraph/graph/edges/utils.py b/pychunkedgraph/graph/edges/utils.py index f79debf94..3af2c8cc4 100644 --- a/pychunkedgraph/graph/edges/utils.py +++ b/pychunkedgraph/graph/edges/utils.py @@ -70,7 +70,11 @@ def merge_cross_edge_dicts(x_edges_d1: Dict, x_edges_d2: Dict) -> Dict: Combines two cross chunk dictionaries of form {node_id: {layer id : edge list}}. """ - node_ids = np.unique(list(x_edges_d1.keys()) + list(x_edges_d2.keys())) + node_ids = np.unique( + np.array( + list(x_edges_d1.keys()) + list(x_edges_d2.keys()), dtype=basetypes.NODE_ID + ) + ) result_d = {} for node_id in node_ids: cross_edge_ds = [x_edges_d1.get(node_id, {}), x_edges_d2.get(node_id, {})] diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index dd2809ec4..3e722ccb1 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -34,7 +34,6 @@ from .utils.generic import get_bounding_box as get_bbox, get_valid_timestamp from ..logging.log_db import TimeIt - if TYPE_CHECKING: from .chunkedgraph import ChunkedGraph @@ -576,6 +575,7 @@ class MergeOperation(GraphEditOperation): "bbox_offset", "allow_same_segment_merge", "do_sanity_check", + "stitch_mode", ] def __init__( @@ -590,6 +590,7 @@ def __init__( affinities: Optional[Sequence[np.float32]] = None, allow_same_segment_merge: Optional[bool] = False, do_sanity_check: Optional[bool] = True, + stitch_mode: bool = False, ) -> None: super().__init__( cg, user_id=user_id, source_coords=source_coords, sink_coords=sink_coords @@ -629,8 +630,11 @@ def _apply( ) if len(root_ids) < 2 and not self.allow_same_segment_merge: raise PreconditionError("Supervoxels must belong to different objects.") - bbox = get_bbox(self.source_coords, self.sink_coords, self.bbox_offset) - with TimeIt("subgraph", self.cg.graph_id, operation_id): + + atomic_edges = self.added_edges + fake_edge_rows = [] + if not self.stitch_mode: + bbox = get_bbox(self.source_coords, self.sink_coords, self.bbox_offset) edges = self.cg.get_subgraph( root_ids, bbox=bbox, @@ -638,10 +642,9 @@ def _apply( edges_only=True, ) - if self.allow_same_segment_merge: - inactive_edges = types.empty_2d - else: - with TimeIt("preprocess", self.cg.graph_id, operation_id): + if self.allow_same_segment_merge: + inactive_edges = types.empty_2d + else: inactive_edges = edits.merge_preprocess( self.cg, subgraph_edges=edges, @@ -649,13 +652,14 @@ def _apply( parent_ts=self.parent_ts, ) - atomic_edges, fake_edge_rows = edits.check_fake_edges( - self.cg, - atomic_edges=self.added_edges, - inactive_edges=inactive_edges, - time_stamp=timestamp, - parent_ts=self.parent_ts, - ) + atomic_edges, fake_edge_rows = edits.check_fake_edges( + self.cg, + atomic_edges=self.added_edges, + inactive_edges=inactive_edges, + time_stamp=timestamp, + parent_ts=self.parent_ts, + ) + with TimeIt("add_edges", self.cg.graph_id, operation_id): new_roots, new_l2_ids, new_entries = edits.add_edges( self.cg, @@ -665,6 +669,7 @@ def _apply( parent_ts=self.parent_ts, allow_same_segment_merge=self.allow_same_segment_merge, do_sanity_check=self.do_sanity_check, + stitch_mode=self.stitch_mode, ) return new_roots, new_l2_ids, fake_edge_rows + new_entries @@ -887,12 +892,14 @@ def __init__( "try placing the points further apart." ) - ids = np.concatenate([self.source_ids, self.sink_ids]) + ids = np.concatenate([self.source_ids, self.sink_ids]).astype(basetypes.NODE_ID) layers = self.cg.get_chunk_layers(ids) assert np.sum(layers) == layers.size, "IDs must be supervoxels." def _update_root_ids(self) -> np.ndarray: - sink_and_source_ids = np.concatenate((self.source_ids, self.sink_ids)) + sink_and_source_ids = np.concatenate((self.source_ids, self.sink_ids)).astype( + basetypes.NODE_ID + ) root_ids = np.unique( self.cg.get_roots( sink_and_source_ids, assert_roots=True, time_stamp=self.parent_ts @@ -908,7 +915,9 @@ def _apply( # Verify that sink and source are from the same root object root_ids = set( self.cg.get_roots( - np.concatenate([self.source_ids, self.sink_ids]), + np.concatenate([self.source_ids, self.sink_ids]).astype( + basetypes.NODE_ID + ), assert_roots=True, time_stamp=self.parent_ts, ) @@ -929,7 +938,7 @@ def _apply( edges = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) supervoxels = np.concatenate( [agg.supervoxels for agg in l2id_agglomeration_d.values()] - ) + ).astype(basetypes.NODE_ID) mask0 = np.isin(edges.node_ids1, supervoxels) mask1 = np.isin(edges.node_ids2, supervoxels) edges = edges[mask0 & mask1] diff --git a/pychunkedgraph/graph/segmenthistory.py b/pychunkedgraph/graph/segmenthistory.py index bc4422490..0f9cee61b 100644 --- a/pychunkedgraph/graph/segmenthistory.py +++ b/pychunkedgraph/graph/segmenthistory.py @@ -78,7 +78,9 @@ def operation_id_root_id_dict(self): @property def operation_ids(self): - return np.array(list(self.operation_id_root_id_dict.keys())) + return np.array( + list(self.operation_id_root_id_dict.keys()), dtype=basetypes.OPERATION_ID + ) @property def _log_rows(self): @@ -328,7 +330,9 @@ def past_future_id_mapping(self, root_id=None): past_id_mapping = {} future_id_mapping = {} for root_id in root_ids: - ancestors = np.array(list(nx_ancestors(self.lineage_graph, root_id)), dtype=np.uint64) + ancestors = np.array( + list(nx_ancestors(self.lineage_graph, root_id)), dtype=np.uint64 + ) if len(ancestors) == 0: past_id_mapping[int(root_id)] = [root_id] else: diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index 0a7aae728..3a7b0c11d 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -68,8 +68,11 @@ def _get_chunk_nodes_and_edges(chunk_edges_d: dict, isolated_ids: Sequence[int]) in-chunk edges and nodes_ids """ isolated_nodes_self_edges = np.vstack([isolated_ids, isolated_ids]).T - node_ids = [isolated_ids] - edge_ids = [isolated_nodes_self_edges] + + node_ids = [isolated_ids] if len(isolated_ids) != 0 else [] + edge_ids = ( + [isolated_nodes_self_edges] if len(isolated_nodes_self_edges) != 0 else [] + ) for edge_type in EDGE_TYPES: edges = chunk_edges_d[edge_type] node_ids.append(edges.node_ids1) @@ -77,9 +80,9 @@ def _get_chunk_nodes_and_edges(chunk_edges_d: dict, isolated_ids: Sequence[int]) node_ids.append(edges.node_ids2) edge_ids.append(edges.get_pairs()) - chunk_node_ids = np.unique(np.concatenate(node_ids)) + chunk_node_ids = np.unique(np.concatenate(node_ids).astype(basetypes.NODE_ID)) edge_ids.append(np.vstack([chunk_node_ids, chunk_node_ids]).T) - return (chunk_node_ids, np.concatenate(edge_ids)) + return (chunk_node_ids, np.concatenate(edge_ids).astype(basetypes.NODE_ID)) def _get_remapping(chunk_edges_d: dict): @@ -116,7 +119,7 @@ def _process_component( r_key = serializers.serialize_uint64(node_id) nodes.append(cg.client.mutate_row(r_key, val_dict, time_stamp=time_stamp)) - chunk_out_edges = np.concatenate(chunk_out_edges) + chunk_out_edges = np.concatenate(chunk_out_edges).astype(basetypes.NODE_ID) cce_layers = cg.get_cross_chunk_edges_layer(chunk_out_edges) u_cce_layers = np.unique(cce_layers) @@ -147,5 +150,7 @@ def _get_outgoing_edges(node_id, chunk_edges_d, sparse_indices, remapping): ] row_ids = row_ids[column_ids == 0] # edges that this node is part of - chunk_out_edges = np.concatenate([chunk_out_edges, edges[row_ids]]) + chunk_out_edges = np.concatenate([chunk_out_edges, edges[row_ids]]).astype( + basetypes.NODE_ID + ) return chunk_out_edges diff --git a/pychunkedgraph/ingest/create/parent_layer.py b/pychunkedgraph/ingest/create/parent_layer.py index 90b24d26a..dfdb48dac 100644 --- a/pychunkedgraph/ingest/create/parent_layer.py +++ b/pychunkedgraph/ingest/create/parent_layer.py @@ -73,7 +73,7 @@ def _read_children_chunks( children_ids = [types.empty_1d] for child_coord in children_coords: children_ids.append(_read_chunk([], cg, layer_id - 1, child_coord)) - return np.concatenate(children_ids) + return np.concatenate(children_ids).astype(basetypes.NODE_ID) with mp.Manager() as manager: children_ids_shared = manager.list() @@ -92,7 +92,7 @@ def _read_children_chunks( multi_args, n_threads=min(len(multi_args), mp.cpu_count()), ) - return np.concatenate(children_ids_shared) + return np.concatenate(children_ids_shared).astype(basetypes.NODE_ID) def _read_chunk_helper(args): diff --git a/pychunkedgraph/ingest/ran_agglomeration.py b/pychunkedgraph/ingest/ran_agglomeration.py index a0ca42d54..d726ba4a5 100644 --- a/pychunkedgraph/ingest/ran_agglomeration.py +++ b/pychunkedgraph/ingest/ran_agglomeration.py @@ -314,7 +314,9 @@ def get_active_edges(edges_d, mapping): if edge_type == EDGE_TYPES.in_chunk: pseudo_isolated_ids.append(edges.node_ids2) - return chunk_edges_active, np.unique(np.concatenate(pseudo_isolated_ids)) + return chunk_edges_active, np.unique( + np.concatenate(pseudo_isolated_ids).astype(basetypes.NODE_ID) + ) def define_active_edges(edge_dict, mapping) -> Union[Dict, np.ndarray]: @@ -380,7 +382,7 @@ def read_raw_agglomeration_data(imanager: IngestionManager, chunk_coord: np.ndar edges_list = _read_agg_files(filenames, chunk_ids, path) G = nx.Graph() - G.add_edges_from(np.concatenate(edges_list)) + G.add_edges_from(np.concatenate(edges_list).astype(basetypes.NODE_ID)) mapping = {} components = list(nx.connected_components(G)) for i_cc, cc in enumerate(components): diff --git a/pychunkedgraph/meshing/meshgen_utils.py b/pychunkedgraph/meshing/meshgen_utils.py index 711c09322..60ad44815 100644 --- a/pychunkedgraph/meshing/meshgen_utils.py +++ b/pychunkedgraph/meshing/meshgen_utils.py @@ -129,7 +129,13 @@ def recursive_helper(cur_node_ids): only_child_mask = np.array( [len(children_for_node) == 1 for children_for_node in children_array] ) - only_children = children_array[only_child_mask].astype(np.uint64).ravel() + # Extract children from object array - each filtered element is a 1-element array + filtered_children = children_array[only_child_mask] + only_children = ( + np.concatenate(filtered_children).astype(np.uint64) + if filtered_children.size + else np.array([], dtype=np.uint64) + ) if np.any(only_child_mask): temp_array = cur_node_ids[stop_layer_mask] temp_array[only_child_mask] = recursive_helper(only_children) @@ -155,7 +161,7 @@ def get_ws_seg_for_chunk(cg, chunk_id, mip, overlap_vx=1): mip_diff = mip - cg.meta.cv.mip mip_chunk_size = np.array(cg.meta.graph_config.CHUNK_SIZE, dtype=int) / np.array( - [2 ** mip_diff, 2 ** mip_diff, 1] + [2**mip_diff, 2**mip_diff, 1] ) mip_chunk_size = mip_chunk_size.astype(int) diff --git a/requirements.in b/requirements.in index 4bd56780b..0ae856c87 100644 --- a/requirements.in +++ b/requirements.in @@ -26,7 +26,7 @@ zmesh>=1.7.0 fastremap>=1.14.0 task-queue>=2.14.0 messagingclient -dracopy>=1.3.0 +dracopy>=1.5.0 datastoreflex>=0.5.0 zstandard>=0.23.0 From d163513f45b0c00b4899e1e24171b2828af01cea Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 25 Feb 2026 17:01:46 +0000 Subject: [PATCH 161/367] fix: initialize stitch mode attr --- pychunkedgraph/graph/operation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 3e722ccb1..80bc823e9 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -599,6 +599,7 @@ def __init__( self.bbox_offset = np.atleast_1d(bbox_offset).astype(basetypes.COORDINATES) self.allow_same_segment_merge = allow_same_segment_merge self.do_sanity_check = do_sanity_check + self.stitch_mode = stitch_mode self.affinities = None if affinities is not None: From df962221426a185d28c28dd9144cdcb25681f51f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 26 Feb 2026 21:35:49 +0000 Subject: [PATCH 162/367] add buildkit env for cloudbuild --- cloudbuild.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 21f4cc58d..5f28ab80b 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -10,7 +10,7 @@ steps: args: - "-c" - | - docker build -t $$USERNAME/pychunkedgraph:$TAG_NAME . + DOCKER_BUILDKIT=1 docker build -t $$USERNAME/pychunkedgraph:$TAG_NAME . timeout: 600s secretEnv: ["USERNAME"] From 45dddf59eec25f0db29a78c95c0b9472063328ed Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 28 Feb 2026 03:21:20 +0000 Subject: [PATCH 163/367] remove bigtable client, reorganize tests, improve redis+rq ops --- build_pypi.sh | 4 - compile_reqs.sh | 1 - pychunkedgraph/app/app_utils.py | 3 +- pychunkedgraph/app/segmentation/common.py | 36 +- pychunkedgraph/export/operation_logs.py | 7 +- pychunkedgraph/graph/__init__.py | 17 + pychunkedgraph/graph/attributes.py | 305 ------ pychunkedgraph/graph/basetypes.py | 1 + pychunkedgraph/graph/cache.py | 24 +- pychunkedgraph/graph/chunkedgraph.py | 61 +- pychunkedgraph/graph/client/__init__.py | 44 - pychunkedgraph/graph/client/base.py | 152 --- .../graph/client/bigtable/__init__.py | 49 - .../graph/client/bigtable/client.py | 948 ------------------ pychunkedgraph/graph/client/bigtable/utils.py | 305 ------ pychunkedgraph/graph/client/utils.py | 3 - pychunkedgraph/graph/cutting.py | 6 +- pychunkedgraph/graph/edges/definitions.py | 3 +- pychunkedgraph/graph/edges/ocdbt.py | 2 +- pychunkedgraph/graph/edges/stale.py | 32 +- pychunkedgraph/graph/edges/utils.py | 2 +- pychunkedgraph/graph/edits.py | 22 +- pychunkedgraph/graph/exceptions.py | 24 +- pychunkedgraph/graph/lineage.py | 68 +- pychunkedgraph/graph/misc.py | 2 +- pychunkedgraph/graph/operation.py | 39 +- pychunkedgraph/graph/segmenthistory.py | 22 +- pychunkedgraph/graph/types.py | 2 +- pychunkedgraph/graph/utils/basetypes.py | 16 - pychunkedgraph/graph/utils/generic.py | 52 - pychunkedgraph/graph/utils/id_helpers.py | 10 +- pychunkedgraph/graph/utils/serializers.py | 160 --- pychunkedgraph/ingest/cli_upgrade.py | 7 +- pychunkedgraph/ingest/cluster.py | 54 +- pychunkedgraph/ingest/create/atomic_layer.py | 5 +- pychunkedgraph/ingest/create/cross_edges.py | 7 +- pychunkedgraph/ingest/create/parent_layer.py | 6 +- pychunkedgraph/ingest/manager.py | 18 +- pychunkedgraph/ingest/ran_agglomeration.py | 2 +- pychunkedgraph/ingest/rq_cli.py | 9 +- pychunkedgraph/ingest/simple_tests.py | 3 +- pychunkedgraph/ingest/upgrade/atomic_layer.py | 13 +- pychunkedgraph/ingest/upgrade/parent_layer.py | 15 +- pychunkedgraph/ingest/upgrade/utils.py | 52 +- pychunkedgraph/ingest/utils.py | 24 +- pychunkedgraph/io/components.py | 2 +- pychunkedgraph/io/edges.py | 5 +- pychunkedgraph/meshing/manifest/sharded.py | 2 +- pychunkedgraph/meshing/manifest/utils.py | 2 +- pychunkedgraph/meshing/meshgen_utils.py | 2 +- pychunkedgraph/repair/edits.py | 6 +- pychunkedgraph/tests/conftest.py | 102 +- pychunkedgraph/tests/graph/__init__.py | 0 .../{ => graph}/test_analysis_pathing.py | 4 +- .../tests/{ => graph}/test_cache.py | 4 +- .../{ => graph}/test_chunkedgraph_extended.py | 8 +- .../{ => graph}/test_chunks_hierarchy.py | 2 +- .../tests/{ => graph}/test_chunks_utils.py | 8 +- .../tests/{ => graph}/test_connectivity.py | 0 .../tests/{ => graph}/test_cutting.py | 0 .../{ => graph}/test_edges_definitions.py | 2 +- .../tests/{ => graph}/test_edges_utils.py | 4 +- .../tests/{ => graph}/test_edits_extended.py | 6 +- .../tests/{ => graph}/test_exceptions.py | 7 +- .../tests/{ => graph}/test_graph_build.py | 90 +- .../tests/{ => graph}/test_graph_queries.py | 2 +- .../tests/{ => graph}/test_history.py | 10 +- .../tests/{ => graph}/test_lineage.py | 10 +- .../tests/{ => graph}/test_locks.py | 10 +- .../tests/{ => graph}/test_merge.py | 78 +- .../tests/{ => graph}/test_merge_split.py | 4 +- pychunkedgraph/tests/{ => graph}/test_meta.py | 0 .../tests/{ => graph}/test_mincut.py | 14 +- pychunkedgraph/tests/{ => graph}/test_misc.py | 4 +- .../tests/{ => graph}/test_multicut.py | 12 +- .../tests/{ => graph}/test_node_conversion.py | 30 +- .../tests/{ => graph}/test_operation.py | 10 +- .../tests/{ => graph}/test_root_lock.py | 8 +- .../tests/{ => graph}/test_segmenthistory.py | 14 +- .../tests/{ => graph}/test_split.py | 38 +- .../tests/{ => graph}/test_stale_edges.py | 6 +- .../tests/{ => graph}/test_subgraph.py | 4 +- .../tests/{ => graph}/test_types.py | 2 +- .../tests/{ => graph}/test_undo_redo.py | 4 +- .../tests/{ => graph}/test_utils_flatgraph.py | 0 .../tests/{ => graph}/test_utils_generic.py | 62 -- .../{ => graph}/test_utils_id_helpers.py | 2 +- pychunkedgraph/tests/hbase_mock_server.py | 473 +++++++++ pychunkedgraph/tests/helpers.py | 2 +- pychunkedgraph/tests/ingest/__init__.py | 0 pychunkedgraph/tests/ingest/test_cluster.py | 125 +++ .../{ => ingest}/test_ingest_atomic_layer.py | 2 +- .../tests/{ => ingest}/test_ingest_config.py | 0 .../{ => ingest}/test_ingest_cross_edges.py | 6 +- .../tests/{ => ingest}/test_ingest_manager.py | 0 .../{ => ingest}/test_ingest_parent_layer.py | 4 +- .../test_ingest_ran_agglomeration.py | 2 +- .../tests/{ => ingest}/test_ingest_utils.py | 0 pychunkedgraph/tests/io/__init__.py | 0 .../tests/{ => io}/test_io_components.py | 2 +- .../tests/{ => io}/test_io_edges.py | 2 +- pychunkedgraph/tests/meshing/__init__.py | 0 .../tests/meshing/test_manifest_utils.py | 40 + .../tests/meshing/test_mesh_analysis.py | 55 + pychunkedgraph/tests/meshing/test_meshgen.py | 164 +++ .../tests/meshing/test_meshgen_utils.py | 62 ++ pychunkedgraph/tests/test_attributes.py | 88 -- pychunkedgraph/tests/test_serializers.py | 143 --- requirements.in | 3 +- requirements.txt | 12 +- 110 files changed, 1577 insertions(+), 2825 deletions(-) delete mode 100644 build_pypi.sh delete mode 100755 compile_reqs.sh delete mode 100644 pychunkedgraph/graph/attributes.py create mode 100644 pychunkedgraph/graph/basetypes.py delete mode 100644 pychunkedgraph/graph/client/__init__.py delete mode 100644 pychunkedgraph/graph/client/base.py delete mode 100644 pychunkedgraph/graph/client/bigtable/__init__.py delete mode 100644 pychunkedgraph/graph/client/bigtable/client.py delete mode 100644 pychunkedgraph/graph/client/bigtable/utils.py delete mode 100644 pychunkedgraph/graph/client/utils.py delete mode 100644 pychunkedgraph/graph/utils/basetypes.py delete mode 100644 pychunkedgraph/graph/utils/serializers.py create mode 100644 pychunkedgraph/tests/graph/__init__.py rename pychunkedgraph/tests/{ => graph}/test_analysis_pathing.py (99%) rename pychunkedgraph/tests/{ => graph}/test_cache.py (97%) rename pychunkedgraph/tests/{ => graph}/test_chunkedgraph_extended.py (99%) rename pychunkedgraph/tests/{ => graph}/test_chunks_hierarchy.py (99%) rename pychunkedgraph/tests/{ => graph}/test_chunks_utils.py (96%) rename pychunkedgraph/tests/{ => graph}/test_connectivity.py (100%) rename pychunkedgraph/tests/{ => graph}/test_cutting.py (100%) rename pychunkedgraph/tests/{ => graph}/test_edges_definitions.py (98%) rename pychunkedgraph/tests/{ => graph}/test_edges_utils.py (97%) rename pychunkedgraph/tests/{ => graph}/test_edits_extended.py (91%) rename pychunkedgraph/tests/{ => graph}/test_exceptions.py (90%) rename pychunkedgraph/tests/{ => graph}/test_graph_build.py (81%) rename pychunkedgraph/tests/{ => graph}/test_graph_queries.py (99%) rename pychunkedgraph/tests/{ => graph}/test_history.py (94%) rename pychunkedgraph/tests/{ => graph}/test_lineage.py (98%) rename pychunkedgraph/tests/{ => graph}/test_locks.py (98%) rename pychunkedgraph/tests/{ => graph}/test_merge.py (93%) rename pychunkedgraph/tests/{ => graph}/test_merge_split.py (97%) rename pychunkedgraph/tests/{ => graph}/test_meta.py (100%) rename pychunkedgraph/tests/{ => graph}/test_mincut.py (96%) rename pychunkedgraph/tests/{ => graph}/test_misc.py (98%) rename pychunkedgraph/tests/{ => graph}/test_multicut.py (90%) rename pychunkedgraph/tests/{ => graph}/test_node_conversion.py (78%) rename pychunkedgraph/tests/{ => graph}/test_operation.py (99%) rename pychunkedgraph/tests/{ => graph}/test_root_lock.py (94%) rename pychunkedgraph/tests/{ => graph}/test_segmenthistory.py (98%) rename pychunkedgraph/tests/{ => graph}/test_split.py (96%) rename pychunkedgraph/tests/{ => graph}/test_stale_edges.py (98%) rename pychunkedgraph/tests/{ => graph}/test_subgraph.py (97%) rename pychunkedgraph/tests/{ => graph}/test_types.py (95%) rename pychunkedgraph/tests/{ => graph}/test_undo_redo.py (97%) rename pychunkedgraph/tests/{ => graph}/test_utils_flatgraph.py (100%) rename pychunkedgraph/tests/{ => graph}/test_utils_generic.py (61%) rename pychunkedgraph/tests/{ => graph}/test_utils_id_helpers.py (99%) create mode 100644 pychunkedgraph/tests/hbase_mock_server.py create mode 100644 pychunkedgraph/tests/ingest/__init__.py create mode 100644 pychunkedgraph/tests/ingest/test_cluster.py rename pychunkedgraph/tests/{ => ingest}/test_ingest_atomic_layer.py (97%) rename pychunkedgraph/tests/{ => ingest}/test_ingest_config.py (100%) rename pychunkedgraph/tests/{ => ingest}/test_ingest_cross_edges.py (98%) rename pychunkedgraph/tests/{ => ingest}/test_ingest_manager.py (100%) rename pychunkedgraph/tests/{ => ingest}/test_ingest_parent_layer.py (94%) rename pychunkedgraph/tests/{ => ingest}/test_ingest_ran_agglomeration.py (99%) rename pychunkedgraph/tests/{ => ingest}/test_ingest_utils.py (100%) create mode 100644 pychunkedgraph/tests/io/__init__.py rename pychunkedgraph/tests/{ => io}/test_io_components.py (97%) rename pychunkedgraph/tests/{ => io}/test_io_edges.py (98%) create mode 100644 pychunkedgraph/tests/meshing/__init__.py create mode 100644 pychunkedgraph/tests/meshing/test_manifest_utils.py create mode 100644 pychunkedgraph/tests/meshing/test_mesh_analysis.py create mode 100644 pychunkedgraph/tests/meshing/test_meshgen.py create mode 100644 pychunkedgraph/tests/meshing/test_meshgen_utils.py delete mode 100644 pychunkedgraph/tests/test_attributes.py delete mode 100644 pychunkedgraph/tests/test_serializers.py diff --git a/build_pypi.sh b/build_pypi.sh deleted file mode 100644 index c952f5cb4..000000000 --- a/build_pypi.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -python setup.py sdist -twine upload dist/* diff --git a/compile_reqs.sh b/compile_reqs.sh deleted file mode 100755 index 2d74c225d..000000000 --- a/compile_reqs.sh +++ /dev/null @@ -1 +0,0 @@ -docker run -v ${PWD}:/app caveconnectome/pychunkedgraph:v2.4.0 /bin/bash -c "pip install pip-tools && pip-compile requirements.in --resolver=backtracking -v --output-file requirements.txt" \ No newline at end of file diff --git a/pychunkedgraph/app/app_utils.py b/pychunkedgraph/app/app_utils.py index b46e4b192..061f60115 100644 --- a/pychunkedgraph/app/app_utils.py +++ b/pychunkedgraph/app/app_utils.py @@ -14,10 +14,9 @@ from pychunkedgraph import __version__ from pychunkedgraph.graph import ChunkedGraph -from pychunkedgraph.graph.client import get_default_client_info +from pychunkedgraph.graph import get_default_client_info from pychunkedgraph.graph import exceptions as cg_exceptions - PCG_CACHE = {} diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 5b44e9379..293b46981 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -26,10 +26,9 @@ exceptions as cg_exceptions, ) from pychunkedgraph.graph.analysis import pathing -from pychunkedgraph.graph.attributes import OperationLogs from pychunkedgraph.graph.misc import get_contact_sites from pychunkedgraph.graph.operation import GraphEditOperation -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes from pychunkedgraph.meshing import mesh_analysis __api_versions__ = [0, 1] @@ -229,7 +228,9 @@ def handle_find_minimal_covering_nodes(table_id, is_binary=True): node_queue[layer].clear() # Return the download list - download_list = np.concatenate([np.array(list(v), dtype=np.uint64) for v in download_list.values()]) + download_list = np.concatenate( + [np.array(list(v), dtype=np.uint64) for v in download_list.values()] + ) return download_list @@ -603,7 +604,9 @@ def all_user_operations( target_user_id = request.args.get("user_id", None) start_time = _parse_timestamp("start_time", 0, return_datetime=True) - end_time = _parse_timestamp("end_time", datetime.now(timezone.utc), return_datetime=True) + end_time = _parse_timestamp( + "end_time", datetime.now(timezone.utc), return_datetime=True + ) # Call ChunkedGraph cg = app_utils.get_cg(table_id) @@ -618,18 +621,19 @@ def all_user_operations( entry_ids = np.sort(list(log_rows.keys())) for entry_id in entry_ids: entry = log_rows[entry_id] - user_id = entry[OperationLogs.UserID] + user_id = entry[attributes.OperationLogs.UserID] should_check = ( - OperationLogs.Status not in entry - or entry[OperationLogs.Status] == OperationLogs.StatusCodes.SUCCESS.value + attributes.OperationLogs.Status not in entry + or entry[attributes.OperationLogs.Status] + == attributes.OperationLogs.StatusCodes.SUCCESS.value ) split_valid = ( include_partial_splits - or (OperationLogs.AddedEdge in entry) - or (OperationLogs.RootID not in entry) - or (len(entry[OperationLogs.RootID]) > 1) + or (attributes.OperationLogs.AddedEdge in entry) + or (attributes.OperationLogs.RootID not in entry) + or (len(entry[attributes.OperationLogs.RootID]) > 1) ) if not split_valid: print("excluding partial split", entry_id) @@ -643,13 +647,13 @@ def all_user_operations( if should_check: # if it is an undo of another operation, mark it as undone - if OperationLogs.UndoOperationID in entry: - undone_id = entry[OperationLogs.UndoOperationID] + if attributes.OperationLogs.UndoOperationID in entry: + undone_id = entry[attributes.OperationLogs.UndoOperationID] undone_ids = np.append(undone_ids, undone_id) # if it is a redo of another operation, unmark it as undone - if OperationLogs.RedoOperationID in entry: - redone_id = entry[OperationLogs.RedoOperationID] + if attributes.OperationLogs.RedoOperationID in entry: + redone_id = entry[attributes.OperationLogs.RedoOperationID] undone_ids = np.delete(undone_ids, np.argwhere(undone_ids == redone_id)) if include_undone: @@ -662,8 +666,8 @@ def all_user_operations( entry = log_rows[entry_id] if ( - OperationLogs.UndoOperationID in entry - or OperationLogs.RedoOperationID in entry + attributes.OperationLogs.UndoOperationID in entry + or attributes.OperationLogs.RedoOperationID in entry ): continue diff --git a/pychunkedgraph/export/operation_logs.py b/pychunkedgraph/export/operation_logs.py index ec7141ce7..1ee22a5a1 100644 --- a/pychunkedgraph/export/operation_logs.py +++ b/pychunkedgraph/export/operation_logs.py @@ -2,9 +2,11 @@ from typing import Iterable from datetime import datetime +from kvdbclient import attributes, basetypes +from kvdbclient.attributes import OperationLogs + from .models import OperationLog from ..graph import ChunkedGraph -from ..graph.attributes import OperationLogs def parse_attr(attr, val) -> str: @@ -54,7 +56,8 @@ def get_logs_with_previous_roots( from numpy import concatenate from ..graph.types import empty_1d from ..graph.lineage import get_previous_root_ids - from ..graph.utils.basetypes import NODE_ID + + NODE_ID = basetypes.NODE_ID print(f"getting olg roots for {len(parsed_logs)} logs.") roots = [empty_1d] diff --git a/pychunkedgraph/graph/__init__.py b/pychunkedgraph/graph/__init__.py index 96b342427..2be4fa1d6 100644 --- a/pychunkedgraph/graph/__init__.py +++ b/pychunkedgraph/graph/__init__.py @@ -1,2 +1,19 @@ +import sys + +from kvdbclient import attributes +from kvdbclient import serializers +from kvdbclient import base as client_base +from kvdbclient import ( + BackendClientInfo, + ClientType, + get_client_class, + get_default_client_info, +) +from kvdbclient.utils import get_valid_timestamp, get_min_time, get_max_time + +# Register submodule aliases so `from pychunkedgraph.graph.attributes import X` works. +sys.modules[f"{__name__}.attributes"] = attributes +sys.modules[f"{__name__}.serializers"] = serializers + from .chunkedgraph import ChunkedGraph from .meta import ChunkedGraphMeta diff --git a/pychunkedgraph/graph/attributes.py b/pychunkedgraph/graph/attributes.py deleted file mode 100644 index 6b7a277f0..000000000 --- a/pychunkedgraph/graph/attributes.py +++ /dev/null @@ -1,305 +0,0 @@ -# pylint: disable=invalid-name, missing-docstring, protected-access, raise-missing-from - -# TODO design to use these attributes across different clients -# `family_id` is specific to bigtable - -from enum import Enum -from typing import NamedTuple - -from .utils import serializers -from .utils import basetypes - - -class _AttributeType(NamedTuple): - key: bytes - family_id: str - serializer: serializers._Serializer - - -class _Attribute(_AttributeType): - __slots__ = () - _attributes = {} - - def __init__(self, **kwargs): - super().__init__() - _Attribute._attributes[(kwargs["family_id"], kwargs["key"])] = self - - def serialize(self, obj): - return self.serializer.serialize(obj) - - def deserialize(self, stream): - return self.serializer.deserialize(stream) - - @property - def basetype(self): - return self.serializer.basetype - - @property - def index(self): - return int(self.key.decode("utf-8").split("_")[-1]) - - -class _AttributeArray: - _attributearrays = {} - - def __init__(self, pattern, family_id, serializer): - self._pattern = pattern - self._family_id = family_id - self._serializer = serializer - _AttributeArray._attributearrays[(family_id, pattern)] = self - - # TODO: Add missing check in `fromkey(family_id, key)` and remove this - # loop (pre-creates `_Attributes`, so that the inverse lookup works) - for i in range(20): - self[i] # pylint: disable=W0104 - - def __getitem__(self, item): - return _Attribute( - key=self.pattern % item, - family_id=self._family_id, - serializer=self._serializer, - ) - - @property - def pattern(self): - return self._pattern - - @property - def serialize(self): - return self._serializer.serialize - - @property - def deserialize(self): - return self._serializer.deserialize - - @property - def basetype(self): - return self._serializer.basetype - - -class Concurrency: - Counter = _Attribute( - key=b"counter", - family_id="1", - serializer=serializers.NumPyValue(dtype=basetypes.COUNTER), - ) - - Lock = _Attribute(key=b"lock", family_id="0", serializer=serializers.UInt64String()) - - IndefiniteLock = _Attribute( - key=b"indefinite_lock", family_id="0", serializer=serializers.UInt64String() - ) - - -class Connectivity: - Affinity = _Attribute( - key=b"affinities", - family_id="0", - serializer=serializers.NumPyArray(dtype=basetypes.EDGE_AFFINITY), - ) - - Area = _Attribute( - key=b"areas", - family_id="0", - serializer=serializers.NumPyArray(dtype=basetypes.EDGE_AREA), - ) - - AtomicCrossChunkEdge = _AttributeArray( - pattern=b"atomic_cross_edges_%d", - family_id="3", - serializer=serializers.NumPyArray( - dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 - ), - ) - - CrossChunkEdge = _AttributeArray( - pattern=b"cross_edges_%d", - family_id="4", - serializer=serializers.NumPyArray( - dtype=basetypes.NODE_ID, shape=(-1, 2), compression_level=22 - ), - ) - - FakeEdgesCF3 = _Attribute( - key=b"fake_edges", - family_id="3", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), - ) - - FakeEdges = _Attribute( - key=b"fake_edges", - family_id="4", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), - ) - - -class Hierarchy: - Child = _Attribute( - key=b"children", - family_id="0", - serializer=serializers.NumPyArray( - dtype=basetypes.NODE_ID, compression_level=22 - ), - ) - - FormerParent = _Attribute( - key=b"former_parents", - family_id="0", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID), - ) - - NewParent = _Attribute( - key=b"new_parents", - family_id="0", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID), - ) - - Parent = _Attribute( - key=b"parents", - family_id="0", - serializer=serializers.NumPyValue(dtype=basetypes.NODE_ID), - ) - - # track when nodes became stale, required for migration - # will be eventually deleted by GC rule for column family_id 3. - StaleTimeStamp = _Attribute( - key=b"stale_ts", family_id="3", serializer=serializers.Pickle() - ) - - -class GraphMeta: - key = b"meta" - Meta = _Attribute(key=key, family_id="0", serializer=serializers.Pickle()) - - -class GraphVersion: - key = b"version" - Version = _Attribute(key=key, family_id="0", serializer=serializers.String("utf-8")) - - -class OperationLogs: - key = b"ioperations" - - class StatusCodes(Enum): - SUCCESS = 0 # all is well, new changes persisted - CREATED = 1 # log record created in storage - EXCEPTION = 2 # edit unsuccessful, unknown error - WRITE_STARTED = 3 # edit successful, start persisting changes - WRITE_FAILED = 4 # edit successful, but changes not persisted - - OperationID = _Attribute( - key=b"operation_id", family_id="0", serializer=serializers.UInt64String() - ) - - UndoOperationID = _Attribute( - key=b"undo_operation_id", family_id="2", serializer=serializers.UInt64String() - ) - - RedoOperationID = _Attribute( - key=b"redo_operation_id", family_id="2", serializer=serializers.UInt64String() - ) - - UserID = _Attribute( - key=b"user", family_id="2", serializer=serializers.String("utf-8") - ) - - RootID = _Attribute( - key=b"roots", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID), - ) - - SourceID = _Attribute( - key=b"source_ids", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID), - ) - - SinkID = _Attribute( - key=b"sink_ids", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID), - ) - - SourceCoordinate = _Attribute( - key=b"source_coords", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.COORDINATES, shape=(-1, 3)), - ) - - SinkCoordinate = _Attribute( - key=b"sink_coords", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.COORDINATES, shape=(-1, 3)), - ) - - BoundingBoxOffset = _Attribute( - key=b"bb_offset", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.COORDINATES), - ) - - AddedEdge = _Attribute( - key=b"added_edges", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), - ) - - RemovedEdge = _Attribute( - key=b"removed_edges", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)), - ) - - Affinity = _Attribute( - key=b"affinities", - family_id="2", - serializer=serializers.NumPyArray(dtype=basetypes.EDGE_AFFINITY), - ) - - Status = _Attribute( - key=b"operation_status", family_id="0", serializer=serializers.Pickle() - ) - - OperationException = _Attribute( - key=b"operation_exception", - family_id="0", - serializer=serializers.String("utf-8"), - ) - - # timestamp at which the new IDs were created during the operation - # this is needed because the timestamp of the operation log - # will change with change in status - OperationTimeStamp = _Attribute( - key=b"operation_ts", family_id="0", serializer=serializers.Pickle() - ) - - @staticmethod - def all(): - return [ - OperationLogs.OperationID, - OperationLogs.UndoOperationID, - OperationLogs.RedoOperationID, - OperationLogs.UserID, - OperationLogs.RootID, - OperationLogs.SourceID, - OperationLogs.SinkID, - OperationLogs.SourceCoordinate, - OperationLogs.SinkCoordinate, - OperationLogs.BoundingBoxOffset, - OperationLogs.AddedEdge, - OperationLogs.RemovedEdge, - OperationLogs.Affinity, - OperationLogs.Status, - OperationLogs.OperationException, - OperationLogs.OperationTimeStamp, - ] - - -def from_key(family_id: str, key: bytes): - try: - return _Attribute._attributes[(family_id, key)] - except KeyError: - # FIXME: Look if the key matches a columnarray pattern and - # remove loop initialization in _AttributeArray.__init__() - raise KeyError(f"Unknown key {family_id}:{key.decode()}") diff --git a/pychunkedgraph/graph/basetypes.py b/pychunkedgraph/graph/basetypes.py new file mode 100644 index 000000000..ff7963363 --- /dev/null +++ b/pychunkedgraph/graph/basetypes.py @@ -0,0 +1 @@ +from kvdbclient.basetypes import * # noqa: F401,F403 diff --git a/pychunkedgraph/graph/cache.py b/pychunkedgraph/graph/cache.py index 011f4099e..430a998c5 100644 --- a/pychunkedgraph/graph/cache.py +++ b/pychunkedgraph/graph/cache.py @@ -2,6 +2,7 @@ """ Cache nodes, parents, children and cross edges. """ + import traceback from collections import defaultdict as defaultd from sys import maxsize @@ -13,7 +14,7 @@ import numpy as np -from .utils.basetypes import NODE_ID +from pychunkedgraph.graph import basetypes def update(cache, keys, vals): @@ -148,7 +149,9 @@ def cross_chunk_edges(self, node_id, *, time_stamp: datetime = None): @cached(cache=self.cross_chunk_edges_cache, key=lambda node_id: node_id) def cross_edges_decorated(node_id): edges = self._cg.get_cross_chunk_edges( - np.array([node_id], dtype=NODE_ID), raw_only=True, time_stamp=time_stamp + np.array([node_id], dtype=basetypes.NODE_ID), + raw_only=True, + time_stamp=time_stamp, ) return edges[node_id] @@ -161,11 +164,13 @@ def parents_multiple( time_stamp: datetime = None, fail_to_zero: bool = False, ): - node_ids = np.asarray(node_ids, dtype=NODE_ID) + node_ids = np.asarray(node_ids, dtype=basetypes.NODE_ID) if not node_ids.size: return node_ids self.stats["parents"]["calls"] += 1 - mask = np.isin(node_ids, np.fromiter(self.parents_cache.keys(), dtype=NODE_ID)) + mask = np.isin( + node_ids, np.fromiter(self.parents_cache.keys(), dtype=basetypes.NODE_ID) + ) hits = int(np.sum(mask)) misses = len(node_ids) - hits self.stats["parents"]["hits"] += hits @@ -185,11 +190,13 @@ def parents_multiple( def children_multiple(self, node_ids: np.ndarray, *, flatten=False): result = {} - node_ids = np.asarray(node_ids, dtype=NODE_ID) + node_ids = np.asarray(node_ids, dtype=basetypes.NODE_ID) if not node_ids.size: return result self.stats["children"]["calls"] += 1 - mask = np.isin(node_ids, np.fromiter(self.children_cache.keys(), dtype=NODE_ID)) + mask = np.isin( + node_ids, np.fromiter(self.children_cache.keys(), dtype=basetypes.NODE_ID) + ) hits = int(np.sum(mask)) misses = len(node_ids) - hits self.stats["children"]["hits"] += hits @@ -209,12 +216,13 @@ def cross_chunk_edges_multiple( self, node_ids: np.ndarray, *, time_stamp: datetime = None ): result = {} - node_ids = np.asarray(node_ids, dtype=NODE_ID) + node_ids = np.asarray(node_ids, dtype=basetypes.NODE_ID) if not node_ids.size: return result self.stats["cross_chunk_edges"]["calls"] += 1 mask = np.isin( - node_ids, np.fromiter(self.cross_chunk_edges_cache.keys(), dtype=NODE_ID) + node_ids, + np.fromiter(self.cross_chunk_edges_cache.keys(), dtype=basetypes.NODE_ID), ) hits = int(np.sum(mask)) misses = len(node_ids) - hits diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 940df2675..89282a58c 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -11,17 +11,19 @@ from . import types from . import operation -from . import attributes +from pychunkedgraph.graph import attributes from . import exceptions -from .client import base -from .client import BigTableClient -from .client import BackendClientInfo -from .client import get_default_client_info +from pychunkedgraph.graph import client_base as base +from pychunkedgraph.graph import BackendClientInfo +from pychunkedgraph.graph import ClientType +from pychunkedgraph.graph import get_client_class +from pychunkedgraph.graph import get_default_client_info from .cache import CacheService from .meta import ChunkedGraphMeta, GraphConfig -from .utils import basetypes +from pychunkedgraph.graph import basetypes from .utils import id_helpers -from .utils import serializers +from pychunkedgraph.graph import serializers +from pychunkedgraph.graph import get_valid_timestamp from .utils import generic as misc_utils from .edges import Edges from .edges import utils as edge_utils @@ -48,21 +50,24 @@ def __init__( 3. Existing graphs in other projects/clients, Requires `graph_id` and `client_info`. """ - # create client based on type - # for now, just use BigTableClient + ClientClass = get_client_class(client_info.TYPE) if meta: graph_id = meta.graph_config.ID_PREFIX + meta.graph_config.ID - bt_client = BigTableClient( - graph_id, config=client_info.CONFIG, graph_meta=meta + _client = ClientClass( + graph_id, + config=client_info.CONFIG, + table_meta=meta, + lock_expiry=meta.graph_config.ROOT_LOCK_EXPIRY, ) self._meta = meta else: - bt_client = BigTableClient(graph_id, config=client_info.CONFIG) - self._meta = bt_client.read_graph_meta() + _client = ClientClass(graph_id, config=client_info.CONFIG) + self._meta = _client.read_table_meta() + _client._lock_expiry = self._meta.graph_config.ROOT_LOCK_EXPIRY - self._client = bt_client - self._id_client = bt_client + self._client = _client + self._id_client = _client self._cache_service = None self.mock_edges = None # hack for unit tests @@ -86,10 +91,10 @@ def graph_id(self) -> str: @property def version(self) -> str: - return self.client.read_graph_version() + return self.client.read_table_version() @property - def client(self) -> BigTableClient: + def client(self) -> ClientType: return self._client @property @@ -110,11 +115,11 @@ def cache(self, cache_service: CacheService): def create(self): """Creates the graph in storage client and stores meta.""" - self._client.create_graph(self._meta, version=__version__) + self._client.create_table(self._meta, version=__version__) def update_meta(self, meta: ChunkedGraphMeta, overwrite: bool): """Update meta of an already existing graph.""" - self.client.update_graph_meta(meta, overwrite=overwrite) + self.client.update_table_meta(meta, overwrite=overwrite) def range_read_chunk( self, @@ -207,7 +212,7 @@ def get_parents( Else all parents along with timestamps. """ if raw_only or not self.cache: - time_stamp = misc_utils.get_valid_timestamp(time_stamp) + time_stamp = get_valid_timestamp(time_stamp) parent_rows = self.client.read_nodes( node_ids=node_ids, properties=attributes.Hierarchy.Parent, @@ -254,7 +259,7 @@ def get_parent( time_stamp: typing.Optional[datetime.datetime] = None, ) -> typing.Union[typing.List[typing.Tuple], np.uint64]: if raw_only or not self.cache: - time_stamp = misc_utils.get_valid_timestamp(time_stamp) + time_stamp = get_valid_timestamp(time_stamp) parents = self.client.read_node( node_id, properties=attributes.Hierarchy.Parent, @@ -347,7 +352,7 @@ def get_cross_chunk_edges( Returns cross edges for `node_ids`. A dict of the form `{node_id: {layer: cross_edges}}`. """ - time_stamp = misc_utils.get_valid_timestamp(time_stamp) + time_stamp = get_valid_timestamp(time_stamp) if raw_only or not self.cache: result = {} node_ids = np.array(node_ids, dtype=basetypes.NODE_ID) @@ -395,7 +400,7 @@ def get_roots( When `assert_roots=False`, returns highest available IDs and cases where there are no root IDs are silently ignored. """ - time_stamp = misc_utils.get_valid_timestamp(time_stamp) + time_stamp = get_valid_timestamp(time_stamp) stop_layer = self.meta.layer_count if not stop_layer else stop_layer assert stop_layer <= self.meta.layer_count layer_mask = np.ones(len(node_ids), dtype=bool) @@ -472,7 +477,7 @@ def get_root( n_tries: int = 1, ) -> typing.Union[typing.List[np.uint64], np.uint64]: """Takes a node id and returns the associated agglomeration ids.""" - time_stamp = misc_utils.get_valid_timestamp(time_stamp) + time_stamp = get_valid_timestamp(time_stamp) parent_id = node_id all_parent_ids = [] stop_layer = self.meta.layer_count if not stop_layer else stop_layer @@ -527,7 +532,7 @@ def is_latest_roots( time_stamp: typing.Optional[datetime.datetime] = None, ) -> typing.Iterable: """Determines whether root ids are superseded.""" - time_stamp = misc_utils.get_valid_timestamp(time_stamp) + time_stamp = get_valid_timestamp(time_stamp) row_dict = self.client.read_nodes( node_ids=root_ids, @@ -1028,9 +1033,9 @@ def get_earliest_timestamp(self): if timestamp is not None: return timestamp - timedelta(milliseconds=500) if _log: - return self.client._read_byte_row(serializers.serialize_uint64(op_id))[ - attributes.OperationLogs.Status - ][-1].timestamp + return self.client.read_node( + op_id, properties=attributes.OperationLogs.Status + )[-1].timestamp def get_operation_ids(self, node_ids: typing.Sequence): response = self.client.read_nodes(node_ids=node_ids) diff --git a/pychunkedgraph/graph/client/__init__.py b/pychunkedgraph/graph/client/__init__.py deleted file mode 100644 index 6e025bd35..000000000 --- a/pychunkedgraph/graph/client/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Sub packages/modules for backend storage clients -Currently supports Google Big Table - -A simple client needs to be able to create the graph, -store graph meta and to write and read node information. -Also needs locking support to prevent race conditions -when modifying root/parent nodes. - -In addition, clients with more features like generating unique IDs -and logging facilities can be implemented by inherting respective base classes. - -These methods are in separate classes because they are logically related. -This also makes it possible to have different backend storage solutions, -making it possible to use any unique features these solutions may provide. - -Please see `base.py` for more details. -""" - -from collections import namedtuple - -from .bigtable.client import Client as BigTableClient - - -_backend_clientinfo_fields = ("TYPE", "CONFIG") -_backend_clientinfo_defaults = (None, None) -BackendClientInfo = namedtuple( - "BackendClientInfo", - _backend_clientinfo_fields, - defaults=_backend_clientinfo_defaults, -) - - -def get_default_client_info(): - """ - Load client from env variables. - """ - - # TODO make dynamic after multiple platform support is added - from .bigtable import get_client_info as get_bigtable_client_info - - return BackendClientInfo( - CONFIG=get_bigtable_client_info(admin=True, read_only=False) - ) diff --git a/pychunkedgraph/graph/client/base.py b/pychunkedgraph/graph/client/base.py deleted file mode 100644 index 953734670..000000000 --- a/pychunkedgraph/graph/client/base.py +++ /dev/null @@ -1,152 +0,0 @@ -from abc import ABC -from abc import abstractmethod - - -class SimpleClient(ABC): - """ - Abstract class for interacting with backend data store where the chunkedgraph is stored. - Eg., BigTableClient for using big table as storage. - """ - - @abstractmethod - def create_graph(self) -> None: - """Initialize the graph and store associated meta.""" - - @abstractmethod - def add_graph_version(self, version: str, overwrite: bool = False): - """Add a version to the graph.""" - - @abstractmethod - def read_graph_version(self): - """Read stored graph version.""" - - @abstractmethod - def update_graph_meta(self, meta): - """Update stored graph meta.""" - - @abstractmethod - def read_graph_meta(self): - """Read stored graph meta.""" - - @abstractmethod - def read_nodes( - self, - start_id=None, - end_id=None, - node_ids=None, - properties=None, - start_time=None, - end_time=None, - end_time_inclusive=False, - ): - """ - Read nodes and their properties. - Accepts a range of node IDs or specific node IDs. - """ - - @abstractmethod - def read_node( - self, - node_id, - properties=None, - start_time=None, - end_time=None, - end_time_inclusive=False, - ): - """Read a single node and it's properties.""" - - @abstractmethod - def write_nodes(self, nodes): - """Writes/updates nodes (IDs along with properties).""" - - @abstractmethod - def lock_root(self, node_id, operation_id): - """Locks root node with operation_id to prevent race conditions.""" - - @abstractmethod - def lock_roots(self, node_ids, operation_id): - """Locks root nodes to prevent race conditions.""" - - @abstractmethod - def lock_root_indefinitely(self, node_id, operation_id): - """Locks root node with operation_id to prevent race conditions.""" - - @abstractmethod - def lock_roots_indefinitely(self, node_ids, operation_id): - """ - Locks root nodes indefinitely to prevent structural damage to graph. - This scenario is rare and needs asynchronous fix or inspection to unlock. - """ - - @abstractmethod - def unlock_root(self, node_id, operation_id): - """Unlocks root node that is locked with operation_id.""" - - @abstractmethod - def unlock_indefinitely_locked_root(self, node_id, operation_id): - """Unlocks root node that is indefinitely locked with operation_id.""" - - @abstractmethod - def renew_lock(self, node_id, operation_id): - """Renews existing node lock with operation_id for extended time.""" - - @abstractmethod - def renew_locks(self, node_ids, operation_id): - """Renews existing node locks with operation_id for extended time.""" - - @abstractmethod - def get_lock_timestamp(self, node_ids, operation_id): - """Reads timestamp from lock row to get a consistent timestamp.""" - - @abstractmethod - def get_consolidated_lock_timestamp(self, root_ids, operation_ids): - """Minimum of multiple lock timestamps.""" - - @abstractmethod - def get_compatible_timestamp(self, time_stamp): - """Datetime time stamp compatible with client's services.""" - - -class ClientWithIDGen(SimpleClient): - """ - Abstract class for client to backend data store that has support for generating IDs. - If not, something else can be used but these methods need to be implemented. - Eg., Big Table row cells can be used to generate unique IDs. - """ - - @abstractmethod - def create_node_ids(self, chunk_id): - """Generate a range of unique IDs in the chunk.""" - - @abstractmethod - def create_node_id(self, chunk_id): - """Generate a unique ID in the chunk.""" - - @abstractmethod - def get_max_node_id(self, chunk_id): - """Gets the current maximum node ID in the chunk.""" - - @abstractmethod - def create_operation_id(self): - """Generate a unique operation ID.""" - - @abstractmethod - def get_max_operation_id(self): - """Gets the current maximum operation ID.""" - - -class OperationLogger(ABC): - """ - Abstract class for interacting with backend data store where the operation logs are stored. - Eg., BigTableClient can be used to store logs in Google BigTable. - """ - - # TODO add functions for writing - - @abstractmethod - def read_log_entry(self, operation_id: int) -> None: - """Read log entry for a given operation ID.""" - - @abstractmethod - def read_log_entries(self, operation_ids) -> None: - """Read log entries for given operation IDs.""" diff --git a/pychunkedgraph/graph/client/bigtable/__init__.py b/pychunkedgraph/graph/client/bigtable/__init__.py deleted file mode 100644 index b3dbd777b..000000000 --- a/pychunkedgraph/graph/client/bigtable/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -from collections import namedtuple -from os import environ - -DEFAULT_PROJECT = "neuromancer-seung-import" -DEFAULT_INSTANCE = "pychunkedgraph" - -_bigtableconfig_fields = ( - "PROJECT", - "INSTANCE", - "ADMIN", - "READ_ONLY", - "CREDENTIALS", - "MAX_ROW_KEY_COUNT" -) -_bigtableconfig_defaults = ( - environ.get("BIGTABLE_PROJECT", DEFAULT_PROJECT), - environ.get("BIGTABLE_INSTANCE", DEFAULT_INSTANCE), - False, - True, - None, - 1000 -) -BigTableConfig = namedtuple( - "BigTableConfig", _bigtableconfig_fields, defaults=_bigtableconfig_defaults -) - - -def get_client_info( - project: str = None, - instance: str = None, - admin: bool = False, - read_only: bool = True, -): - """Helper function to load config from env.""" - _project = environ.get("BIGTABLE_PROJECT", DEFAULT_PROJECT) - if project: - _project = project - - _instance = environ.get("BIGTABLE_INSTANCE", DEFAULT_INSTANCE) - if instance: - _instance = instance - - kwargs = { - "PROJECT": _project, - "INSTANCE": _instance, - "ADMIN": admin, - "READ_ONLY": read_only, - } - return BigTableConfig(**kwargs) diff --git a/pychunkedgraph/graph/client/bigtable/client.py b/pychunkedgraph/graph/client/bigtable/client.py deleted file mode 100644 index 260d985ab..000000000 --- a/pychunkedgraph/graph/client/bigtable/client.py +++ /dev/null @@ -1,948 +0,0 @@ -# pylint: disable=invalid-name, missing-docstring, import-outside-toplevel, line-too-long, protected-access, arguments-differ, arguments-renamed, logging-fstring-interpolation, too-many-arguments - -import sys -import time -import typing -import logging -from datetime import datetime -from datetime import timedelta -from concurrent.futures import ThreadPoolExecutor, as_completed - -import numpy as np -from multiwrapper import multiprocessing_utils as mu -from google.cloud import bigtable -from google.api_core.retry import Retry -from google.api_core.retry import if_exception_type -from google.api_core.exceptions import Aborted -from google.api_core.exceptions import DeadlineExceeded -from google.api_core.exceptions import ServiceUnavailable -from google.cloud.bigtable.column_family import MaxAgeGCRule -from google.cloud.bigtable.column_family import MaxVersionsGCRule -from google.cloud.bigtable.table import Table -from google.cloud.bigtable.row_set import RowSet -from google.cloud.bigtable.row_data import DEFAULT_RETRY_READ_ROWS, PartialRowData -from google.cloud.bigtable.row_filters import RowFilter - -from . import utils -from . import BigTableConfig -from ..base import ClientWithIDGen -from ..base import OperationLogger -from ... import attributes -from ... import exceptions -from ...utils import basetypes -from ...utils.serializers import pad_node_id -from ...utils.serializers import serialize_key -from ...utils.serializers import serialize_uint64 -from ...utils.serializers import deserialize_uint64 -from ...meta import ChunkedGraphMeta -from ...utils.generic import get_valid_timestamp - - -class Client(bigtable.Client, ClientWithIDGen, OperationLogger): - def __init__( - self, - table_id: str, - config: BigTableConfig = BigTableConfig(), - graph_meta: ChunkedGraphMeta = None, - ): - if config.CREDENTIALS: - super(Client, self).__init__( - project=config.PROJECT, - read_only=config.READ_ONLY, - admin=config.ADMIN, - credentials=config.CREDENTIALS, - ) - else: - super(Client, self).__init__( - project=config.PROJECT, - read_only=config.READ_ONLY, - admin=config.ADMIN, - ) - self._instance = self.instance(config.INSTANCE) - self._table = self._instance.table(table_id) - - self.logger = logging.getLogger( - f"{config.PROJECT}/{config.INSTANCE}/{table_id}" - ) - self.logger.setLevel(logging.WARNING) - if not self.logger.handlers: - sh = logging.StreamHandler(sys.stdout) - sh.setLevel(logging.WARNING) - self.logger.addHandler(sh) - self._graph_meta = graph_meta - self._version = None - self._max_row_key_count = config.MAX_ROW_KEY_COUNT - - def _create_column_families(self): - f = self._table.column_family("0") - f.create() - f = self._table.column_family("1", gc_rule=MaxVersionsGCRule(1)) - f.create() - f = self._table.column_family("2") - f.create() - f = self._table.column_family("3", gc_rule=MaxAgeGCRule(timedelta(days=365))) - f.create() - f = self._table.column_family("4") - f.create() - - @property - def graph_meta(self): - return self._graph_meta - - def create_graph(self, meta: ChunkedGraphMeta, version: str) -> None: - """Initialize the graph and store associated meta.""" - if self._table.exists(): - raise ValueError(f"{self._table.table_id} already exists.") - self._table.create() - self._create_column_families() - self.add_graph_version(version) - self.update_graph_meta(meta) - - def add_graph_version(self, version: str, overwrite: bool = False): - if not overwrite: - assert self.read_graph_version() is None, self.read_graph_version() - self._version = version - row = self.mutate_row( - attributes.GraphVersion.key, - {attributes.GraphVersion.Version: version}, - ) - self.write([row]) - - def read_graph_version(self) -> str: - try: - row = self._read_byte_row(attributes.GraphVersion.key) - self._version = row[attributes.GraphVersion.Version][0].value - return self._version - except KeyError: - return None - - def _delete_meta(self): - # temprorary fix, use new column with GCRule for permanent fix - # delete existing meta before update, but compatibilty issues - meta_row = self._table.direct_row(attributes.GraphMeta.key) - meta_row.delete() - meta_row.commit() - - def update_graph_meta( - self, meta: ChunkedGraphMeta, overwrite: typing.Optional[bool] = False - ): - if overwrite: - self._delete_meta() - self._graph_meta = meta - row = self.mutate_row( - attributes.GraphMeta.key, - {attributes.GraphMeta.Meta: meta}, - ) - self.write([row]) - - def read_graph_meta(self) -> ChunkedGraphMeta: - row = self._read_byte_row(attributes.GraphMeta.key) - self._graph_meta = row[attributes.GraphMeta.Meta][0].value - return self._graph_meta - - def read_nodes( - self, - start_id=None, - end_id=None, - end_id_inclusive=False, - user_id=None, - node_ids=None, - properties=None, - start_time=None, - end_time=None, - end_time_inclusive: bool = False, - fake_edges: bool = False, - attr_keys: bool = True, - ): - """ - Read nodes and their properties. - Accepts a range of node IDs or specific node IDs. - """ - if node_ids is not None and len(node_ids) > self._max_row_key_count: - # bigtable reading is faster - # when all IDs in a block are within a range - node_ids = np.sort(node_ids) - rows = self._read_byte_rows( - start_key=( - serialize_uint64(start_id, fake_edges=fake_edges) - if start_id is not None - else None - ), - end_key=( - serialize_uint64(end_id, fake_edges=fake_edges) - if end_id is not None - else None - ), - end_key_inclusive=end_id_inclusive, - row_keys=( - ( - serialize_uint64(node_id, fake_edges=fake_edges) - for node_id in node_ids - ) - if node_ids is not None - else None - ), - columns=properties, - start_time=start_time, - end_time=end_time, - end_time_inclusive=end_time_inclusive, - user_id=user_id, - ) - if attr_keys: - return { - deserialize_uint64(row_key, fake_edges=fake_edges): data - for (row_key, data) in rows.items() - } - return { - deserialize_uint64(row_key, fake_edges=fake_edges): { - k.key: v for k, v in data.items() - } - for (row_key, data) in rows.items() - } - - def read_node( - self, - node_id: np.uint64, - properties: typing.Optional[ - typing.Union[typing.Iterable[attributes._Attribute], attributes._Attribute] - ] = None, - start_time: typing.Optional[datetime] = None, - end_time: typing.Optional[datetime] = None, - end_time_inclusive: bool = False, - fake_edges: bool = False, - ) -> typing.Union[ - typing.Dict[attributes._Attribute, typing.List[bigtable.row_data.Cell]], - typing.List[bigtable.row_data.Cell], - ]: - """Convenience function for reading a single node from Bigtable. - Arguments: - node_id {np.uint64} -- the NodeID of the row to be read. - Keyword Arguments: - columns {typing.Optional[typing.Union[typing.Iterable[attributes._Attribute], attributes._Attribute]]} -- - typing.Optional filtering by columns to speed up the query. If `columns` is a single - column (not iterable), the column key will be omitted from the result. - (default: {None}) - start_time {typing.Optional[datetime]} -- Ignore cells with timestamp before - `start_time`. If None, no lower bound. (default: {None}) - end_time {typing.Optional[datetime]} -- Ignore cells with timestamp after `end_time`. - If None, no upper bound. (default: {None}) - end_time_inclusive {bool} -- Whether or not `end_time` itself should be included in the - request, ignored if `end_time` is None. (default: {False}) - Returns: - typing.Union[typing.Dict[attributes._Attribute, typing.List[bigtable.row_data.Cell]], - typing.List[bigtable.row_data.Cell]] -- - Returns a mapping of columns to a typing.List of cells (one cell per timestamp). Each cell - has a `value` property, which returns the deserialized field, and a `timestamp` - property, which returns the timestamp as `datetime` object. - If only a single `attributes._Attribute` was requested, the typing.List of cells is returned - directly. - """ - return self._read_byte_row( - row_key=serialize_uint64(node_id, fake_edges=fake_edges), - columns=properties, - start_time=start_time, - end_time=end_time, - end_time_inclusive=end_time_inclusive, - ) - - def write_nodes(self, nodes, root_ids=None, operation_id=None): - """ - Writes/updates nodes (IDs along with properties) - by locking root nodes until changes are written. - """ - - def read_log_entry( - self, operation_id: np.uint64 - ) -> typing.Tuple[typing.Dict, datetime]: - log_record = self.read_node( - operation_id, properties=attributes.OperationLogs.all() - ) - if len(log_record) == 0: - return {}, None - try: - timestamp = log_record[attributes.OperationLogs.OperationTimeStamp][0].value - except KeyError: - timestamp = log_record[attributes.OperationLogs.RootID][0].timestamp - log_record.update((column, v[0].value) for column, v in log_record.items()) - return log_record, timestamp - - def read_log_entries( - self, - operation_ids: typing.Optional[typing.Iterable] = None, - user_id: typing.Optional[str] = None, - properties: typing.Optional[typing.Iterable[attributes._Attribute]] = None, - start_time: typing.Optional[datetime] = None, - end_time: typing.Optional[datetime] = None, - end_time_inclusive: bool = False, - ): - if properties is None: - properties = attributes.OperationLogs.all() - - if operation_ids is None: - logs_d = self.read_nodes( - start_id=np.uint64(0), - end_id=self.get_max_operation_id(), - end_id_inclusive=True, - user_id=user_id, - properties=properties, - start_time=start_time, - end_time=end_time, - end_time_inclusive=end_time_inclusive, - ) - else: - logs_d = self.read_nodes( - node_ids=operation_ids, - properties=properties, - start_time=start_time, - end_time=end_time, - end_time_inclusive=end_time_inclusive, - user_id=user_id, - ) - if not logs_d: - return {} - for operation_id in logs_d: - log_record = logs_d[operation_id] - try: - timestamp = log_record[attributes.OperationLogs.OperationTimeStamp][ - 0 - ].value - except KeyError: - timestamp = log_record[attributes.OperationLogs.RootID][0].timestamp - log_record.update((column, v[0].value) for column, v in log_record.items()) - log_record["timestamp"] = timestamp - return logs_d - - # Helpers - def write( - self, - rows: typing.Iterable[bigtable.row.DirectRow], - root_ids: typing.Optional[ - typing.Union[np.uint64, typing.Iterable[np.uint64]] - ] = None, - operation_id: typing.Optional[np.uint64] = None, - slow_retry: bool = True, - block_size: int = 2000, - ): - """Writes a list of mutated rows in bulk - WARNING: If contains the same row (same row_key) and column - key two times only the last one is effectively written to the BigTable - (even when the mutations were applied to different columns) - --> no versioning! - :param rows: list - list of mutated rows - :param root_ids: list if uint64 - :param operation_id: uint64 or None - operation_id (or other unique id) that *was* used to lock the root - the bulk write is only executed if the root is still locked with - the same id. - :param slow_retry: bool - :param block_size: int - """ - if slow_retry: - initial = 5 - else: - initial = 1 - - exception_types = (Aborted, DeadlineExceeded, ServiceUnavailable) - retry = Retry( - predicate=if_exception_type(exception_types), - initial=initial, - maximum=15.0, - multiplier=2.0, - deadline=self.graph_meta.graph_config.ROOT_LOCK_EXPIRY.seconds, - ) - - if root_ids is not None and operation_id is not None: - if isinstance(root_ids, int): - root_ids = [root_ids] - if not self.renew_locks(root_ids, operation_id): - raise exceptions.LockingError( - f"Root lock renewal failed: operation {operation_id}" - ) - - for i in range(0, len(rows), block_size): - status = self._table.mutate_rows(rows[i : i + block_size], retry=retry) - if not all(status): - raise exceptions.ChunkedGraphError( - f"Bulk write failed: operation {operation_id}" - ) - - def mutate_row( - self, - row_key: bytes, - val_dict: typing.Dict[attributes._Attribute, typing.Any], - time_stamp: typing.Optional[datetime] = None, - ) -> bigtable.row.Row: - """Mutates a single row (doesn't write to big table).""" - row = self._table.direct_row(row_key) - for column, value in val_dict.items(): - row.set_cell( - column_family_id=column.family_id, - column=column.key, - value=column.serialize(value), - timestamp=time_stamp, - ) - return row - - # Locking - def lock_root( - self, - root_id: np.uint64, - operation_id: np.uint64, - ) -> bool: - """Attempts to lock the latest version of a root node.""" - lock_expiry = self.graph_meta.graph_config.ROOT_LOCK_EXPIRY - lock_column = attributes.Concurrency.Lock - indefinite_lock_column = attributes.Concurrency.IndefiniteLock - filter_ = utils.get_root_lock_filter( - lock_column, lock_expiry, indefinite_lock_column - ) - - root_row = self._table.conditional_row( - serialize_uint64(root_id), filter_=filter_ - ) - # Set row lock if condition returns no results (state == False) - root_row.set_cell( - lock_column.family_id, - lock_column.key, - serialize_uint64(operation_id), - state=False, - timestamp=get_valid_timestamp(None), - ) - - # The lock was acquired when set_cell returns False (state) - lock_acquired = not root_row.commit() - if not lock_acquired: - row = self._read_byte_row(serialize_uint64(root_id), columns=lock_column) - l_operation_ids = [cell.value for cell in row] - self.logger.debug(f"Locked operation ids: {l_operation_ids}") - return lock_acquired - - def lock_root_indefinitely( - self, - root_id: np.uint64, - operation_id: np.uint64, - ) -> bool: - """Attempts to indefinitely lock the latest version of a root node.""" - lock_column = attributes.Concurrency.IndefiniteLock - filter_ = utils.get_indefinite_root_lock_filter(lock_column) - root_row = self._table.conditional_row( - serialize_uint64(root_id), filter_=filter_ - ) - # Set row lock if condition returns no results (state == False) - root_row.set_cell( - lock_column.family_id, - lock_column.key, - serialize_uint64(operation_id), - state=False, - timestamp=get_valid_timestamp(None), - ) - - # The lock was acquired when set_cell returns False (state) - lock_acquired = not root_row.commit() - if not lock_acquired: - row = self._read_byte_row(serialize_uint64(root_id), columns=lock_column) - l_operation_ids = [cell.value for cell in row] - self.logger.debug(f"Indefinitely locked operation ids: {l_operation_ids}") - return lock_acquired - - def lock_roots( - self, - root_ids: typing.Sequence[np.uint64], - operation_id: np.uint64, - future_root_ids_d: typing.Dict, - max_tries: int = 1, - waittime_s: float = 0.5, - ) -> typing.Tuple[bool, typing.Iterable]: - """Attempts to lock multiple nodes with same operation id in parallel""" - i_try = 0 - while i_try < max_tries: - new_root_ids: typing.List[np.uint64] = [] - for root_id in root_ids: - future_root_ids = future_root_ids_d[root_id] - if not future_root_ids.size: - new_root_ids.append(root_id) - else: - new_root_ids.extend(future_root_ids) - - lock_results = {} - root_ids = np.unique(new_root_ids) - max_workers = min(8, max(1, len(root_ids))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_root = { - executor.submit(self.lock_root, root_id, operation_id): root_id - for root_id in root_ids - } - for future in as_completed(future_to_root): - root_id = future_to_root[future] - try: - lock_results[root_id] = future.result() - except Exception as e: - self.logger.error(f"Failed to lock root {root_id}: {e}") - lock_results[root_id] = False - - all_locked = all(lock_results.values()) - if all_locked: - return True, root_ids - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - unlock_futures = [ - executor.submit(self.unlock_root, root_id, operation_id) - for root_id in root_ids - ] - for future in as_completed(unlock_futures): - try: - future.result() - except Exception as e: - self.logger.error(f"Failed to unlock root: {e}") - time.sleep(waittime_s) - i_try += 1 - self.logger.debug(f"Try {i_try}") - return False, root_ids - - def lock_roots_indefinitely( - self, - root_ids: typing.Sequence[np.uint64], - operation_id: np.uint64, - future_root_ids_d: typing.Dict, - ) -> typing.Tuple[bool, typing.Iterable, typing.Iterable]: - """Attempts to indefinitely lock multiple nodes with same operation id""" - # Collect latest root ids - new_root_ids: typing.List[np.uint64] = [] - for _id in root_ids: - future_root_ids = future_root_ids_d.get(_id) - if not future_root_ids.size: - new_root_ids.append(_id) - else: - new_root_ids.extend(future_root_ids) - - root_ids = np.unique(new_root_ids) - lock_results = {} - max_workers = min(8, max(1, len(root_ids))) - failed_to_lock = [] - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_root = { - executor.submit( - self.lock_root_indefinitely, root_id, operation_id - ): root_id - for root_id in root_ids - } - for future in as_completed(future_to_root): - root_id = future_to_root[future] - try: - lock_results[root_id] = future.result() - if lock_results[root_id] is False: - failed_to_lock.append(root_id) - except Exception as e: - self.logger.error(f"Failed to lock root {root_id}: {e}") - lock_results[root_id] = False - failed_to_lock.append(root_id) - - all_locked = all(lock_results.values()) - if all_locked: - return True, root_ids, failed_to_lock - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - unlock_futures = [ - executor.submit( - self.unlock_indefinitely_locked_root, root_id, operation_id - ) - for root_id in root_ids - ] - for future in as_completed(unlock_futures): - try: - future.result() - except Exception as e: - self.logger.error(f"Failed to unlock root: {e}") - return False, root_ids, failed_to_lock - - def unlock_root(self, root_id: np.uint64, operation_id: np.uint64): - """Unlocks root node that is locked with operation_id.""" - lock_column = attributes.Concurrency.Lock - expiry = self.graph_meta.graph_config.ROOT_LOCK_EXPIRY - root_row = self._table.conditional_row( - serialize_uint64(root_id), - filter_=utils.get_unlock_root_filter(lock_column, expiry, operation_id), - ) - # Delete row if conditions are met (state == True) - root_row.delete_cell(lock_column.family_id, lock_column.key, state=True) - return root_row.commit() - - def unlock_indefinitely_locked_root( - self, root_id: np.uint64, operation_id: np.uint64 - ): - """Unlocks root node that is indefinitely locked with operation_id.""" - lock_column = attributes.Concurrency.IndefiniteLock - # Get conditional row using the chained filter - root_row = self._table.conditional_row( - serialize_uint64(root_id), - filter_=utils.get_indefinite_unlock_root_filter(lock_column, operation_id), - ) - # Delete row if conditions are met (state == True) - root_row.delete_cell(lock_column.family_id, lock_column.key, state=True) - return root_row.commit() - - def renew_lock(self, root_id: np.uint64, operation_id: np.uint64) -> bool: - """Renews existing root node lock with operation_id to extend time.""" - lock_column = attributes.Concurrency.Lock - root_row = self._table.conditional_row( - serialize_uint64(root_id), - filter_=utils.get_renew_lock_filter(lock_column, operation_id), - ) - # Set row lock if condition returns a result (state == True) - root_row.set_cell( - lock_column.family_id, - lock_column.key, - lock_column.serialize(operation_id), - state=False, - ) - # The lock was acquired when set_cell returns True (state) - return not root_row.commit() - - def renew_locks(self, root_ids: np.uint64, operation_id: np.uint64) -> bool: - """Renews existing root node locks with operation_id to extend time.""" - max_workers = min(8, max(1, len(root_ids))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit(self.renew_lock, root_id, operation_id): root_id - for root_id in root_ids - } - for future in as_completed(futures): - root_id = futures[future] - try: - result = future.result() - if not result: - self.logger.warning(f"renew_lock failed - {root_id}") - return False - except Exception as e: - self.logger.error(f"Exception during renew_lock({root_id}): {e}") - return False - return True - - def get_lock_timestamp( - self, root_id: np.uint64, operation_id: np.uint64 - ) -> typing.Union[datetime, None]: - """Lock timestamp for a Root ID operation.""" - row = self.read_node(root_id, properties=attributes.Concurrency.Lock) - if len(row) == 0: - self.logger.warning(f"No lock found for {root_id}") - return None - if row[0].value != operation_id: - self.logger.warning(f"{root_id} not locked with {operation_id}") - return None - return row[0].timestamp - - def get_consolidated_lock_timestamp( - self, - root_ids: typing.Sequence[np.uint64], - operation_ids: typing.Sequence[np.uint64], - ) -> typing.Union[datetime, None]: - """Minimum of multiple lock timestamps.""" - if len(root_ids) == 0: - return None - max_workers = min(8, max(1, len(root_ids))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit(self.get_lock_timestamp, root_id, op_id): ( - root_id, - op_id, - ) - for root_id, op_id in zip(root_ids, operation_ids) - } - timestamps = [] - for future in as_completed(futures): - root_id, op_id = futures[future] - try: - ts = future.result() - if ts is None: - return None - timestamps.append(ts) - except Exception as exc: - self.logger.warning(f"({root_id}, {op_id}): {exc}") - return None - if not timestamps: - return None - return np.min(timestamps) - - # IDs - def create_node_ids( - self, chunk_id: np.uint64, size: int, root_chunk=False - ) -> np.ndarray: - """Generates a list of unique node IDs for the given chunk.""" - if root_chunk: - new_ids = self._get_root_segment_ids_range(chunk_id, size) - else: - low, high = self._get_ids_range( - serialize_uint64(chunk_id, counter=True), size - ) - low, high = basetypes.SEGMENT_ID.type(low), basetypes.SEGMENT_ID.type(high) - new_ids = np.arange(low, high + np.uint64(1), dtype=basetypes.SEGMENT_ID) - return new_ids | chunk_id - - def create_node_id( - self, chunk_id: np.uint64, root_chunk=False - ) -> basetypes.NODE_ID: - """Generate a unique node ID in the chunk.""" - return self.create_node_ids(chunk_id, 1, root_chunk=root_chunk)[0] - - def get_max_node_id( - self, chunk_id: basetypes.CHUNK_ID, root_chunk=False - ) -> basetypes.NODE_ID: - """Gets the current maximum segment ID in the chunk.""" - if root_chunk: - n_counters = np.uint64(2**8) - max_value = 0 - for counter in range(n_counters): - row = self._read_byte_row( - serialize_key(f"i{pad_node_id(chunk_id)}_{counter}"), - columns=attributes.Concurrency.Counter, - ) - val = ( - basetypes.SEGMENT_ID.type(row[0].value if row else 0) * n_counters - + counter - ) - max_value = val if val > max_value else max_value - return chunk_id | basetypes.SEGMENT_ID.type(max_value) - column = attributes.Concurrency.Counter - row = self._read_byte_row( - serialize_uint64(chunk_id, counter=True), columns=column - ) - return chunk_id | basetypes.SEGMENT_ID.type(row[0].value if row else 0) - - def create_operation_id(self): - """Generate a unique operation ID.""" - return self._get_ids_range(attributes.OperationLogs.key, 1)[1] - - def get_max_operation_id(self): - """Gets the current maximum operation ID.""" - column = attributes.Concurrency.Counter - row = self._read_byte_row(attributes.OperationLogs.key, columns=column) - return row[0].value if row else column.basetype(0) - - def get_compatible_timestamp( - self, time_stamp: datetime, round_up: bool = False - ) -> datetime: - return utils.get_google_compatible_time_stamp(time_stamp, round_up=round_up) - - # PRIVATE METHODS - def _get_ids_range(self, key: bytes, size: int) -> typing.Tuple: - """Returns a range (min, max) of IDs for a given `key`.""" - column = attributes.Concurrency.Counter - row = self._table.append_row(key) - row.increment_cell_value(column.family_id, column.key, size) - row = row.commit() - high = column.deserialize(row[column.family_id][column.key][0][0]) - return high + np.uint64(1) - size, high - - def _get_root_segment_ids_range( - self, chunk_id: basetypes.CHUNK_ID, size: int = 1, counter: int = None - ) -> np.ndarray: - """Return unique segment ID for the root chunk.""" - n_counters = np.uint64(2**8) - counter = ( - np.uint64(counter % n_counters) - if counter - else np.uint64(np.random.randint(0, n_counters)) - ) - key = serialize_key(f"i{pad_node_id(chunk_id)}_{counter}") - min_, max_ = self._get_ids_range(key=key, size=size) - return np.arange( - min_ * n_counters + counter, - max_ * n_counters + np.uint64(1) + counter, - n_counters, - dtype=basetypes.SEGMENT_ID, - ) - - def _read_byte_rows( - self, - start_key: typing.Optional[bytes] = None, - end_key: typing.Optional[bytes] = None, - end_key_inclusive: bool = False, - row_keys: typing.Optional[typing.Iterable[bytes]] = None, - columns: typing.Optional[ - typing.Union[typing.Iterable[attributes._Attribute], attributes._Attribute] - ] = None, - start_time: typing.Optional[datetime] = None, - end_time: typing.Optional[datetime] = None, - end_time_inclusive: bool = False, - user_id: typing.Optional[str] = None, - ) -> typing.Dict[ - bytes, - typing.Union[ - typing.Dict[attributes._Attribute, typing.List[bigtable.row_data.Cell]], - typing.List[bigtable.row_data.Cell], - ], - ]: - """Main function for reading a row range or non-contiguous row sets from Bigtable using - `bytes` keys. - - Keyword Arguments: - start_key {typing.Optional[bytes]} -- The first row to be read, ignored if `row_keys` is set. - If None, no lower boundary is used. (default: {None}) - end_key {typing.Optional[bytes]} -- The end of the row range, ignored if `row_keys` is set. - If None, no upper boundary is used. (default: {None}) - end_key_inclusive {bool} -- Whether or not `end_key` itself should be included in the - request, ignored if `row_keys` is set or `end_key` is None. (default: {False}) - row_keys {typing.Optional[typing.Iterable[bytes]]} -- An `typing.Iterable` containing possibly - non-contiguous row keys. Takes precedence over `start_key` and `end_key`. - (default: {None}) - columns {typing.Optional[typing.Union[typing.Iterable[attributes._Attribute], attributes._Attribute]]} -- - typing.Optional filtering by columns to speed up the query. If `columns` is a single - column (not iterable), the column key will be omitted from the result. - (default: {None}) - start_time {typing.Optional[datetime]} -- Ignore cells with timestamp before - `start_time`. If None, no lower bound. (default: {None}) - end_time {typing.Optional[datetime]} -- Ignore cells with timestamp after `end_time`. - If None, no upper bound. (default: {None}) - end_time_inclusive {bool} -- Whether or not `end_time` itself should be included in the - request, ignored if `end_time` is None. (default: {False}) - user_id {typing.Optional[str]} -- Only return cells with userID equal to this - - Returns: - typing.Dict[bytes, typing.Union[typing.Dict[attributes._Attribute, typing.List[bigtable.row_data.Cell]], - typing.List[bigtable.row_data.Cell]]] -- - Returns a dictionary of `byte` rows as keys. Their value will be a mapping of - columns to a typing.List of cells (one cell per timestamp). Each cell has a `value` - property, which returns the deserialized field, and a `timestamp` property, which - returns the timestamp as `datetime` object. - If only a single `attributes._Attribute` was requested, the typing.List of cells will be - attached to the row dictionary directly (skipping the column dictionary). - """ - - # Create filters: Rows - row_set = RowSet() - if row_keys is not None: - row_set.row_keys = list(row_keys) - elif start_key is not None and end_key is not None: - row_set.add_row_range_from_keys( - start_key=start_key, - start_inclusive=True, - end_key=end_key, - end_inclusive=end_key_inclusive, - ) - else: - raise exceptions.PreconditionError( - "Need to either provide a valid set of rows, or" - " both, a start row and an end row." - ) - filter_ = utils.get_time_range_and_column_filter( - columns=columns, - start_time=start_time, - end_time=end_time, - end_inclusive=end_time_inclusive, - user_id=user_id, - ) - # Bigtable read with retries - rows = self._read(row_set=row_set, row_filter=filter_) - - # Deserialize cells - for row_key, column_dict in rows.items(): - for column, cell_entries in column_dict.items(): - for cell_entry in cell_entries: - cell_entry.value = column.deserialize(cell_entry.value) - # If no column array was requested, reattach single column's values directly to the row - if isinstance(columns, attributes._Attribute): - rows[row_key] = cell_entries - return rows - - def _read_byte_row( - self, - row_key: bytes, - columns: typing.Optional[ - typing.Union[typing.Iterable[attributes._Attribute], attributes._Attribute] - ] = None, - start_time: typing.Optional[datetime] = None, - end_time: typing.Optional[datetime] = None, - end_time_inclusive: bool = False, - ) -> typing.Union[ - typing.Dict[attributes._Attribute, typing.List[bigtable.row_data.Cell]], - typing.List[bigtable.row_data.Cell], - ]: - """Convenience function for reading a single row from Bigtable using its `bytes` keys. - - Arguments: - row_key {bytes} -- The row to be read. - - Keyword Arguments: - columns {typing.Optional[typing.Union[typing.Iterable[attributes._Attribute], attributes._Attribute]]} -- - typing.Optional filtering by columns to speed up the query. If `columns` is a single - column (not iterable), the column key will be omitted from the result. - (default: {None}) - start_time {typing.Optional[datetime]} -- Ignore cells with timestamp before - `start_time`. If None, no lower bound. (default: {None}) - end_time {typing.Optional[datetime]} -- Ignore cells with timestamp after `end_time`. - If None, no upper bound. (default: {None}) - end_time_inclusive {bool} -- Whether or not `end_time` itself should be included in the - request, ignored if `end_time` is None. (default: {False}) - - Returns: - typing.Union[typing.Dict[attributes._Attribute, typing.List[bigtable.row_data.Cell]], - typing.List[bigtable.row_data.Cell]] -- - Returns a mapping of columns to a typing.List of cells (one cell per timestamp). Each cell - has a `value` property, which returns the deserialized field, and a `timestamp` - property, which returns the timestamp as `datetime` object. - If only a single `attributes._Attribute` was requested, the typing.List of cells is returned - directly. - """ - row = self._read_byte_rows( - row_keys=[row_key], - columns=columns, - start_time=start_time, - end_time=end_time, - end_time_inclusive=end_time_inclusive, - ) - return ( - row.get(row_key, []) - if isinstance(columns, attributes._Attribute) - else row.get(row_key, {}) - ) - - def _execute_read_thread(self, args: typing.Tuple[Table, RowSet, RowFilter]): - table, row_set, row_filter = args - if not row_set.row_keys and not row_set.row_ranges: - # Check for everything falsy, because Bigtable considers even empty - # lists of row_keys as no upper/lower bound! - return {} - retry = DEFAULT_RETRY_READ_ROWS.with_timeout(600) - range_read = table.read_rows(row_set=row_set, filter_=row_filter, retry=retry) - res = {v.row_key: utils.partial_row_data_to_column_dict(v) for v in range_read} - return res - - def _read( - self, row_set: RowSet, row_filter: RowFilter = None - ) -> typing.Dict[bytes, typing.Dict[attributes._Attribute, PartialRowData]]: - """Core function to read rows from Bigtable. Uses standard Bigtable retry logic - :param row_set: BigTable RowSet - :param row_filter: BigTable RowFilter - :return: typing.Dict[bytes, typing.Dict[attributes._Attribute, bigtable.row_data.PartialRowData]] - """ - # FIXME: Bigtable limits the length of the serialized request to 512 KiB. We should - # calculate this properly (range_read.request.SerializeToString()), but this estimate is - # good enough for now - - n_subrequests = max( - 1, int(np.ceil(len(row_set.row_keys) / self._max_row_key_count)) - ) - n_threads = min(n_subrequests, 2 * mu.n_cpus) - - row_sets = [] - for i in range(n_subrequests): - r = RowSet() - r.row_keys = row_set.row_keys[ - i * self._max_row_key_count : (i + 1) * self._max_row_key_count - ] - row_sets.append(r) - - # Don't forget the original RowSet's row_ranges - row_sets[0].row_ranges = row_set.row_ranges - responses = mu.multithread_func( - self._execute_read_thread, - params=((self._table, r, row_filter) for r in row_sets), - debug=n_threads == 1, - n_threads=n_threads, - ) - - combined_response = {} - for resp in responses: - combined_response.update(resp) - return combined_response diff --git a/pychunkedgraph/graph/client/bigtable/utils.py b/pychunkedgraph/graph/client/bigtable/utils.py deleted file mode 100644 index 3f14e125d..000000000 --- a/pychunkedgraph/graph/client/bigtable/utils.py +++ /dev/null @@ -1,305 +0,0 @@ -from typing import Dict -from typing import Union -from typing import Iterable -from typing import Optional -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -import numpy as np -from google.cloud.bigtable.row_data import PartialRowData -from google.cloud.bigtable.row_filters import RowFilter -from google.cloud.bigtable.row_filters import PassAllFilter -from google.cloud.bigtable.row_filters import BlockAllFilter -from google.cloud.bigtable.row_filters import TimestampRange -from google.cloud.bigtable.row_filters import RowFilterChain -from google.cloud.bigtable.row_filters import RowFilterUnion -from google.cloud.bigtable.row_filters import ValueRangeFilter -from google.cloud.bigtable.row_filters import CellsRowLimitFilter -from google.cloud.bigtable.row_filters import ColumnRangeFilter -from google.cloud.bigtable.row_filters import TimestampRangeFilter -from google.cloud.bigtable.row_filters import ConditionalRowFilter -from google.cloud.bigtable.row_filters import ColumnQualifierRegexFilter - -from ... import attributes - - -def partial_row_data_to_column_dict( - partial_row_data: PartialRowData, -) -> Dict[attributes._Attribute, PartialRowData]: - new_column_dict = {} - for family_id, column_dict in partial_row_data._cells.items(): - for column_key, column_values in column_dict.items(): - column = attributes.from_key(family_id, column_key) - new_column_dict[column] = column_values - return new_column_dict - - -def get_google_compatible_time_stamp( - time_stamp: datetime, round_up: bool = False -) -> datetime: - """ - Makes a datetime time stamp compatible with googles' services. - Google restricts the accuracy of time stamps to milliseconds. Hence, the - microseconds are cut of. By default, time stamps are rounded to the lower - number. - """ - micro_s_gap = timedelta(microseconds=time_stamp.microsecond % 1000) - if micro_s_gap == 0: - return time_stamp - if round_up: - time_stamp += timedelta(microseconds=1000) - micro_s_gap - else: - time_stamp -= micro_s_gap - return time_stamp - - -def _get_column_filter( - columns: Union[Iterable[attributes._Attribute], attributes._Attribute] = None -) -> RowFilter: - """Generates a RowFilter that accepts the specified columns""" - if isinstance(columns, attributes._Attribute): - return ColumnRangeFilter( - columns.family_id, start_column=columns.key, end_column=columns.key - ) - elif len(columns) == 1: - return ColumnRangeFilter( - columns[0].family_id, start_column=columns[0].key, end_column=columns[0].key - ) - return RowFilterUnion( - [ - ColumnRangeFilter(col.family_id, start_column=col.key, end_column=col.key) - for col in columns - ] - ) - - -def _get_user_filter(user_id: str): - """generates a ColumnRegEx Filter which filters user ids - - Args: - user_id (str): userID to select for - """ - - condition = RowFilterChain( - [ - ColumnQualifierRegexFilter(attributes.OperationLogs.UserID.key), - ValueRangeFilter(str.encode(user_id), str.encode(user_id)), - CellsRowLimitFilter(1), - ] - ) - - conditional_filter = ConditionalRowFilter( - base_filter=condition, - true_filter=PassAllFilter(True), - false_filter=BlockAllFilter(True), - ) - return conditional_filter - - -def _get_time_range_filter( - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - end_inclusive: bool = True, -) -> RowFilter: - """Generates a TimeStampRangeFilter which is inclusive for start and (optionally) end. - - :param start: - :param end: - :return: - """ - # Comply to resolution of BigTables TimeRange - if start_time is not None: - start_time = get_google_compatible_time_stamp(start_time, round_up=False) - if end_time is not None: - end_time = get_google_compatible_time_stamp(end_time, round_up=end_inclusive) - return TimestampRangeFilter(TimestampRange(start=start_time, end=end_time)) - - -def get_time_range_and_column_filter( - columns: Optional[ - Union[Iterable[attributes._Attribute], attributes._Attribute] - ] = None, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - end_inclusive: bool = False, - user_id: Optional[str] = None, -) -> RowFilter: - time_filter = _get_time_range_filter( - start_time=start_time, end_time=end_time, end_inclusive=end_inclusive - ) - filters = [time_filter] - if columns is not None: - if len(columns) == 0: - raise ValueError( - f"Empty column filter {columns} is ambiguous. Pass `None` if no column filter should be applied." - ) - column_filter = _get_column_filter(columns) - filters = [column_filter, time_filter] - if user_id is not None: - user_filter = _get_user_filter(user_id=user_id) - filters.append(user_filter) - if len(filters) > 1: - return RowFilterChain(filters) - return filters[0] - - -def get_root_lock_filter( - lock_column, lock_expiry, indefinite_lock_column -) -> ConditionalRowFilter: - time_cutoff = datetime.now(timezone.utc) - lock_expiry - # Comply to resolution of BigTables TimeRange - time_cutoff -= timedelta(microseconds=time_cutoff.microsecond % 1000) - time_filter = TimestampRangeFilter(TimestampRange(start=time_cutoff)) - - # Build a column filter which tests if a lock was set (== lock column - # exists) and if it is still valid (timestamp younger than - # LOCK_EXPIRED_TIME_DELTA) and if there is no new parent (== new_parents - # exists) - lock_key_filter = ColumnRangeFilter( - column_family_id=lock_column.family_id, - start_column=lock_column.key, - end_column=lock_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - indefinite_lock_key_filter = ColumnRangeFilter( - column_family_id=indefinite_lock_column.family_id, - start_column=indefinite_lock_column.key, - end_column=indefinite_lock_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - new_parents_column = attributes.Hierarchy.NewParent - new_parents_key_filter = ColumnRangeFilter( - column_family_id=new_parents_column.family_id, - start_column=new_parents_column.key, - end_column=new_parents_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - temporal_lock_filter = RowFilterChain([time_filter, lock_key_filter]) - return ConditionalRowFilter( - base_filter=RowFilterUnion([indefinite_lock_key_filter, temporal_lock_filter]), - true_filter=PassAllFilter(True), - false_filter=new_parents_key_filter, - ) - - -def get_indefinite_root_lock_filter(lock_column) -> ConditionalRowFilter: - lock_key_filter = ColumnRangeFilter( - column_family_id=lock_column.family_id, - start_column=lock_column.key, - end_column=lock_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - new_parents_column = attributes.Hierarchy.NewParent - new_parents_key_filter = ColumnRangeFilter( - column_family_id=new_parents_column.family_id, - start_column=new_parents_column.key, - end_column=new_parents_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - return ConditionalRowFilter( - base_filter=lock_key_filter, - true_filter=PassAllFilter(True), - false_filter=new_parents_key_filter, - ) - - -def get_renew_lock_filter( - lock_column: attributes._Attribute, operation_id: np.uint64 -) -> ConditionalRowFilter: - new_parents_column = attributes.Hierarchy.NewParent - operation_id_b = lock_column.serialize(operation_id) - - # Build a column filter which tests if a lock was set (== lock column - # exists) and if the given operation_id is still the active lock holder - # and there is no new parent (== new_parents column exists). The latter - # is not necessary but we include it as a backup to prevent things - # from going really bad. - - column_key_filter = ColumnRangeFilter( - column_family_id=lock_column.family_id, - start_column=lock_column.key, - end_column=lock_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - value_filter = ValueRangeFilter( - start_value=operation_id_b, - end_value=operation_id_b, - inclusive_start=True, - inclusive_end=True, - ) - - new_parents_key_filter = ColumnRangeFilter( - column_family_id=new_parents_column.family_id, - start_column=new_parents_column.key, - end_column=new_parents_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - return ConditionalRowFilter( - base_filter=RowFilterChain([column_key_filter, value_filter]), - true_filter=new_parents_key_filter, - false_filter=PassAllFilter(True), - ) - - -def get_unlock_root_filter(lock_column, lock_expiry, operation_id) -> RowFilterChain: - time_cutoff = datetime.now(timezone.utc) - lock_expiry - # Comply to resolution of BigTables TimeRange - time_cutoff -= timedelta(microseconds=time_cutoff.microsecond % 1000) - time_filter = TimestampRangeFilter(TimestampRange(start=time_cutoff)) - - # Build a column filter which tests if a lock was set (== lock column - # exists) and if it is still valid (timestamp younger than - # LOCK_EXPIRED_TIME_DELTA) and if the given operation_id is still - # the active lock holder - column_key_filter = ColumnRangeFilter( - column_family_id=lock_column.family_id, - start_column=lock_column.key, - end_column=lock_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - value_filter = ValueRangeFilter( - start_value=lock_column.serialize(operation_id), - end_value=lock_column.serialize(operation_id), - inclusive_start=True, - inclusive_end=True, - ) - - # Chain these filters together - return RowFilterChain([time_filter, column_key_filter, value_filter]) - - -def get_indefinite_unlock_root_filter(lock_column, operation_id) -> RowFilterChain: - column_key_filter = ColumnRangeFilter( - column_family_id=lock_column.family_id, - start_column=lock_column.key, - end_column=lock_column.key, - inclusive_start=True, - inclusive_end=True, - ) - - value_filter = ValueRangeFilter( - start_value=lock_column.serialize(operation_id), - end_value=lock_column.serialize(operation_id), - inclusive_start=True, - inclusive_end=True, - ) - - # Chain these filters together - return RowFilterChain([column_key_filter, value_filter]) diff --git a/pychunkedgraph/graph/client/utils.py b/pychunkedgraph/graph/client/utils.py deleted file mode 100644 index 12eebec82..000000000 --- a/pychunkedgraph/graph/client/utils.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Common client util functions -""" \ No newline at end of file diff --git a/pychunkedgraph/graph/cutting.py b/pychunkedgraph/graph/cutting.py index a2fca8023..c5c24cf51 100644 --- a/pychunkedgraph/graph/cutting.py +++ b/pychunkedgraph/graph/cutting.py @@ -14,7 +14,7 @@ from typing import Iterable from .utils import flatgraph -from .utils import basetypes +from pychunkedgraph.graph import basetypes from .utils.generic import get_bounding_box from .edges import Edges from .exceptions import PreconditionError @@ -398,7 +398,9 @@ def _remap_cut_edge_set(self, cut_edge_set): remapped_cutset_flattened_view = remapped_cutset.view(dtype="u8,u8") edges_flattened_view = self.cg_edges.view(dtype="u8,u8") - cutset_mask = np.isin(remapped_cutset_flattened_view, edges_flattened_view).ravel() + cutset_mask = np.isin( + remapped_cutset_flattened_view, edges_flattened_view + ).ravel() return remapped_cutset[cutset_mask] diff --git a/pychunkedgraph/graph/edges/definitions.py b/pychunkedgraph/graph/edges/definitions.py index 26a14dd82..831ca9798 100644 --- a/pychunkedgraph/graph/edges/definitions.py +++ b/pychunkedgraph/graph/edges/definitions.py @@ -7,8 +7,7 @@ import numpy as np -from ..utils import basetypes - +from pychunkedgraph.graph import basetypes _edge_type_fileds = ("in_chunk", "between_chunk", "cross_chunk") _edge_type_defaults = ("in", "between", "cross") diff --git a/pychunkedgraph/graph/edges/ocdbt.py b/pychunkedgraph/graph/edges/ocdbt.py index 99fa1ba68..6c7fba353 100644 --- a/pychunkedgraph/graph/edges/ocdbt.py +++ b/pychunkedgraph/graph/edges/ocdbt.py @@ -9,7 +9,7 @@ import zstandard as zstd from graph_tool import Graph -from ..utils import basetypes +from pychunkedgraph.graph import basetypes from .definitions import ADJACENCY_DTYPE, ZSTD_EDGE_COMPRESSION, Edges diff --git a/pychunkedgraph/graph/edges/stale.py b/pychunkedgraph/graph/edges/stale.py index e09dbac35..17ded90d0 100644 --- a/pychunkedgraph/graph/edges/stale.py +++ b/pychunkedgraph/graph/edges/stale.py @@ -13,10 +13,9 @@ from pychunkedgraph.graph import types from pychunkedgraph.graph.chunks.utils import get_l2chunkids_along_boundary -from ..utils import basetypes +from pychunkedgraph.graph import basetypes from ..utils.generic import get_parents_at_timestamp - PARENTS_CACHE: LRUCache = None CHILDREN_CACHE: LRUCache = None @@ -210,9 +209,7 @@ def _get_hierarchy(self, nodes, layer): _children = _children[_children_layers > 2] while _children.size: _hierarchy.append(_children) - _children = self.cg.get_children( - _children, flatten=True, raw_only=True - ) + _children = self.cg.get_children(_children, flatten=True, raw_only=True) _children_layers = self.cg.get_chunk_layers(_children) _hierarchy.append(_children[_children_layers == 2]) _children = _children[_children_layers > 2] @@ -281,9 +278,7 @@ def _get_parents_b(self, edges, parent_ts, layer, fallback: bool = False): # this cache is set only during migration # also, fallback is not applicable if no migration children_b = self.cg.get_children(edges[:, 1], flatten=True) - parents_b = np.unique( - self.cg.get_parents(children_b, time_stamp=parent_ts) - ) + parents_b = np.unique(self.cg.get_parents(children_b, time_stamp=parent_ts)) fallback = False else: children_b = self._get_children_from_cache(edges[:, 1]) @@ -314,9 +309,7 @@ def _get_parents_b(self, edges, parent_ts, layer, fallback: bool = False): _parents_b = [] for _node, _edges_d in _cx_edges_d.items(): _edges = _edges_d.get(layer, types.empty_2d) - if self._check_cross_edges_from_a( - _node, _edges[:, 1], layer, parent_ts - ): + if self._check_cross_edges_from_a(_node, _edges[:, 1], layer, parent_ts): _parents_b.append(_node) elif self._check_hierarchy_a_from_b( parents_a, _edges[:, 1], layer, parent_ts @@ -361,9 +354,7 @@ def _get_dilated_edges(self, edges): _node_a, _node_b = _edge _nodes_b = self.cg.get_l2children([_node_b]) _l2_edges.append( - np.array( - [[_node_a, _b] for _b in _nodes_b], dtype=basetypes.NODE_ID - ) + np.array([[_node_a, _b] for _b in _nodes_b], dtype=basetypes.NODE_ID) ) return np.unique(np.concatenate(_l2_edges), axis=0) @@ -391,9 +382,7 @@ def _get_new_edge( try: _edges = self._get_cx_edges(l2ids_a, max_ts, edge_layer) except ValueError: - _edges = self._get_cx_edges( - l2ids_a, max_ts, edge_layer, raw_only=False - ) + _edges = self._get_cx_edges(l2ids_a, max_ts, edge_layer, raw_only=False) except ValueError: return types.empty_2d.copy() @@ -405,9 +394,7 @@ def _get_new_edge( _edges = self._get_dilated_edges(_edges) mask = np.isin(_edges[:, 1], l2ids_b) if np.any(mask): - parents_b = self._get_parents_b( - _edges[mask], parent_ts, edge_layer - ) + parents_b = self._get_parents_b(_edges[mask], parent_ts, edge_layer) else: # if none of `l2ids_b` were found in edges, `l2ids_a` already have new edges # so get the new identities of `l2ids_b` by using chunk mask @@ -422,9 +409,7 @@ def _get_new_edge( _edges, parent_ts, edge_layer, True ) - parents_b = np.unique( - get_new_nodes(self.cg, parents_b, mlayer, parent_ts) - ) + parents_b = np.unique(get_new_nodes(self.cg, parents_b, mlayer, parent_ts)) parents_a = np.array([node_a] * parents_b.size, dtype=basetypes.NODE_ID) return np.column_stack((parents_a, parents_b)) @@ -450,6 +435,7 @@ def run(self): result.append(_new_edges) return np.concatenate(result) + def get_latest_edges( cg, stale_edges: Iterable, diff --git a/pychunkedgraph/graph/edges/utils.py b/pychunkedgraph/graph/edges/utils.py index 3af2c8cc4..70a0ae32f 100644 --- a/pychunkedgraph/graph/edges/utils.py +++ b/pychunkedgraph/graph/edges/utils.py @@ -16,7 +16,7 @@ from . import Edges from . import EDGE_TYPES -from ..utils import basetypes +from pychunkedgraph.graph import basetypes from ..chunks import utils as chunk_utils from ..meta import ChunkedGraphMeta from ...utils.general import in2d diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 25f31dd02..b29675661 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -14,14 +14,14 @@ from pychunkedgraph.debug.profiler import HierarchicalProfiler, get_profiler from . import types -from . import attributes +from pychunkedgraph.graph import attributes from . import cache as cache_utils from .edges import get_latest_edges_wrapper, get_new_nodes from .edges.utils import concatenate_cross_edge_dicts from .edges.utils import merge_cross_edge_dicts -from .utils import basetypes +from pychunkedgraph.graph import basetypes from .utils import flatgraph -from .utils.serializers import serialize_uint64 +from pychunkedgraph.graph import serializers from ..utils.general import in2d from ..debug.utils import sanity_check, sanity_check_single @@ -186,7 +186,7 @@ def check_fake_edges( val_dict[attributes.Connectivity.FakeEdges] = np.array( [[edge]], dtype=basetypes.NODE_ID ) - id1 = serialize_uint64(id1, fake_edges=True) + id1 = serializers.serialize_uint64(id1, fake_edges=True) rows.append( cg.client.mutate_row( id1, @@ -198,7 +198,7 @@ def check_fake_edges( val_dict[attributes.Connectivity.FakeEdges] = np.array( [edge[::-1]], dtype=basetypes.NODE_ID ) - id2 = serialize_uint64(id2, fake_edges=True) + id2 = serializers.serialize_uint64(id2, fake_edges=True) rows.append( cg.client.mutate_row( id2, @@ -574,7 +574,7 @@ def _update_neighbor_cx_edges( updated_counterparts.update(result) updated_entries = [] for node, val_dict in updated_counterparts.items(): - rowkey = serialize_uint64(node) + rowkey = serializers.serialize_uint64(node) row = cg.client.mutate_row(rowkey, val_dict, time_stamp=time_stamp) updated_entries.append(row) return updated_entries @@ -696,7 +696,7 @@ def _update_cross_edge_cache_batched(self, new_ids: list): for c, cx_edges_map in children_cx_edges.items(): self.cg.cache.cross_chunk_edges_cache[c] = cx_edges_map - rowkey = serialize_uint64(c) + rowkey = serializers.serialize_uint64(c) row = self.cg.client.mutate_row(rowkey, val_ds[c], time_stamp=self._last_ts) updated_entries.append(row) @@ -857,7 +857,7 @@ def _update_root_id_lineage(self): } self.new_entries.append( self.cg.client.mutate_row( - serialize_uint64(new_root_id), + serializers.serialize_uint64(new_root_id), val_dict, time_stamp=self._time_stamp, ) @@ -872,7 +872,7 @@ def _update_root_id_lineage(self): } self.new_entries.append( self.cg.client.mutate_row( - serialize_uint64(former_root_id), + serializers.serialize_uint64(former_root_id), val_dict, time_stamp=self._time_stamp, ) @@ -920,7 +920,7 @@ def create_new_entries(self) -> List: val_dict[attributes.Hierarchy.Child] = children self.new_entries.append( self.cg.client.mutate_row( - serialize_uint64(id_), + serializers.serialize_uint64(id_), val_dict, time_stamp=self._time_stamp, ) @@ -928,7 +928,7 @@ def create_new_entries(self) -> List: for child_id in children: self.new_entries.append( self.cg.client.mutate_row( - serialize_uint64(child_id), + serializers.serialize_uint64(child_id), {attributes.Hierarchy.Parent: id_}, time_stamp=self._time_stamp, ) diff --git a/pychunkedgraph/graph/exceptions.py b/pychunkedgraph/graph/exceptions.py index 45aa57fc7..f41cc2971 100644 --- a/pychunkedgraph/graph/exceptions.py +++ b/pychunkedgraph/graph/exceptions.py @@ -1,23 +1,19 @@ from six.moves import http_client +from kvdbclient.exceptions import KVDBClientError +from kvdbclient.exceptions import LockingError +from kvdbclient.exceptions import PreconditionError -class ChunkedGraphError(Exception): - """Base class for all exceptions raised by the ChunkedGraph""" - pass - - -class LockingError(ChunkedGraphError): - """Raised when a Bigtable Lock could not be acquired""" - pass +class ChunkedGraphError(KVDBClientError): + """Base class for all exceptions raised by the ChunkedGraph""" -class PreconditionError(ChunkedGraphError): - """Raised when preconditions for Chunked Graph operations are not met""" pass class PostconditionError(ChunkedGraphError): """Raised when postconditions for Chunked Graph operations are not met""" + pass @@ -42,7 +38,7 @@ def __init__(self, message): self.message = message def __str__(self): - return f'[{self.status_code}]: {self.message}' + return f"[{self.status_code}]: {self.message}" class ClientError(ChunkedGraphAPIError): @@ -51,21 +47,25 @@ class ClientError(ChunkedGraphAPIError): class BadRequest(ClientError): """Exception mapping a ``400 Bad Request`` response.""" + status_code = http_client.BAD_REQUEST class Unauthorized(ClientError): """Exception mapping a ``401 Unauthorized`` response.""" + status_code = http_client.UNAUTHORIZED class Forbidden(ClientError): """Exception mapping a ``403 Forbidden`` response.""" + status_code = http_client.FORBIDDEN class Conflict(ClientError): """Exception mapping a ``409 Conflict`` response.""" + status_code = http_client.CONFLICT @@ -75,9 +75,11 @@ class ServerError(ChunkedGraphAPIError): class InternalServerError(ServerError): """Exception mapping a ``500 Internal Server Error`` response.""" + status_code = http_client.INTERNAL_SERVER_ERROR class GatewayTimeout(ServerError): """Exception mapping a ``504 Gateway Timeout`` response.""" + status_code = http_client.GATEWAY_TIMEOUT diff --git a/pychunkedgraph/graph/lineage.py b/pychunkedgraph/graph/lineage.py index 70d112f97..5b35a7951 100644 --- a/pychunkedgraph/graph/lineage.py +++ b/pychunkedgraph/graph/lineage.py @@ -1,6 +1,7 @@ """ Functions for tracking root ID changes over time. """ + from typing import Union from typing import Optional from typing import Iterable @@ -10,17 +11,17 @@ import numpy as np from networkx import DiGraph -from . import attributes +from pychunkedgraph.graph import ( + attributes, + basetypes, + get_min_time, + get_max_time, + get_valid_timestamp, +) from .exceptions import ChunkedGraphError -from .attributes import Hierarchy -from .attributes import OperationLogs -from .utils.basetypes import NODE_ID -from .utils.generic import get_min_time -from .utils.generic import get_max_time -from .utils.generic import get_valid_timestamp -def get_latest_root_id(cg, root_id: NODE_ID.type) -> np.ndarray: +def get_latest_root_id(cg, root_id: basetypes.NODE_ID.type) -> np.ndarray: """Returns the latest root id associated with the provided root id""" id_working_set = [root_id] latest_root_ids = [] @@ -38,7 +39,7 @@ def get_latest_root_id(cg, root_id: NODE_ID.type) -> np.ndarray: def get_future_root_ids( cg, - root_id: NODE_ID, + root_id: basetypes.NODE_ID, time_stamp: Optional[datetime] = get_max_time(), ) -> np.ndarray: """ @@ -69,12 +70,12 @@ def get_future_root_ids( if next_id != root_id: id_history.append(next_id) next_ids = temp_next_ids - return np.unique(np.array(id_history, dtype=NODE_ID)) + return np.unique(np.array(id_history, dtype=basetypes.NODE_ID)) def get_past_root_ids( cg, - root_id: NODE_ID, + root_id: basetypes.NODE_ID, time_stamp: Optional[datetime] = get_min_time(), ) -> np.ndarray: """ @@ -108,12 +109,12 @@ def get_past_root_ids( if next_id != root_id: id_history.append(next_id) next_ids = temp_next_ids - return np.unique(np.array(id_history, dtype=NODE_ID)) + return np.unique(np.array(id_history, dtype=basetypes.NODE_ID)) def get_previous_root_ids( cg, - root_ids: Iterable[NODE_ID.type], + root_ids: Iterable[basetypes.NODE_ID.type], ) -> dict: """Returns immediate former root IDs (1 step history)""" nodes_d = cg.client.read_nodes( @@ -128,7 +129,7 @@ def get_previous_root_ids( def get_root_id_history( cg, - root_id: NODE_ID, + root_id: basetypes.NODE_ID, time_stamp_past: Optional[datetime] = get_min_time(), time_stamp_future: Optional[datetime] = get_max_time(), ) -> np.ndarray: @@ -140,18 +141,24 @@ def get_root_id_history( """ past_ids = get_past_root_ids(cg, root_id, time_stamp=time_stamp_past) future_ids = get_future_root_ids(cg, root_id, time_stamp=time_stamp_future) - return np.concatenate([past_ids, np.array([root_id], dtype=NODE_ID), future_ids]) + return np.concatenate( + [past_ids, np.array([root_id], dtype=basetypes.NODE_ID), future_ids] + ) def _get_node_properties(node_entry: dict) -> dict: node_d = {} - node_d["timestamp"] = node_entry[Hierarchy.Child][0].timestamp.timestamp() - if OperationLogs.OperationID in node_entry: - if len(node_entry[OperationLogs.OperationID]) == 2 or ( - len(node_entry[OperationLogs.OperationID]) == 1 - and Hierarchy.NewParent in node_entry + node_d["timestamp"] = node_entry[attributes.Hierarchy.Child][ + 0 + ].timestamp.timestamp() + if attributes.OperationLogs.OperationID in node_entry: + if len(node_entry[attributes.OperationLogs.OperationID]) == 2 or ( + len(node_entry[attributes.OperationLogs.OperationID]) == 1 + and attributes.Hierarchy.NewParent in node_entry ): - node_d["operation_id"] = node_entry[OperationLogs.OperationID][0].value + node_d["operation_id"] = node_entry[attributes.OperationLogs.OperationID][ + 0 + ].value return node_d @@ -170,8 +177,8 @@ def lineage_graph( node_ids = [node_ids] graph = DiGraph() - past_ids = np.array(node_ids, dtype=NODE_ID) - future_ids = np.array(node_ids, dtype=NODE_ID) + past_ids = np.array(node_ids, dtype=basetypes.NODE_ID) + future_ids = np.array(node_ids, dtype=basetypes.NODE_ID) timestamp_past = float(0) if timestamp_past is None else timestamp_past.timestamp() timestamp_future = ( datetime.now(timezone.utc).timestamp() @@ -190,10 +197,10 @@ def lineage_graph( graph.add_node(k, **node_d) if ( node_d["timestamp"] < timestamp_past - or not Hierarchy.FormerParent in val + or not attributes.Hierarchy.FormerParent in val ): continue - former_ids = val[Hierarchy.FormerParent][0].value + former_ids = val[attributes.Hierarchy.FormerParent][0].value next_past_ids.extend( [former_id for former_id in former_ids if not former_id in graph.nodes] ) @@ -206,7 +213,10 @@ def lineage_graph( val = nodes_raw[k] node_d = _get_node_properties(val) graph.add_node(k, **node_d) - if node_d["timestamp"] > timestamp_future or not Hierarchy.NewParent in val: + if ( + node_d["timestamp"] > timestamp_future + or not attributes.Hierarchy.NewParent in val + ): continue try: future_operation_id_dict[node_d["operation_id"]].append(k) @@ -215,13 +225,13 @@ def lineage_graph( logs_raw = cg.client.read_log_entries(list(future_operation_id_dict.keys())) for operation_id in future_operation_id_dict: - new_ids = logs_raw[operation_id][OperationLogs.RootID] + new_ids = logs_raw[operation_id][attributes.OperationLogs.RootID] next_future_ids.extend( [new_id for new_id in new_ids if not new_id in graph.nodes] ) for new_id in new_ids: for k in future_operation_id_dict[operation_id]: graph.add_edge(k, new_id) - past_ids = np.array(np.unique(next_past_ids), dtype=NODE_ID) - future_ids = np.array(np.unique(next_future_ids), dtype=NODE_ID) + past_ids = np.array(np.unique(next_past_ids), dtype=basetypes.NODE_ID) + future_ids = np.array(np.unique(next_future_ids), dtype=basetypes.NODE_ID) return graph diff --git a/pychunkedgraph/graph/misc.py b/pychunkedgraph/graph/misc.py index faaa7fb29..a9d1fbcac 100644 --- a/pychunkedgraph/graph/misc.py +++ b/pychunkedgraph/graph/misc.py @@ -10,7 +10,7 @@ import numpy as np from . import ChunkedGraph -from . import attributes +from pychunkedgraph.graph import attributes from .edges import Edges from .utils import flatgraph from .types import Agglomeration diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 80bc823e9..14e5f7715 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -10,28 +10,29 @@ from typing import Type from typing import Tuple from typing import Union +from typing import Any from typing import Optional from typing import Sequence from functools import reduce import numpy as np -from google.cloud import bigtable logger = logging.getLogger(__name__) from . import locks from . import edits from . import types -from . import attributes +from pychunkedgraph.graph import attributes from .edges import Edges from .edges.utils import get_edges_status -from .utils import basetypes -from .utils import serializers +from pychunkedgraph.graph import basetypes +from pychunkedgraph.graph import serializers from .cache import CacheService from .cutting import run_multicut from .exceptions import PreconditionError from .exceptions import PostconditionError -from .utils.generic import get_bounding_box as get_bbox, get_valid_timestamp +from .utils.generic import get_bounding_box as get_bbox +from pychunkedgraph.graph import get_valid_timestamp from ..logging.log_db import TimeIt if TYPE_CHECKING: @@ -365,10 +366,10 @@ def _update_root_ids(self) -> np.ndarray: @abstractmethod def _apply( self, *, operation_id, timestamp - ) -> Tuple[np.ndarray, np.ndarray, List["bigtable.row.Row"]]: + ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: """Initiates the graph operation calculation. :return: New root IDs, new Lvl2 node IDs, and affected records - :rtype: Tuple[np.ndarray, np.ndarray, List["bigtable.row.Row"]] + :rtype: Tuple[np.ndarray, np.ndarray, List[Any]] """ @abstractmethod @@ -381,11 +382,11 @@ def _create_log_record( new_root_ids, status=1, exception="", - ) -> "bigtable.row.Row": + ) -> Any: """Creates a log record with all necessary information to replay the current GraphEditOperation :return: Bigtable row containing the log record - :rtype: bigtable.row.Row + :rtype: row mutation object """ @abstractmethod @@ -623,7 +624,7 @@ def _update_root_ids(self) -> np.ndarray: def _apply( self, *, operation_id, timestamp - ) -> Tuple[np.ndarray, np.ndarray, List["bigtable.row.Row"]]: + ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: root_ids = set( self.cg.get_roots( self.added_edges.ravel(), assert_roots=True, time_stamp=self.parent_ts @@ -683,7 +684,7 @@ def _create_log_record( new_root_ids: Sequence[np.uint64], status: int = 1, exception: str = "", - ) -> "bigtable.row.Row": + ) -> Any: val_dict = { attributes.OperationLogs.UserID: self.user_id, attributes.OperationLogs.RootID: new_root_ids, @@ -768,7 +769,7 @@ def _update_root_ids(self) -> np.ndarray: def _apply( self, *, operation_id, timestamp - ) -> Tuple[np.ndarray, np.ndarray, List["bigtable.row.Row"]]: + ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: if ( len( set( @@ -802,7 +803,7 @@ def _create_log_record( new_root_ids: Sequence[np.uint64], status: int = 1, exception: str = "", - ) -> "bigtable.row.Row": + ) -> Any: val_dict = { attributes.OperationLogs.UserID: self.user_id, attributes.OperationLogs.RootID: new_root_ids, @@ -912,7 +913,7 @@ def _update_root_ids(self) -> np.ndarray: def _apply( self, *, operation_id, timestamp - ) -> Tuple[np.ndarray, np.ndarray, List["bigtable.row.Row"]]: + ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: # Verify that sink and source are from the same root object root_ids = set( self.cg.get_roots( @@ -976,7 +977,7 @@ def _create_log_record( new_root_ids: Sequence[np.uint64], status: int = 1, exception: str = "", - ) -> "bigtable.row.Row": + ) -> Any: val_dict = { attributes.OperationLogs.UserID: self.user_id, attributes.OperationLogs.RootID: new_root_ids, @@ -1072,7 +1073,7 @@ def _update_root_ids(self): def _apply( self, *, operation_id, timestamp - ) -> Tuple[np.ndarray, np.ndarray, List["bigtable.row.Row"]]: + ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: return self.superseded_operation._apply( operation_id=operation_id, timestamp=timestamp ) @@ -1086,7 +1087,7 @@ def _create_log_record( new_root_ids: Sequence[np.uint64], status: int = 1, exception: str = "", - ) -> "bigtable.row.Row": + ) -> Any: val_dict = { attributes.OperationLogs.UserID: self.user_id, attributes.OperationLogs.RedoOperationID: self.superseded_operation_id, @@ -1205,7 +1206,7 @@ def _update_root_ids(self): def _apply( self, *, operation_id, timestamp - ) -> Tuple[np.ndarray, np.ndarray, List["bigtable.row.Row"]]: + ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: if isinstance(self.inverse_superseded_operation, MergeOperation): return edits.add_edges( self.inverse_superseded_operation.cg, @@ -1228,7 +1229,7 @@ def _create_log_record( new_root_ids: Sequence[np.uint64], status: int = 1, exception: str = "", - ) -> "bigtable.row.Row": + ) -> Any: val_dict = { attributes.OperationLogs.UserID: self.user_id, attributes.OperationLogs.UndoOperationID: self.superseded_operation_id, diff --git a/pychunkedgraph/graph/segmenthistory.py b/pychunkedgraph/graph/segmenthistory.py index 0f9cee61b..83dc8175a 100644 --- a/pychunkedgraph/graph/segmenthistory.py +++ b/pychunkedgraph/graph/segmenthistory.py @@ -6,8 +6,8 @@ import fastremap from networkx.algorithms.dag import ancestors as nx_ancestors -from .attributes import OperationLogs -from .utils import basetypes +from pychunkedgraph.graph import attributes +from pychunkedgraph.graph import basetypes class SegmentHistory: @@ -379,11 +379,11 @@ def __init__(self, row, timestamp): @property def is_merge(self): - return OperationLogs.AddedEdge in self.row + return attributes.OperationLogs.AddedEdge in self.row @property def user_id(self): - return self.row[OperationLogs.UserID] + return self.row[attributes.OperationLogs.UserID] @property def log_type(self): @@ -391,7 +391,7 @@ def log_type(self): @property def root_ids(self): - return self.row[OperationLogs.RootID] + return self.row[attributes.OperationLogs.RootID] @property def edges_failsafe(self): @@ -407,27 +407,27 @@ def edges_failsafe(self): def sink_source_ids(self): return np.concatenate( [ - self.row[OperationLogs.SinkID], - self.row[OperationLogs.SourceID], + self.row[attributes.OperationLogs.SinkID], + self.row[attributes.OperationLogs.SourceID], ] ) @property def added_edges(self): assert self.is_merge, "Not a merge operation." - return self.row[OperationLogs.AddedEdge] + return self.row[attributes.OperationLogs.AddedEdge] @property def removed_edges(self): assert not self.is_merge, "Not a split operation." - return self.row[OperationLogs.RemovedEdge] + return self.row[attributes.OperationLogs.RemovedEdge] @property def coordinates(self): return np.array( [ - self.row[OperationLogs.SourceCoordinate], - self.row[OperationLogs.SinkCoordinate], + self.row[attributes.OperationLogs.SourceCoordinate], + self.row[attributes.OperationLogs.SinkCoordinate], ] ) diff --git a/pychunkedgraph/graph/types.py b/pychunkedgraph/graph/types.py index 1f35e5f6b..f6d4395d9 100644 --- a/pychunkedgraph/graph/types.py +++ b/pychunkedgraph/graph/types.py @@ -3,7 +3,7 @@ import numpy as np -from .utils import basetypes +from pychunkedgraph.graph import basetypes empty_1d = np.empty(0, dtype=basetypes.NODE_ID) empty_2d = np.empty((0, 2), dtype=basetypes.NODE_ID) diff --git a/pychunkedgraph/graph/utils/basetypes.py b/pychunkedgraph/graph/utils/basetypes.py deleted file mode 100644 index c6b0b1974..000000000 --- a/pychunkedgraph/graph/utils/basetypes.py +++ /dev/null @@ -1,16 +0,0 @@ -import numpy as np - - -CHUNK_ID = SEGMENT_ID = NODE_ID = OPERATION_ID = np.dtype("uint64").newbyteorder("L") -EDGE_AFFINITY = np.dtype("float32").newbyteorder("L") -EDGE_AREA = np.dtype("uint64").newbyteorder("L") - -COUNTER = np.dtype("int64").newbyteorder("B") - -COORDINATES = np.dtype("int64").newbyteorder("L") -CHUNKSIZE = np.dtype("uint64").newbyteorder("L") -FANOUT = np.dtype("uint64").newbyteorder("L") -LAYERCOUNT = np.dtype("uint64").newbyteorder("L") -SPATIALBITS = np.dtype("uint64").newbyteorder("L") -ROOTCOUNTERBITS = np.dtype("uint64").newbyteorder("L") -SKIPCONNECTIONS = np.dtype("uint64").newbyteorder("L") diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 696a03801..d48da9cf2 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -15,7 +15,6 @@ import numpy as np import pandas as pd -import pytz from ..chunks import utils as chunk_utils @@ -75,36 +74,6 @@ def compute_bitmasks(n_layers: int, s_bits_atomic_layer: int = 8) -> Dict[int, i return bitmask_dict -def get_max_time(): - """Returns the (almost) max time in datetime.datetime - :return: datetime.datetime - """ - return datetime.datetime(9999, 12, 31, 23, 59, 59, 0) - - -def get_min_time(): - """Returns the min time in datetime.datetime - :return: datetime.datetime - """ - return datetime.datetime.strptime("01/01/00 00:00", "%d/%m/%y %H:%M") - - -def time_min(): - """Returns a minimal time stamp that still works with google - :return: datetime.datetime - """ - return datetime.datetime.strptime("01/01/00 00:00", "%d/%m/%y %H:%M") - - -def get_valid_timestamp(timestamp): - if timestamp is None: - timestamp = datetime.datetime.now(datetime.timezone.utc) - if timestamp.tzinfo is None: - timestamp = pytz.UTC.localize(timestamp) - # Comply to resolution of BigTables TimeRange - return _get_google_compatible_time_stamp(timestamp, round_up=False) - - def get_bounding_box( source_coords: Sequence[Sequence[int]], sink_coords: Sequence[Sequence[int]], @@ -137,27 +106,6 @@ def filter_failed_node_ids(row_ids, segment_ids, max_children_ids): return row_ids[max_child_ids_occ_so_far == 0] -def _get_google_compatible_time_stamp( - time_stamp: datetime.datetime, round_up: bool = False -) -> datetime.datetime: - """Makes a datetime.datetime time stamp compatible with googles' services. - Google restricts the accuracy of time stamps to milliseconds. Hence, the - microseconds are cut of. By default, time stamps are rounded to the lower - number. - :param time_stamp: datetime.datetime - :param round_up: bool - :return: datetime.datetime - """ - micro_s_gap = datetime.timedelta(microseconds=time_stamp.microsecond % 1000) - if micro_s_gap == 0: - return time_stamp - if round_up: - time_stamp += datetime.timedelta(microseconds=1000) - micro_s_gap - else: - time_stamp -= micro_s_gap - return time_stamp - - def mask_nodes_by_bounding_box( meta, nodes: Union[Iterable[np.uint64], np.uint64], diff --git a/pychunkedgraph/graph/utils/id_helpers.py b/pychunkedgraph/graph/utils/id_helpers.py index 2a245f79c..5cbc3c061 100644 --- a/pychunkedgraph/graph/utils/id_helpers.py +++ b/pychunkedgraph/graph/utils/id_helpers.py @@ -9,7 +9,7 @@ import numpy as np -from . import basetypes +from pychunkedgraph.graph import basetypes from ..meta import ChunkedGraphMeta from ..chunks import utils as chunk_utils @@ -20,7 +20,7 @@ def get_segment_id_limit( """Get maximum possible Segment ID for given Node ID or Chunk ID.""" layer = chunk_utils.get_chunk_layer(meta, node_or_chunk_id) chunk_offset = 64 - meta.graph_config.LAYER_ID_BITS - 3 * meta.bitmasks[layer] - return np.uint64(2 ** chunk_offset - 1) + return np.uint64(2**chunk_offset - 1) def get_segment_id( @@ -60,8 +60,8 @@ def get_atomic_id_from_coord( time_stamp: Optional[datetime] = None, ) -> np.uint64: """Determines atomic id given a coordinate.""" - x = int(x / 2 ** meta.data_source.CV_MIP) - y = int(y / 2 ** meta.data_source.CV_MIP) + x = int(x / 2**meta.data_source.CV_MIP) + y = int(y / 2**meta.data_source.CV_MIP) z = int(z) checked = [] @@ -161,7 +161,7 @@ def get_atomic_ids_from_coords( local_sv_ids, time_stamp=parent_ts, stop_layer=parent_id_layer, - fail_to_zero=True + fail_to_zero=True, ) local_parent_seg = fastremap.remap( diff --git a/pychunkedgraph/graph/utils/serializers.py b/pychunkedgraph/graph/utils/serializers.py deleted file mode 100644 index a09094b33..000000000 --- a/pychunkedgraph/graph/utils/serializers.py +++ /dev/null @@ -1,160 +0,0 @@ -from typing import Any, Iterable -import json -import pickle - -import numpy as np -import zstandard as zstd - - -class _Serializer: - def __init__(self, serializer, deserializer, basetype=Any, compression_level=None): - self._serializer = serializer - self._deserializer = deserializer - self._basetype = basetype - self._compression_level = compression_level - - def serialize(self, obj): - content = self._serializer(obj) - if self._compression_level: - return zstd.ZstdCompressor(level=self._compression_level).compress(content) - return content - - def deserialize(self, obj): - if self._compression_level: - obj = zstd.ZstdDecompressor().decompressobj().decompress(obj) - return self._deserializer(obj) - - @property - def basetype(self): - return self._basetype - - -class NumPyArray(_Serializer): - @staticmethod - def _deserialize(val, dtype, shape=None, order=None): - data = np.frombuffer(val, dtype=dtype) - if shape is not None: - return data.reshape(shape, order=order) - if order is not None: - return data.reshape(data.shape, order=order) - return data - - def __init__(self, dtype, shape=None, order=None, compression_level=None): - super().__init__( - serializer=lambda x: np.asarray(x) - .view(x.dtype.newbyteorder(dtype.byteorder)) - .tobytes(), - deserializer=lambda x: NumPyArray._deserialize( - x, dtype, shape=shape, order=order - ), - basetype=dtype.type, - compression_level=compression_level, - ) - - -class NumPyValue(_Serializer): - def __init__(self, dtype): - super().__init__( - serializer=lambda x: np.asarray(x) - .view(np.dtype(type(x)).newbyteorder(dtype.byteorder)) - .tobytes(), - deserializer=lambda x: np.frombuffer(x, dtype=dtype)[0], - basetype=dtype.type, - ) - - -class String(_Serializer): - def __init__(self, encoding="utf-8"): - super().__init__( - serializer=lambda x: x.encode(encoding), - deserializer=lambda x: x.decode(), - basetype=str, - ) - - -class JSON(_Serializer): - def __init__(self): - super().__init__( - serializer=lambda x: json.dumps(x).encode("utf-8"), - deserializer=lambda x: json.loads(x.decode()), - basetype=str, - ) - - -class Pickle(_Serializer): - def __init__(self): - super().__init__( - serializer=lambda x: pickle.dumps(x), - deserializer=lambda x: pickle.loads(x), - basetype=str, - ) - - -class UInt64String(_Serializer): - def __init__(self): - super().__init__( - serializer=serialize_uint64, - deserializer=deserialize_uint64, - basetype=np.uint64, - ) - - -def pad_node_id(node_id: np.uint64) -> str: - """Pad node id to 20 digits - - :param node_id: int - :return: str - """ - return "%.20d" % node_id - - -def serialize_uint64(node_id: np.uint64, counter=False, fake_edges=False) -> bytes: - """Serializes an id to be ingested by a bigtable table row - - :param node_id: int - :return: str - """ - if counter: - return serialize_key("i%s" % pad_node_id(node_id)) # type: ignore - if fake_edges: - return serialize_key("f%s" % pad_node_id(node_id)) # type: ignore - return serialize_key(pad_node_id(node_id)) # type: ignore - - -def serialize_uint64s_to_regex(node_ids: Iterable[np.uint64]) -> bytes: - """Serializes an id to be ingested by a bigtable table row - - :param node_id: int - :return: str - """ - node_id_str = "".join(["%s|" % pad_node_id(node_id) for node_id in node_ids])[:-1] - return serialize_key(node_id_str) # type: ignore - - -def deserialize_uint64(node_id: bytes, fake_edges=False) -> np.uint64: - """De-serializes a node id from a BigTable row - - :param node_id: bytes - :return: np.uint64 - """ - if fake_edges: - return np.uint64(node_id[1:].decode()) # type: ignore - return np.uint64(node_id.decode()) # type: ignore - - -def serialize_key(key: str) -> bytes: - """Serializes a key to be ingested by a bigtable table row - - :param key: str - :return: bytes - """ - return key.encode("utf-8") - - -def deserialize_key(key: bytes) -> str: - """Deserializes a row key - - :param key: bytes - :return: str - """ - return key.decode() diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index 3c4e6f7f8..d7b7a56dd 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -63,7 +63,7 @@ def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): redis.set(r_keys.JOB_TYPE, group_name) ingest_config = IngestConfig(TEST_RUN=test) cg = ChunkedGraph(graph_id=graph_id) - cg.client.add_graph_version(__version__, overwrite=True) + cg.client.add_table_version(__version__, overwrite=True) if graph_id != cg.graph_id: gc = cg.meta.graph_config._asdict() @@ -76,8 +76,7 @@ def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): try: # create new column family for cross chunk edges - f = cg.client._table.column_family("4") - f.create() + cg.client.create_column_family("4") except Exception: ... @@ -99,7 +98,7 @@ def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): @click.argument("parent_layer", type=int) @click.option("--splits", default=0, help="Split chunks into multiple tasks.") @job_type_guard(group_name) -def queue_layer(parent_layer:int, splits:int=0): +def queue_layer(parent_layer: int, splits: int = 0): """ Queue all chunk tasks at a given layer. Must be used when all the chunks at `parent_layer - 1` have completed. diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 219cae07b..360b5a15d 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -27,18 +27,27 @@ from ..graph.edges import EDGE_TYPES, Edges, put_edges from ..graph import ChunkedGraph, ChunkedGraphMeta from ..graph.chunks.hierarchy import get_children_chunk_coords -from ..graph.utils.basetypes import NODE_ID +from ..graph.basetypes import NODE_ID from ..io.edges import get_chunk_edges from ..io.components import get_chunk_components from ..utils.redis import keys as r_keys, get_redis_connection from ..utils.general import chunked +_CACHED_IMANAGER = None + + +def _get_imanager(): + """Cache IngestionManager per worker process to avoid repeated Redis GETs + deserializations.""" + global _CACHED_IMANAGER + if _CACHED_IMANAGER is not None: + return _CACHED_IMANAGER + redis = get_redis_connection() + _CACHED_IMANAGER = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + return _CACHED_IMANAGER + def _post_task_completion( - imanager: IngestionManager, - layer: int, - coords: np.ndarray, - split:int=None + imanager: IngestionManager, layer: int, coords: np.ndarray, split: int = None ): chunk_str = "_".join(map(str, coords)) if split is not None: @@ -52,8 +61,7 @@ def create_parent_chunk( parent_layer: int, parent_coords: Sequence[int], ) -> None: - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + imanager = _get_imanager() add_parent_chunk( imanager.cg, parent_layer, @@ -70,12 +78,13 @@ def create_parent_chunk( def upgrade_parent_chunk( parent_layer: int, parent_coords: Sequence[int], - split:int=None, - splits:int=None + split: int = None, + splits: int = None, ) -> None: - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - update_parent_chunk(imanager.cg, parent_coords, layer=parent_layer, split=split, splits=splits) + imanager = _get_imanager() + update_parent_chunk( + imanager.cg, parent_coords, layer=parent_layer, split=split, splits=splits + ) _post_task_completion(imanager, parent_layer, parent_coords, split=split) @@ -121,8 +130,7 @@ def _check_edges_direction( def create_atomic_chunk(coords: Sequence[int]): """Creates single atomic chunk""" - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + imanager = _get_imanager() coords = np.array(list(coords), dtype=int) chunk_edges_all, mapping = _get_atomic_chunk_data(imanager, coords) @@ -138,8 +146,7 @@ def create_atomic_chunk(coords: Sequence[int]): def upgrade_atomic_chunk(coords: Sequence[int]): """Upgrades single atomic chunk""" - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + imanager = _get_imanager() coords = np.array(list(coords), dtype=int) update_atomic_chunk(imanager.cg, coords) _post_task_completion(imanager, 2, coords) @@ -149,8 +156,7 @@ def convert_to_ocdbt(coords: Sequence[int]): """ Convert edges stored per chunk to ajacency list in the tensorstore ocdbt kv store. """ - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + imanager = _get_imanager() coords = np.array(list(coords), dtype=int) chunk_edges_all, mapping = _get_atomic_chunk_data(imanager, coords) @@ -200,13 +206,15 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl batches = chunked(coords, batch_size) retry = int(environ.get("RETRY_COUNT", 0)) failure_ttl = int(environ.get("FAILURE_TTL", 300)) + max_queue_size = int(environ.get("QUEUE_SIZE", 1000000)) for batch in batches: _coords = get_chunks_not_done(imanager, 2, batch) # buffer for optimal use of redis memory - if len(q) > int(environ.get("QUEUE_SIZE", 1000000)): - interval = int(environ.get("QUEUE_INTERVAL", 300)) - logging.info(f"Queue full; sleeping {interval}s...") - sleep(interval) + while len(q) > max_queue_size: + logging.info( + f"Queue has {len(q)} items (limit {max_queue_size}), waiting..." + ) + sleep(10) job_datas = [] for chunk_coord in _coords: @@ -219,7 +227,7 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl job_id=chunk_id_str(2, chunk_coord), retry=Retry(retry) if retry > 1 else None, description="", - failure_ttl=failure_ttl + failure_ttl=failure_ttl, ) ) q.enqueue_many(job_datas) diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index 3a7b0c11d..b226004f2 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -11,14 +11,11 @@ import numpy as np -from ...graph import attributes +from ...graph import attributes, basetypes, serializers, get_valid_timestamp from ...graph.chunkedgraph import ChunkedGraph -from ...graph.utils import basetypes -from ...graph.utils import serializers from ...graph.edges import Edges from ...graph.edges import EDGE_TYPES from ...graph.utils.generic import compute_indices_pandas -from ...graph.utils.generic import get_valid_timestamp from ...graph.utils.flatgraph import build_gt_graph from ...graph.utils.flatgraph import connected_components diff --git a/pychunkedgraph/ingest/create/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py index 9581838af..e8ddfe894 100644 --- a/pychunkedgraph/ingest/create/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -9,9 +9,8 @@ import numpy as np from multiwrapper.multiprocessing_utils import multiprocess_func -from ...graph import attributes +from ...graph import attributes, basetypes from ...graph.types import empty_2d -from ...graph.utils import basetypes from ...graph.chunkedgraph import ChunkedGraph from ...graph.utils.generic import filter_failed_node_ids from ...graph.chunks.atomic import get_touching_atomic_chunks @@ -63,7 +62,9 @@ def _get_children_chunk_cross_edges_helper(args) -> None: edge_ids_shared.append(_get_children_chunk_cross_edges(cg, atomic_chunks, layer)) -def _get_children_chunk_cross_edges(cg: ChunkedGraph, atomic_chunks, layer) -> np.ndarray: +def _get_children_chunk_cross_edges( + cg: ChunkedGraph, atomic_chunks, layer +) -> np.ndarray: """ Non parallelized version Cross edges that connect children chunks. diff --git a/pychunkedgraph/ingest/create/parent_layer.py b/pychunkedgraph/ingest/create/parent_layer.py index dfdb48dac..11134b1d0 100644 --- a/pychunkedgraph/ingest/create/parent_layer.py +++ b/pychunkedgraph/ingest/create/parent_layer.py @@ -14,15 +14,11 @@ import numpy as np from multiwrapper import multiprocessing_utils as mu -from ...graph import types -from ...graph import attributes +from ...graph import types, attributes, basetypes, serializers, get_valid_timestamp from ...utils.general import chunked from ...graph.utils import flatgraph -from ...graph.utils import basetypes -from ...graph.utils import serializers from ...graph.chunkedgraph import ChunkedGraph from ...graph.edges.utils import concatenate_cross_edge_dicts -from ...graph.utils.generic import get_valid_timestamp from ...graph.utils.generic import filter_failed_node_ids from ...graph.chunks.hierarchy import get_children_chunk_coords from .cross_edges import get_children_chunk_cross_edges diff --git a/pychunkedgraph/ingest/manager.py b/pychunkedgraph/ingest/manager.py index 55e7d253f..c23c3cca4 100644 --- a/pychunkedgraph/ingest/manager.py +++ b/pychunkedgraph/ingest/manager.py @@ -11,15 +11,22 @@ class IngestionManager: - def __init__(self, config: IngestConfig, chunkedgraph_meta: ChunkedGraphMeta): + def __init__( + self, + config: IngestConfig, + chunkedgraph_meta: ChunkedGraphMeta, + _from_pickle: bool = False, + ): self._config = config self._chunkedgraph_meta = chunkedgraph_meta self._cg = None self._redis = None self._task_queues = {} + self._from_pickle = _from_pickle - # initiate redis and cache info - self.redis # pylint: disable=pointless-statement + if not _from_pickle: + # initiate redis and store serialized state + self.redis # pylint: disable=pointless-statement @property def config(self): @@ -40,7 +47,8 @@ def redis(self): if self._redis is not None: return self._redis self._redis = get_redis_connection() - self._redis.set(r_keys.INGESTION_MANAGER, self.serialized(pickled=True)) + if not self._from_pickle: + self._redis.set(r_keys.INGESTION_MANAGER, self.serialized(pickled=True)) return self._redis def serialized(self, pickled=False): @@ -51,7 +59,7 @@ def serialized(self, pickled=False): @classmethod def from_pickle(cls, serialized_info): - return cls(**pickle.loads(serialized_info)) + return cls(**pickle.loads(serialized_info), _from_pickle=True) def get_task_queue(self, q_name): if q_name in self._task_queues: diff --git a/pychunkedgraph/ingest/ran_agglomeration.py b/pychunkedgraph/ingest/ran_agglomeration.py index d726ba4a5..c386f88e0 100644 --- a/pychunkedgraph/ingest/ran_agglomeration.py +++ b/pychunkedgraph/ingest/ran_agglomeration.py @@ -19,7 +19,7 @@ from .utils import postprocess_edge_data from ..io.edges import put_chunk_edges from ..io.components import put_chunk_components -from ..graph.utils import basetypes +from ..graph import basetypes from ..graph.edges import EDGE_TYPES, Edges from ..graph.types import empty_2d from ..graph.chunks.utils import get_chunk_id diff --git a/pychunkedgraph/ingest/rq_cli.py b/pychunkedgraph/ingest/rq_cli.py index 6a1a4882d..62367860b 100644 --- a/pychunkedgraph/ingest/rq_cli.py +++ b/pychunkedgraph/ingest/rq_cli.py @@ -3,10 +3,10 @@ """ cli for redis jobs """ + import sys import click -from redis import Redis from rq import Queue from rq.job import Job from rq.exceptions import InvalidJobOperationError @@ -15,14 +15,11 @@ from rq.registry import FailedJobRegistry from flask.cli import AppGroup -from ..utils.redis import REDIS_HOST -from ..utils.redis import REDIS_PORT -from ..utils.redis import REDIS_PASSWORD - +from ..utils.redis import get_redis_connection # rq extended rq_cli = AppGroup("rq") -connection = Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, password=REDIS_PASSWORD) +connection = get_redis_connection() @rq_cli.command("failed") diff --git a/pychunkedgraph/ingest/simple_tests.py b/pychunkedgraph/ingest/simple_tests.py index 48a49f922..9ea600af5 100644 --- a/pychunkedgraph/ingest/simple_tests.py +++ b/pychunkedgraph/ingest/simple_tests.py @@ -7,7 +7,8 @@ from datetime import datetime, timezone import numpy as np -from pychunkedgraph.graph import attributes, ChunkedGraph +from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph import attributes def family(cg: ChunkedGraph): diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index 43270081b..81122c5a8 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -8,8 +8,7 @@ import fastremap import numpy as np from pychunkedgraph.graph import ChunkedGraph, types -from pychunkedgraph.graph.attributes import Connectivity, Hierarchy -from pychunkedgraph.graph.utils import serializers +from pychunkedgraph.graph import attributes, serializers from pychunkedgraph.graph.utils.generic import get_parents_at_timestamp from .utils import fix_corrupt_nodes, get_end_timestamps, get_parent_timestamps @@ -54,7 +53,7 @@ def update_cross_edges( ) layer_edges[:, 0] = node layer_edges = np.unique(layer_edges, axis=0) - col = Connectivity.CrossChunkEdge[layer] + col = attributes.Connectivity.CrossChunkEdge[layer] val_dict[col] = layer_edges row_id = serializers.serialize_uint64(node) rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=ts)) @@ -90,7 +89,7 @@ def update_nodes(cg: ChunkedGraph, nodes, nodes_ts, children_map=None) -> list: if is_stale: end_ts -= timedelta(milliseconds=1) row_id = serializers.serialize_uint64(node) - val_dict = {Hierarchy.StaleTimeStamp: 0} + val_dict = {attributes.Hierarchy.StaleTimeStamp: 0} rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=end_ts)) if not _cx_edges_d: @@ -130,9 +129,9 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int]): corrupt_nodes = [] for k, v in rr.items(): try: - CHILDREN[k] = v[Hierarchy.Child][0].value - ts = v[Hierarchy.Child][0].timestamp - _ = v[Hierarchy.Parent] + CHILDREN[k] = v[attributes.Hierarchy.Child][0].value + ts = v[attributes.Hierarchy.Child][0].timestamp + _ = v[attributes.Hierarchy.Parent] nodes.append(k) nodes_ts.append(earliest_ts if ts < earliest_ts else ts) except KeyError: diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 436aca49c..773fc9ed0 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -13,14 +13,12 @@ from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph.edges import stale, get_latest_edges_wrapper -from pychunkedgraph.graph.attributes import Connectivity, Hierarchy -from pychunkedgraph.graph.utils import serializers, basetypes +from pychunkedgraph.graph import attributes, serializers, basetypes from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.utils.general import chunked from .utils import fix_corrupt_nodes, get_end_timestamps, get_parent_timestamps - CHILDREN = {} CX_EDGES = {} CG: ChunkedGraph = None @@ -37,7 +35,7 @@ def _populate_nodes_and_children( if len(v): CHILDREN[k] = v return - response = cg.range_read_chunk(chunk_id, properties=Hierarchy.Child) + response = cg.range_read_chunk(chunk_id, properties=attributes.Hierarchy.Child) for k, v in response.items(): CHILDREN[k] = v[0].value @@ -78,7 +76,10 @@ def _populate_cx_edges_with_timestamps( start = time.time() global CX_EDGES - attrs = [Connectivity.CrossChunkEdge[l] for l in range(layer, cg.meta.layer_count)] + attrs = [ + attributes.Connectivity.CrossChunkEdge[l] + for l in range(layer, cg.meta.layer_count) + ] all_children = np.concatenate(list(CHILDREN.values())) response = cg.client.read_nodes(node_ids=all_children, properties=attrs) timestamps_d = get_parent_timestamps(cg, nodes) @@ -113,7 +114,7 @@ def _populate_cx_edges_with_timestamps( if is_stale: row_id = serializers.serialize_uint64(node) - val_dict = {Hierarchy.StaleTimeStamp: 0} + val_dict = {attributes.Hierarchy.StaleTimeStamp: 0} rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=node_end_ts)) cg.client.write(rows) @@ -141,7 +142,7 @@ def update_cross_edges(cg: ChunkedGraph, layer, node, node_ts) -> list: ) layer_edges[:, 0] = node layer_edges = np.unique(layer_edges, axis=0) - col = Connectivity.CrossChunkEdge[_layer] + col = attributes.Connectivity.CrossChunkEdge[_layer] val_dict[col] = layer_edges rows.append(cg.client.mutate_row(row_id, val_dict, time_stamp=ts)) return rows diff --git a/pychunkedgraph/ingest/upgrade/utils.py b/pychunkedgraph/ingest/upgrade/utils.py index 0410245c3..caa1f067b 100644 --- a/pychunkedgraph/ingest/upgrade/utils.py +++ b/pychunkedgraph/ingest/upgrade/utils.py @@ -5,16 +5,16 @@ import numpy as np from pychunkedgraph.graph import ChunkedGraph -from pychunkedgraph.graph.attributes import Hierarchy -from pychunkedgraph.graph.utils import serializers -from google.cloud.bigtable.row_filters import TimestampRange +from pychunkedgraph.graph import attributes, serializers def exists_as_parent(cg: ChunkedGraph, parent, nodes) -> bool: """ Check if a given l2 parent is in the history of given nodes. """ - response = cg.client.read_nodes(node_ids=nodes, properties=Hierarchy.Parent) + response = cg.client.read_nodes( + node_ids=nodes, properties=attributes.Hierarchy.Parent + ) parents = set() for cells in response.values(): parents.update([cell.value for cell in cells]) @@ -37,7 +37,9 @@ def get_edit_timestamps(cg: ChunkedGraph, edges_d, start_ts, end_ts) -> list: def _get_end_timestamps_helper(cg: ChunkedGraph, nodes: list) -> defaultdict[int, set]: result = defaultdict(set) - response = cg.client.read_nodes(node_ids=nodes, properties=Hierarchy.StaleTimeStamp) + response = cg.client.read_nodes( + node_ids=nodes, properties=attributes.Hierarchy.StaleTimeStamp + ) for k, v in response.items(): result[k].add(v[0].timestamp) return result @@ -92,7 +94,7 @@ def get_parent_timestamps( earliest_ts = cg.get_earliest_timestamp() response = cg.client.read_nodes( node_ids=nodes, - properties=[Hierarchy.Parent], + properties=[attributes.Hierarchy.Parent], start_time=start_time, end_time=end_time, end_time_inclusive=False, @@ -100,7 +102,7 @@ def get_parent_timestamps( result = defaultdict(set) for k, v in response.items(): - for cell in v[Hierarchy.Parent]: + for cell in v[attributes.Hierarchy.Parent]: ts = cell.timestamp result[k].add(earliest_ts if ts < earliest_ts else ts) return result @@ -111,27 +113,27 @@ def fix_corrupt_nodes(cg: ChunkedGraph, nodes: list, children_d: dict): For each node: delete it from parent column of its children. Then deletes the node itself, effectively erasing it from hierarchy. """ - table = cg.client._table - batcher = table.mutations_batcher(flush_count=500) + mutations = [] + row_keys_to_delete = [] for node in nodes: children = children_d[node] - _map = cg.client.read_nodes(node_ids=children, properties=Hierarchy.Parent) + _map = cg.client.read_nodes( + node_ids=children, properties=attributes.Hierarchy.Parent + ) for child, parent_cells in _map.items(): - row = table.direct_row(serializers.serialize_uint64(child)) - for cell in parent_cells: - if cell.value == node: - start = cell.timestamp - end = start + timedelta(microseconds=1) - row.delete_cell( - column_family_id=Hierarchy.Parent.family_id, - column=Hierarchy.Parent.key, - time_range=TimestampRange(start=start, end=end), + timestamps_to_delete = [ + cell.timestamp for cell in parent_cells if cell.value == node + ] + if timestamps_to_delete: + mutations.append( + ( + serializers.serialize_uint64(child), + attributes.Hierarchy.Parent, + timestamps_to_delete, ) - batcher.mutate(row) - - row = table.direct_row(serializers.serialize_uint64(node)) - row.delete() - batcher.mutate(row) + ) + row_keys_to_delete.append(serializers.serialize_uint64(node)) - batcher.flush() + if mutations or row_keys_to_delete: + cg.client.delete_cells(mutations, row_keys_to_delete=row_keys_to_delete) diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 83d2716d8..c41a41a56 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -15,8 +15,8 @@ from . import IngestConfig from .manager import IngestionManager from ..graph.meta import ChunkedGraphMeta, DataSource, GraphConfig -from ..graph.client import BackendClientInfo -from ..graph.client.bigtable import BigTableConfig +from ..graph import BackendClientInfo +from kvdbclient import BigTableConfig, HBaseConfig from ..utils.general import chunked from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys @@ -37,8 +37,12 @@ def bootstrap( USE_RAW_COMPONENTS=raw, TEST_RUN=test_run, ) - client_config = BigTableConfig(**config["backend_client"]["CONFIG"]) - client_info = BackendClientInfo(config["backend_client"]["TYPE"], client_config) + backend_type = config["backend_client"].get("TYPE", "bigtable") + if backend_type == "hbase": + client_config = HBaseConfig(**config["backend_client"]["CONFIG"]) + else: + client_config = BigTableConfig(**config["backend_client"]["CONFIG"]) + client_info = BackendClientInfo(backend_type, client_config) graph_config = GraphConfig( ID=f"{graph_id}", @@ -215,16 +219,18 @@ def queue_layer_helper( timeout_scale = int(environ.get("TIMEOUT_SCALE_FACTOR", 1)) batches = chunked(chunk_coords, batch_size) failure_ttl = int(environ.get("FAILURE_TTL", 300)) + retry = int(environ.get("RETRY_COUNT", 0)) + max_queue_size = int(environ.get("QUEUE_SIZE", 100000)) for batch in batches: _coords = get_chunks_not_done(imanager, parent_layer, batch, splits=splits) # buffer for optimal use of redis memory - if len(q) > int(environ.get("QUEUE_SIZE", 100000)): - interval = int(environ.get("QUEUE_INTERVAL", 300)) - logging.info(f"Queue full; sleeping {interval}s...") - sleep(interval) + while len(q) > max_queue_size: + logging.info( + f"Queue has {len(q)} items (limit {max_queue_size}), waiting..." + ) + sleep(10) job_datas = [] - retry = int(environ.get("RETRY_COUNT", 0)) for chunk_coord in _coords: if splits > 0: coord, split = chunk_coord diff --git a/pychunkedgraph/io/components.py b/pychunkedgraph/io/components.py index a6301c7d2..6d554c7e5 100644 --- a/pychunkedgraph/io/components.py +++ b/pychunkedgraph/io/components.py @@ -4,7 +4,7 @@ from cloudfiles import CloudFiles from .protobuf.chunkComponents_pb2 import ChunkComponentsMsg -from ..graph.utils import basetypes +from ..graph import basetypes def serialize(connected_components: Iterable) -> ChunkComponentsMsg: diff --git a/pychunkedgraph/io/edges.py b/pychunkedgraph/io/edges.py index 82595e139..a9fa76aa6 100644 --- a/pychunkedgraph/io/edges.py +++ b/pychunkedgraph/io/edges.py @@ -2,6 +2,7 @@ """ Functions for reading and writing edges from cloud storage. """ + import os from typing import Dict from typing import List @@ -15,7 +16,7 @@ from .protobuf.chunkEdges_pb2 import ChunkEdgesMsg from ..graph.edges import Edges from ..graph.edges import EDGE_TYPES -from ..graph.utils import basetypes +from ..graph import basetypes from ..graph.edges.utils import concatenate_chunk_edges @@ -38,7 +39,7 @@ def deserialize(edges_message: EdgesMsg) -> Tuple[np.ndarray, np.ndarray, np.nda def _parse_edges(compressed: List[bytes]) -> List[Dict]: result = [] - if(len(compressed) == 0): + if len(compressed) == 0: return result zdc = zstd.ZstdDecompressor() try: diff --git a/pychunkedgraph/meshing/manifest/sharded.py b/pychunkedgraph/meshing/manifest/sharded.py index 2576fcb2f..8b122b235 100644 --- a/pychunkedgraph/meshing/manifest/sharded.py +++ b/pychunkedgraph/meshing/manifest/sharded.py @@ -8,7 +8,7 @@ from .utils import get_children_before_start_layer from ...graph import ChunkedGraph from ...graph.types import empty_1d -from ...graph.utils.basetypes import NODE_ID +from ...graph.basetypes import NODE_ID from ...graph.chunks import utils as chunk_utils diff --git a/pychunkedgraph/meshing/manifest/utils.py b/pychunkedgraph/meshing/manifest/utils.py index 90963570c..e51296ca5 100644 --- a/pychunkedgraph/meshing/manifest/utils.py +++ b/pychunkedgraph/meshing/manifest/utils.py @@ -16,7 +16,7 @@ from ..meshgen_utils import get_json_info from ...graph import ChunkedGraph from ...graph.types import empty_1d -from ...graph.utils.basetypes import NODE_ID +from ...graph.basetypes import NODE_ID from ...graph.utils import generic as misc_utils diff --git a/pychunkedgraph/meshing/meshgen_utils.py b/pychunkedgraph/meshing/meshgen_utils.py index 60ad44815..2c150a785 100644 --- a/pychunkedgraph/meshing/meshgen_utils.py +++ b/pychunkedgraph/meshing/meshgen_utils.py @@ -12,7 +12,7 @@ from cloudvolume.lib import Vec from multiwrapper import multiprocessing_utils as mu -from pychunkedgraph.graph.utils.basetypes import NODE_ID # noqa +from pychunkedgraph.graph.basetypes import NODE_ID # noqa from ..graph.types import empty_1d diff --git a/pychunkedgraph/repair/edits.py b/pychunkedgraph/repair/edits.py index 849b17e08..028362f3f 100644 --- a/pychunkedgraph/repair/edits.py +++ b/pychunkedgraph/repair/edits.py @@ -3,7 +3,7 @@ from datetime import timedelta from pychunkedgraph.graph import ChunkedGraph -from pychunkedgraph.graph.attributes import Concurrency +from pychunkedgraph.graph import attributes from pychunkedgraph.graph.operation import GraphEditOperation @@ -51,8 +51,8 @@ def repair_operation( cg = ChunkedGraph(graph_id="") node_attrs = cg.client.read_nodes(node_ids=locked_roots) for node_id, attrs in node_attrs.items(): - if Concurrency.IndefiniteLock in attrs: - locked_op = attrs[Concurrency.IndefiniteLock][0].value + if attributes.Concurrency.IndefiniteLock in attrs: + locked_op = attrs[attributes.Concurrency.IndefiniteLock][0].value op_ids_to_retry.append(locked_op) print(f"{node_id} indefinitely locked by op {locked_op}") print(f"total to retry: {len(op_ids_to_retry)}") diff --git a/pychunkedgraph/tests/conftest.py b/pychunkedgraph/tests/conftest.py index a502ba505..0e737f7f5 100644 --- a/pychunkedgraph/tests/conftest.py +++ b/pychunkedgraph/tests/conftest.py @@ -24,11 +24,22 @@ to_label, get_layer_chunk_bounds, ) +from .hbase_mock_server import start_hbase_mock_server _emulator_proc = None _emulator_cleaned = False +def _delete_test_table(graph, backend="bigtable"): + """Test-only: delete the backing table for cleanup.""" + if backend == "bigtable": + graph.client._admin_table.delete() + else: + resp = graph.client._session.delete(graph.client._table_url("/schema")) + if resp.status_code not in (200, 404): + resp.raise_for_status() + + def _cleanup_emulator(): global _emulator_cleaned if _emulator_cleaned or _emulator_proc is None: @@ -118,9 +129,40 @@ def bigtable_emulator(request): request.addfinalizer(_cleanup_emulator) -@pytest.fixture(scope="function") -def gen_graph(request): +@pytest.fixture(scope="session") +def hbase_emulator(): + """Start an in-process mock HBase REST server for the session.""" + _data, server, port = start_hbase_mock_server() + yield port + server.shutdown() + + +@pytest.fixture(scope="function", params=["bigtable", "hbase"]) +def gen_graph(request, bigtable_emulator, hbase_emulator): + backend = request.param + def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([])): + if backend == "bigtable": + backend_client = { + "TYPE": "bigtable", + "CONFIG": { + "ADMIN": True, + "READ_ONLY": False, + "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", + "INSTANCE": "emulated_instance", + "CREDENTIALS": credentials.AnonymousCredentials(), + "MAX_ROW_KEY_COUNT": 1000, + }, + } + else: + backend_client = { + "TYPE": "hbase", + "CONFIG": { + "BASE_URL": f"http://127.0.0.1:{hbase_emulator}", + "MAX_ROW_KEY_COUNT": 1000, + }, + } + config = { "data_source": { "EDGES": "gs://chunked-graph/minnie65_0/edges", @@ -134,17 +176,7 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) "ID_PREFIX": "", "ROOT_LOCK_EXPIRY": timedelta(seconds=5), }, - "backend_client": { - "TYPE": "bigtable", - "CONFIG": { - "ADMIN": True, - "READ_ONLY": False, - "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", - "INSTANCE": "emulated_instance", - "CREDENTIALS": credentials.AnonymousCredentials(), - "MAX_ROW_KEY_COUNT": 1000, - }, - }, + "backend_client": backend_client, "ingest_config": {}, } @@ -159,9 +191,8 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) graph.create() - # setup Chunked Graph - Finalizer def fin(): - graph.client._table.delete() + _delete_test_table(graph, backend) request.addfinalizer(fin) return graph @@ -169,13 +200,36 @@ def fin(): return partial(_cgraph, request) -@pytest.fixture(scope="function") -def gen_graph_with_edges(request, tmp_path): +@pytest.fixture(scope="function", params=["bigtable", "hbase"]) +def gen_graph_with_edges(request, tmp_path, bigtable_emulator, hbase_emulator): """Like gen_graph but with real edge/component I/O via local filesystem (file:// protocol).""" + backend = request.param def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([])): edges_dir = f"file://{tmp_path}/edges" components_dir = f"file://{tmp_path}/components" + + if backend == "bigtable": + backend_client = { + "TYPE": "bigtable", + "CONFIG": { + "ADMIN": True, + "READ_ONLY": False, + "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", + "INSTANCE": "emulated_instance", + "CREDENTIALS": credentials.AnonymousCredentials(), + "MAX_ROW_KEY_COUNT": 1000, + }, + } + else: + backend_client = { + "TYPE": "hbase", + "CONFIG": { + "BASE_URL": f"http://127.0.0.1:{hbase_emulator}", + "MAX_ROW_KEY_COUNT": 1000, + }, + } + config = { "data_source": { "EDGES": edges_dir, @@ -189,17 +243,7 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) "ID_PREFIX": "", "ROOT_LOCK_EXPIRY": timedelta(seconds=5), }, - "backend_client": { - "TYPE": "bigtable", - "CONFIG": { - "ADMIN": True, - "READ_ONLY": False, - "PROJECT": "IGNORE_ENVIRONMENT_PROJECT", - "INSTANCE": "emulated_instance", - "CREDENTIALS": credentials.AnonymousCredentials(), - "MAX_ROW_KEY_COUNT": 1000, - }, - }, + "backend_client": backend_client, "ingest_config": {}, } @@ -215,7 +259,7 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) graph.create() def fin(): - graph.client._table.delete() + _delete_test_table(graph, backend) request.addfinalizer(fin) return graph diff --git a/pychunkedgraph/tests/graph/__init__.py b/pychunkedgraph/tests/graph/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pychunkedgraph/tests/test_analysis_pathing.py b/pychunkedgraph/tests/graph/test_analysis_pathing.py similarity index 99% rename from pychunkedgraph/tests/test_analysis_pathing.py rename to pychunkedgraph/tests/graph/test_analysis_pathing.py index 872158c6e..31cf2d30a 100644 --- a/pychunkedgraph/tests/test_analysis_pathing.py +++ b/pychunkedgraph/tests/graph/test_analysis_pathing.py @@ -15,8 +15,8 @@ compute_rough_coordinate_path, ) -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestGetFirstSharedParent: diff --git a/pychunkedgraph/tests/test_cache.py b/pychunkedgraph/tests/graph/test_cache.py similarity index 97% rename from pychunkedgraph/tests/test_cache.py rename to pychunkedgraph/tests/graph/test_cache.py index aadffcd3e..aab52af83 100644 --- a/pychunkedgraph/tests/test_cache.py +++ b/pychunkedgraph/tests/graph/test_cache.py @@ -7,8 +7,8 @@ from pychunkedgraph.graph.cache import CacheService, update -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestUpdate: diff --git a/pychunkedgraph/tests/test_chunkedgraph_extended.py b/pychunkedgraph/tests/graph/test_chunkedgraph_extended.py similarity index 99% rename from pychunkedgraph/tests/test_chunkedgraph_extended.py rename to pychunkedgraph/tests/graph/test_chunkedgraph_extended.py index dd398f27e..ef854c098 100644 --- a/pychunkedgraph/tests/test_chunkedgraph_extended.py +++ b/pychunkedgraph/tests/graph/test_chunkedgraph_extended.py @@ -6,10 +6,10 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk -from ..graph.operation import GraphEditOperation, MergeOperation, SplitOperation -from ..graph.exceptions import PreconditionError +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk +from ...graph.operation import GraphEditOperation, MergeOperation, SplitOperation +from ...graph.exceptions import PreconditionError class TestChunkedGraphExtended: diff --git a/pychunkedgraph/tests/test_chunks_hierarchy.py b/pychunkedgraph/tests/graph/test_chunks_hierarchy.py similarity index 99% rename from pychunkedgraph/tests/test_chunks_hierarchy.py rename to pychunkedgraph/tests/graph/test_chunks_hierarchy.py index 40841997d..2b63bf84b 100644 --- a/pychunkedgraph/tests/test_chunks_hierarchy.py +++ b/pychunkedgraph/tests/graph/test_chunks_hierarchy.py @@ -5,7 +5,7 @@ from pychunkedgraph.graph.chunks import hierarchy from pychunkedgraph.graph.chunks import utils as chunk_utils -from .helpers import to_label +from ..helpers import to_label class TestGetChildrenChunkCoords: diff --git a/pychunkedgraph/tests/test_chunks_utils.py b/pychunkedgraph/tests/graph/test_chunks_utils.py similarity index 96% rename from pychunkedgraph/tests/test_chunks_utils.py rename to pychunkedgraph/tests/graph/test_chunks_utils.py index e5830f80d..1d7764561 100644 --- a/pychunkedgraph/tests/test_chunks_utils.py +++ b/pychunkedgraph/tests/graph/test_chunks_utils.py @@ -9,7 +9,7 @@ class TestGetChunkLayer: def test_basic(self, gen_graph): graph = gen_graph(n_layers=4) - from .helpers import to_label + from ..helpers import to_label node_id = to_label(graph, 1, 0, 0, 0, 1) assert chunk_utils.get_chunk_layer(graph.meta, node_id) == 1 @@ -28,7 +28,7 @@ def test_empty(self, gen_graph): def test_multiple(self, gen_graph): graph = gen_graph(n_layers=4) - from .helpers import to_label + from ..helpers import to_label ids = [ to_label(graph, 1, 0, 0, 0, 1), @@ -66,7 +66,7 @@ def test_empty(self, gen_graph): class TestGetChunkId: def test_from_node_id(self, gen_graph): graph = gen_graph(n_layers=4) - from .helpers import to_label + from ..helpers import to_label node_id = to_label(graph, 1, 2, 3, 1, 5) chunk_id = chunk_utils.get_chunk_id(graph.meta, node_id=node_id) @@ -101,7 +101,7 @@ def test_basic(self, gen_graph): class TestGetChunkIdsFromNodeIds: def test_basic(self, gen_graph): graph = gen_graph(n_layers=4) - from .helpers import to_label + from ..helpers import to_label ids = np.array( [ diff --git a/pychunkedgraph/tests/test_connectivity.py b/pychunkedgraph/tests/graph/test_connectivity.py similarity index 100% rename from pychunkedgraph/tests/test_connectivity.py rename to pychunkedgraph/tests/graph/test_connectivity.py diff --git a/pychunkedgraph/tests/test_cutting.py b/pychunkedgraph/tests/graph/test_cutting.py similarity index 100% rename from pychunkedgraph/tests/test_cutting.py rename to pychunkedgraph/tests/graph/test_cutting.py diff --git a/pychunkedgraph/tests/test_edges_definitions.py b/pychunkedgraph/tests/graph/test_edges_definitions.py similarity index 98% rename from pychunkedgraph/tests/test_edges_definitions.py rename to pychunkedgraph/tests/graph/test_edges_definitions.py index e1ab45288..b7b9b18c3 100644 --- a/pychunkedgraph/tests/test_edges_definitions.py +++ b/pychunkedgraph/tests/graph/test_edges_definitions.py @@ -9,7 +9,7 @@ DEFAULT_AFFINITY, DEFAULT_AREA, ) -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes class TestEdgeTypes: diff --git a/pychunkedgraph/tests/test_edges_utils.py b/pychunkedgraph/tests/graph/test_edges_utils.py similarity index 97% rename from pychunkedgraph/tests/test_edges_utils.py rename to pychunkedgraph/tests/graph/test_edges_utils.py index 775823870..2e01898e6 100644 --- a/pychunkedgraph/tests/test_edges_utils.py +++ b/pychunkedgraph/tests/graph/test_edges_utils.py @@ -9,9 +9,9 @@ merge_cross_edge_dicts, get_cross_chunk_edges_layer, ) -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes -from .helpers import to_label +from ..helpers import to_label class TestConcatenateChunkEdges: diff --git a/pychunkedgraph/tests/test_edits_extended.py b/pychunkedgraph/tests/graph/test_edits_extended.py similarity index 91% rename from pychunkedgraph/tests/test_edits_extended.py rename to pychunkedgraph/tests/graph/test_edits_extended.py index bc1227de7..34be9c473 100644 --- a/pychunkedgraph/tests/test_edits_extended.py +++ b/pychunkedgraph/tests/graph/test_edits_extended.py @@ -7,10 +7,10 @@ import pytest from pychunkedgraph.graph.edits import flip_ids -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestFlipIds: diff --git a/pychunkedgraph/tests/test_exceptions.py b/pychunkedgraph/tests/graph/test_exceptions.py similarity index 90% rename from pychunkedgraph/tests/test_exceptions.py rename to pychunkedgraph/tests/graph/test_exceptions.py index 2c054bfb0..82de4c063 100644 --- a/pychunkedgraph/tests/test_exceptions.py +++ b/pychunkedgraph/tests/graph/test_exceptions.py @@ -4,6 +4,7 @@ from http.client import BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, CONFLICT from http.client import INTERNAL_SERVER_ERROR, GATEWAY_TIMEOUT +from kvdbclient.exceptions import KVDBClientError from pychunkedgraph.graph.exceptions import ( ChunkedGraphError, LockingError, @@ -27,12 +28,12 @@ def test_base_error(self): raise ChunkedGraphError("test") def test_locking_error_inherits(self): - assert issubclass(LockingError, ChunkedGraphError) - with pytest.raises(ChunkedGraphError): + assert issubclass(LockingError, KVDBClientError) + with pytest.raises(KVDBClientError): raise LockingError("locked") def test_precondition_error(self): - assert issubclass(PreconditionError, ChunkedGraphError) + assert issubclass(PreconditionError, KVDBClientError) def test_postcondition_error(self): assert issubclass(PostconditionError, ChunkedGraphError) diff --git a/pychunkedgraph/tests/test_graph_build.py b/pychunkedgraph/tests/graph/test_graph_build.py similarity index 81% rename from pychunkedgraph/tests/test_graph_build.py rename to pychunkedgraph/tests/graph/test_graph_build.py index 23ffebe0f..575141abb 100644 --- a/pychunkedgraph/tests/test_graph_build.py +++ b/pychunkedgraph/tests/graph/test_graph_build.py @@ -4,11 +4,9 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import attributes -from ..graph.utils import basetypes -from ..graph.utils.serializers import serialize_uint64 -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph import attributes, basetypes, serializers +from ...ingest.create.parent_layer import add_parent_chunk class TestGraphBuild: @@ -27,20 +25,22 @@ def test_build_single_node(self, gen_graph): # Add Chunk A create_chunk(cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)]) - res = cg.client._table.read_rows() + res = cg.client.read_all_rows() res.consume_all() - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) assert parent == to_label(cg, 2, 0, 0, 0, 1) # Check for the one Level 2 node that should have been created. - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows atomic_cross_edge_d = cg.get_atomic_cross_edges( np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) ) attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + row = res.rows[serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells[ + "0" + ] children = attr.deserialize(row[attr.key][0].value) for aces in atomic_cross_edge_d.values(): @@ -71,25 +71,27 @@ def test_build_single_edge(self, gen_graph): edges=[(to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 1), 0.5)], ) - res = cg.client._table.read_rows() + res = cg.client.read_all_rows() res.consume_all() - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) assert parent == to_label(cg, 2, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) assert parent == to_label(cg, 2, 0, 0, 0, 1) # Check for the one Level 2 node that should have been created. - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows atomic_cross_edge_d = cg.get_atomic_cross_edges( np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) ) attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + row = res.rows[serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells[ + "0" + ] children = attr.deserialize(row[attr.key][0].value) for aces in atomic_cross_edge_d.values(): @@ -133,21 +135,21 @@ def test_build_single_across_edge(self, gen_graph): ) add_parent_chunk(cg, 3, [0, 0, 0], n_threads=1) - res = cg.client._table.read_rows() + res = cg.client.read_all_rows() res.consume_all() - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) assert parent == to_label(cg, 2, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) assert parent == to_label(cg, 2, 1, 0, 0, 1) # Check for the two Level 2 nodes that should have been created. Since Level 2 has the same # dimensions as Level 1, we also expect them to be in different chunks # to_label(cg, 2, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows atomic_cross_edge_d = cg.get_atomic_cross_edges( np.array([to_label(cg, 2, 0, 0, 0, 1)], dtype=basetypes.NODE_ID) ) @@ -156,7 +158,9 @@ def test_build_single_across_edge(self, gen_graph): ] attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + row = res.rows[serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells[ + "0" + ] children = attr.deserialize(row[attr.key][0].value) test_ace = np.array( @@ -168,7 +172,7 @@ def test_build_single_across_edge(self, gen_graph): assert len(children) == 1 and to_label(cg, 1, 0, 0, 0, 0) in children # to_label(cg, 2, 1, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows atomic_cross_edge_d = cg.get_atomic_cross_edges( np.array([to_label(cg, 2, 1, 0, 0, 1)], dtype=basetypes.NODE_ID) ) @@ -177,7 +181,9 @@ def test_build_single_across_edge(self, gen_graph): ] attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells["0"] + row = res.rows[serializers.serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells[ + "0" + ] children = attr.deserialize(row[attr.key][0].value) test_ace = np.array( @@ -191,10 +197,12 @@ def test_build_single_across_edge(self, gen_graph): # Check for the one Level 3 node that should have been created. This one combines the two # connected components of Level 2 # to_label(cg, 3, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows attr = attributes.Hierarchy.Child - row = res.rows[serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells["0"] + row = res.rows[serializers.serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells[ + "0" + ] children = attr.deserialize(row[attr.key][0].value) assert ( len(children) == 2 @@ -238,28 +246,30 @@ def test_build_single_edge_and_single_across_edge(self, gen_graph): ) add_parent_chunk(cg, 3, np.array([0, 0, 0]), n_threads=1) - res = cg.client._table.read_rows() + res = cg.client.read_all_rows() res.consume_all() - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 0)) assert parent == to_label(cg, 2, 0, 0, 0, 1) # to_label(cg, 1, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 0, 0, 0, 1)) in res.rows parent = cg.get_parent(to_label(cg, 1, 0, 0, 0, 1)) assert parent == to_label(cg, 2, 0, 0, 0, 1) # to_label(cg, 1, 1, 0, 0, 0) - assert serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 1, 0, 0, 0)) in res.rows parent = cg.get_parent(to_label(cg, 1, 1, 0, 0, 0)) assert parent == to_label(cg, 2, 1, 0, 0, 1) # Check for the two Level 2 nodes that should have been created. Since Level 2 has the same # dimensions as Level 1, we also expect them to be in different chunks # to_label(cg, 2, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows - row = res.rows[serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells["0"] + assert serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1)) in res.rows + row = res.rows[serializers.serialize_uint64(to_label(cg, 2, 0, 0, 0, 1))].cells[ + "0" + ] atomic_cross_edge_d = cg.get_atomic_cross_edges([to_label(cg, 2, 0, 0, 0, 1)]) atomic_cross_edge_d = atomic_cross_edge_d[ np.uint64(to_label(cg, 2, 0, 0, 0, 1)) @@ -280,8 +290,10 @@ def test_build_single_edge_and_single_across_edge(self, gen_graph): ) # to_label(cg, 2, 1, 0, 0, 1) - assert serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows - row = res.rows[serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells["0"] + assert serializers.serialize_uint64(to_label(cg, 2, 1, 0, 0, 1)) in res.rows + row = res.rows[serializers.serialize_uint64(to_label(cg, 2, 1, 0, 0, 1))].cells[ + "0" + ] atomic_cross_edge_d = cg.get_atomic_cross_edges([to_label(cg, 2, 1, 0, 0, 1)]) atomic_cross_edge_d = atomic_cross_edge_d[ np.uint64(to_label(cg, 2, 1, 0, 0, 1)) @@ -299,8 +311,10 @@ def test_build_single_edge_and_single_across_edge(self, gen_graph): # Check for the one Level 3 node that should have been created. This one combines the two # connected components of Level 2 # to_label(cg, 3, 0, 0, 0, 1) - assert serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows - row = res.rows[serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells["0"] + assert serializers.serialize_uint64(to_label(cg, 3, 0, 0, 0, 1)) in res.rows + row = res.rows[serializers.serialize_uint64(to_label(cg, 3, 0, 0, 0, 1))].cells[ + "0" + ] column = attributes.Hierarchy.Child children = column.deserialize(row[column.key][0].value) @@ -339,13 +353,13 @@ def test_build_big_graph(self, gen_graph): add_parent_chunk(cg, 4, [0, 0, 0], n_threads=1) add_parent_chunk(cg, 5, [0, 0, 0], n_threads=1) - res = cg.client._table.read_rows() + res = cg.client.read_all_rows() res.consume_all() - assert serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows - assert serialize_uint64(to_label(cg, 1, 7, 7, 7, 0)) in res.rows - assert serialize_uint64(to_label(cg, 5, 0, 0, 0, 1)) in res.rows - assert serialize_uint64(to_label(cg, 5, 0, 0, 0, 2)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 0, 0, 0, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 1, 7, 7, 7, 0)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 5, 0, 0, 0, 1)) in res.rows + assert serializers.serialize_uint64(to_label(cg, 5, 0, 0, 0, 2)) in res.rows @pytest.mark.timeout(30) def test_double_chunk_creation(self, gen_graph): diff --git a/pychunkedgraph/tests/test_graph_queries.py b/pychunkedgraph/tests/graph/test_graph_queries.py similarity index 99% rename from pychunkedgraph/tests/test_graph_queries.py rename to pychunkedgraph/tests/graph/test_graph_queries.py index 9845b121e..1dccfb092 100644 --- a/pychunkedgraph/tests/test_graph_queries.py +++ b/pychunkedgraph/tests/graph/test_graph_queries.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label +from ..helpers import create_chunk, to_label class TestGraphSimpleQueries: diff --git a/pychunkedgraph/tests/test_history.py b/pychunkedgraph/tests/graph/test_history.py similarity index 94% rename from pychunkedgraph/tests/test_history.py rename to pychunkedgraph/tests/graph/test_history.py index 0f0e2fa16..845271a06 100644 --- a/pychunkedgraph/tests/test_history.py +++ b/pychunkedgraph/tests/graph/test_history.py @@ -3,11 +3,11 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import ChunkedGraph -from ..graph.lineage import lineage_graph, get_root_id_history -from ..graph.misc import get_delta_roots -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph import ChunkedGraph +from ...graph.lineage import lineage_graph, get_root_id_history +from ...graph.misc import get_delta_roots +from ...ingest.create.parent_layer import add_parent_chunk class TestGraphHistory: diff --git a/pychunkedgraph/tests/test_lineage.py b/pychunkedgraph/tests/graph/test_lineage.py similarity index 98% rename from pychunkedgraph/tests/test_lineage.py rename to pychunkedgraph/tests/graph/test_lineage.py index 118393e8e..3f2211f6c 100644 --- a/pychunkedgraph/tests/test_lineage.py +++ b/pychunkedgraph/tests/graph/test_lineage.py @@ -18,8 +18,8 @@ ) from pychunkedgraph.graph import attributes -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestLineage: @@ -155,7 +155,7 @@ def _build_graph_with_two_merges(self, gen_graph): graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) fake_ts = datetime.now(UTC) - timedelta(days=10) - from .helpers import create_chunk, to_label + from ..helpers import create_chunk, to_label create_chunk( graph, @@ -226,7 +226,7 @@ def _build_and_merge(self, gen_graph): graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) fake_ts = datetime.now(UTC) - timedelta(days=10) - from .helpers import create_chunk, to_label + from ..helpers import create_chunk, to_label create_chunk( graph, @@ -278,7 +278,7 @@ def _build_and_merge(self, gen_graph): graph = gen_graph(n_layers=2, atomic_chunk_bounds=atomic_chunk_bounds) fake_ts = datetime.now(UTC) - timedelta(days=10) - from .helpers import create_chunk, to_label + from ..helpers import create_chunk, to_label create_chunk( graph, diff --git a/pychunkedgraph/tests/test_locks.py b/pychunkedgraph/tests/graph/test_locks.py similarity index 98% rename from pychunkedgraph/tests/test_locks.py rename to pychunkedgraph/tests/graph/test_locks.py index a0f7161cd..97da9334c 100644 --- a/pychunkedgraph/tests/test_locks.py +++ b/pychunkedgraph/tests/graph/test_locks.py @@ -4,9 +4,9 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph.lineage import get_future_root_ids -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph.lineage import get_future_root_ids +from ...ingest.create.parent_layer import add_parent_chunk class TestGraphLocks: @@ -422,8 +422,8 @@ def test_indefinite_lock_with_normal_lock_expiration(self, gen_graph): from collections import defaultdict import networkx as nx -from ..graph.locks import RootLock, IndefiniteRootLock -from ..graph.exceptions import LockingError +from ...graph.locks import RootLock, IndefiniteRootLock +from ...graph.exceptions import LockingError def _make_mock_cg(): diff --git a/pychunkedgraph/tests/test_merge.py b/pychunkedgraph/tests/graph/test_merge.py similarity index 93% rename from pychunkedgraph/tests/test_merge.py rename to pychunkedgraph/tests/graph/test_merge.py index 9c6a3148c..73925c240 100644 --- a/pychunkedgraph/tests/test_merge.py +++ b/pychunkedgraph/tests/graph/test_merge.py @@ -5,10 +5,10 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import ChunkedGraph -from ..graph.utils.serializers import serialize_uint64 -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph import ChunkedGraph +from ...graph import serializers +from ...ingest.create.parent_layer import add_parent_chunk class TestGraphMerge: @@ -141,16 +141,32 @@ def test_merge_pair_disconnected_chunks(self, gen_graph): ) add_parent_chunk( - cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, ) add_parent_chunk( - cg, 3, [3, 3, 3], time_stamp=fake_timestamp, n_threads=1, + cg, + 3, + [3, 3, 3], + time_stamp=fake_timestamp, + n_threads=1, ) add_parent_chunk( - cg, 4, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + cg, + 4, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, ) add_parent_chunk( - cg, 5, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + cg, + 5, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, ) # Merge @@ -199,7 +215,7 @@ def test_merge_pair_already_connected(self, gen_graph): timestamp=fake_timestamp, ) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() # Merge @@ -208,10 +224,10 @@ def test_merge_pair_already_connected(self, gen_graph): "Jane Doe", [to_label(cg, 1, 0, 0, 0, 1), to_label(cg, 1, 0, 0, 0, 0)], ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() - res_new.rows.pop(b'ioperations', None) - res_new.rows.pop(b'00000000000000000001', None) + res_new.rows.pop(b"ioperations", None) + res_new.rows.pop(b"00000000000000000001", None) # Check if res_old.rows != res_new.rows: @@ -291,7 +307,11 @@ def test_merge_triple_chain_to_full_circle_neighboring_chunks(self, gen_graph): ) add_parent_chunk( - cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1, + cg, + 3, + [0, 0, 0], + time_stamp=fake_timestamp, + n_threads=1, ) # Merge @@ -331,9 +351,7 @@ def test_merge_triple_chain_to_full_circle_disconnected_chunks(self, gen_graph): create_chunk( cg, vertices=[to_label(cg, 1, 7, 7, 7, 0)], - edges=[ - (to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 1), inf) - ], + edges=[(to_label(cg, 1, 7, 7, 7, 0), to_label(cg, 1, 0, 0, 0, 1), inf)], timestamp=fake_timestamp, ) @@ -385,7 +403,7 @@ def test_merge_same_node(self, gen_graph): timestamp=fake_timestamp, ) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() # Merge @@ -395,7 +413,7 @@ def test_merge_same_node(self, gen_graph): [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 1, 0, 0, 0, 0)], ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() assert res_new.rows == res_old.rows @@ -412,17 +430,23 @@ def test_merge_pair_abstract_nodes(self, gen_graph): # Preparation: Build Chunk A fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( - cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, ) # Preparation: Build Chunk B create_chunk( - cg, vertices=[to_label(cg, 1, 1, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, ) add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() # Merge @@ -432,7 +456,7 @@ def test_merge_pair_abstract_nodes(self, gen_graph): [to_label(cg, 1, 0, 0, 0, 0), to_label(cg, 2, 1, 0, 0, 1)], ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() assert res_new.rows == res_old.rows @@ -542,7 +566,9 @@ def test_cross_edges(self, gen_graph): # Preparation: Build Chunk C create_chunk( - cg, vertices=[to_label(cg, 1, 2, 0, 0, 0)], timestamp=fake_timestamp, + cg, + vertices=[to_label(cg, 1, 2, 0, 0, 0)], + timestamp=fake_timestamp, ) add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) @@ -660,9 +686,9 @@ def test_merge_multi_layer_hierarchy_correctness(self, gen_graph): prev_layer = 1 for p in parents: layer = cg.get_chunk_layer(p) - assert layer > prev_layer, ( - f"Parent chain not monotonically increasing: {prev_layer} -> {layer}" - ) + assert ( + layer > prev_layer + ), f"Parent chain not monotonically increasing: {prev_layer} -> {layer}" prev_layer = layer # Last parent should be the root assert parents[-1] == result.new_root_ids[0] diff --git a/pychunkedgraph/tests/test_merge_split.py b/pychunkedgraph/tests/graph/test_merge_split.py similarity index 97% rename from pychunkedgraph/tests/test_merge_split.py rename to pychunkedgraph/tests/graph/test_merge_split.py index 45e67a483..2279b6c6a 100644 --- a/pychunkedgraph/tests/test_merge_split.py +++ b/pychunkedgraph/tests/graph/test_merge_split.py @@ -4,8 +4,8 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import types +from ..helpers import create_chunk, to_label +from ...graph import types class TestGraphMergeSplit: diff --git a/pychunkedgraph/tests/test_meta.py b/pychunkedgraph/tests/graph/test_meta.py similarity index 100% rename from pychunkedgraph/tests/test_meta.py rename to pychunkedgraph/tests/graph/test_meta.py diff --git a/pychunkedgraph/tests/test_mincut.py b/pychunkedgraph/tests/graph/test_mincut.py similarity index 96% rename from pychunkedgraph/tests/test_mincut.py rename to pychunkedgraph/tests/graph/test_mincut.py index 6208c444a..8ef6ba239 100644 --- a/pychunkedgraph/tests/test_mincut.py +++ b/pychunkedgraph/tests/graph/test_mincut.py @@ -4,9 +4,9 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import exceptions -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph import exceptions +from ...ingest.create.parent_layer import add_parent_chunk class TestGraphMinCut: @@ -121,7 +121,7 @@ def test_cut_no_link(self, gen_graph): n_threads=1, ) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() # Mincut @@ -139,7 +139,7 @@ def test_cut_no_link(self, gen_graph): mincut=True, ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() assert res_new.rows == res_old.rows @@ -188,7 +188,7 @@ def test_cut_old_link(self, gen_graph): mincut=False, ) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() # Mincut @@ -206,7 +206,7 @@ def test_cut_old_link(self, gen_graph): mincut=True, ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() assert res_new.rows == res_old.rows diff --git a/pychunkedgraph/tests/test_misc.py b/pychunkedgraph/tests/graph/test_misc.py similarity index 98% rename from pychunkedgraph/tests/test_misc.py rename to pychunkedgraph/tests/graph/test_misc.py index 0181934c2..e51502be8 100644 --- a/pychunkedgraph/tests/test_misc.py +++ b/pychunkedgraph/tests/graph/test_misc.py @@ -16,8 +16,8 @@ from pychunkedgraph.graph.edges import Edges from pychunkedgraph.graph.types import Agglomeration -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestGetLatestRoots: diff --git a/pychunkedgraph/tests/test_multicut.py b/pychunkedgraph/tests/graph/test_multicut.py similarity index 90% rename from pychunkedgraph/tests/test_multicut.py rename to pychunkedgraph/tests/graph/test_multicut.py index 078a74f9e..19507465e 100644 --- a/pychunkedgraph/tests/test_multicut.py +++ b/pychunkedgraph/tests/graph/test_multicut.py @@ -1,9 +1,9 @@ import numpy as np import pytest -from ..graph.edges import Edges -from ..graph import exceptions -from ..graph.cutting import run_multicut +from ...graph.edges import Edges +from ...graph import exceptions +from ...graph.cutting import run_multicut class TestGraphMultiCut: @@ -26,7 +26,11 @@ def test_cut_multi_tree(self, gen_graph): sink_ids = np.array([5, 6], dtype=np.uint64) cut_edges = run_multicut( - edges, source_ids, sink_ids, path_augment=False, disallow_isolating_cut=False + edges, + source_ids, + sink_ids, + path_augment=False, + disallow_isolating_cut=False, ) assert cut_edges.shape[0] > 0 diff --git a/pychunkedgraph/tests/test_node_conversion.py b/pychunkedgraph/tests/graph/test_node_conversion.py similarity index 78% rename from pychunkedgraph/tests/test_node_conversion.py rename to pychunkedgraph/tests/graph/test_node_conversion.py index 68ca2810f..9181a8146 100644 --- a/pychunkedgraph/tests/test_node_conversion.py +++ b/pychunkedgraph/tests/graph/test_node_conversion.py @@ -1,9 +1,8 @@ import numpy as np import pytest -from .helpers import to_label -from ..graph.utils.serializers import serialize_uint64 -from ..graph.utils.serializers import deserialize_uint64 +from ..helpers import to_label +from ...graph import serializers class TestGraphNodeConversion: @@ -53,13 +52,15 @@ def test_node_id_adjacency(self, gen_graph): def test_serialize_node_id(self, gen_graph): cg = gen_graph(n_layers=10) - assert serialize_uint64( + assert serializers.serialize_uint64( cg.get_node_id(np.uint64(0), layer=2, x=3, y=1, z=0) - ) < serialize_uint64(cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0)) + ) < serializers.serialize_uint64( + cg.get_node_id(np.uint64(1), layer=2, x=3, y=1, z=0) + ) - assert serialize_uint64( + assert serializers.serialize_uint64( cg.get_node_id(np.uint64(2**53 - 2), layer=10, x=0, y=0, z=0) - ) < serialize_uint64( + ) < serializers.serialize_uint64( cg.get_node_id(np.uint64(2**53 - 1), layer=10, x=0, y=0, z=0) ) @@ -67,8 +68,8 @@ def test_serialize_node_id(self, gen_graph): def test_deserialize_node_id(self, gen_graph): cg = gen_graph(n_layers=10) node_id = cg.get_node_id(np.uint64(4), layer=2, x=3, y=1, z=0) - serialized = serialize_uint64(node_id) - assert deserialize_uint64(serialized) == node_id + serialized = serializers.serialize_uint64(node_id) + assert serializers.deserialize_uint64(serialized) == node_id @pytest.mark.timeout(30) def test_serialization_roundtrip(self, gen_graph): @@ -77,9 +78,16 @@ def test_serialization_roundtrip(self, gen_graph): for layer in [2, 5, 10]: for seg_id in [0, 1, 42, 2**16]: node_id = cg.get_node_id(np.uint64(seg_id), layer=layer, x=0, y=0, z=0) - assert deserialize_uint64(serialize_uint64(node_id)) == node_id + assert ( + serializers.deserialize_uint64( + serializers.serialize_uint64(node_id) + ) + == node_id + ) @pytest.mark.timeout(30) def test_serialize_valid_label_id(self): label = np.uint64(0x01FF031234556789) - assert deserialize_uint64(serialize_uint64(label)) == label + assert ( + serializers.deserialize_uint64(serializers.serialize_uint64(label)) == label + ) diff --git a/pychunkedgraph/tests/test_operation.py b/pychunkedgraph/tests/graph/test_operation.py similarity index 99% rename from pychunkedgraph/tests/test_operation.py rename to pychunkedgraph/tests/graph/test_operation.py index 626efbf7e..db5878842 100644 --- a/pychunkedgraph/tests/test_operation.py +++ b/pychunkedgraph/tests/graph/test_operation.py @@ -11,9 +11,9 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import attributes -from ..graph.operation import ( +from ..helpers import create_chunk, to_label +from ...graph import attributes +from ...graph.operation import ( GraphEditOperation, MergeOperation, MulticutOperation, @@ -21,8 +21,8 @@ RedoOperation, UndoOperation, ) -from ..graph.exceptions import PreconditionError, PostconditionError -from ..ingest.create.parent_layer import add_parent_chunk +from ...graph.exceptions import PreconditionError, PostconditionError +from ...ingest.create.parent_layer import add_parent_chunk def _build_two_sv_disconnected(gen_graph): diff --git a/pychunkedgraph/tests/test_root_lock.py b/pychunkedgraph/tests/graph/test_root_lock.py similarity index 94% rename from pychunkedgraph/tests/test_root_lock.py rename to pychunkedgraph/tests/graph/test_root_lock.py index 1228c8ae9..ea0e5a21d 100644 --- a/pychunkedgraph/tests/test_root_lock.py +++ b/pychunkedgraph/tests/graph/test_root_lock.py @@ -8,10 +8,10 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import exceptions -from ..graph.locks import RootLock -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph import exceptions +from ...graph.locks import RootLock +from ...ingest.create.parent_layer import add_parent_chunk class TestRootLock: diff --git a/pychunkedgraph/tests/test_segmenthistory.py b/pychunkedgraph/tests/graph/test_segmenthistory.py similarity index 98% rename from pychunkedgraph/tests/test_segmenthistory.py rename to pychunkedgraph/tests/graph/test_segmenthistory.py index 0ccb2ab55..2d158b73f 100644 --- a/pychunkedgraph/tests/test_segmenthistory.py +++ b/pychunkedgraph/tests/graph/test_segmenthistory.py @@ -12,8 +12,10 @@ get_all_log_entries, ) -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from pychunkedgraph.graph import attributes + +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestSegmentHistory: @@ -245,7 +247,7 @@ class TestLogEntryUnit: """Pure unit tests for LogEntry class (no emulator needed).""" def test_merge_entry(self): - from pychunkedgraph.graph.attributes import OperationLogs + OperationLogs = attributes.OperationLogs row = { OperationLogs.AddedEdge: np.array([[1, 2]], dtype=np.uint64), @@ -272,7 +274,7 @@ def test_merge_entry(self): assert len(ef) > 0 def test_split_entry(self): - from pychunkedgraph.graph.attributes import OperationLogs + OperationLogs = attributes.OperationLogs row = { OperationLogs.RemovedEdge: np.array([[3, 4]], dtype=np.uint64), @@ -295,7 +297,7 @@ def test_split_entry(self): assert len(list(entry)) == 4 def test_added_edges_on_split_raises(self): - from pychunkedgraph.graph.attributes import OperationLogs + OperationLogs = attributes.OperationLogs row = { OperationLogs.RemovedEdge: np.array([[3, 4]], dtype=np.uint64), @@ -307,7 +309,7 @@ def test_added_edges_on_split_raises(self): entry.added_edges def test_removed_edges_on_merge_raises(self): - from pychunkedgraph.graph.attributes import OperationLogs + OperationLogs = attributes.OperationLogs row = { OperationLogs.AddedEdge: np.array([[1, 2]], dtype=np.uint64), diff --git a/pychunkedgraph/tests/test_split.py b/pychunkedgraph/tests/graph/test_split.py similarity index 96% rename from pychunkedgraph/tests/test_split.py rename to pychunkedgraph/tests/graph/test_split.py index 6b814268a..42dc7cee6 100644 --- a/pychunkedgraph/tests/test_split.py +++ b/pychunkedgraph/tests/graph/test_split.py @@ -5,11 +5,11 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph import ChunkedGraph -from ..graph import exceptions -from ..graph.misc import get_latest_roots -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph import ChunkedGraph +from ...graph import exceptions +from ...graph.misc import get_latest_roots +from ...ingest.create.parent_layer import add_parent_chunk class TestGraphSplit: @@ -327,7 +327,7 @@ def test_split_pair_already_disconnected(self, gen_graph): edges=[], timestamp=fake_timestamp, ) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() with pytest.raises(exceptions.PreconditionError): @@ -338,7 +338,7 @@ def test_split_pair_already_disconnected(self, gen_graph): mincut=False, ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() if res_old.rows != res_new.rows: @@ -471,7 +471,7 @@ def test_split_same_node(self, gen_graph): timestamp=fake_timestamp, ) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() with pytest.raises(exceptions.PreconditionError): cg.remove_edges( @@ -481,7 +481,7 @@ def test_split_same_node(self, gen_graph): mincut=False, ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() assert res_new.rows == res_old.rows @@ -495,14 +495,20 @@ def test_split_pair_abstract_nodes(self, gen_graph): cg: ChunkedGraph = gen_graph(n_layers=3) fake_timestamp = datetime.now(UTC) - timedelta(days=10) create_chunk( - cg, vertices=[to_label(cg, 1, 0, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + cg, + vertices=[to_label(cg, 1, 0, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, ) create_chunk( - cg, vertices=[to_label(cg, 1, 1, 0, 0, 0)], edges=[], timestamp=fake_timestamp, + cg, + vertices=[to_label(cg, 1, 1, 0, 0, 0)], + edges=[], + timestamp=fake_timestamp, ) add_parent_chunk(cg, 3, [0, 0, 0], time_stamp=fake_timestamp, n_threads=1) - res_old = cg.client._table.read_rows() + res_old = cg.client.read_all_rows() res_old.consume_all() with pytest.raises((exceptions.PreconditionError, AssertionError)): cg.remove_edges( @@ -512,7 +518,7 @@ def test_split_pair_abstract_nodes(self, gen_graph): mincut=False, ) - res_new = cg.client._table.read_rows() + res_new = cg.client.read_all_rows() res_new.consume_all() assert res_new.rows == res_old.rows @@ -625,9 +631,9 @@ def test_split_multi_layer_hierarchy_correctness(self, gen_graph): prev_layer = 1 for p in parents: layer = cg.get_chunk_layer(p) - assert layer > prev_layer, ( - f"Parent chain not monotonically increasing: {prev_layer} -> {layer}" - ) + assert ( + layer > prev_layer + ), f"Parent chain not monotonically increasing: {prev_layer} -> {layer}" prev_layer = layer # Last parent should be one of the new roots assert parents[-1] in result.new_root_ids diff --git a/pychunkedgraph/tests/test_stale_edges.py b/pychunkedgraph/tests/graph/test_stale_edges.py similarity index 98% rename from pychunkedgraph/tests/test_stale_edges.py rename to pychunkedgraph/tests/graph/test_stale_edges.py index bf160bdcc..35a6a3fa7 100644 --- a/pychunkedgraph/tests/test_stale_edges.py +++ b/pychunkedgraph/tests/graph/test_stale_edges.py @@ -9,9 +9,9 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..graph.edges.stale import get_stale_nodes, get_new_nodes -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...graph.edges.stale import get_stale_nodes, get_new_nodes +from ...ingest.create.parent_layer import add_parent_chunk class TestStaleEdges: diff --git a/pychunkedgraph/tests/test_subgraph.py b/pychunkedgraph/tests/graph/test_subgraph.py similarity index 97% rename from pychunkedgraph/tests/test_subgraph.py rename to pychunkedgraph/tests/graph/test_subgraph.py index e9ca7cd66..9ed062b35 100644 --- a/pychunkedgraph/tests/test_subgraph.py +++ b/pychunkedgraph/tests/graph/test_subgraph.py @@ -8,8 +8,8 @@ from pychunkedgraph.graph.subgraph import SubgraphProgress, get_subgraph_nodes -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestSubgraphProgress: diff --git a/pychunkedgraph/tests/test_types.py b/pychunkedgraph/tests/graph/test_types.py similarity index 95% rename from pychunkedgraph/tests/test_types.py rename to pychunkedgraph/tests/graph/test_types.py index ed6f5212b..ec7bb1851 100644 --- a/pychunkedgraph/tests/test_types.py +++ b/pychunkedgraph/tests/graph/test_types.py @@ -3,7 +3,7 @@ import numpy as np from pychunkedgraph.graph.types import empty_1d, empty_2d, Agglomeration -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes class TestEmptyArrays: diff --git a/pychunkedgraph/tests/test_undo_redo.py b/pychunkedgraph/tests/graph/test_undo_redo.py similarity index 97% rename from pychunkedgraph/tests/test_undo_redo.py rename to pychunkedgraph/tests/graph/test_undo_redo.py index a49f01fe0..63708d3fa 100644 --- a/pychunkedgraph/tests/test_undo_redo.py +++ b/pychunkedgraph/tests/graph/test_undo_redo.py @@ -9,8 +9,8 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestUndoRedo: diff --git a/pychunkedgraph/tests/test_utils_flatgraph.py b/pychunkedgraph/tests/graph/test_utils_flatgraph.py similarity index 100% rename from pychunkedgraph/tests/test_utils_flatgraph.py rename to pychunkedgraph/tests/graph/test_utils_flatgraph.py diff --git a/pychunkedgraph/tests/test_utils_generic.py b/pychunkedgraph/tests/graph/test_utils_generic.py similarity index 61% rename from pychunkedgraph/tests/test_utils_generic.py rename to pychunkedgraph/tests/graph/test_utils_generic.py index b6c51ea31..58444c838 100644 --- a/pychunkedgraph/tests/test_utils_generic.py +++ b/pychunkedgraph/tests/graph/test_utils_generic.py @@ -3,20 +3,14 @@ import datetime import numpy as np -import pytz import pytest from pychunkedgraph.graph.utils.generic import ( compute_indices_pandas, log_n, compute_bitmasks, - get_max_time, - get_min_time, - time_min, - get_valid_timestamp, get_bounding_box, filter_failed_node_ids, - _get_google_compatible_time_stamp, mask_nodes_by_bounding_box, get_parents_at_timestamp, ) @@ -54,62 +48,6 @@ def test_insufficient_bits_raises(self): compute_bitmasks(4, s_bits_atomic_layer=0) -class TestTimeFunctions: - def test_get_max_time(self): - t = get_max_time() - assert isinstance(t, datetime.datetime) - assert t.year == 9999 - - def test_get_min_time(self): - t = get_min_time() - assert isinstance(t, datetime.datetime) - assert t.year == 2000 - - def test_time_min(self): - assert time_min() == get_min_time() - - -class TestGetValidTimestamp: - def test_none_returns_utc_now(self): - before = datetime.datetime.now(datetime.timezone.utc) - result = get_valid_timestamp(None) - after = datetime.datetime.now(datetime.timezone.utc) - assert result.tzinfo is not None - # get_valid_timestamp rounds down to millisecond precision, - # so result may be slightly before `before` - tolerance = datetime.timedelta(milliseconds=1) - assert before - tolerance <= result <= after - - def test_naive_gets_localized(self): - naive = datetime.datetime(2023, 6, 15, 12, 0, 0) - result = get_valid_timestamp(naive) - assert result.tzinfo is not None - - def test_aware_passthrough(self): - aware = datetime.datetime(2023, 6, 15, 12, 0, 0, tzinfo=pytz.UTC) - result = get_valid_timestamp(aware) - assert result.tzinfo is not None - - -class TestGoogleCompatibleTimestamp: - def test_round_down(self): - ts = datetime.datetime(2023, 6, 15, 12, 0, 0, 1500) - result = _get_google_compatible_time_stamp(ts, round_up=False) - assert result.microsecond % 1000 == 0 - assert result.microsecond == 1000 - - def test_round_up(self): - ts = datetime.datetime(2023, 6, 15, 12, 0, 0, 1500) - result = _get_google_compatible_time_stamp(ts, round_up=True) - assert result.microsecond % 1000 == 0 - assert result.microsecond == 2000 - - def test_exact_no_change(self): - ts = datetime.datetime(2023, 6, 15, 12, 0, 0, 3000) - result = _get_google_compatible_time_stamp(ts) - assert result == ts - - class TestGetBoundingBox: def test_normal(self): source = np.array([[10, 20, 30]]) diff --git a/pychunkedgraph/tests/test_utils_id_helpers.py b/pychunkedgraph/tests/graph/test_utils_id_helpers.py similarity index 99% rename from pychunkedgraph/tests/test_utils_id_helpers.py rename to pychunkedgraph/tests/graph/test_utils_id_helpers.py index df8349962..f1b78c37e 100644 --- a/pychunkedgraph/tests/test_utils_id_helpers.py +++ b/pychunkedgraph/tests/graph/test_utils_id_helpers.py @@ -7,7 +7,7 @@ from pychunkedgraph.graph.utils import id_helpers from pychunkedgraph.graph.chunks import utils as chunk_utils -from .helpers import to_label +from ..helpers import to_label class TestGetSegmentIdLimit: diff --git a/pychunkedgraph/tests/hbase_mock_server.py b/pychunkedgraph/tests/hbase_mock_server.py new file mode 100644 index 000000000..0b9bb53b8 --- /dev/null +++ b/pychunkedgraph/tests/hbase_mock_server.py @@ -0,0 +1,473 @@ +"""In-process mock HBase REST (Stargate) server for testing. + +Implements the subset of the HBase REST API used by +``pychunkedgraph.graph.client.hbase.client.Client``. +Runs in a daemon thread on a random port using stdlib ``http.server``. +""" + +import base64 +import json +import struct +import threading +import time +import uuid +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +class ScannerState: + __slots__ = ("rows", "position", "batch_size") + + def __init__(self, rows, batch_size): + self.rows = rows # list of (row_key_bytes, cells_dict) + self.position = 0 + self.batch_size = batch_size + + +class HBaseMockData: + """Thread-safe shared state for the mock server.""" + + def __init__(self): + self.lock = threading.Lock() + # tables[table_name][row_key: bytes][col_spec: str] = [(value: bytes, ts_ms: int), ...] + self.tables: dict = {} + self.table_schemas: dict = {} + self.scanners: dict = {} + self._scanner_counter = 0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _b64enc(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def _b64dec(s: str) -> bytes: + return base64.b64decode(s) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _insert_cell(cell_list: list, value: bytes, ts_ms: int): + """Insert (value, ts_ms) keeping descending ts order.""" + entry = (value, ts_ms) + for i, (_, t) in enumerate(cell_list): + if ts_ms >= t: + cell_list.insert(i, entry) + return + cell_list.append(entry) + + +# --------------------------------------------------------------------------- +# Request handler +# --------------------------------------------------------------------------- + + +def _make_handler_class(data: HBaseMockData): + """Create a handler class bound to the given shared data.""" + + class Handler(BaseHTTPRequestHandler): + + def log_message(self, format, *args): + pass # silence per-request logging + + # -- routing helpers ------------------------------------------------ + + def _parse(self): + parsed = urlparse(self.path) + parts = [p for p in parsed.path.split("/") if p] + query = parse_qs(parsed.query) + # flatten single-valued params + qflat = {k: v[0] if len(v) == 1 else v for k, v in query.items()} + return parts, qflat + + def _read_body(self) -> bytes: + length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(length) if length else b"" + + def _json_body(self): + raw = self._read_body() + return json.loads(raw) if raw else {} + + def _send_json(self, obj, code=200): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_empty(self, code=200): + self.send_response(code) + self.send_header("Content-Length", "0") + self.end_headers() + + def _send_bytes( + self, raw: bytes, code=200, content_type="application/octet-stream" + ): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + # -- GET ------------------------------------------------------------ + + def do_GET(self): + parts, query = self._parse() + if len(parts) < 2: + return self._send_empty(404) + + table = parts[0] + + # GET /{table}/schema + if parts[1] == "schema": + with data.lock: + if table in data.table_schemas: + return self._send_json(data.table_schemas[table]) + return self._send_empty(404) + + # GET /{table}/scanner/{id} + if parts[1] == "scanner" and len(parts) >= 3: + scanner_id = parts[2] + with data.lock: + sc = data.scanners.get(scanner_id) + if sc is None: + return self._send_empty(404) + if sc.position >= len(sc.rows): + return self._send_empty(204) + end = sc.position + sc.batch_size + batch = sc.rows[sc.position : end] + sc.position = end + return self._send_json(self._rows_to_cellset(batch)) + + # GET /{table}/{key_b64}[/{col_spec}] + row_key = _b64dec(parts[1]) + col_specs = parts[2].split(",") if len(parts) >= 3 else None + max_versions = int(query.get("v", "1")) + ts_from = int(query["ts.from"]) if "ts.from" in query else None + ts_to = int(query["ts.to"]) if "ts.to" in query else None + + with data.lock: + tbl = data.tables.get(table) + if tbl is None or row_key not in tbl: + return self._send_empty(404) + row_data = tbl[row_key] + cells = self._filter_cells( + row_data, col_specs, ts_from, ts_to, max_versions + ) + if not cells: + return self._send_empty(404) + return self._send_json(self._rows_to_cellset([(row_key, cells)])) + + # -- PUT ------------------------------------------------------------ + + def do_PUT(self): + parts, query = self._parse() + if len(parts) < 2: + return self._send_empty(400) + + table = parts[0] + + # PUT /{table}/schema + if parts[1] == "schema": + body = self._json_body() + with data.lock: + data.table_schemas[table] = body + data.tables.setdefault(table, {}) + return self._send_empty(200) + + # PUT /{table}/scanner + if parts[1] == "scanner": + return self._handle_create_scanner(table) + + # check=put / check=delete + if "check" in query: + if query["check"] == "put": + return self._handle_check_and_put(table, parts, query) + if query["check"] == "delete": + return self._handle_check_and_delete(table, parts, query) + + # Batch write (PUT /{table}/{any_key}) + body = self._json_body() + with data.lock: + tbl = data.tables.setdefault(table, {}) + for row in body.get("Row", []): + rk = _b64dec(row["key"]) + row_dict = tbl.setdefault(rk, {}) + for cell in row.get("Cell", []): + col = _b64dec(cell["column"]).decode("utf-8") + val = _b64dec(cell["$"]) + ts = cell.get("timestamp", _now_ms()) + cell_list = row_dict.setdefault(col, []) + _insert_cell(cell_list, val, ts) + return self._send_empty(200) + + # -- POST (atomic increment) --------------------------------------- + + def do_POST(self): + parts, _ = self._parse() + if len(parts) < 3: + return self._send_empty(400) + table = parts[0] + row_key = _b64dec(parts[1]) + col_spec = parts[2] + + body = self._json_body() + # Extract increment value from CellSet body + inc_val = 0 + for row in body.get("Row", []): + for cell in row.get("Cell", []): + raw = _b64dec(cell["$"]) + inc_val = struct.unpack(">q", raw)[0] + break + break + + with data.lock: + tbl = data.tables.setdefault(table, {}) + row_dict = tbl.setdefault(row_key, {}) + cell_list = row_dict.get(col_spec, []) + current = 0 + if cell_list: + # latest value is first (newest) + try: + current = struct.unpack(">q", cell_list[0][0])[0] + except struct.error: + current = 0 + new_val = current + inc_val + new_bytes = struct.pack(">q", new_val) + ts = _now_ms() + new_list = [(new_bytes, ts)] + row_dict[col_spec] = new_list + + return self._send_bytes(new_bytes) + + # -- DELETE --------------------------------------------------------- + + def do_DELETE(self): + parts, _ = self._parse() + if len(parts) < 2: + return self._send_empty(400) + + table = parts[0] + + # DELETE /{table}/schema + if parts[1] == "schema": + with data.lock: + data.tables.pop(table, None) + data.table_schemas.pop(table, None) + return self._send_empty(200) + + # DELETE /{table}/scanner/{id} + if parts[1] == "scanner" and len(parts) >= 3: + with data.lock: + data.scanners.pop(parts[2], None) + return self._send_empty(200) + + row_key = _b64dec(parts[1]) + + with data.lock: + tbl = data.tables.get(table) + if tbl is None: + return self._send_empty(200) + + if len(parts) == 2: + # DELETE row + tbl.pop(row_key, None) + elif len(parts) == 3: + # DELETE column + col_spec = parts[2] + row_dict = tbl.get(row_key, {}) + row_dict.pop(col_spec, None) + if not row_dict: + tbl.pop(row_key, None) + elif len(parts) >= 4: + # DELETE cell version + col_spec = parts[2] + ts_ms = int(parts[3]) + row_dict = tbl.get(row_key, {}) + cell_list = row_dict.get(col_spec, []) + row_dict[col_spec] = [(v, t) for v, t in cell_list if t != ts_ms] + if not row_dict.get(col_spec): + row_dict.pop(col_spec, None) + if not row_dict: + tbl.pop(row_key, None) + + return self._send_empty(200) + + # -- Scanner -------------------------------------------------------- + + def _handle_create_scanner(self, table): + body = self._json_body() + start_row = _b64dec(body["startRow"]) if "startRow" in body else b"" + end_row = _b64dec(body["endRow"]) if "endRow" in body else None + batch_size = body.get("batch", 100) + col_filter = body.get("column") # list of col_spec strings or None + start_time = body.get("startTime") # ms, inclusive + end_time = body.get("endTime") # ms, exclusive + + with data.lock: + tbl = data.tables.get(table, {}) + filtered = [] + for rk in sorted(tbl.keys()): + if rk < start_row: + continue + if end_row is not None and rk >= end_row: + continue + cells = self._filter_cells( + tbl[rk], col_filter, start_time, end_time, max_versions=None + ) + if cells: + filtered.append((rk, cells)) + + data._scanner_counter += 1 + scanner_id = str(data._scanner_counter) + data.scanners[scanner_id] = ScannerState(filtered, batch_size) + + host, port = self.server.server_address + loc = f"http://{host}:{port}/{table}/scanner/{scanner_id}" + self.send_response(201) + self.send_header("Location", loc) + self.send_header("Content-Length", "0") + self.end_headers() + + # -- Check-and-put -------------------------------------------------- + + def _handle_check_and_put(self, table, parts, query): + body = self._json_body() + row_key = _b64dec(parts[1]) + check_col_spec = parts[2] # the column to check + + row_cells = body.get("Row", [{}])[0].get("Cell", []) + + if len(row_cells) >= 2: + # First cell is the check cell, second is the put cell + check_value = _b64dec(row_cells[0]["$"]) + put_cell = row_cells[1] + else: + # Single cell: check that column does NOT exist + check_value = None + put_cell = row_cells[0] if row_cells else None + + with data.lock: + tbl = data.tables.setdefault(table, {}) + row_dict = tbl.get(row_key, {}) + current_cells = row_dict.get(check_col_spec, []) + + if check_value is None: + # Column must not exist + if current_cells: + return self._send_empty(304) + else: + # Latest value must match + if not current_cells or current_cells[0][0] != check_value: + return self._send_empty(304) + + # Condition met - apply the put + if put_cell: + put_col = _b64dec(put_cell["column"]).decode("utf-8") + put_val = _b64dec(put_cell["$"]) + put_ts = put_cell.get("timestamp", _now_ms()) + row_dict = tbl.setdefault(row_key, {}) + cell_list = row_dict.setdefault(put_col, []) + _insert_cell(cell_list, put_val, put_ts) + + return self._send_empty(200) + + # -- Check-and-delete ----------------------------------------------- + + def _handle_check_and_delete(self, table, parts, query): + body = self._json_body() + row_key = _b64dec(parts[1]) + check_col_spec = parts[2] + + row_cells = body.get("Row", [{}])[0].get("Cell", []) + check_value = _b64dec(row_cells[0]["$"]) if row_cells else None + + with data.lock: + tbl = data.tables.get(table, {}) + row_dict = tbl.get(row_key, {}) + current_cells = row_dict.get(check_col_spec, []) + + if not current_cells or current_cells[0][0] != check_value: + return self._send_empty(304) + + # Match - delete the column + row_dict.pop(check_col_spec, None) + if not row_dict: + tbl.pop(row_key, None) + + return self._send_empty(200) + + # -- Utility -------------------------------------------------------- + + def _filter_cells(self, row_data, col_specs, ts_from, ts_to, max_versions): + """Filter a row's cells by column specs and time range.""" + result = {} + for col, cell_list in row_data.items(): + if col_specs and col not in col_specs: + continue + filtered = [] + for val, ts in cell_list: + if ts_from is not None and ts < ts_from: + continue + if ts_to is not None and ts >= ts_to: + continue + filtered.append((val, ts)) + if max_versions is not None: + filtered = filtered[:max_versions] + if filtered: + result[col] = filtered + return result + + def _rows_to_cellset(self, rows): + """Convert list of (row_key_bytes, cells_dict) to CellSet JSON.""" + out_rows = [] + for rk, cells in rows: + out_cells = [] + for col, cell_list in cells.items(): + for val, ts in cell_list: + out_cells.append( + { + "column": _b64enc(col.encode("utf-8")), + "$": _b64enc(val), + "timestamp": ts, + } + ) + out_rows.append( + { + "key": _b64enc(rk), + "Cell": out_cells, + } + ) + return {"Row": out_rows} + + return Handler + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def start_hbase_mock_server(host="127.0.0.1", port=0): + """Start mock HBase REST server in a daemon thread. + + Returns ``(data, server, port)`` where *port* is the actual bound port. + """ + mock_data = HBaseMockData() + handler_cls = _make_handler_class(mock_data) + server = ThreadingHTTPServer((host, port), handler_cls) + actual_port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return mock_data, server, actual_port diff --git a/pychunkedgraph/tests/helpers.py b/pychunkedgraph/tests/helpers.py index 335b44fd0..c41d629f6 100644 --- a/pychunkedgraph/tests/helpers.py +++ b/pychunkedgraph/tests/helpers.py @@ -4,7 +4,7 @@ from ..graph.edges import Edges from ..graph.edges import EDGE_TYPES -from ..graph.utils import basetypes +from ..graph import basetypes from ..ingest.create.atomic_layer import add_atomic_chunk diff --git a/pychunkedgraph/tests/ingest/__init__.py b/pychunkedgraph/tests/ingest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pychunkedgraph/tests/ingest/test_cluster.py b/pychunkedgraph/tests/ingest/test_cluster.py new file mode 100644 index 000000000..d804aecd0 --- /dev/null +++ b/pychunkedgraph/tests/ingest/test_cluster.py @@ -0,0 +1,125 @@ +"""Tests for pychunkedgraph.ingest.cluster""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from pychunkedgraph.ingest.cluster import _check_edges_direction, _post_task_completion +from pychunkedgraph.graph.edges import Edges, EDGE_TYPES +from pychunkedgraph.graph import basetypes + + +class TestCheckEdgesDirection: + def test_correct_direction_passes(self, gen_graph): + """Edges with node_ids1 inside the chunk should pass.""" + cg = gen_graph(n_layers=4) + coord = [0, 0, 0] + chunk_id = cg.get_chunk_id(layer=1, x=0, y=0, z=0) + node1 = cg.get_node_id(np.uint64(1), np.uint64(chunk_id)) + # node2 in a different chunk + other_chunk_id = cg.get_chunk_id(layer=1, x=1, y=0, z=0) + node2 = cg.get_node_id(np.uint64(1), np.uint64(other_chunk_id)) + + chunk_edges = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges( + np.array([node1], dtype=basetypes.NODE_ID), + np.array([node2], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.cross_chunk: Edges([], []), + } + _check_edges_direction(chunk_edges, cg, coord) # should not raise + + def test_wrong_direction_raises(self, gen_graph): + """Edges with node_ids1 outside the chunk should raise AssertionError.""" + cg = gen_graph(n_layers=4) + coord = [0, 0, 0] + chunk_id = cg.get_chunk_id(layer=1, x=0, y=0, z=0) + node_inside = cg.get_node_id(np.uint64(1), np.uint64(chunk_id)) + other_chunk_id = cg.get_chunk_id(layer=1, x=1, y=0, z=0) + node_outside = cg.get_node_id(np.uint64(1), np.uint64(other_chunk_id)) + + chunk_edges = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges( + np.array([node_outside], dtype=basetypes.NODE_ID), + np.array([node_inside], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.cross_chunk: Edges([], []), + } + with pytest.raises(AssertionError, match="all IDs must belong to same chunk"): + _check_edges_direction(chunk_edges, cg, coord) + + def test_empty_edges_passes(self, gen_graph): + """Empty between/cross chunk edges should not raise.""" + cg = gen_graph(n_layers=4) + coord = [0, 0, 0] + chunk_edges = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges([], []), + } + _check_edges_direction(chunk_edges, cg, coord) # should not raise + + def test_cross_chunk_direction(self, gen_graph): + """Cross-chunk edges also checked — node_ids1 must be inside the chunk.""" + cg = gen_graph(n_layers=4) + coord = [0, 0, 0] + chunk_id = cg.get_chunk_id(layer=1, x=0, y=0, z=0) + node1 = cg.get_node_id(np.uint64(1), np.uint64(chunk_id)) + other_chunk_id = cg.get_chunk_id(layer=1, x=1, y=0, z=0) + node2 = cg.get_node_id(np.uint64(1), np.uint64(other_chunk_id)) + + chunk_edges = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges([], []), + EDGE_TYPES.cross_chunk: Edges( + np.array([node1], dtype=basetypes.NODE_ID), + np.array([node2], dtype=basetypes.NODE_ID), + ), + } + _check_edges_direction(chunk_edges, cg, coord) # should not raise + + def test_multiple_edges_one_wrong(self, gen_graph): + """If any edge has wrong direction, assertion should fail.""" + cg = gen_graph(n_layers=4) + coord = [0, 0, 0] + chunk_id = cg.get_chunk_id(layer=1, x=0, y=0, z=0) + node_inside = cg.get_node_id(np.uint64(1), np.uint64(chunk_id)) + node_inside2 = cg.get_node_id(np.uint64(2), np.uint64(chunk_id)) + other_chunk_id = cg.get_chunk_id(layer=1, x=1, y=0, z=0) + node_outside = cg.get_node_id(np.uint64(1), np.uint64(other_chunk_id)) + + chunk_edges = { + EDGE_TYPES.in_chunk: Edges([], []), + EDGE_TYPES.between_chunk: Edges( + np.array([node_inside, node_outside], dtype=basetypes.NODE_ID), + np.array([node_outside, node_inside2], dtype=basetypes.NODE_ID), + ), + EDGE_TYPES.cross_chunk: Edges([], []), + } + with pytest.raises(AssertionError): + _check_edges_direction(chunk_edges, cg, coord) + + +class TestPostTaskCompletion: + def test_marks_chunk_complete(self): + """Should call redis sadd with correct key format.""" + imanager = MagicMock() + _post_task_completion(imanager, layer=2, coords=np.array([1, 2, 3])) + imanager.redis.sadd.assert_called_once_with("2c", "1_2_3") + + def test_with_split_index(self): + """When split is provided, appends split suffix to the key.""" + imanager = MagicMock() + _post_task_completion(imanager, layer=3, coords=np.array([0, 0, 0]), split=1) + imanager.redis.sadd.assert_called_once_with("3c", "0_0_0_1") + + def test_without_split(self): + """When split is None, no suffix appended.""" + imanager = MagicMock() + _post_task_completion(imanager, layer=5, coords=np.array([4, 5, 6])) + call_args = imanager.redis.sadd.call_args[0] + assert call_args[0] == "5c" + assert "_" not in call_args[1].split("_", 3)[-1] or call_args[1] == "4_5_6" diff --git a/pychunkedgraph/tests/test_ingest_atomic_layer.py b/pychunkedgraph/tests/ingest/test_ingest_atomic_layer.py similarity index 97% rename from pychunkedgraph/tests/test_ingest_atomic_layer.py rename to pychunkedgraph/tests/ingest/test_ingest_atomic_layer.py index c55318c8f..3868bef43 100644 --- a/pychunkedgraph/tests/test_ingest_atomic_layer.py +++ b/pychunkedgraph/tests/ingest/test_ingest_atomic_layer.py @@ -10,7 +10,7 @@ _get_remapping, ) from pychunkedgraph.graph.edges import Edges, EDGE_TYPES -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes class TestGetChunkNodesAndEdges: diff --git a/pychunkedgraph/tests/test_ingest_config.py b/pychunkedgraph/tests/ingest/test_ingest_config.py similarity index 100% rename from pychunkedgraph/tests/test_ingest_config.py rename to pychunkedgraph/tests/ingest/test_ingest_config.py diff --git a/pychunkedgraph/tests/test_ingest_cross_edges.py b/pychunkedgraph/tests/ingest/test_ingest_cross_edges.py similarity index 98% rename from pychunkedgraph/tests/test_ingest_cross_edges.py rename to pychunkedgraph/tests/ingest/test_ingest_cross_edges.py index 1084fb4a9..d65b767f4 100644 --- a/pychunkedgraph/tests/test_ingest_cross_edges.py +++ b/pychunkedgraph/tests/ingest/test_ingest_cross_edges.py @@ -12,10 +12,10 @@ get_chunk_nodes_cross_edge_layer, _get_chunk_nodes_cross_edge_layer_helper, ) -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestFindMinLayer: diff --git a/pychunkedgraph/tests/test_ingest_manager.py b/pychunkedgraph/tests/ingest/test_ingest_manager.py similarity index 100% rename from pychunkedgraph/tests/test_ingest_manager.py rename to pychunkedgraph/tests/ingest/test_ingest_manager.py diff --git a/pychunkedgraph/tests/test_ingest_parent_layer.py b/pychunkedgraph/tests/ingest/test_ingest_parent_layer.py similarity index 94% rename from pychunkedgraph/tests/test_ingest_parent_layer.py rename to pychunkedgraph/tests/ingest/test_ingest_parent_layer.py index 2e46a5e67..cdcd2ce5a 100644 --- a/pychunkedgraph/tests/test_ingest_parent_layer.py +++ b/pychunkedgraph/tests/ingest/test_ingest_parent_layer.py @@ -6,8 +6,8 @@ import numpy as np import pytest -from .helpers import create_chunk, to_label -from ..ingest.create.parent_layer import add_parent_chunk +from ..helpers import create_chunk, to_label +from ...ingest.create.parent_layer import add_parent_chunk class TestAddParentChunk: diff --git a/pychunkedgraph/tests/test_ingest_ran_agglomeration.py b/pychunkedgraph/tests/ingest/test_ingest_ran_agglomeration.py similarity index 99% rename from pychunkedgraph/tests/test_ingest_ran_agglomeration.py rename to pychunkedgraph/tests/ingest/test_ingest_ran_agglomeration.py index 9d02fd306..cc0683120 100644 --- a/pychunkedgraph/tests/test_ingest_ran_agglomeration.py +++ b/pychunkedgraph/tests/ingest/test_ingest_ran_agglomeration.py @@ -12,7 +12,7 @@ get_active_edges, ) from pychunkedgraph.graph.edges import Edges, EDGE_TYPES -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes class TestCrcCheck: diff --git a/pychunkedgraph/tests/test_ingest_utils.py b/pychunkedgraph/tests/ingest/test_ingest_utils.py similarity index 100% rename from pychunkedgraph/tests/test_ingest_utils.py rename to pychunkedgraph/tests/ingest/test_ingest_utils.py diff --git a/pychunkedgraph/tests/io/__init__.py b/pychunkedgraph/tests/io/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pychunkedgraph/tests/test_io_components.py b/pychunkedgraph/tests/io/test_io_components.py similarity index 97% rename from pychunkedgraph/tests/test_io_components.py rename to pychunkedgraph/tests/io/test_io_components.py index 63ac5abaa..f7f93b802 100644 --- a/pychunkedgraph/tests/test_io_components.py +++ b/pychunkedgraph/tests/io/test_io_components.py @@ -9,7 +9,7 @@ put_chunk_components, get_chunk_components, ) -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes class TestSerializeDeserialize: diff --git a/pychunkedgraph/tests/test_io_edges.py b/pychunkedgraph/tests/io/test_io_edges.py similarity index 98% rename from pychunkedgraph/tests/test_io_edges.py rename to pychunkedgraph/tests/io/test_io_edges.py index 2111bbc6b..ad3c057e7 100644 --- a/pychunkedgraph/tests/test_io_edges.py +++ b/pychunkedgraph/tests/io/test_io_edges.py @@ -11,7 +11,7 @@ _parse_edges, ) from pychunkedgraph.graph.edges import Edges, EDGE_TYPES -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes class TestSerializeDeserialize: diff --git a/pychunkedgraph/tests/meshing/__init__.py b/pychunkedgraph/tests/meshing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pychunkedgraph/tests/meshing/test_manifest_utils.py b/pychunkedgraph/tests/meshing/test_manifest_utils.py new file mode 100644 index 000000000..37c05a714 --- /dev/null +++ b/pychunkedgraph/tests/meshing/test_manifest_utils.py @@ -0,0 +1,40 @@ +"""Tests for pychunkedgraph.meshing.manifest.utils""" + +from pychunkedgraph.meshing.manifest.utils import _del_none_keys + + +class TestDelNoneKeys: + def test_removes_none_values(self): + d = {"a": 1, "b": None, "c": 3} + result, removed = _del_none_keys(d) + assert result == {"a": 1, "c": 3} + assert set(removed) == {"b"} + + def test_no_none_values(self): + d = {"a": 1, "b": 2} + result, removed = _del_none_keys(d) + assert result == {"a": 1, "b": 2} + assert removed == [] + + def test_all_none_values(self): + d = {"a": None, "b": None} + result, removed = _del_none_keys(d) + assert result == {} + assert set(removed) == {"a", "b"} + + def test_empty_dict(self): + result, removed = _del_none_keys({}) + assert result == {} + assert removed == [] + + def test_original_not_mutated(self): + d = {"a": 1, "b": None} + _del_none_keys(d) + assert d == {"a": 1, "b": None} + + def test_falsy_values_removed(self): + """The function uses `if v:` so falsy values like 0, [], '' are also removed.""" + d = {"a": 0, "b": [], "c": "", "d": "valid"} + result, removed = _del_none_keys(d) + assert result == {"d": "valid"} + assert set(removed) == {"a", "b", "c"} diff --git a/pychunkedgraph/tests/meshing/test_mesh_analysis.py b/pychunkedgraph/tests/meshing/test_mesh_analysis.py new file mode 100644 index 000000000..e457aba09 --- /dev/null +++ b/pychunkedgraph/tests/meshing/test_mesh_analysis.py @@ -0,0 +1,55 @@ +"""Tests for pychunkedgraph.meshing.mesh_analysis""" + +import numpy as np + +from pychunkedgraph.meshing.mesh_analysis import compute_centroid_by_range + + +class TestComputeCentroidByRange: + def test_single_point(self): + vertices = np.array([[5.0, 10.0, 15.0]]) + centroid = compute_centroid_by_range(vertices) + np.testing.assert_array_equal(centroid, [5.0, 10.0, 15.0]) + + def test_two_points(self): + vertices = np.array([[0.0, 0.0, 0.0], [10.0, 20.0, 30.0]]) + centroid = compute_centroid_by_range(vertices) + np.testing.assert_array_equal(centroid, [5.0, 10.0, 15.0]) + + def test_symmetric_cube(self): + """Centroid of a unit cube centered at origin.""" + vertices = np.array( + [ + [-1.0, -1.0, -1.0], + [1.0, 1.0, 1.0], + [-1.0, 1.0, -1.0], + [1.0, -1.0, 1.0], + ] + ) + centroid = compute_centroid_by_range(vertices) + np.testing.assert_array_equal(centroid, [0.0, 0.0, 0.0]) + + def test_asymmetric_distribution(self): + """Many points clustered but centroid is bbox midpoint, not mean.""" + vertices = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + [10.0, 10.0, 10.0], + ] + ) + centroid = compute_centroid_by_range(vertices) + # bbox: [0,0,0] to [10,10,10], midpoint = [5,5,5] + np.testing.assert_array_equal(centroid, [5.0, 5.0, 5.0]) + + def test_negative_coordinates(self): + vertices = np.array([[-10.0, -20.0, -30.0], [-2.0, -4.0, -6.0]]) + centroid = compute_centroid_by_range(vertices) + np.testing.assert_array_equal(centroid, [-6.0, -12.0, -18.0]) + + def test_mixed_coordinates(self): + vertices = np.array([[-5.0, 0.0, 10.0], [5.0, 20.0, 30.0]]) + centroid = compute_centroid_by_range(vertices) + np.testing.assert_array_equal(centroid, [0.0, 10.0, 20.0]) diff --git a/pychunkedgraph/tests/meshing/test_meshgen.py b/pychunkedgraph/tests/meshing/test_meshgen.py new file mode 100644 index 000000000..8481facf3 --- /dev/null +++ b/pychunkedgraph/tests/meshing/test_meshgen.py @@ -0,0 +1,164 @@ +"""Tests for pychunkedgraph.meshing.meshgen""" + +import numpy as np +import pytest + +from pychunkedgraph.meshing.meshgen import ( + black_out_dust_from_segmentation, + calculate_quantization_bits_and_range, + remap_seg_using_unsafe_dict, + transform_draco_vertices, +) + + +class TestBlackOutDust: + def test_removes_small_interior_segments(self): + """Small segments not on boundary should be zeroed out.""" + seg = np.zeros((10, 10, 10), dtype=np.uint64) + # Place a small segment (3 voxels) in the interior + seg[4, 4, 4] = 5 + seg[4, 4, 5] = 5 + seg[4, 5, 4] = 5 + black_out_dust_from_segmentation(seg, dust_threshold=5) + assert np.sum(seg == 5) == 0 + + def test_preserves_large_segments(self): + """Segments above threshold should be preserved.""" + seg = np.zeros((10, 10, 10), dtype=np.uint64) + seg[3:6, 3:6, 3:6] = 7 # 27 voxels + black_out_dust_from_segmentation(seg, dust_threshold=5) + assert np.sum(seg == 7) == 27 + + def test_preserves_boundary_segments(self): + """Small segments on the boundary should NOT be removed.""" + seg = np.zeros((10, 10, 10), dtype=np.uint64) + # Place segment on the -2 boundary face (second-to-last) + seg[8, 5, 5] = 3 # x=-2 face + black_out_dust_from_segmentation(seg, dust_threshold=5) + assert np.sum(seg == 3) == 1 + + def test_empty_segmentation(self): + """All-zero segmentation should not raise.""" + seg = np.zeros((5, 5, 5), dtype=np.uint64) + black_out_dust_from_segmentation(seg, dust_threshold=10) + assert np.sum(seg) == 0 + + def test_preserves_boundary_last_face(self): + """Segment on the last face should be preserved.""" + seg = np.zeros((10, 10, 10), dtype=np.uint64) + seg[9, 5, 5] = 2 # x=-1 face + black_out_dust_from_segmentation(seg, dust_threshold=5) + assert np.sum(seg == 2) == 1 + + +class TestCalculateQuantizationBitsAndRange: + def test_returns_three_values(self): + bits, qrange, bin_size = calculate_quantization_bits_and_range( + min_quantization_range=1000, max_draco_bin_size=2 + ) + assert isinstance(bits, (int, float, np.integer, np.floating)) + assert isinstance(qrange, (int, float, np.integer, np.floating)) + assert isinstance(bin_size, (int, float, np.integer, np.floating)) + + def test_range_covers_minimum(self): + """Quantization range must be >= min_quantization_range.""" + for min_range in [100, 500, 1000, 5000]: + bits, qrange, bin_size = calculate_quantization_bits_and_range( + min_quantization_range=min_range, max_draco_bin_size=2 + ) + assert qrange >= min_range + + def test_bin_size_within_max(self): + """Bin size must not exceed max_draco_bin_size.""" + bits, qrange, bin_size = calculate_quantization_bits_and_range( + min_quantization_range=1000, max_draco_bin_size=4 + ) + assert bin_size <= 4 + + def test_explicit_bits(self): + """When bits are provided, they should be used.""" + bits, qrange, bin_size = calculate_quantization_bits_and_range( + min_quantization_range=100, max_draco_bin_size=2, draco_quantization_bits=10 + ) + assert bits == 10 + + def test_small_range(self): + bits, qrange, bin_size = calculate_quantization_bits_and_range( + min_quantization_range=10, max_draco_bin_size=1 + ) + assert qrange >= 10 + assert bin_size >= 1 + + def test_consistency(self): + """num_bins * bin_size == quantization_range.""" + bits, qrange, bin_size = calculate_quantization_bits_and_range( + min_quantization_range=500, max_draco_bin_size=2 + ) + num_bins = 2**bits - 1 + assert num_bins * bin_size == qrange + + +class TestTransformDracoVertices: + def test_in_place_transform(self): + """Vertices should be quantized in place.""" + mesh = { + "num_vertices": 2, + "vertices": np.array([10.0, 20.0, 30.0, 40.0, 50.0, 60.0]), + } + settings = { + "quantization_bits": 10, + "quantization_range": 1023.0, + "quantization_origin": np.array([0.0, 0.0, 0.0]), + } + transform_draco_vertices(mesh, settings) + # Vertices should be modified (quantized) + assert mesh["vertices"] is not None + assert len(mesh["vertices"]) == 6 + + def test_origin_offset(self): + """Vertices at the origin should map back to origin.""" + mesh = { + "num_vertices": 1, + "vertices": np.array([100.0, 200.0, 300.0]), + } + settings = { + "quantization_bits": 16, + "quantization_range": 65535.0, + "quantization_origin": np.array([100.0, 200.0, 300.0]), + } + transform_draco_vertices(mesh, settings) + # After subtracting origin, dividing by bin_size=1, floor, multiply back, add origin + np.testing.assert_array_equal(mesh["vertices"], np.array([100.0, 200.0, 300.0])) + + +class TestRemapSegUsingUnsafeDict: + def test_no_unsafe_ids(self): + """Empty unsafe_dict should leave seg unchanged.""" + seg = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], dtype=np.uint64) + original = seg.copy() + result = remap_seg_using_unsafe_dict(seg, {}) + np.testing.assert_array_equal(result, original) + + def test_unsafe_id_not_in_seg(self): + """Unsafe ID not present in seg should be a no-op.""" + seg = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], dtype=np.uint64) + original = seg.copy() + result = remap_seg_using_unsafe_dict(seg, {99: [10, 20]}) + np.testing.assert_array_equal(result, original) + + def test_single_component_with_overlap(self): + """Single connected component that overlaps with a linked l2 id.""" + seg = np.zeros((4, 4, 4), dtype=np.uint64) + seg[2, 2, 2] = 100 # unsafe root id in interior + seg[2, 2, 3] = 42 # linked l2 id on the -2 boundary + result = remap_seg_using_unsafe_dict(seg, {100: [42]}) + # The unsafe voxel should be remapped to linked id or zeroed + # Since seg[-2,:,:] at position (2,2,3) overlaps with 42, CC should link + assert result[2, 2, 2] in (0, 42) + + def test_zeroes_when_no_linked_ids(self): + """Unsafe component with no linked l2 neighbors gets zeroed.""" + seg = np.zeros((4, 4, 4), dtype=np.uint64) + seg[1, 1, 1] = 100 # interior, no neighbors on boundary + result = remap_seg_using_unsafe_dict(seg, {100: [42]}) + assert result[1, 1, 1] == 0 diff --git a/pychunkedgraph/tests/meshing/test_meshgen_utils.py b/pychunkedgraph/tests/meshing/test_meshgen_utils.py new file mode 100644 index 000000000..e86934fb2 --- /dev/null +++ b/pychunkedgraph/tests/meshing/test_meshgen_utils.py @@ -0,0 +1,62 @@ +"""Tests for pychunkedgraph.meshing.meshgen_utils""" + +import numpy as np +import pytest + +from pychunkedgraph.meshing.meshgen_utils import str_to_slice, slice_to_str + + +class TestStrToSlice: + def test_basic_conversion(self): + result = str_to_slice("0-10_0-20_0-30") + assert result == (slice(0, 10), slice(0, 20), slice(0, 30)) + + def test_nonzero_starts(self): + result = str_to_slice("5-15_10-25_100-200") + assert result == (slice(5, 15), slice(10, 25), slice(100, 200)) + + def test_single_voxel_slices(self): + result = str_to_slice("0-1_0-1_0-1") + assert result == (slice(0, 1), slice(0, 1), slice(0, 1)) + + def test_large_values(self): + result = str_to_slice("1024-2048_512-1024_256-512") + assert result == (slice(1024, 2048), slice(512, 1024), slice(256, 512)) + + +class TestSliceToStr: + def test_basic_conversion(self): + slices = (slice(0, 10), slice(0, 20), slice(0, 30)) + assert slice_to_str(slices) == "0-10_0-20_0-30" + + def test_nonzero_starts(self): + slices = (slice(5, 15), slice(10, 25), slice(100, 200)) + assert slice_to_str(slices) == "5-15_10-25_100-200" + + def test_single_slice(self): + assert slice_to_str(slice(3, 7)) == "3-7" + + def test_large_values(self): + slices = (slice(1024, 2048), slice(512, 1024), slice(256, 512)) + assert slice_to_str(slices) == "1024-2048_512-1024_256-512" + + +class TestRoundTrip: + def test_str_to_slice_to_str(self): + original = "0-10_20-30_40-50" + assert slice_to_str(str_to_slice(original)) == original + + def test_slice_to_str_to_slice(self): + original = (slice(5, 15), slice(10, 25), slice(100, 200)) + assert str_to_slice(slice_to_str(original)) == original + + @pytest.mark.parametrize( + "s", + [ + "0-1_0-1_0-1", + "128-256_64-128_32-64", + "0-512_0-512_0-512", + ], + ) + def test_roundtrip_parametrized(self, s): + assert slice_to_str(str_to_slice(s)) == s diff --git a/pychunkedgraph/tests/test_attributes.py b/pychunkedgraph/tests/test_attributes.py deleted file mode 100644 index e630353d7..000000000 --- a/pychunkedgraph/tests/test_attributes.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Tests for pychunkedgraph.graph.attributes""" - -import numpy as np -import pytest - -from pychunkedgraph.graph.attributes import ( - _Attribute, - _AttributeArray, - Concurrency, - Connectivity, - Hierarchy, - GraphMeta, - GraphVersion, - OperationLogs, - from_key, -) -from pychunkedgraph.graph.utils import basetypes - - -class TestAttribute: - def test_serialize_deserialize_numpy(self): - attr = Hierarchy.Child - arr = np.array([1, 2, 3], dtype=basetypes.NODE_ID) - data = attr.serialize(arr) - result = attr.deserialize(data) - np.testing.assert_array_equal(result, arr) - - def test_serialize_deserialize_string(self): - attr = OperationLogs.UserID - data = attr.serialize("test_user") - assert attr.deserialize(data) == "test_user" - - def test_basetype(self): - assert Hierarchy.Child.basetype == basetypes.NODE_ID.type - assert OperationLogs.UserID.basetype == str - - def test_index(self): - attr = Connectivity.CrossChunkEdge[5] - assert attr.index == 5 - - def test_family_id(self): - assert Hierarchy.Child.family_id == "0" - assert Concurrency.Counter.family_id == "1" - assert OperationLogs.UserID.family_id == "2" - - -class TestAttributeArray: - def test_getitem(self): - attr = Connectivity.AtomicCrossChunkEdge[3] - assert isinstance(attr, _Attribute) - assert attr.key == b"atomic_cross_edges_3" - - def test_pattern(self): - assert Connectivity.CrossChunkEdge.pattern == b"cross_edges_%d" - - def test_serialize_deserialize(self): - arr = np.array([[1, 2], [3, 4]], dtype=basetypes.NODE_ID) - data = Connectivity.CrossChunkEdge.serialize(arr) - result = Connectivity.CrossChunkEdge.deserialize(data) - np.testing.assert_array_equal(result, arr) - - -class TestFromKey: - def test_valid_key(self): - result = from_key("0", b"children") - assert result is Hierarchy.Child - - def test_invalid_key_raises(self): - with pytest.raises(KeyError, match="Unknown key"): - from_key("99", b"nonexistent") - - -class TestOperationLogs: - def test_all_returns_list(self): - result = OperationLogs.all() - assert isinstance(result, list) - assert len(result) == 16 - assert OperationLogs.OperationID in result - assert OperationLogs.UserID in result - assert OperationLogs.RootID in result - assert OperationLogs.AddedEdge in result - - def test_status_codes(self): - assert OperationLogs.StatusCodes.SUCCESS.value == 0 - assert OperationLogs.StatusCodes.CREATED.value == 1 - assert OperationLogs.StatusCodes.EXCEPTION.value == 2 - assert OperationLogs.StatusCodes.WRITE_STARTED.value == 3 - assert OperationLogs.StatusCodes.WRITE_FAILED.value == 4 diff --git a/pychunkedgraph/tests/test_serializers.py b/pychunkedgraph/tests/test_serializers.py deleted file mode 100644 index 59f1ed8c3..000000000 --- a/pychunkedgraph/tests/test_serializers.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Tests for pychunkedgraph.graph.utils.serializers""" - -import numpy as np - -from pychunkedgraph.graph.utils.serializers import ( - _Serializer, - NumPyArray, - NumPyValue, - String, - JSON, - Pickle, - UInt64String, - pad_node_id, - serialize_uint64, - deserialize_uint64, - serialize_uint64s_to_regex, - serialize_key, - deserialize_key, -) -from pychunkedgraph.graph.utils import basetypes - - -class TestNumPyArray: - def test_roundtrip(self): - s = NumPyArray(dtype=basetypes.NODE_ID) - arr = np.array([1, 2, 3], dtype=basetypes.NODE_ID) - data = s.serialize(arr) - result = s.deserialize(data) - np.testing.assert_array_equal(result, arr) - - def test_with_shape(self): - s = NumPyArray(dtype=basetypes.NODE_ID, shape=(-1, 2)) - arr = np.array([[1, 2], [3, 4]], dtype=basetypes.NODE_ID) - data = s.serialize(arr) - result = s.deserialize(data) - assert result.shape == (2, 2) - np.testing.assert_array_equal(result, arr) - - def test_with_compression(self): - s = NumPyArray(dtype=basetypes.NODE_ID, compression_level=3) - arr = np.array([1, 2, 3, 4, 5], dtype=basetypes.NODE_ID) - data = s.serialize(arr) - result = s.deserialize(data) - np.testing.assert_array_equal(result, arr) - - def test_basetype(self): - s = NumPyArray(dtype=basetypes.NODE_ID) - assert s.basetype == basetypes.NODE_ID.type - - -class TestNumPyValue: - def test_roundtrip(self): - s = NumPyValue(dtype=basetypes.NODE_ID) - val = np.uint64(42) - data = s.serialize(val) - result = s.deserialize(data) - assert result == val - - -class TestString: - def test_roundtrip(self): - s = String() - data = s.serialize("hello") - assert s.deserialize(data) == "hello" - - -class TestJSON: - def test_roundtrip(self): - s = JSON() - obj = {"key": "value", "nested": [1, 2, 3]} - data = s.serialize(obj) - assert s.deserialize(data) == obj - - -class TestPickle: - def test_roundtrip(self): - s = Pickle() - obj = {"complex": [1, 2], "nested": {"a": True}} - data = s.serialize(obj) - assert s.deserialize(data) == obj - - -class TestUInt64String: - def test_roundtrip(self): - s = UInt64String() - val = np.uint64(12345) - data = s.serialize(val) - result = s.deserialize(data) - assert result == val - - -class TestPadNodeId: - def test_padding(self): - result = pad_node_id(np.uint64(42)) - assert len(result) == 20 - assert result == "00000000000000000042" - - def test_large_id(self): - result = pad_node_id(np.uint64(12345678901234567890)) - assert len(result) == 20 - - -class TestSerializeUint64: - def test_default(self): - result = serialize_uint64(np.uint64(42)) - assert isinstance(result, bytes) - assert b"00000000000000000042" in result - - def test_counter(self): - result = serialize_uint64(np.uint64(42), counter=True) - assert result.startswith(b"i") - - def test_fake_edges(self): - result = serialize_uint64(np.uint64(42), fake_edges=True) - assert result.startswith(b"f") - - -class TestDeserializeUint64: - def test_default(self): - serialized = serialize_uint64(np.uint64(42)) - result = deserialize_uint64(serialized) - assert result == np.uint64(42) - - def test_fake_edges(self): - serialized = serialize_uint64(np.uint64(42), fake_edges=True) - result = deserialize_uint64(serialized, fake_edges=True) - assert result == np.uint64(42) - - -class TestSerializeUint64sToRegex: - def test_multiple_ids(self): - ids = [np.uint64(1), np.uint64(2)] - result = serialize_uint64s_to_regex(ids) - assert isinstance(result, bytes) - assert b"|" in result - - -class TestSerializeKey: - def test_roundtrip(self): - key = "test_key_123" - serialized = serialize_key(key) - assert isinstance(serialized, bytes) - assert deserialize_key(serialized) == key diff --git a/requirements.in b/requirements.in index 0ae856c87..00da81c18 100644 --- a/requirements.in +++ b/requirements.in @@ -1,11 +1,9 @@ click>=8.0 protobuf>=4.22.0 requests>=2.25.0 -grpcio>=1.36.1 numpy pandas networkx>=2.1 -google-cloud-bigtable>=2.0.0 google-cloud-datastore>=1.8 flask flask_cors @@ -28,6 +26,7 @@ task-queue>=2.14.0 messagingclient dracopy>=1.5.0 datastoreflex>=0.5.0 +kvdbclient>=0.2.0 zstandard>=0.23.0 # Conda only - use requirements.yml (or install manually): diff --git a/requirements.txt b/requirements.txt index 5005893d7..6b61e845b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ google-auth==2.48.0 # google-cloud-storage # task-queue google-cloud-bigtable==2.35.0 - # via -r requirements.in + # via kvdbclient google-cloud-core==2.5.0 # via # cloud-files @@ -190,6 +190,8 @@ jsonschema==4.26.0 # python-jsonschema-objects jsonschema-specifications==2025.9.1 # via jsonschema +kvdbclient==0.2.0 + # via -r requirements.in markdown==3.10.2 # via python-jsonschema-objects markupsafe==3.0.3 @@ -221,6 +223,7 @@ numpy==2.4.2 # cloud-volume # compressed-segmentation # fastremap + # kvdbclient # messagingclient # microviewer # ml-dtypes @@ -315,7 +318,9 @@ python-json-logger==4.0.0 python-jsonschema-objects==0.5.7 # via cloud-volume pytz==2025.2 - # via croniter + # via + # croniter + # kvdbclient pyyaml==6.0.3 # via -r requirements.in redis==7.2.0 @@ -333,6 +338,7 @@ requests==2.32.5 # cloud-volume # google-api-core # google-cloud-storage + # kvdbclient # middle-auth-client # task-queue rpds-py==0.30.0 @@ -363,6 +369,7 @@ tenacity==9.1.4 # via # cloud-files # cloud-volume + # kvdbclient # task-queue tensorstore==0.1.81 # via -r requirements.in @@ -401,6 +408,7 @@ zstandard==0.25.0 # via # -r requirements.in # cloud-files + # kvdbclient # The following packages are considered to be unsafe in a requirements file: # setuptools From 803acd624c6c44f99b2d7c0893b769640a4ad26d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 4 Mar 2026 18:15:02 +0000 Subject: [PATCH 164/367] upgrade kvdbclient --- requirements.in | 2 +- requirements.txt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/requirements.in b/requirements.in index 00da81c18..3343123b1 100644 --- a/requirements.in +++ b/requirements.in @@ -26,7 +26,7 @@ task-queue>=2.14.0 messagingclient dracopy>=1.5.0 datastoreflex>=0.5.0 -kvdbclient>=0.2.0 +kvdbclient>=0.4.0 zstandard>=0.23.0 # Conda only - use requirements.yml (or install manually): diff --git a/requirements.txt b/requirements.txt index 6b61e845b..72b58e8be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -153,7 +153,6 @@ grpc-google-iam-v1==0.14.3 # google-cloud-pubsub grpcio==1.78.0 # via - # -r requirements.in # google-api-core # google-cloud-datastore # google-cloud-pubsub @@ -190,7 +189,7 @@ jsonschema==4.26.0 # python-jsonschema-objects jsonschema-specifications==2025.9.1 # via jsonschema -kvdbclient==0.2.0 +kvdbclient==0.4.0 # via -r requirements.in markdown==3.10.2 # via python-jsonschema-objects From e758d6899aff0f43a1e3478c6ecb435970a65354 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 18 Mar 2026 19:22:43 +0000 Subject: [PATCH 165/367] remove multiwrapper from ingest module --- pychunkedgraph/ingest/create/cross_edges.py | 16 ++++------------ pychunkedgraph/ingest/create/parent_layer.py | 16 ++++------------ 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/pychunkedgraph/ingest/create/cross_edges.py b/pychunkedgraph/ingest/create/cross_edges.py index e8ddfe894..f6a6b34b0 100644 --- a/pychunkedgraph/ingest/create/cross_edges.py +++ b/pychunkedgraph/ingest/create/cross_edges.py @@ -7,8 +7,6 @@ from typing import Dict import numpy as np -from multiwrapper.multiprocessing_utils import multiprocess_func - from ...graph import attributes, basetypes from ...graph.types import empty_2d from ...graph.chunkedgraph import ChunkedGraph @@ -44,11 +42,8 @@ def get_children_chunk_cross_edges( (edge_ids_shared, cg.get_serialized_info(), atomic_chunks, layer - 1) ) - multiprocess_func( - _get_children_chunk_cross_edges_helper, - multi_args, - n_threads=min(len(multi_args), mp.cpu_count()), - ) + with mp.Pool(processes=min(len(multi_args), mp.cpu_count())) as pool: + pool.map(_get_children_chunk_cross_edges_helper, multi_args) cross_edges = np.concatenate(edge_ids_shared) if cross_edges.size: @@ -137,11 +132,8 @@ def get_chunk_nodes_cross_edge_layer( (node_ids_shared, node_layers_shared, cg_info, atomic_chunks, layer) ) - multiprocess_func( - _get_chunk_nodes_cross_edge_layer_helper, - multi_args, - n_threads=min(len(multi_args), mp.cpu_count()), - ) + with mp.Pool(processes=min(len(multi_args), mp.cpu_count())) as pool: + pool.map(_get_chunk_nodes_cross_edge_layer_helper, multi_args) node_layer_d_shared = manager.dict() _find_min_layer(node_layer_d_shared, node_ids_shared, node_layers_shared) diff --git a/pychunkedgraph/ingest/create/parent_layer.py b/pychunkedgraph/ingest/create/parent_layer.py index 11134b1d0..a12d2b858 100644 --- a/pychunkedgraph/ingest/create/parent_layer.py +++ b/pychunkedgraph/ingest/create/parent_layer.py @@ -12,8 +12,6 @@ import fastremap import numpy as np -from multiwrapper import multiprocessing_utils as mu - from ...graph import types, attributes, basetypes, serializers, get_valid_timestamp from ...utils.general import chunked from ...graph.utils import flatgraph @@ -83,11 +81,8 @@ def _read_children_chunks( child_coord, ) ) - mu.multiprocess_func( - _read_chunk_helper, - multi_args, - n_threads=min(len(multi_args), mp.cpu_count()), - ) + with mp.Pool(processes=min(len(multi_args), mp.cpu_count())) as pool: + pool.map(_read_chunk_helper, multi_args) return np.concatenate(children_ids_shared).astype(basetypes.NODE_ID) @@ -137,11 +132,8 @@ def _write_connected_components( for ccs in chunked_ccs: args = (cg_info, layer, pcoords, ccs, node_layer_d, time_stamp) multi_args.append(args) - mu.multiprocess_func( - _write_components_helper, - multi_args, - n_threads=min(len(multi_args), mp.cpu_count()), - ) + with mp.Pool(processes=min(len(multi_args), mp.cpu_count())) as pool: + pool.map(_write_components_helper, multi_args) def _write_components_helper(args): From 90e8258279f2c39289aad4b4a2a2680dba8f442a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 10 Sep 2025 21:35:10 +0000 Subject: [PATCH 166/367] feat(sv_split): track max sv id to create new ids; convert ws seg to ocdbt --- pychunkedgraph/graph/ocdbt.py | 63 ++++++++++++++++++++ pychunkedgraph/ingest/cli.py | 2 + pychunkedgraph/ingest/cluster.py | 4 ++ pychunkedgraph/ingest/create/atomic_layer.py | 5 +- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 pychunkedgraph/graph/ocdbt.py diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py new file mode 100644 index 000000000..03c6d9b65 --- /dev/null +++ b/pychunkedgraph/graph/ocdbt.py @@ -0,0 +1,63 @@ +import os +import numpy as np +import tensorstore as ts + +OCDBT_SEG_COMPRESSION_LEVEL = 17 + + +def get_seg_source_and_destination_ocdbt(ws_path: str, create: bool = False) -> tuple: + src_spec = { + "driver": "neuroglancer_precomputed", + "kvstore": ws_path, + } + src = ts.open(src_spec).result() + schema = src.schema + + ocdbt_path = os.path.join(ws_path, "ocdbt", "base") + dst_spec = { + "driver": "neuroglancer_precomputed", + "kvstore": { + "driver": "ocdbt", + "base": ocdbt_path, + "config": { + "compression": {"id": "zstd", "level": OCDBT_SEG_COMPRESSION_LEVEL}, + }, + }, + } + + dst = ts.open( + dst_spec, + create=create, + rank=schema.rank, + dtype=schema.dtype, + codec=schema.codec, + domain=schema.domain, + shape=schema.shape, + chunk_layout=schema.chunk_layout, + dimension_units=schema.dimension_units, + delete_existing=create, + ).result() + return (src, dst) + + +def copy_ws_chunk( + source, + destination, + chunk_size: tuple, + coords: list, + voxel_bounds: np.ndarray, +): + coords = np.array(coords, dtype=int) + chunk_size = np.array(chunk_size, dtype=int) + vx_start = coords * chunk_size + voxel_bounds[:, 0] + vx_end = vx_start + chunk_size + xE, yE, zE = voxel_bounds[:, 1] + + x0, y0, z0 = vx_start + x1, y1, z1 = vx_end + x1 = min(x1, xE) + y1 = min(y1, yE) + z1 = min(z1, zE) + + data = source[x0:x1, y0:y1, z0:z1].read().result() + destination[x0:x1, y0:y1, z0:z1].write(data).result() diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index c50525ec6..8d44bf276 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -23,6 +23,7 @@ from .simple_tests import run_all from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph +from ..graph.ocdbt import get_seg_source_and_destination_ocdbt from ..utils.redis import get_redis_connection, keys as r_keys group_name = "ingest" @@ -71,6 +72,7 @@ def ingest_graph( imanager = IngestionManager(ingest_config, meta) enqueue_l2_tasks(imanager, create_atomic_chunk) + get_seg_source_and_destination_ocdbt(cg.meta, create=True) @ingest_cli.command("imanager") diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 360b5a15d..473a61b22 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -26,6 +26,7 @@ from .upgrade.parent_layer import update_chunk as update_parent_chunk from ..graph.edges import EDGE_TYPES, Edges, put_edges from ..graph import ChunkedGraph, ChunkedGraphMeta +from ..graph.ocdbt import copy_ws_chunk, get_seg_source_and_destination_ocdbt from ..graph.chunks.hierarchy import get_children_chunk_coords from ..graph.basetypes import NODE_ID from ..io.edges import get_chunk_edges @@ -141,6 +142,9 @@ def create_atomic_chunk(coords: Sequence[int]): logging.debug(f"{k}: {len(v)}") for k, v in chunk_edges_active.items(): logging.debug(f"active_{k}: {len(v)}") + + src, dst = get_seg_source_and_destination_ocdbt(imanager.cg.meta) + copy_ws_chunk(imanager.cg, coords, src, dst) _post_task_completion(imanager, 2, coords) diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index b226004f2..30043710d 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -32,7 +32,10 @@ def add_atomic_chunk( return chunk_ids = cg.get_chunk_ids_from_node_ids(chunk_node_ids) - assert len(np.unique(chunk_ids)) == 1 + assert len(np.unique(chunk_ids)) == 1, np.unique(chunk_ids) + + max_node_id = np.max(chunk_node_ids) + cg.id_client.set_max_node_id(chunk_ids[0], max_node_id) graph, _, _, unique_ids = build_gt_graph(chunk_edge_ids, make_directed=True) ccs = connected_components(graph) From a2a51f948d729d4f1d92c270d6525dfb28b49acb Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 10 Sep 2025 21:36:20 +0000 Subject: [PATCH 167/367] feat(sv_split): metadata changes to support ocdbt seg --- pychunkedgraph/graph/meta.py | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index 83d670ffe..6a938f802 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -2,17 +2,16 @@ from datetime import timedelta from typing import Dict from typing import List -from typing import Tuple from typing import Sequence from collections import namedtuple import numpy as np from cloudvolume import CloudVolume +from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt + from .utils.generic import compute_bitmasks from .chunks.utils import get_chunks_boundary -from ..utils.redis import keys as r_keys -from ..utils.redis import get_rq_queue from ..utils.redis import get_redis_connection @@ -64,9 +63,11 @@ def __init__( self._custom_data = custom_data self._ws_cv = None + self._ws_ocdbt = None self._layer_bounds_d = None self._layer_count = None self._bitmasks = None + self._ocdbt_seg = None @property def graph_config(self): @@ -91,15 +92,33 @@ def ws_cv(self): # useful to avoid md5 errors on high gcs load redis = get_redis_connection() cached_info = json.loads(redis.get(cache_key)) - self._ws_cv = CloudVolume(self._data_source.WATERSHED, info=cached_info) + self._ws_cv = CloudVolume( + self._data_source.WATERSHED, info=cached_info, progress=False + ) except Exception: - self._ws_cv = CloudVolume(self._data_source.WATERSHED) + self._ws_cv = CloudVolume(self._data_source.WATERSHED, progress=False) try: redis.set(cache_key, json.dumps(self._ws_cv.info)) except Exception: ... return self._ws_cv + @property + def ocdbt_seg(self) -> bool: + if self._ocdbt_seg is None: + self._ocdbt_seg = self._custom_data.get("seg", {}).get("ocdbt", False) + return self._ocdbt_seg + + @property + def ws_ocdbt(self): + assert self.ocdbt_seg, "make sure this pcg has segmentation in ocdbt format" + if self._ws_ocdbt: + return self._ws_ocdbt + + _, _ocdbt_seg = get_seg_source_and_destination_ocdbt(self.data_source.WATERSHED) + self._ws_ocdbt = _ocdbt_seg + return self._ws_ocdbt + @property def resolution(self): return self.ws_cv.resolution # pylint: disable=no-member @@ -235,11 +254,14 @@ def split_bounding_offset(self): @property def dataset_info(self) -> Dict: info = self.ws_cv.info # pylint: disable=no-member - info.update( { "chunks_start_at_voxel_offset": True, - "data_dir": self.data_source.WATERSHED, + "data_dir": ( + self.ws_ocdbt.kvstore.base.url + if self.ocdbt_seg + else self.data_source.WATERSHED + ), "graph": { "chunk_size": self.graph_config.CHUNK_SIZE, "bounding_box": [2048, 2048, 512], From ea78867f634960553c2c7a02d3721db43b835fc6 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 10 Sep 2025 21:39:58 +0000 Subject: [PATCH 168/367] feat(sv_split): split sv, update seg and edges, read and write new edges from pcg --- pychunkedgraph/graph/chunkedgraph.py | 42 +- pychunkedgraph/graph/chunks/utils.py | 54 +- pychunkedgraph/graph/cutting_sv.py | 1284 ++++++++++++++++++++++ pychunkedgraph/graph/edits_sv.py | 439 ++++++++ pychunkedgraph/graph/types.py | 3 +- pychunkedgraph/graph/utils/__init__.py | 1 + pychunkedgraph/graph/utils/generic.py | 12 + pychunkedgraph/graph/utils/id_helpers.py | 6 +- pychunkedgraph/meshing/meshgen_utils.py | 18 +- requirements.in | 3 + 10 files changed, 1828 insertions(+), 34 deletions(-) create mode 100644 pychunkedgraph/graph/cutting_sv.py create mode 100644 pychunkedgraph/graph/edits_sv.py diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 89282a58c..4dbdcdac9 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -672,22 +672,44 @@ def get_subgraph_leaves( self, node_id_or_ids, bbox, bbox_is_coordinate, False, True ) - def get_fake_edges( + def get_edited_edges( self, chunk_ids: np.ndarray, time_stamp: datetime.datetime = None ) -> typing.Dict: + """ + Edges stored within a pcg that were created as a result of edits. + Either 'fake' edges that were adding for a merge edit; + Or 'split' edges resulting from a supervoxel split. + """ result = {} - fake_edges_d = self.client.read_nodes( + properties = [ + attributes.Connectivity.FakeEdges, + attributes.Connectivity.SplitEdges, + attributes.Connectivity.Affinity, + attributes.Connectivity.Area, + ] + _edges_d = self.client.read_nodes( node_ids=chunk_ids, - properties=attributes.Connectivity.FakeEdges, + properties=properties, end_time=time_stamp, end_time_inclusive=True, fake_edges=True, ) - for id_, val in fake_edges_d.items(): - edges = np.concatenate( - [np.asarray(e.value, dtype=basetypes.NODE_ID) for e in val] - ) - result[id_] = Edges(edges[:, 0], edges[:, 1]) + for id_, val in _edges_d.items(): + edges = val.get(attributes.Connectivity.FakeEdges, []) + edges = np.concatenate([types.empty_2d, *[e.value for e in edges]]) + fake_edges_ = Edges(edges[:, 0], edges[:, 1]) + + edges = val.get(attributes.Connectivity.SplitEdges, []) + edges = np.concatenate([types.empty_2d, *[e.value for e in edges]]) + + aff = val.get(attributes.Connectivity.Affinity, []) + aff = np.concatenate([types.empty_affinities, *[e.value for e in aff]]) + + areas = val.get(attributes.Connectivity.Area, []) + areas = np.concatenate([types.empty_areas, *[e.value for e in areas]]) + split_edges_ = Edges(edges[:, 0], edges[:, 1], affinities=aff, areas=areas) + + result[id_] = fake_edges_ + split_edges_ return result def copy_fake_edges(self, chunk_id: np.uint64) -> None: @@ -726,10 +748,10 @@ def get_l2_agglomerations( if self.mock_edges is None: edges_d = self.read_chunk_edges(chunk_ids) - fake_edges = self.get_fake_edges(chunk_ids) + edited_edges = self.get_edited_edges(chunk_ids) all_chunk_edges = reduce( lambda x, y: x + y, - chain(edges_d.values(), fake_edges.values()), + chain(edges_d.values(), edited_edges.values()), Edges([], []), ) if self.mock_edges is not None: diff --git a/pychunkedgraph/graph/chunks/utils.py b/pychunkedgraph/graph/chunks/utils.py index 5b6d0ae78..0e39fbf9f 100644 --- a/pychunkedgraph/graph/chunks/utils.py +++ b/pychunkedgraph/graph/chunks/utils.py @@ -169,9 +169,7 @@ def _compute_chunk_id( z: int, ) -> np.uint64: s_bits_per_dim = meta.bitmasks[layer] - if not ( - x < 2**s_bits_per_dim and y < 2**s_bits_per_dim and z < 2**s_bits_per_dim - ): + if not (x < 2**s_bits_per_dim and y < 2**s_bits_per_dim and z < 2**s_bits_per_dim): raise ValueError( f"Coordinate is out of range \ layer: {layer} bits/dim {s_bits_per_dim}. \ @@ -284,3 +282,53 @@ def get_l2chunkids_along_boundary(cg_meta, mlayer: int, coord_a, coord_b, paddin l2chunk_ids_a = get_chunk_ids_from_coords(cg_meta, 2, l2chunks_a) l2chunk_ids_b = get_chunk_ids_from_coords(cg_meta, 2, l2chunks_b) return l2chunk_ids_a, l2chunk_ids_b + + +def chunks_overlapping_bbox(bbox_min, bbox_max, chunk_size) -> dict: + """ + Find octree chunks overlapping with a bounding box in 3D + and return a dictionary mapping chunk indices to clipped bounding boxes. + """ + bbox_min = np.asarray(bbox_min, dtype=int) + bbox_max = np.asarray(bbox_max, dtype=int) + chunk_size = np.asarray(chunk_size, dtype=int) + + start_idx = np.floor_divide(bbox_min, chunk_size).astype(int) + end_idx = np.floor_divide(bbox_max, chunk_size).astype(int) + + ix = np.arange(start_idx[0], end_idx[0] + 1) + iy = np.arange(start_idx[1], end_idx[1] + 1) + iz = np.arange(start_idx[2], end_idx[2] + 1) + grid = np.stack(np.meshgrid(ix, iy, iz, indexing="ij"), axis=-1, dtype=int) + grid = grid.reshape(-1, 3) + + chunk_min = grid * chunk_size + chunk_max = chunk_min + chunk_size + clipped_min = np.maximum(chunk_min, bbox_min) + clipped_max = np.minimum(chunk_max, bbox_max) + return { + tuple(idx): np.stack([cmin, cmax], axis=0, dtype=int) + for idx, cmin, cmax in zip(grid, clipped_min, clipped_max) + } + + +def get_neighbors(coord, inclusive: bool = True, min_coord=None, max_coord=None): + """ + Get all valid coordinates in the 3×3×3 cube around a given chunk, + including the chunk itself (if inclusive=True), + respecting bounding box constraints. + """ + offsets = np.array(np.meshgrid([-1, 0, 1], [-1, 0, 1], [-1, 0, 1])).T.reshape(-1, 3) + if not inclusive: + offsets = offsets[~np.all(offsets == 0, axis=1)] + + neighbors = np.array(coord) + offsets + if min_coord is None: + min_coord = (0, 0, 0) + min_coord = np.array(min_coord) + neighbors = neighbors[(neighbors >= min_coord).all(axis=1)] + + if max_coord is not None: + max_coord = np.array(max_coord) + neighbors = neighbors[(neighbors <= max_coord).all(axis=1)] + return neighbors diff --git a/pychunkedgraph/graph/cutting_sv.py b/pychunkedgraph/graph/cutting_sv.py new file mode 100644 index 000000000..5f9ba58c5 --- /dev/null +++ b/pychunkedgraph/graph/cutting_sv.py @@ -0,0 +1,1284 @@ +from time import perf_counter + +import numpy as np +from typing import Dict, Tuple, Optional, Sequence +from scipy.spatial import cKDTree + + +# EDT backends: prefer Seung-Lab edt, fallback to scipy.ndimage +try: + from edt import edt as _edt_fast + + _HAVE_EDT_FAST = True +except Exception: + _HAVE_EDT_FAST = False + +from scipy import ndimage as ndi +from scipy.spatial import cKDTree +from skimage.graph import MCP_Geometric +from skimage.morphology import ( + ball, +) # keep only ball; use ndi.binary_dilation everywhere + +# ---------- Fast CC wrappers ---------- +try: + import cc3d + + _HAVE_CC3D = True +except Exception: + _HAVE_CC3D = False + from skimage.measure import label as _sk_label + +try: + import fastremap as _fr + + _HAVE_FASTREMAP = True +except Exception: + _HAVE_FASTREMAP = False + + +def _cc_label_26(mask: np.ndarray): + """ + Fast 3D connected components (26-connectivity). + Returns (labels:int32, n_components:int). + """ + if _HAVE_CC3D: + lbl = cc3d.connected_components( + mask.astype(np.uint8, copy=False), connectivity=26, out_dtype=np.uint32 + ) + return lbl, int(lbl.max()) + # Fallback: skimage (connectivity=3 ~ 26-neighborhood) + lbl = _sk_label(mask, connectivity=3).astype(np.int32, copy=False) + return lbl, int(lbl.max()) + + +def _largest_component_id(lbl: np.ndarray): + """ + Return the label ID (>=1) of the largest component in 'lbl'. + lbl should already be a CC label image where 0=background. + """ + if _HAVE_FASTREMAP: + u, counts = _fr.unique(lbl, return_counts=True) + if u.size: + bg = np.where(u == 0)[0] + if bg.size: + counts[bg[0]] = 0 + return int(u[np.argmax(counts)]) + return 0 + cnt = np.bincount(lbl.ravel()) + if cnt.size: + cnt[0] = 0 + return int(np.argmax(cnt)) if cnt.size else 0 + + +# ========================= +# Order / utility helpers +# ========================= +def _to_zyx_sampling(vs, vox_order): + vs = tuple(map(float, vs)) + if vox_order.lower() == "xyz": # (x,y,z) -> (z,y,x) + return (vs[2], vs[1], vs[0]) + if vox_order.lower() == "zyx": + return vs + raise ValueError("vox_order must be 'xyz' or 'zyx'") + + +def _to_internal_zyx_volume(vol, vol_order): + if vol_order.lower() == "zyx": + return vol, False + if vol_order.lower() == "xyz": # (x,y,z) -> (z,y,x) + return np.transpose(vol, (2, 1, 0)), True + raise ValueError("vol_order must be 'xyz' or 'zyx'") + + +def _from_internal_zyx_volume(vol_zyx, vol_order): + if vol_order.lower() == "zyx": + return vol_zyx + if vol_order.lower() == "xyz": # (z,y,x) -> (x,y,z) + return np.transpose(vol_zyx, (2, 1, 0)) + raise ValueError("vol_order must be 'xyz' or 'zyx'") + + +def _seeds_to_zyx(seeds, seed_order): + arr = np.asarray(seeds, dtype=float).reshape(-1, 3) + if seed_order.lower() == "xyz": + arr = arr[:, [2, 1, 0]] # (x,y,z) -> (z,y,x) + elif seed_order.lower() != "zyx": + raise ValueError("seed_order must be 'xyz' or 'zyx'") + return np.round(arr).astype(int) + + +def _seeds_from_zyx(seeds_zyx, seed_order): + arr = np.asarray(seeds_zyx, dtype=int).reshape(-1, 3) + if seed_order.lower() == "xyz": + return arr[:, [2, 1, 0]] # (z,y,x) -> (x,y,z) + elif seed_order.lower() == "zyx": + return arr + else: + raise ValueError("seed_order must be 'xyz' or 'zyx'") + + +# ========================= +# Snapping (KDTree-based) +# ========================= +def _extract_mask_boundary(mask, erosion_iters=1): + """ + Extract boundary voxels of a 3D mask using binary erosion. + Boundary = mask & (~eroded(mask)) + + Parameters: + mask : 3D boolean array + erosion_iters : number of erosion iterations (higher removes thicker border) + + Returns: + boundary_mask : 3D boolean array of the same shape + """ + if erosion_iters < 1: + # No erosion => boundary = mask (not recommended unless extremely thin structures) + return mask.copy() + + structure = np.ones((3, 3, 3), dtype=bool) + interior = ndi.binary_erosion( + mask, structure=structure, iterations=erosion_iters, border_value=0 + ) + boundary = mask & (~interior) + return boundary + + +def _downsample_points(points, mode="stride", stride=2, target=None, rng=None): + """ + Downsample a set of points (N,3) by either: + - 'stride': take one every 'stride' points (fast, deterministic), + - 'random': keep ~target points uniformly at random. + + Args: + points : (N, 3) int or float array of coordinates + mode : 'stride' or 'random' + stride : int >= 1 (for 'stride' mode) + target : number of points to keep (for 'random' mode); if None, default is 50k + rng : np.random.Generator for reproducible random sampling + + Returns: + (M, 3) array with M <= N + """ + n = points.shape[0] + if n == 0: + return points + + if mode == "stride": + stride = max(1, int(stride)) + return points[::stride] + + elif mode == "random": + if target is None: + target = min(n, 50_000) # default target + target = max(1, int(target)) + if target >= n: + return points + if rng is None: + rng = np.random.default_rng() + idx = rng.choice(n, size=target, replace=False) + return points[idx] + + else: + raise ValueError("downsample mode must be 'stride' or 'random'") + + +def snap_seeds_to_segment( + seeds_xyz, + mask, + mask_order="zyx", + voxel_size=(1.0, 1.0, 1.0), + use_boundary=True, + erosion_iters=1, + downsample=True, + downsample_mode="stride", # 'stride' or 'random' + downsample_stride=2, # used if mode='stride' + downsample_target=None, # used if mode='random' + rng=None, + return_index=False, + leafsize=16, + log=lambda x: None, + tag="snap", + method="kdtree", # accepted for compatibility; only 'kdtree' currently +): + """ + Snap seeds (in XYZ) to the closest True voxel of a 3D mask using cKDTree over + a *reduced* set of candidate voxels: + - boundary-only (mask & ~eroded(mask)), if use_boundary=True + - optionally downsampled (stride or random) + + This approach works well for speed while retaining high accuracy for snapping. + + Parameters: + seeds_xyz : (N,3) float or int array in XYZ order. + mask : 3D boolean array; binary segment. + mask_order : 'zyx' (default) or 'xyz' indicating memory layout of mask. + voxel_size : (vx, vy, vz) in XYZ physical units (e.g., (8.0, 8.0, 40.0)). + use_boundary : If True, only use boundary voxels for KDTree. + erosion_iters : Number of erosion iterations for boundary extraction. + downsample : If True, further reduce boundary points (stride or random). + downsample_mode : 'stride' or 'random' for boundary sampling. + downsample_stride : If stride mode, use every Nth boundary voxel. + downsample_target : If random mode, target number of boundary points to keep. + rng : Optional np.random.Generator for reproducible random sampling. + return_index : If True, also return indices of nearest boundary points. + leafsize : cKDTree leafsize parameter. + log : callable for logging + tag : string to prefix timings + method : currently only 'kdtree' supported. Present for backward compatibility. + + Returns: + snapped_xyz : (N,3) int array in XYZ order, coordinates within volume bounds. + match_idx : (optional) indices into the candidate points array, if return_index=True. + + Notes: + - Seeds outside the volume are supported; they will snap to the nearest segment voxel. + - If use_boundary=True yields no boundary (thin segment), we fall back to the full mask. + - If the mask is empty, we raise ValueError. + """ + t0 = perf_counter() + if method != "kdtree": + log(f"[{tag}] Warning: 'method={method}' not supported; using 'kdtree'.") + + # Validate mask + if mask.ndim != 3: + raise ValueError("mask must be a 3D boolean array") + if mask.dtype != bool: + mask = mask.astype(bool) + + if mask_order not in ("zyx", "xyz"): + raise ValueError("mask_order must be 'zyx' or 'xyz'") + + # Optional boundary extraction for speed + tb = perf_counter() + if use_boundary: + candidate_mask = _extract_mask_boundary(mask, erosion_iters=erosion_iters) + # Fallback to full mask if boundary is empty + if not candidate_mask.any(): + candidate_mask = mask + log(f"[{tag}] boundary empty → fallback to full mask") + else: + candidate_mask = mask + log(f"[{tag}] candidate extraction | {perf_counter()-tb:.3f}s") + + # Obtain candidate voxel coordinates in XYZ order + tc = perf_counter() + if mask_order == "zyx": + # mask shape is (Z, Y, X), np.where -> (z, y, x) + zc, yc, xc = np.where(candidate_mask) + points_xyz = np.stack([xc, yc, zc], axis=1) + max_x, max_y, max_z = mask.shape[2] - 1, mask.shape[1] - 1, mask.shape[0] - 1 + else: + # mask shape is (X, Y, Z), np.where -> (x, y, z) + xc, yc, zc = np.where(candidate_mask) + points_xyz = np.stack([xc, yc, zc], axis=1) + max_x, max_y, max_z = mask.shape[0] - 1, mask.shape[1] - 1, mask.shape[2] - 1 + log( + f"[{tag}] candidate coordinates | {perf_counter()-tc:.3f}s (n={len(points_xyz)})" + ) + + if points_xyz.shape[0] == 0: + raise ValueError( + "The mask (or boundary) contains no True voxels (empty segment)." + ) + + # Optional: further downsample candidate points + td = perf_counter() + if downsample: + before = len(points_xyz) + points_xyz = _downsample_points( + points_xyz, + mode=downsample_mode, + stride=downsample_stride, + target=downsample_target, + rng=rng, + ) + after = len(points_xyz) + log(f"[{tag}] downsample points {before} → {after} | {perf_counter()-td:.3f}s") + + # Prepare seeds array + seeds_xyz = np.asarray(seeds_xyz, dtype=np.float64) + if seeds_xyz.ndim == 1: + seeds_xyz = seeds_xyz[None, :] + if seeds_xyz.shape[1] != 3: + raise ValueError("seeds_xyz must be shape (N, 3)") + + # Scale coordinates to physical space to respect anisotropy + vx, vy, vz = voxel_size + scale = np.array([vx, vy, vz], dtype=np.float64) + + points_scaled = points_xyz * scale[None, :] + seeds_scaled = seeds_xyz * scale[None, :] + + # cKDTree nearest neighbor lookup + te = perf_counter() + tree = cKDTree(points_scaled, leafsize=leafsize) + _, nn_indices = tree.query(seeds_scaled, k=1, workers=-1) + log(f"[{tag}] KDTree build+query | {perf_counter()-te:.3f}s") + + # Map back to integer voxel coords (XYZ) + snapped_xyz = points_xyz[nn_indices].astype(np.int64) + + # Ensure snapped coords are valid (should already be in bounds) + snapped_xyz[:, 0] = np.clip(snapped_xyz[:, 0], 0, max_x) + snapped_xyz[:, 1] = np.clip(snapped_xyz[:, 1], 0, max_y) + snapped_xyz[:, 2] = np.clip(snapped_xyz[:, 2], 0, max_z) + + log(f"[{tag}] snapped {len(seeds_xyz)} seeds | total {perf_counter()-t0:.3f}s") + if return_index: + return snapped_xyz, nn_indices + else: + return snapped_xyz + + +# ============================================================ +# EDT wrapper (Seung-Lab edt preferred, fallback to scipy) +# ============================================================ +def _compute_edt(mask: np.ndarray, sampling_zyx, log=lambda x: None, tag="edt"): + """ + Compute Euclidean distance transform using Seung-Lab edt if available, + otherwise fallback to scipy.ndimage.distance_transform_edt. + + - mask: boolean array in ZYX order + - sampling_zyx: anisotropy tuple in ZYX (float) + """ + t0 = perf_counter() + if _HAVE_EDT_FAST: + dist = _edt_fast(mask.astype(np.uint8, copy=False), anisotropy=sampling_zyx) + log(f"[{tag}] Seung-Lab edt | {perf_counter()-t0:.3f}s") + return dist + else: + dist = ndi.distance_transform_edt(mask, sampling=sampling_zyx) + log(f"[{tag}] SciPy EDT | {perf_counter()-t0:.3f}s") + return dist + + +# ------------------------------------------------------------ +# Helpers for upsampling +# ------------------------------------------------------------ +def _upsample_bool(mask_ds, steps, target_shape): + up = mask_ds.repeat(steps[0], 0).repeat(steps[1], 1).repeat(steps[2], 2) + return up[: target_shape[0], : target_shape[1], : target_shape[2]] + + +def _upsample_labels(lbl_ds, steps, target_shape): + up = lbl_ds.repeat(steps[0], 0).repeat(steps[1], 1).repeat(steps[2], 2) + return up[: target_shape[0], : target_shape[1], : target_shape[2]] + + +# ============================================================ +# Combined connector (ROI + DS + MST paths) — uses snapping + fast EDT +# ============================================================ +def connect_both_seeds_via_ridge( + binary_sv: np.ndarray, + seeds_a, + seeds_b, + voxel_size=(1.0, 1.0, 1.0), + *, + vol_order: str = "xyz", + vox_order: str = "xyz", + seed_order: str = "xyz", + ridge_power: float = 2.0, + roi_pad_zyx=(24, 48, 48), + downsample=(2, 2, 1), + refine_fullres_when_fail: bool = True, + snap_method: str = "kdtree", + snap_kwargs: dict | None = None, + verbose: bool = True, +): + def log(msg: str): + if verbose: + print(msg, flush=True) + + def _bbox_pad_zyx(points_zyx, shape, pad=(24, 48, 48)): + pts = np.asarray(points_zyx, int) + if pts.size == 0: + return (0, 0, 0, shape[0], shape[1], shape[2]) + z0, y0, x0 = pts.min(0) + z1, y1, x1 = pts.max(0) + 1 + z0 = max(0, z0 - pad[0]) + y0 = max(0, y0 - pad[1]) + x0 = max(0, x0 - pad[2]) + z1 = min(shape[0], z1 + pad[0]) + y1 = min(shape[1], y1 + pad[1]) + x1 = min(shape[2], x1 + pad[2]) + return (z0, y0, x0, z1, y1, x1) + + def _mst_edges_phys(pts_zyx, sampling): + P = np.asarray(pts_zyx, float) + if len(P) <= 1: + return [] + S = np.array(sampling, float)[None, :] + phys = P * S + n = len(P) + in_tree = np.zeros(n, bool) + in_tree[0] = True + best = np.full(n, np.inf) + parent = np.full(n, -1, int) + d0 = np.sqrt(((phys - phys[0]) ** 2).sum(1)) + best[:] = d0 + best[0] = np.inf + parent[:] = 0 + edges = [] + for _ in range(n - 1): + i = int(np.argmin(best)) + if not np.isfinite(best[i]): + break + edges.append((int(parent[i]), i)) + in_tree[i] = True + best[i] = np.inf + di = np.sqrt(((phys - phys[i]) ** 2).sum(1)) + relax = (~in_tree) & (di < best) + parent[relax] = i + best[relax] = di[relax] + return edges + + t0 = perf_counter() + log( + f"[connect] vol_order={vol_order}, vox_order={vox_order}, seed_order={seed_order}" + ) + log( + f"[connect] mask shape: {binary_sv.shape}, ridge_power={ridge_power}, ds={downsample}" + ) + + sv_zyx, _ = _to_internal_zyx_volume(binary_sv, vol_order) + sampling = _to_zyx_sampling(voxel_size, vox_order) + + # SNAP seeds to mask + A_in_zyx = _seeds_to_zyx(seeds_a, seed_order) + B_in_zyx = _seeds_to_zyx(seeds_b, seed_order) + + # Default snapping config; override via snap_kwargs + snap_cfg = dict( + use_boundary=True, + erosion_iters=1, + downsample=True, + downsample_mode="random", + downsample_target=50_000, + method=snap_method, # allow pass-through compatibility + ) + if snap_kwargs is not None: + snap_cfg.update(snap_kwargs) + + def _snap(pts_zyx, name): + if pts_zyx.size == 0: + return np.empty((0, 3), dtype=int) + # Convert ZYX -> XYZ for snapper + pts_xyz = pts_zyx[:, [2, 1, 0]] + # Use snapping over full 3D sv_zyx with ZYX mask + snapped_xyz = snap_seeds_to_segment( + pts_xyz, + mask=sv_zyx, + mask_order="zyx", + voxel_size=( + sampling[2], + sampling[1], + sampling[0], + ), # convert ZYX->XYZ spacing + log=log, + tag=f"{name}@snap", + **snap_cfg, + ) + # Back to ZYX + return snapped_xyz[:, [2, 1, 0]] + + A_zyx = _snap(A_in_zyx, "A") + B_zyx = _snap(B_in_zyx, "B") + + if len(A_zyx) == 0 or len(B_zyx) == 0: + log("[connect] after snapping, one side has no seeds; skipping connection") + return ( + _seeds_from_zyx(A_zyx, seed_order), + _seeds_from_zyx(B_zyx, seed_order), + (len(A_zyx) > 0), + (len(B_zyx) > 0), + ) + + # ROI for speed + z0, y0, x0, z1, y1, x1 = _bbox_pad_zyx( + np.vstack([A_zyx, B_zyx]), sv_zyx.shape, pad=roi_pad_zyx + ) + roi = sv_zyx[z0:z1, y0:y1, x0:x1] + log(f"[connect] ROI: z[{z0}:{z1}] y[{y0}:{y1}] x[{x0}:{x1}] → shape {roi.shape}") + + # Downsample ROI + sz, sy, sx = map(int, downsample) + ti_ds = perf_counter() + if (sz, sy, sx) != (1, 1, 1): + roi_ds = roi[::sz, ::sy, ::sx] + else: + roi_ds = roi + sampling_ds = (sampling[0] * sz, sampling[1] * sy, sampling[2] * sx) + log( + f"[connect] ROI downsampled {roi.shape} -> {roi_ds.shape} | {perf_counter()-ti_ds:.3f}s" + ) + + # Robust seed placement on the downsampled grid: + # (1) Map to ROI-local coords + # (2) Divide by (sz,sy,sx) to approximate DS coords + # (3) SNAP them to the nearest True voxel in roi_ds using KDTree + def _to_roi_ds_snapped(pts_zyx, name="seedDS"): + if pts_zyx.size == 0: + return np.empty((0, 3), dtype=int) + local = np.asarray(pts_zyx, int) - np.array([z0, y0, x0]) # roi-local + seeds_ds = local / np.array( + [sz, sy, sx], dtype=float + ) # DS coordinates (float OK) + # Convert to XYZ for snapper + seeds_ds_xyz = seeds_ds[:, [2, 1, 0]] + try: + snapped_ds_xyz = snap_seeds_to_segment( + seeds_ds_xyz, + mask=roi_ds, + mask_order="zyx", + voxel_size=(sampling_ds[2], sampling_ds[1], sampling_ds[0]), + log=log, + tag=f"{name}@roi_ds", + use_boundary=False, + downsample=False, + method="kdtree", + ) + snapped_ds_zyx = snapped_ds_xyz[:, [2, 1, 0]] + return snapped_ds_zyx.astype(int) + except ValueError as e: + # If roi_ds is empty or degenerate, bail out gracefully: + log( + f"[{name}@roi_ds] snapping failed ({e}); falling back to nearest-int grid & mask check." + ) + approx = np.floor(seeds_ds + 0.5).astype(int) + Z, Y, X = roi_ds.shape + approx[:, 0] = np.clip(approx[:, 0], 0, Z - 1) + approx[:, 1] = np.clip(approx[:, 1], 0, Y - 1) + approx[:, 2] = np.clip(approx[:, 2], 0, X - 1) + # Keep only those approx coords that are inside mask + valid = [tuple(p) for p in approx if roi_ds[tuple(p)]] + return np.array(valid, dtype=int) + + A_ds = _to_roi_ds_snapped(A_zyx, "A") + B_ds = _to_roi_ds_snapped(B_zyx, "B") + + okA = len(A_ds) >= 1 + okB = len(B_ds) >= 1 + if not (okA and okB): + log( + "[connect] seeds disappeared or failed to map on DS grid; consider smaller ds or use_boundary=False/downsample=False in snapping." + ) + return ( + _seeds_from_zyx(A_zyx, seed_order), + _seeds_from_zyx(B_zyx, seed_order), + okA, + okB, + ) + + # EDT and cost on DS ROI (Seung-Lab edt if available) + t1 = perf_counter() + dist = _compute_edt(roi_ds, sampling_ds, log=log, tag="connect:EDT") + if dist.max() <= 0: + log("[connect] empty EDT in ROI; skipping connection") + return ( + _seeds_from_zyx(A_zyx, seed_order), + _seeds_from_zyx(B_zyx, seed_order), + False, + False, + ) + dn = dist / dist.max() + eps = 1e-6 + cost = np.full_like(dn, 1e12, dtype=float) + cost[roi_ds] = 1.0 / (eps + np.clip(dn[roi_ds], 0, 1) ** max(0.0, ridge_power)) + log(f"[connect] EDT/cost ready on DS-ROI | {perf_counter()-t1:.3f}s") + + # Shortest paths via MST + def _path_mask_ds(start, end): + tmcp = perf_counter() + mcp = MCP_Geometric(cost, sampling=sampling_ds) + costs, _ = mcp.find_costs([tuple(start)], find_all_ends=False) + mid = perf_counter() + v = costs[tuple(end)] + if not np.isfinite(v): + log( + f"[MCP] start={tuple(start)} -> end={tuple(end)} FAILED | setup+run={mid-tmcp:.3f}s" + ) + return None + path = np.asarray(mcp.traceback(tuple(end)), int) + m = np.zeros_like(roi_ds, bool) + m[tuple(path.T)] = True + log( + f"[MCP] start={tuple(start)} -> end={tuple(end)} OK | total={perf_counter()-tmcp:.3f}s" + ) + return m + + def _augment_team_ds(team_name, pts_ds): + if len(pts_ds) <= 1: + return np.zeros_like(roi_ds, bool), True + edges = _mst_edges_phys(pts_ds, sampling_ds) + pmask = np.zeros_like(roi_ds, bool) + ok = True + for i, j in edges: + m = _path_mask_ds(pts_ds[i], pts_ds[j]) + if m is None: + log(f"[connect:{team_name}] DS path FAILED for edge {i}-{j}") + ok = False + if refine_fullres_when_fail: + # fallback full-res EDT and path + tfr = perf_counter() + dist_fr = _compute_edt( + roi, sampling, log=log, tag="connect:EDT(fullres)" + ) + dnm = dist_fr / (dist_fr.max() if dist_fr.max() > 0 else 1.0) + cost_fr = np.full_like(dist_fr, 1e12, dtype=float) + cost_fr[roi] = 1.0 / ( + eps + np.clip(dnm[roi], 0, 1) ** max(0.0, ridge_power) + ) + s = np.array(pts_ds[i]) * np.array([sz, sy, sx]) + e = np.array(pts_ds[j]) * np.array([sz, sy, sx]) + mcp_fr = MCP_Geometric(cost_fr, sampling=sampling) + costs_fr, _ = mcp_fr.find_costs([tuple(s)], find_all_ends=False) + if np.isfinite(costs_fr[tuple(e)]): + path_fr = np.asarray(mcp_fr.traceback(tuple(e)), int) + m_fr = np.zeros_like(roi, bool) + m_fr[tuple(path_fr.T)] = True + m = m_fr[::sz, ::sy, ::sx] + ok = True + log( + f"[connect:{team_name}] fallback full-res path OK | {perf_counter()-tfr:.3f}s" + ) + else: + log( + f"[connect:{team_name}] Full-res ROI path also FAILED for edge {i}-{j}" + ) + m = None + if m is not None: + pmask |= m + return pmask, ok + + t_aug = perf_counter() + pA_ds, okA2 = _augment_team_ds("A", A_ds) + pB_ds, okB2 = _augment_team_ds("B", B_ds) + okA &= okA2 + okB &= okB2 + log(f"[connect] MST+paths built | {perf_counter()-t_aug:.3f}s") + + if not (okA and okB): + log( + "[connect] connection failed for at least one team — consider smaller downsample or refine_fullres_when_fail." + ) + return ( + _seeds_from_zyx(A_zyx, seed_order), + _seeds_from_zyx(B_zyx, seed_order), + okA, + okB, + ) + + # Up-project to full resolution and dilate + pA = _upsample_bool(pA_ds, (sz, sy, sx), roi.shape) & roi + pB = _upsample_bool(pB_ds, (sz, sy, sx), roi.shape) & roi + struc = ball(1) + tpost = perf_counter() + pA = ndi.binary_dilation(pA, structure=struc) & roi + pB = ndi.binary_dilation(pB, structure=struc) & roi + log(f"[connect] postproc dilation on paths | {perf_counter()-tpost:.3f}s") + + A_aug = set(map(tuple, A_zyx)) + B_aug = set(map(tuple, B_zyx)) + Az, Ay, Ax = np.nonzero(pA) + Bz, By, Bx = np.nonzero(pB) + for z, y, x in zip(Az, Ay, Ax): + A_aug.add((z0 + z, y0 + y, x0 + x)) + for z, y, x in zip(Bz, By, Bx): + B_aug.add((z0 + z, y0 + y, x0 + x)) + + A_aug = _seeds_from_zyx(np.array(sorted(list(A_aug)), int), seed_order) + B_aug = _seeds_from_zyx(np.array(sorted(list(B_aug)), int), seed_order) + log( + f"[connect] done; +{len(A_aug)-len(seeds_a)} vox for A, +{len(B_aug)-len(seeds_b)} for B | total {perf_counter()-t0:.3f}s" + ) + return A_aug, B_aug, True, True + + +def split_supervoxel_growing( + binary_sv: np.ndarray, + seeds_a, + seeds_b, + voxel_size=(1.0, 1.0, 1.0), + *, + # conventions / orders + vol_order: str = "xyz", + vox_order: str = "xyz", + seed_order: str = "xyz", + # geometry / cost + halo: int = 1, + gamma_neck: float = 1.6, # boundary slowdown + k_prox: float = 2.0, # proximity boost strength + lambda_prox: float = 1.0, # proximity decay + narrow_band_rel: float = 0.08, # relative difference threshold + nb_dilate: int = 1, # dilate band to stabilize + # optional: compute TA/TB on a downsampled grid + downsample_geodesic: tuple | None = None, # e.g. (1,2,2) + # post-processing / guarantees + allow_third_label: bool = True, + enforce_single_cc: bool = True, + # final validation + check_seeds_same_cc: bool = True, + raise_if_seed_split: bool = True, + raise_if_multi_cc: bool = False, + # snapping control (NEW) + snap_method: str = "kdtree", + snap_kwargs: dict | None = None, + # logging + verbose: bool = True, +): + def log(msg: str): + if verbose: + print(msg, flush=True) + + # Helpers reused from the module: _cc_label_26, _largest_component_id, _to_internal_zyx_volume, _from_internal_zyx_volume + # _seeds_to_zyx, _compute_edt, etc. are assumed available. + + # ---------- helpers ---------- + def _enforce_single_component(out_labels, lab, seed_pts_global, allow3=True): + t = perf_counter() + mask = out_labels == lab + if not np.any(mask): + return 0, 0 + comp, ncomp = _cc_label_26(mask) + if ncomp <= 1: + log(f"[single-cc:{lab}] ncomp=1 | {perf_counter()-t:.3f}s") + return 1, 0 + + keep_ids = set() + for z, y, x in seed_pts_global: + if ( + 0 <= z < out_labels.shape[0] + and 0 <= y < out_labels.shape[1] + and 0 <= x < out_labels.shape[2] + ): + if out_labels[z, y, x] == lab: + cid = comp[z, y, x] + if cid > 0: + keep_ids.add(int(cid)) + + if not keep_ids: + keep_ids = {_largest_component_id(comp)} + + lut = np.zeros(ncomp + 1, dtype=np.bool_) + lut[list(keep_ids)] = True + bad_mask = (comp > 0) & (~lut[comp]) + moved = int(bad_mask.sum()) + if allow3 and moved: + out_labels[bad_mask] = 3 + log( + f"[single-cc:{lab}] kept={len(keep_ids)}, moved_to_3={moved} | {perf_counter()-t:.3f}s" + ) + return len(keep_ids), moved + + def _resolve_label3_touching_vectorized( + out_labels, seedsA=None, seedsB=None, sampling=(1, 1, 1) + ): + t0 = perf_counter() + comp3, n3 = _cc_label_26(out_labels == 3) + n3_vox = int((out_labels == 3).sum()) + log(f"[touching] n3 comps={n3}, vox={n3_vox}") + if n3 == 0: + log(f"[touching] no label-3 components | {perf_counter()-t0:.3f}s") + return 0, 0 + + t1 = perf_counter() + struc = np.ones((3, 3, 3), bool) + N1 = ndi.binary_dilation(out_labels == 1, structure=struc) & (comp3 > 0) + N2 = ndi.binary_dilation(out_labels == 2, structure=struc) & (comp3 > 0) + + cnt1 = np.bincount(comp3[N1], minlength=n3 + 1) + cnt2 = np.bincount(comp3[N2], minlength=n3 + 1) + + assign = np.zeros(n3 + 1, dtype=np.int16) # 0=undecided, 1 or 2 otherwise + assign[cnt1 > cnt2] = 1 + assign[cnt2 > cnt1] = 2 + undec = np.where(assign[1:] == 0)[0] + 1 + log( + f"[touching] maj→1={int((assign==1).sum())}, maj→2={int((assign==2).sum())}, ties={len(undec)} | {perf_counter()-t1:.3f}s" + ) + + if ( + len(undec) > 0 + and (seedsA is not None) + and (seedsB is not None) + and len(seedsA) + and len(seedsB) + ): + t2 = perf_counter() + sA = np.zeros_like(out_labels, bool) + sA[tuple(np.array(seedsA).T)] = True + sB = np.zeros_like(out_labels, bool) + sB[tuple(np.array(seedsB).T)] = True + dA = _compute_edt(~sA, sampling, log=log, tag="split:EDT(dA)") + dB = _compute_edt(~sB, sampling, log=log, tag="split:EDT(dB)") + closer2 = (dB < dA) & (comp3 > 0) + + pref2 = np.bincount(comp3[closer2], minlength=n3 + 1) + total = np.bincount(comp3[comp3 > 0], minlength=n3 + 1) + + tie_ids = np.array(undec, dtype=int) + choose2 = pref2[tie_ids] > (total[tie_ids] - pref2[tie_ids]) + assign[tie_ids[choose2]] = 2 + assign[tie_ids[~choose2]] = 1 + log( + f"[touching] tie-break EDT done: to2={int(choose2.sum())}, to1={int((~choose2).sum())} | {perf_counter()-t2:.3f}s" + ) + + moved1 = moved2 = 0 + if (assign == 1).any(): + mask1 = assign[comp3] == 1 + moved1 = int(mask1.sum()) + out_labels[mask1] = 1 + if (assign == 2).any(): + mask2 = assign[comp3] == 2 + moved2 = int(mask2.sum()) + out_labels[mask2] = 2 + + log( + f"[touching] reassigned 3→1: {moved1}, 3→2: {moved2} | total {perf_counter()-t0:.3f}s" + ) + return moved1, moved2 + + # ---------- begin ---------- + t0 = perf_counter() + log(f"[init] vol_order={vol_order}, vox_order={vox_order}, seed_order={seed_order}") + log(f"[init] input volume shape: {binary_sv.shape}") + + # Convert input volumes and sampling into internal ZYX + sv_zyx, _ = _to_internal_zyx_volume(binary_sv, vol_order) + sampling = _to_zyx_sampling(voxel_size, vox_order) + log(f"[init] internal shape (z,y,x): {sv_zyx.shape}") + log(f"[init] sampling (z,y,x): {sampling}") + + # SNAP seeds to mask using the same KDTree-based method + A_all = _seeds_to_zyx(seeds_a, seed_order) + B_all = _seeds_to_zyx(seeds_b, seed_order) + log("[snap] snapping seeds to segment mask...") + + snap_cfg = dict( + use_boundary=True, + erosion_iters=1, + downsample=True, + downsample_mode="random", + downsample_target=50_000, + method=snap_method, # compatibility key + ) + if snap_kwargs is not None: + snap_cfg.update(snap_kwargs) + + def _snap_ZYX(pts_zyx, tagname): + if pts_zyx.size == 0: + return np.empty((0, 3), dtype=int) + # Convert ZYX -> XYZ for snapper + pts_xyz = pts_zyx[:, [2, 1, 0]] + snapped_xyz = snap_seeds_to_segment( + pts_xyz, + mask=sv_zyx, + mask_order="zyx", + voxel_size=( + sampling[2], + sampling[1], + sampling[0], + ), # convert ZYX→XYZ spacing + log=log, + tag=tagname, + **snap_cfg, + ) + return snapped_xyz[:, [2, 1, 0]] + + A = _snap_ZYX(A_all, "A@snap") + B = _snap_ZYX(B_all, "B@snap") + log(f"[seeds] A={len(A)}, B={len(B)}") + + out_zyx = np.zeros_like(sv_zyx, dtype=np.int16) + if A.size == 0 or B.size == 0 or not np.any(sv_zyx): + log("[seeds] missing seeds or empty SV; returning label=1 for entire SV") + out_zyx[sv_zyx] = 1 + return _from_internal_zyx_volume(out_zyx, vol_order) + + # Tight bbox ROI around mask with halo + t_bbox = perf_counter() + Z, Y, X = sv_zyx.shape + coords = np.argwhere(sv_zyx) + z0, y0, x0 = coords.min(0) + z1, y1, x1 = coords.max(0) + 1 + z0h = max(z0 - halo, 0) + y0h = max(y0 - halo, 0) + x0h = max(x0 - halo, 0) + z1h = min(z1 + halo, Z) + y1h = min(y1 + halo, Y) + x1h = min(x1 + halo, X) + sv = sv_zyx[z0h:z1h, y0h:y1h, x0h:x1h] + A_roi = A - np.array([z0h, y0h, x0h]) + B_roi = B - np.array([z0h, y0h, x0h]) + log( + f"[crop] ROI shape (internal): {sv.shape} (halo {halo}) | {perf_counter()-t_bbox:.3f}s" + ) + + # Build travel cost via EDT (Seung-Lab edt if available) + t1 = perf_counter() + dist = _compute_edt(sv, sampling, log=log, tag="split:EDT(mask)") + distn = dist / dist.max() if dist.max() > 0 else dist + eps = 1e-6 + speed = np.clip(distn ** max(gamma_neck, 0.0), eps, 1.0) + travel_cost = np.full_like(speed, 1e12, dtype=float) + travel_cost[sv] = 1.0 / speed[sv] + log( + f"[speed] EDT + speed map | {perf_counter()-t1:.3f}s (total {perf_counter()-t0:.3f}s)" + ) + + # Optional downsample for geodesic + use_ds = downsample_geodesic is not None + if use_ds: + dz, dy, dx = map(int, downsample_geodesic) + log(f"[geodesic] downsample grid: {downsample_geodesic}") + cost_ds = travel_cost[::dz, ::dy, ::dx] + mask_ds = sv[::dz, ::dy, ::dx] + sampling_ds = (sampling[0] * dz, sampling[1] * dy, sampling[2] * dx) + + def _to_ds(pts): + pts = (np.asarray(pts, int) // np.array([dz, dy, dx])).astype(int) + Zs, Ys, Xs = mask_ds.shape + keep = [] + for z, y, x in pts: + if 0 <= z < Zs and 0 <= y < Ys and 0 <= x < Xs and mask_ds[z, y, x]: + keep.append((z, y, x)) + return keep + + A_sub = _to_ds(A_roi) + B_sub = _to_ds(B_roi) + log(f"[geodesic] seeds on DS grid: A={len(A_sub)}, B={len(B_sub)}") + if len(A_sub) == 0 or len(B_sub) == 0: + log("[geodesic] DS removed all seeds; falling back to full-res") + use_ds = False + if not use_ds: + cost_ds = travel_cost + mask_ds = sv + sampling_ds = sampling + A_sub = [tuple(p) for p in A_roi.tolist()] + B_sub = [tuple(p) for p in B_roi.tolist()] + + # Geodesic arrival times + t2 = perf_counter() + mcpA = MCP_Geometric(cost_ds, sampling=sampling_ds) + TA, _ = mcpA.find_costs(A_sub, find_all_ends=False) + mcpB = MCP_Geometric(cost_ds, sampling=sampling_ds) + TB, _ = mcpB.find_costs(B_sub, find_all_ends=False) + TA = np.where(mask_ds, TA, np.inf) + TB = np.where(mask_ds, TB, np.inf) + log( + f"[geodesic] TA/TB computed | {perf_counter()-t2:.3f}s (total {perf_counter()-t0:.3f}s)" + ) + + # Narrow band + t3 = perf_counter() + finite = np.isfinite(TA) & np.isfinite(TB) & mask_ds + denom = TA + TB + 1e-12 + reldiff = np.zeros_like(TA) + reldiff[finite] = np.abs(TA[finite] - TB[finite]) / denom[finite] + band = finite & (reldiff <= narrow_band_rel) + if nb_dilate > 0: + band = ndi.binary_dilation(band, structure=ball(nb_dilate)) & mask_ds + if band.sum() < 64: + band = mask_ds.copy() + log("[band] tiny band -> using full ROI on current grid") + log( + f"[band] voxels: {int(band.sum())} | {perf_counter()-t3:.3f}s (total {perf_counter()-t0:.3f}s)" + ) + + # Proximity-boosted labeling + t4 = perf_counter() + denomA = 1.0 + k_prox * np.exp(-lambda_prox * np.clip(TB, 0, np.inf)) + denomB = 1.0 + k_prox * np.exp(-lambda_prox * np.clip(TA, 0, np.inf)) + CA = TA / denomA + CB = TB / denomB + sub_labels_ds = np.zeros_like(mask_ds, dtype=np.int16) + sub_labels_ds[(CA <= CB) & band] = 1 + sub_labels_ds[(CB < CA) & band] = 2 + outer = mask_ds & (sub_labels_ds == 0) + sub_labels_ds[(TA <= TB) & outer] = 1 + sub_labels_ds[(TB < TA) & outer] = 2 + for z, y, x in A_sub: + sub_labels_ds[z, y, x] = 1 + for z, y, x in B_sub: + sub_labels_ds[z, y, x] = 2 + log( + f"[label] DS labeling done | {perf_counter()-t4:.3f}s (total {perf_counter()-t0:.3f}s)" + ) + + # Upsample if needed + if use_ds: + sub_labels = _upsample_labels(sub_labels_ds, (dz, dy, dx), sv.shape) + sub_labels[~sv] = 0 + for z, y, x in A_roi: + sub_labels[z, y, x] = 1 + for z, y, x in B_roi: + sub_labels[z, y, x] = 2 + log(f"[label] upsampled DS→full ROI") + else: + sub_labels = sub_labels_ds + + # Writeback + out_zyx[sv_zyx] = 1 + out_zyx[z0h:z1h, y0h:y1h, x0h:x1h][sub_labels == 1] = 1 + out_zyx[z0h:z1h, y0h:y1h, x0h:x1h][sub_labels == 2] = 2 + log("[writeback] labels written to full volume") + + # Enforce single CC per label + if enforce_single_cc: + keptA, movedA = _enforce_single_component( + out_zyx, 1, A, allow3=allow_third_label + ) + keptB, movedB = _enforce_single_component( + out_zyx, 2, B, allow3=allow_third_label + ) + log( + f"[single-cc] label1 kept {keptA}, moved {movedA} -> 3; label2 kept {keptB}, moved {movedB} -> 3" + ) + + # Resolve 3-touching + moved1, moved2 = _resolve_label3_touching_vectorized(out_zyx, A, B, sampling) + if moved1 or moved2: + if enforce_single_cc: + keptA, movedA = _enforce_single_component( + out_zyx, 1, A, allow3=allow_third_label + ) + keptB, movedB = _enforce_single_component( + out_zyx, 2, B, allow3=allow_third_label + ) + log( + f"[single-cc 2nd] label1 kept {keptA}, moved {movedA}; label2 kept {keptB}, moved {movedB}" + ) + + # Final check + for lab in (1, 2): + _, ncomp = _cc_label_26(out_zyx == lab) + if ncomp > 1: + msg = f"[check] label {lab} has {ncomp} connected components" + if raise_if_multi_cc: + raise ValueError(msg) + else: + log(msg) + + log(f"[done] total elapsed {perf_counter()-t0:.3f}s") + return _from_internal_zyx_volume(out_zyx, vol_order) + + +def build_kdtrees_by_label( + vol: np.ndarray, + *, + background: int = 0, + leafsize: int = 16, + balanced_tree: bool = True, + compact_nodes: bool = True, + min_points: int = 1, + dtype: np.dtype = np.float32, +) -> Tuple[Dict[int, cKDTree], Dict[int, int]]: + """ + Build a cKDTree of voxel coordinates for every unique (non-background) label in a 3D volume. + + Parameters + ---------- + vol : np.ndarray + 3D label volume (e.g., shape (Z, Y, X)). Can be any integer dtype (incl. uint64). + background : int, default 0 + Label treated as background and skipped. + leafsize : int, default 16 + Passed to cKDTree (larger can be faster for queries on large trees). + balanced_tree : bool, default True + Passed to cKDTree. + compact_nodes : bool, default True + Passed to cKDTree. + min_points : int, default 1 + Skip labels with fewer than this many voxels. + dtype : np.dtype, default np.float32 + Coordinate dtype used to build the trees (lower memory than float64). + + Returns + ------- + trees : Dict[int, cKDTree] + Mapping label -> cKDTree built + from the (z, y, x) coordinates of that label’s voxels. + counts : Dict[int, int] + Mapping label -> number of voxels used to build the tree. + + Notes + ----- + - This runs in O(N log N) due to a single sort over N foreground voxels. + - Uses one pass over non-background voxels; avoids per-label boolean masking. + - Coordinates are (z, y, x) in voxel units. + """ + if vol.ndim != 3: + raise ValueError("`vol` must be a 3D array.") + Z, Y, X = vol.shape + + # Flatten once and select foreground voxels + flat = vol.ravel() + if background == 0: + nz = np.flatnonzero(flat) # fast path when background is 0 + else: + nz = np.flatnonzero(flat != background) + + if nz.size == 0: + return {}, {} + + # Labels of foreground voxels (kept as integer/uint64) + labels = flat[nz] + + # Coordinates for those voxels (computed once) + z, y, x = np.unravel_index(nz, (Z, Y, X)) + coords = np.column_stack((z, y, x)).astype(dtype, copy=False) + + # Group by label via sort (stable to preserve any incidental ordering) + order = np.argsort(labels, kind="mergesort") + labels_sorted = labels[order] + + # Find group boundaries (run-length encoding over sorted labels) + starts = np.flatnonzero(np.r_[True, labels_sorted[1:] != labels_sorted[:-1]]) + ends = np.r_[starts[1:], labels_sorted.size] + + trees: Dict[int, cKDTree] = {} + counts: Dict[int, int] = {} + + for s, e in zip(starts, ends): + lab = int(labels_sorted[s]) # Python int key (handles uint64 safely) + block = coords[order[s:e]] + n = block.shape[0] + if n < min_points: + continue + # cKDTree copies data into its own memory; no need to keep `block` afterwards. + trees[lab] = cKDTree( + block, + leafsize=leafsize, + balanced_tree=balanced_tree, + compact_nodes=compact_nodes, + ) + counts[lab] = n + + return trees, counts + + +def pairwise_min_distance_two_sets( + trees_a: Sequence[cKDTree], + trees_b: Sequence[cKDTree], + *, + max_distance: Optional[float] = None, + workers: int = -1, +) -> np.ndarray: + """ + Compute pairwise shortest distances between point sets represented by two lists + of cKDTrees. Result has shape (len(trees_a), len(trees_b)). + + Parameters + ---------- + trees_a, trees_b : sequences of cKDTree + Each tree encodes the (z,y,x) points for one segment. + max_distance : float or None + If None (default): compute exact min distances (dense, finite). + If set: compute within this cutoff using sparse_distance_matrix; pairs with + no neighbors within cutoff are set to np.inf. + workers : int + Parallelism for cKDTree.query (SciPy >= 1.6). -1 uses all cores. + + Returns + ------- + D : ndarray, shape (len(trees_a), len(trees_b)) + D[i,j] = min distance between any point in trees_a[i] and trees_b[j]. + If max_distance is not None, entries may be np.inf. + """ + A, B = len(trees_a), len(trees_b) + if A == 0 or B == 0: + return np.zeros((A, B), dtype=float) + + D = np.zeros((A, B), dtype=float) + + if max_distance is not None: + # Cutoff mode: faster when many pairs are far apart. + D.fill(np.inf) + for i in range(A): + ti = trees_a[i] + for j in range(B): + tj = trees_b[j] + s = ti.sparse_distance_matrix( + tj, max_distance, output_type="coo_matrix" + ) + if s.nnz > 0: + D[i, j] = float(s.data.min()) + return D + + # Exact mode: query points of the smaller tree into the larger tree (k=1) and take min. + for i in range(A): + ti = trees_a[i] + ni = ti.n + for j in range(B): + tj = trees_b[j] + nj = tj.n + if ni <= nj: + d, _ = tj.query(ti.data, k=1, workers=workers) + else: + d, _ = ti.query(tj.data, k=1, workers=workers) + # d can be scalar if one tree has 1 point; np.min handles both + D[i, j] = float(np.min(d)) + return D + + +def split_supervoxel_helper( + binary_seg: np.ndarray, + source_coords: np.ndarray, + sink_coords: np.ndarray, + voxel_size: tuple, + verbose: bool = False, +): + voxel_size = np.array(voxel_size) + downsample = voxel_size.max() // voxel_size + + # 1) Connect seed teams first + A_aug, B_aug, okA, okB = connect_both_seeds_via_ridge( + binary_seg, + source_coords, + sink_coords, + voxel_size=voxel_size, + downsample=downsample, + vol_order="xyz", + vox_order="xyz", + seed_order="xyz", + snap_method="kdtree", + snap_kwargs=dict( + use_boundary=False, # disables boundary-only snapping for maximum safety + downsample=False, # avoids losing candidates + method="kdtree", + ), + verbose=verbose, + ) + if not (okA and okB): + raise RuntimeError( + "In-mask connection failed for at least one team; skipping split." + ) + + # 2) Run the corridor-free splitter with same snapping settings + return split_supervoxel_growing( + binary_seg, + A_aug, + B_aug, + voxel_size=voxel_size, + vol_order="xyz", + vox_order="xyz", + seed_order="xyz", + halo=1, + gamma_neck=1.6, + narrow_band_rel=0.08, + nb_dilate=1, + downsample_geodesic=(1, 2, 2), + enforce_single_cc=True, + raise_if_seed_split=True, + raise_if_multi_cc=True, + verbose=verbose, + snap_method="kdtree", + snap_kwargs=dict( + use_boundary=False, # match the connector for consistency + downsample=False, + method="kdtree", + ), + ) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py new file mode 100644 index 000000000..bb50505b0 --- /dev/null +++ b/pychunkedgraph/graph/edits_sv.py @@ -0,0 +1,439 @@ +""" +Manage new supervoxels after a supervoxel split. +""" + +from functools import reduce +import logging +import multiprocessing as mp +from typing import Callable, Iterable +from datetime import datetime +from collections import defaultdict, deque + +import fastremap +import numpy as np +from tqdm import tqdm +from pychunkedgraph.graph import ChunkedGraph, cache as cache_utils +from pychunkedgraph.graph.attributes import Connectivity +from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox, get_neighbors +from pychunkedgraph.graph.cutting_sv import ( + build_kdtrees_by_label, + pairwise_min_distance_two_sets, + split_supervoxel_helper, +) +from pychunkedgraph.graph.attributes import Hierarchy, OperationLogs +from pychunkedgraph.graph.edges import Edges +from pychunkedgraph.graph.types import empty_2d +from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph.utils import get_local_segmentation +from pychunkedgraph.graph.utils.serializers import serialize_uint64 +from pychunkedgraph.io.edges import get_chunk_edges + + +def _get_whole_sv( + cg: ChunkedGraph, node: basetypes.NODE_ID, min_coord, max_coord +) -> set: + cx_edges = [empty_2d] + explored_chunks = set() + explored_nodes = set([node]) + queue = deque([node]) + + while len(queue) > 0: + vertex = queue.popleft() + chunk = cg.get_chunk_coordinates(vertex) + chunks = get_neighbors(chunk, min_coord=min_coord, max_coord=max_coord) + + unexplored_chunks = [] + for _chunk in chunks: + if tuple(_chunk) not in explored_chunks: + unexplored_chunks.append(tuple(_chunk)) + + edges = get_chunk_edges(cg.meta.data_source.EDGES, unexplored_chunks) + explored_chunks.update(unexplored_chunks) + _cx_edges = edges["cross"].get_pairs() + cx_edges.append(_cx_edges) + _cx_edges = np.concatenate(cx_edges) + + mask = _cx_edges[:, 0] == vertex + neighbors = _cx_edges[mask][:, 1] + + neighbor_coords = cg.get_chunk_coordinates_multiple(neighbors) + min_mask = (neighbor_coords >= min_coord).all(axis=1) + max_mask = (neighbor_coords < max_coord).all(axis=1) + neighbors = neighbors[min_mask & max_mask] + + for neighbor in neighbors: + if neighbor in explored_nodes: + continue + explored_nodes.add(neighbor) + queue.append(neighbor) + return explored_nodes + + +def _update_chunk(args): + """ + For a chunk that overlaps bounding box for supervoxel split, + If chunk contains mask for the split supervoxel, + return indices of mask, old and new supervoxel IDs from this chunk. + """ + graph_id, chunk_coord, chunk_bbox, seg, result_seg, bb_start = args + cg = ChunkedGraph(graph_id=graph_id) + x, y, z = chunk_coord + chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) + + # TODO: remove these 3 lines, testing only + rr = cg.range_read_chunk(chunk_id) + max_node_id = max(rr.keys()) + cg.id_client.set_max_node_id(chunk_id, max_node_id) + + _s, _e = chunk_bbox - bb_start + og_chunk_seg = seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] + chunk_seg = result_seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] + + labels = fastremap.unique(chunk_seg[chunk_seg != 0]) + if labels.size < 2: + return None + + _indices = [] + _old_values = [] + _new_values = [] + for _id in labels: + _mask = chunk_seg == _id + if np.any(_mask): + _idx = np.unravel_index(np.flatnonzero(_mask)[0], og_chunk_seg.shape) + _og_value = og_chunk_seg[_idx] + _index = np.argwhere(_mask) + _indices.append(_index) + _ones = np.ones(len(_index), dtype=basetypes.NODE_ID) + _old_values.append(_ones * _og_value) + _new_values.append(_ones * cg.id_client.create_node_id(chunk_id)) + + _indices = np.concatenate(_indices) + (chunk_bbox[0] - bb_start) + _old_values = np.concatenate(_old_values) + _new_values = np.concatenate(_new_values) + return (_indices, _old_values, _new_values) + + +def _voxel_crop(bbs, bbe, bbs_, bbe_): + xS, yS, zS = bbs - bbs_ + xE, yE, zE = (None if i == 0 else -1 for i in bbe_ - bbe) + voxel_overlap_crop = np.s_[xS:xE, yS:yE, zS:zE] + logging.info(f"voxel_overlap_crop: {voxel_overlap_crop}") + return voxel_overlap_crop + + +def _parse_results(results, seg, bbs, bbe): + old_new_map = defaultdict(set) + for result in results: + if result: + indexer, old_values, new_values = result + seg[tuple(indexer.T)] = new_values + for old_sv, new_sv in zip(old_values, new_values): + old_new_map[old_sv].add(new_sv) + + assert np.all(seg.shape == bbe - bbs), f"{seg.shape} != {bbe - bbs}" + slices = tuple(slice(start, end) for start, end in zip(bbs, bbe)) + (slice(None),) + logging.info(f"slices {slices}") + return seg, old_new_map, slices + + +def _get_new_edges( + edges_info: tuple, + sv_ids: np.ndarray, + old_new_map: dict, + distances: np.ndarray, + dist_vec: Callable, + new_dist_vec: Callable, +): + THRESHOLD = 10 + new_edges, new_affs, new_areas = [], [], [] + edges, affinities, areas = edges_info + + for old, new in old_new_map.items(): + logging.info(f"old and new {old, new}") + new_ids = np.array(list(new), dtype=basetypes.NODE_ID) + edges_m = np.any(edges == old, axis=1) + selected_edges = edges[edges_m] + sel_m = selected_edges != old + assert np.all(np.sum(sel_m, axis=1) == 1) + + partners = selected_edges[sel_m] + active_m = np.isin(partners, sv_ids) + + logging.info(f"sv_ids: {np.sum(sv_ids > 0)}") + logging.info(f"edges: {edges.shape} {np.sum(edges_m)} {np.sum(sel_m)}") + logging.info(f"selected_edges: {selected_edges.shape}") + + # inactive + for new_id in new_ids: + _a = [[new_id] * np.sum(~active_m), partners[~active_m]] + new_edges.extend(np.array(_a, dtype=np.uint64).T) + new_affs.extend(affinities[edges_m][np.any(sel_m, axis=1)][~active_m]) + new_areas.extend(areas[edges_m][np.any(sel_m, axis=1)][~active_m]) + + # active + active_partners_ = partners[active_m] + active_affs_ = affinities[edges_m][np.any(sel_m, axis=1)][active_m] + active_areas_ = areas[edges_m][np.any(sel_m, axis=1)][active_m] + + logging.info(f"partners: {partners.shape} {active_partners_.shape}") + + active_partners = [] + active_affs = [] + active_areas = [] + for i in range(len(active_partners_)): + remapped_ = old_new_map.get(active_partners_[i], [active_partners_[i]]) + active_partners.extend(remapped_) + active_affs.extend([active_affs_[i]] * len(remapped_)) + active_areas.extend([active_areas_[i]] * len(remapped_)) + + logging.info(f"new_ids, active_partners: {new_ids, len(active_partners)}") + logging.info(f"new_dist_vec(new_ids): {new_dist_vec(new_ids)}") + logging.info(f"dist_vec(active_partners): {dist_vec(active_partners)}") + distances_ = distances[new_dist_vec(new_ids)][:, dist_vec(active_partners)].T + for i, _ in enumerate(active_partners): + new_ids_ = new_ids[distances_[i] < THRESHOLD] + if len(new_ids_): + _a = [new_ids_, [active_partners[i]] * len(new_ids_)] + new_edges.extend(np.array(_a, dtype=np.uint64).T) + new_affs.extend([active_affs[i]] * len(new_ids_)) + new_areas.extend([active_areas[i]] * len(new_ids_)) + else: + close_new_sv_id = new_ids[np.argmin(distances_[i])] + _a = [close_new_sv_id, active_partners[i]] + new_edges.append(np.array(_a, dtype=np.uint64)) + new_affs.append(active_affs[i]) + new_areas.append(active_areas[i]) + + # edges between split fragments + for i in range(len(new_ids)): + for j in range(i + 1, len(new_ids)): # includes no selfedges + _a = [new_ids[i], new_ids[j]] + new_edges.append(np.array(_a, dtype=np.uint64)) + new_affs.append(0.001) + new_areas.append(0) + + affinites = np.array(new_affs, dtype=basetypes.EDGE_AFFINITY) + areas = np.array(new_areas, dtype=basetypes.EDGE_AREA) + edges = np.array(new_edges, dtype=basetypes.NODE_ID) + edges, idx = np.unique(edges, return_index=True, axis=0) + return edges, affinites[idx], areas[idx] + + +def _update_edges( + cg: ChunkedGraph, + sv_ids: np.ndarray, + root_id: basetypes.NODE_ID, + bbox: np.ndarray, + new_seg: np.ndarray, + old_new_map: dict, +): + old_new_map = dict(old_new_map) + kdtrees, _ = build_kdtrees_by_label(new_seg) + distance_map = dict(zip(kdtrees.keys(), np.arange(len(kdtrees)))) + dist_vec = np.vectorize(distance_map.get) + + _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) + edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) + + edges = edges_.get_pairs() + affinities = edges_.affinities + areas = edges_.areas + + edges = np.sort(edges, axis=1) + _, edges_idx = np.unique(edges, axis=0, return_index=True) + edges_idx = edges_idx[edges[edges_idx, 0] != edges[edges_idx, 1]] + + edges = edges[edges_idx] + affinities = affinities[edges_idx] + areas = areas[edges_idx] + logging.info(f"edges.shape, affinities.shape {edges.shape, affinities.shape}") + + new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) + new_kdtrees = [kdtrees[k] for k in new_ids] + new_disance_map = dict(zip(new_ids, np.arange(len(new_ids)))) + new_dist_vec = np.vectorize(new_disance_map.get) + distances = pairwise_min_distance_two_sets(new_kdtrees, list(kdtrees.values())) + return _get_new_edges( + (edges, affinities, areas), + sv_ids, + old_new_map, + distances, + dist_vec, + new_dist_vec, + ) + + +def _add_new_edges(cg: ChunkedGraph, edges_tuple: tuple, time_stamp: datetime = None): + edges_, affinites_, areas_ = edges_tuple + logging.info(f"new edges: {edges_.shape}") + + nodes = fastremap.unique(edges_) + chunks = cg.get_chunk_ids_from_node_ids(cg.get_parents(nodes)) + node_chunks = dict(zip(nodes, chunks)) + + edges = np.r_[edges_, edges_[:, ::-1]] + affinites = np.r_[affinites_, affinites_] + areas = np.r_[areas_, areas_] + + rows = [] + chunks_arr = fastremap.remap(edges, node_chunks) + for chunk_id in np.unique(chunks): + val_dict = {} + mask = chunks_arr[:, 0] == chunk_id + val_dict[Connectivity.SplitEdges] = edges[mask] + val_dict[Connectivity.Affinity] = affinites[mask] + val_dict[Connectivity.Area] = areas[mask] + rows.append( + cg.client.mutate_row( + serialize_uint64(chunk_id, fake_edges=True), + val_dict=val_dict, + time_stamp=time_stamp, + ) + ) + logging.info(f"writing {edges[mask].shape} edges to {chunk_id}") + return rows + + +def split_supervoxel( + cg: ChunkedGraph, + sv_id: basetypes.NODE_ID, + source_coords: np.ndarray, + sink_coords: np.ndarray, + operation_id: int, + verbose: bool = True, + time_stamp: datetime = None, +) -> dict[int, set]: + """ + Lookups coordinates of given supervoxel in segmentation. + Finds its counterparts split by chunk boundaries and splits them as a whole. + Updates the segmentation with new IDs. + """ + vol_start = cg.meta.voxel_bounds[:, 0] + vol_end = cg.meta.voxel_bounds[:, 1] + chunk_size = cg.meta.graph_config.CHUNK_SIZE + _coords = np.concatenate([source_coords, sink_coords]) + _padding = np.array([64] * 3) / cg.meta.resolution + + bbs = np.clip((np.min(_coords, 0) - _padding).astype(int), vol_start, vol_end) + bbe = np.clip((np.max(_coords, 0) + _padding).astype(int), vol_start, vol_end) + chunk_min, chunk_max = bbs // chunk_size, np.ceil(bbe / chunk_size).astype(int) + bbs, bbe = chunk_min * chunk_size, chunk_max * chunk_size + logging.info(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}") + logging.info(f"{chunk_size}; {_padding}; {(bbs, bbe)}; {(chunk_min, chunk_max)}") + + cut_supervoxels = _get_whole_sv(cg, sv_id, min_coord=chunk_min, max_coord=chunk_max) + supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) + logging.info(f"{sv_id} -> {cut_supervoxels}") + + # one voxel overlap for neighbors + bbs_ = np.clip(bbs - 1, vol_start, vol_end) + bbe_ = np.clip(bbe + 1, vol_start, vol_end) + seg = get_local_segmentation(cg.meta, bbs_, bbe_).squeeze() + binary_seg = np.isin(seg, supervoxel_ids) + logging.info(f"{seg.shape}; {binary_seg.shape}; {bbs, bbe}; {bbs_, bbe_}") + + voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) + split_result = split_supervoxel_helper( + binary_seg[voxel_overlap_crop], + source_coords - bbs, + sink_coords - bbs, + cg.meta.resolution, + verbose=verbose, + ) + logging.info(f"split_result: {split_result.shape}") + + chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) + tasks = [ + (cg.graph_id, *item, seg[voxel_overlap_crop], split_result, bbs) + for item in chunks_bbox_map.items() + ] + logging.info(f"tasks count: {len(tasks)}") + with mp.Pool() as pool: + results = [*tqdm(pool.imap_unordered(_update_chunk, tasks), total=len(tasks))] + seg_cropped = seg[voxel_overlap_crop].copy() + new_seg, old_new_map, slices = _parse_results(results, seg_cropped, bbs, bbe) + + seg_roots = seg.copy() + sv_ids = fastremap.unique(seg) + roots = cg.get_roots(sv_ids) + seg_roots = fastremap.remap(seg_roots, dict(zip(sv_ids, roots)), in_place=True) + + root = cg.get_root(sv_id) + logging.info(f"root {root}") + + seg_masked = seg.copy() + seg_masked[seg_roots != root] = 0 + sv_ids = fastremap.unique(seg_masked) + + seg_masked[voxel_overlap_crop] = new_seg + edges_tuple = _update_edges( + cg, sv_ids, root, np.array([bbs, bbe]), seg_masked, old_new_map + ) + + rows0 = copy_parents_and_add_lineage(cg, operation_id, old_new_map) + rows1 = _add_new_edges(cg, edges_tuple, time_stamp=time_stamp) + rows = rows0 + rows1 + logging.info(f"{operation_id}: writing {len(rows)} new rows") + + cg.client.write(rows) + cg.meta.ws_ocdbt[slices] = new_seg[..., np.newaxis] + return old_new_map, edges_tuple + + +def copy_parents_and_add_lineage( + cg: ChunkedGraph, + operation_id: int, + old_new_map: dict, +) -> list: + """ + Copy parents column from `old_id` to each of `new_ids`. + This makes it easy to get old hierarchy with `new_ids` using an older timestamp. + Link `old_id` and `new_ids` to create a lineage at supervoxel layer. + Returns a list of mutations to be persisted. + """ + result = [] + parents = set() + old_new_map = {k: list(v) for k, v in old_new_map.items()} + parent_cells_map = cg.client.read_nodes( + node_ids=list(old_new_map.keys()), properties=Hierarchy.Parent + ) + for old_id, new_ids in old_new_map.items(): + for new_id in new_ids: + val_dict = { + Hierarchy.FormerIdentity: np.array([old_id], dtype=basetypes.NODE_ID), + OperationLogs.OperationID: operation_id, + } + result.append(cg.client.mutate_row(serialize_uint64(new_id), val_dict)) + for cell in parent_cells_map[old_id]: + cache_utils.update(cg.cache.parents_cache, [new_id], cell.value) + parents.add(cell.value) + result.append( + cg.client.mutate_row( + serialize_uint64(new_id), + {Hierarchy.Parent: cell.value}, + time_stamp=cell.timestamp, + ) + ) + val_dict = {Hierarchy.NewIdentity: np.array(new_ids, dtype=basetypes.NODE_ID)} + result.append(cg.client.mutate_row(serialize_uint64(old_id), val_dict)) + + children_cells_map = cg.client.read_nodes( + node_ids=list(parents), properties=Hierarchy.Child + ) + for parent, children_cells in children_cells_map.items(): + assert len(children_cells) == 1, children_cells + for cell in children_cells: + logging.info(f"{parent}: {cell.value}") + mask = np.isin(cell.value, list(old_new_map.keys())) + replace = np.concatenate([old_new_map[x] for x in cell.value[mask]]) + children = np.concatenate([cell.value[~mask], replace]) + logging.info(f"{parent}: {children}") + cg.cache.children_cache[parent] = children + result.append( + cg.client.mutate_row( + serialize_uint64(parent), + {Hierarchy.Child: children}, + time_stamp=cell.timestamp, + ) + ) + return result diff --git a/pychunkedgraph/graph/types.py b/pychunkedgraph/graph/types.py index f6d4395d9..fb7789cf1 100644 --- a/pychunkedgraph/graph/types.py +++ b/pychunkedgraph/graph/types.py @@ -7,7 +7,8 @@ empty_1d = np.empty(0, dtype=basetypes.NODE_ID) empty_2d = np.empty((0, 2), dtype=basetypes.NODE_ID) - +empty_affinities = np.empty(0, dtype=basetypes.EDGE_AFFINITY) +empty_areas = np.empty(0, dtype=basetypes.EDGE_AREA) """ An Agglomeration is syntactic sugar for representing diff --git a/pychunkedgraph/graph/utils/__init__.py b/pychunkedgraph/graph/utils/__init__.py index e69de29bb..c1d56e0fe 100644 --- a/pychunkedgraph/graph/utils/__init__.py +++ b/pychunkedgraph/graph/utils/__init__.py @@ -0,0 +1 @@ +from .generic import get_local_segmentation \ No newline at end of file diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index d48da9cf2..0b5cf5c5c 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -151,3 +151,15 @@ def get_parents_at_timestamp(nodes, parents_ts_map, time_stamp, unique: bool = F except KeyError: skipped_nodes.append(node) return list(parents), skipped_nodes + + + +def get_local_segmentation(meta, bbox_start, bbox_end) -> np.ndarray: + result = None + xL, yL, zL = bbox_start + xH, yH, zH = bbox_end + if meta.ocdbt_seg: + result = meta.ws_ocdbt[xL:xH, yL:yH, zL:zH].read().result() + else: + result = meta.cv[xL:xH, yL:yH, zL:zH] + return result diff --git a/pychunkedgraph/graph/utils/id_helpers.py b/pychunkedgraph/graph/utils/id_helpers.py index 5cbc3c061..43faf2160 100644 --- a/pychunkedgraph/graph/utils/id_helpers.py +++ b/pychunkedgraph/graph/utils/id_helpers.py @@ -10,6 +10,7 @@ import numpy as np from pychunkedgraph.graph import basetypes +from .generic import get_local_segmentation from ..meta import ChunkedGraphMeta from ..chunks import utils as chunk_utils @@ -140,10 +141,7 @@ def get_atomic_ids_from_coords( ] ) - local_sv_seg = meta.cv[ - bbox[0, 0] : bbox[1, 0], bbox[0, 1] : bbox[1, 1], bbox[0, 2] : bbox[1, 2] - ].squeeze() - + local_sv_seg = get_local_segmentation(meta, bbox[0], bbox[1]).squeeze() # limit get_roots calls to the relevant areas of the data lower_bs = np.floor( (np.array(coordinates_nm) - max_dist_nm) / np.array(meta.resolution) - bbox[0] diff --git a/pychunkedgraph/meshing/meshgen_utils.py b/pychunkedgraph/meshing/meshgen_utils.py index 2c150a785..8fbe237c3 100644 --- a/pychunkedgraph/meshing/meshgen_utils.py +++ b/pychunkedgraph/meshing/meshgen_utils.py @@ -1,19 +1,13 @@ import re -import multiprocessing as mp -from time import time -from typing import List -from typing import Dict -from typing import Tuple from typing import Sequence from functools import lru_cache import numpy as np -from cloudvolume import CloudVolume from cloudvolume.lib import Vec -from multiwrapper import multiprocessing_utils as mu from pychunkedgraph.graph.basetypes import NODE_ID # noqa from ..graph.types import empty_1d +from pychunkedgraph.graph.utils import get_local_segmentation def str_to_slice(slice_str: str): @@ -157,9 +151,7 @@ def get_json_info(cg): def get_ws_seg_for_chunk(cg, chunk_id, mip, overlap_vx=1): - cv = CloudVolume(cg.meta.cv.cloudpath, mip=mip, fill_missing=True) mip_diff = mip - cg.meta.cv.mip - mip_chunk_size = np.array(cg.meta.graph_config.CHUNK_SIZE, dtype=int) / np.array( [2**mip_diff, 2**mip_diff, 1] ) @@ -175,11 +167,5 @@ def get_ws_seg_for_chunk(cg, chunk_id, mip, overlap_vx=1): cg.meta.cv.mip_voxel_offset(mip), cg.meta.cv.mip_voxel_offset(mip) + cg.meta.cv.mip_volume_size(mip), ) - - ws_seg = cv[ - chunk_start[0] : chunk_end[0], - chunk_start[1] : chunk_end[1], - chunk_start[2] : chunk_end[2], - ].squeeze() - + ws_seg = get_local_segmentation(cg.meta, chunk_start, chunk_end).squeeze() return ws_seg diff --git a/requirements.in b/requirements.in index 3343123b1..2d8112537 100644 --- a/requirements.in +++ b/requirements.in @@ -14,6 +14,9 @@ pyyaml cachetools werkzeug tensorstore +edt +connected-components-3d +scikit-image # PyPI only: cloud-files>=6.0.0 From 14338c63b266961bbe2d53c3cec62788f44afab4 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 10 Sep 2025 21:40:37 +0000 Subject: [PATCH 169/367] feat(sv_split): sv split in frontend --- pychunkedgraph/app/segmentation/common.py | 95 ++++++++++++++++------- pychunkedgraph/graph/cutting.py | 20 ++--- pychunkedgraph/graph/exceptions.py | 18 +++++ pychunkedgraph/graph/operation.py | 11 ++- 4 files changed, 105 insertions(+), 39 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 293b46981..61466dc54 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -9,16 +9,13 @@ import numpy as np import pandas as pd +import fastremap from flask import current_app, g, jsonify, make_response, request from pytz import UTC from pychunkedgraph import __version__ from pychunkedgraph.app import app_utils -from pychunkedgraph.graph import ( - attributes, - cutting, - segmenthistory, -) +from pychunkedgraph.graph import attributes, cutting, segmenthistory, ChunkedGraph from pychunkedgraph.graph import ( edges as cg_edges, ) @@ -26,6 +23,8 @@ exceptions as cg_exceptions, ) from pychunkedgraph.graph.analysis import pathing +from pychunkedgraph.graph.attributes import OperationLogs +from pychunkedgraph.graph.edits_sv import split_supervoxel from pychunkedgraph.graph.misc import get_contact_sites from pychunkedgraph.graph.operation import GraphEditOperation from pychunkedgraph.graph import basetypes @@ -396,7 +395,7 @@ def handle_merge(table_id, allow_same_segment_merge=False): current_app.operation_id = ret.operation_id if ret.new_root_ids is None: raise cg_exceptions.InternalServerError( - "Could not merge selected " "supervoxel." + f"{ret.operation_id}: Could not merge selected supervoxels." ) current_app.logger.debug(("lvl2_nodes:", ret.new_lvl2_ids)) @@ -410,24 +409,10 @@ def handle_merge(table_id, allow_same_segment_merge=False): ### SPLIT ---------------------------------------------------------------------- -def handle_split(table_id): - current_app.table_id = table_id - user_id = str(g.auth_user.get("id", current_app.user_id)) - - data = json.loads(request.data) - is_priority = request.args.get("priority", True, type=str2bool) - remesh = request.args.get("remesh", True, type=str2bool) - mincut = request.args.get("mincut", True, type=str2bool) - +def _get_sources_and_sinks(cg: ChunkedGraph, data): current_app.logger.debug(data) - - # Call ChunkedGraph - cg = app_utils.get_cg(table_id, skip_cache=True) node_idents = [] - node_ident_map = { - "sources": 0, - "sinks": 1, - } + node_ident_map = {"sources": 0, "sinks": 1} coords = [] node_ids = [] @@ -440,18 +425,74 @@ def handle_split(table_id): node_ids = np.array(node_ids, dtype=np.uint64) coords = np.array(coords) node_idents = np.array(node_idents) + + start = time.time() sv_ids = app_utils.handle_supervoxel_id_lookup(cg, coords, node_ids) + current_app.logger.info(f"SV lookup took {time.time() - start}s.") current_app.logger.debug( {"node_id": node_ids, "sv_id": sv_ids, "node_ident": node_idents} ) + source_ids = sv_ids[node_idents == 0] + sink_ids = sv_ids[node_idents == 1] + source_coords = coords[node_idents == 0] + sink_coords = coords[node_idents == 1] + return (source_ids, sink_ids, source_coords, sink_coords) + + +def handle_split(table_id): + current_app.table_id = table_id + user_id = str(g.auth_user.get("id", current_app.user_id)) + + data = json.loads(request.data) + is_priority = request.args.get("priority", True, type=str2bool) + remesh = request.args.get("remesh", True, type=str2bool) + mincut = request.args.get("mincut", True, type=str2bool) + + cg = app_utils.get_cg(table_id, skip_cache=True) + sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) try: ret = cg.remove_edges( user_id=user_id, - source_ids=sv_ids[node_idents == 0], - sink_ids=sv_ids[node_idents == 1], - source_coords=coords[node_idents == 0], - sink_coords=coords[node_idents == 1], + source_ids=sources, + sink_ids=sinks, + source_coords=source_coords, + sink_coords=sink_coords, + mincut=mincut, + ) + except cg_exceptions.SupervoxelSplitRequiredError as e: + current_app.logger.info(e) + sources_remapped = fastremap.remap( + sources, + e.sv_remapping, + preserve_missing_labels=True, + in_place=False, + ) + sinks_remapped = fastremap.remap( + sinks, + e.sv_remapping, + preserve_missing_labels=True, + in_place=False, + ) + overlap_mask = np.isin(sources_remapped, sinks_remapped) + for sv_to_split in np.unique(sources_remapped[overlap_mask]): + _mask0 = sources_remapped[sources_remapped == sv_to_split] + _mask1 = sinks_remapped[sinks_remapped == sv_to_split] + split_supervoxel( + cg, + sv_to_split, + source_coords[_mask0], + sink_coords[_mask1], + e.operation_id, + ) + + sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) + ret = cg.remove_edges( + user_id=user_id, + source_ids=sources, + sink_ids=sinks, + source_coords=source_coords, + sink_coords=sink_coords, mincut=mincut, do_sanity_check=True, ) @@ -463,7 +504,7 @@ def handle_split(table_id): current_app.operation_id = ret.operation_id if ret.new_root_ids is None: raise cg_exceptions.InternalServerError( - "Could not split selected segment groups." + f"{ret.operation_id}: Could not split selected segment groups." ) current_app.logger.debug(("after split:", ret.new_root_ids)) diff --git a/pychunkedgraph/graph/cutting.py b/pychunkedgraph/graph/cutting.py index c5c24cf51..bd236397c 100644 --- a/pychunkedgraph/graph/cutting.py +++ b/pychunkedgraph/graph/cutting.py @@ -1,15 +1,11 @@ -import collections import fastremap import numpy as np import itertools -import logging import time import graph_tool import graph_tool.flow -from typing import Dict from typing import Tuple -from typing import Optional from typing import Sequence from typing import Iterable @@ -17,7 +13,7 @@ from pychunkedgraph.graph import basetypes from .utils.generic import get_bounding_box from .edges import Edges -from .exceptions import PreconditionError +from .exceptions import PreconditionError, SupervoxelSplitRequiredError from .exceptions import PostconditionError DEBUG_MODE = False @@ -116,6 +112,10 @@ def __init__( self.cross_chunk_edge_remapping, ) = merge_cross_chunk_edges_graph_tool(cg_edges, cg_affs) + # save this representative mapping for supervoxel splitting + # passed along with SupervoxelSplitRequiredError + self.sv_remapping = dict(complete_mapping) + dt = time.time() - time_start if logger is not None: logger.debug("Cross edge merging: %.2fms" % (dt * 1000)) @@ -233,9 +233,10 @@ def _augment_mincut_capacity(self): self.source_graph_ids, ) except AssertionError: - raise PreconditionError( + raise SupervoxelSplitRequiredError( "Paths between source or sink points irreparably overlap other labels from other side. " - "Check that labels are correct and consider spreading points out farther." + "Check that labels are correct and consider spreading points out farther.", + self.sv_remapping ) paths_e_s_no, paths_e_y_no, do_check = flatgraph.remove_overlapping_edges( @@ -586,11 +587,12 @@ def _sink_and_source_connectivity_sanity_check(self, cut_edge_set): # but return a flag to return a message to the user illegal_split = True else: - raise PreconditionError( + raise SupervoxelSplitRequiredError( "Failed to find a cut that separated the sources from the sinks. " "Please try another cut that partitions the sets cleanly if possible. " "If there is a clear path between all the supervoxels in each set, " - "that helps the mincut algorithm." + "that helps the mincut algorithm.", + self.sv_remapping ) except IsolatingCutException as e: if self.split_preview: diff --git a/pychunkedgraph/graph/exceptions.py b/pychunkedgraph/graph/exceptions.py index f41cc2971..496f55e4f 100644 --- a/pychunkedgraph/graph/exceptions.py +++ b/pychunkedgraph/graph/exceptions.py @@ -83,3 +83,21 @@ class GatewayTimeout(ServerError): """Exception mapping a ``504 Gateway Timeout`` response.""" status_code = http_client.GATEWAY_TIMEOUT + + +class SupervoxelSplitRequiredError(ChunkedGraphError): + """ + Raised when supervoxel splitting is necessary. + Edit process should catch this error and retry after supervoxel has been split. + Saves remapping required for detecting which supervoxels need to be split. + """ + + def __init__( + self, + message: str, + sv_remapping: dict, + operation_id: int | None = None, + ): + super().__init__(message) + self.sv_remapping = sv_remapping + self.operation_id = operation_id diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 14e5f7715..5bf221e01 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -29,7 +29,7 @@ from pychunkedgraph.graph import serializers from .cache import CacheService from .cutting import run_multicut -from .exceptions import PreconditionError +from .exceptions import PreconditionError, SupervoxelSplitRequiredError from .exceptions import PostconditionError from .utils.generic import get_bounding_box as get_bbox from pychunkedgraph.graph import get_valid_timestamp @@ -460,6 +460,10 @@ def execute( new_lvl2_ids=new_lvl2_ids, old_root_ids=root_ids, ) + except SupervoxelSplitRequiredError as err: + raise SupervoxelSplitRequiredError( + str(err), err.sv_remapping, operation_id=lock.operation_id + ) from err except PreconditionError as err: self.cg.cache = None raise PreconditionError(err) from err @@ -889,9 +893,10 @@ def __init__( self.disallow_isolating_cut = disallow_isolating_cut self.do_sanity_check = do_sanity_check if np.any(np.isin(self.sink_ids, self.source_ids)): - raise PreconditionError( + raise SupervoxelSplitRequiredError( "Supervoxels exist in both sink and source, " - "try placing the points further apart." + "try placing the points further apart.", + None, ) ids = np.concatenate([self.source_ids, self.sink_ids]).astype(basetypes.NODE_ID) From fb5049704fee50f4b3ec3921a94af7fcaf4175ca Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 26 Feb 2026 23:38:23 +0000 Subject: [PATCH 170/367] fix(sv_split): update multicut tests, add other tests --- pychunkedgraph/app/segmentation/common.py | 4 +- pychunkedgraph/graph/edits_sv.py | 9 +- .../tests/graph/test_chunks_utils.py | 98 +++ pychunkedgraph/tests/graph/test_exceptions.py | 24 + .../tests/graph/test_graph_build.py | 8 +- pychunkedgraph/tests/graph/test_meta.py | 73 +- pychunkedgraph/tests/graph/test_multicut.py | 2 +- pychunkedgraph/tests/graph/test_operation.py | 8 +- .../tests/graph/test_utils_generic.py | 28 + .../tests/graph/test_utils_id_helpers.py | 1 + pychunkedgraph/tests/test_cutting_sv.py | 729 ++++++++++++++++++ pychunkedgraph/tests/test_edits_sv.py | 220 ++++++ pychunkedgraph/tests/test_ocdbt.py | 141 ++++ 13 files changed, 1329 insertions(+), 16 deletions(-) create mode 100644 pychunkedgraph/tests/test_cutting_sv.py create mode 100644 pychunkedgraph/tests/test_edits_sv.py create mode 100644 pychunkedgraph/tests/test_ocdbt.py diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 61466dc54..0a9c1789f 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -476,8 +476,8 @@ def handle_split(table_id): ) overlap_mask = np.isin(sources_remapped, sinks_remapped) for sv_to_split in np.unique(sources_remapped[overlap_mask]): - _mask0 = sources_remapped[sources_remapped == sv_to_split] - _mask1 = sinks_remapped[sinks_remapped == sv_to_split] + _mask0 = sources_remapped == sv_to_split + _mask1 = sinks_remapped == sv_to_split split_supervoxel( cg, sv_to_split, diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index bb50505b0..4ac3a40f7 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -56,10 +56,11 @@ def _get_whole_sv( mask = _cx_edges[:, 0] == vertex neighbors = _cx_edges[mask][:, 1] - neighbor_coords = cg.get_chunk_coordinates_multiple(neighbors) - min_mask = (neighbor_coords >= min_coord).all(axis=1) - max_mask = (neighbor_coords < max_coord).all(axis=1) - neighbors = neighbors[min_mask & max_mask] + if len(neighbors) > 0: + neighbor_coords = cg.get_chunk_coordinates_multiple(neighbors) + min_mask = (neighbor_coords >= min_coord).all(axis=1) + max_mask = (neighbor_coords < max_coord).all(axis=1) + neighbors = neighbors[min_mask & max_mask] for neighbor in neighbors: if neighbor in explored_nodes: diff --git a/pychunkedgraph/tests/graph/test_chunks_utils.py b/pychunkedgraph/tests/graph/test_chunks_utils.py index 1d7764561..5ff14e417 100644 --- a/pychunkedgraph/tests/graph/test_chunks_utils.py +++ b/pychunkedgraph/tests/graph/test_chunks_utils.py @@ -125,9 +125,107 @@ def test_none(self, gen_graph): assert chunk_utils.normalize_bounding_box(graph.meta, None, False) is None +class TestChunksOverlappingBbox: + def test_single_chunk(self): + result = chunk_utils.chunks_overlapping_bbox( + bbox_min=[0, 0, 0], bbox_max=[63, 63, 63], chunk_size=[64, 64, 64] + ) + assert (0, 0, 0) in result + assert len(result) == 1 + + def test_multiple_chunks(self): + result = chunk_utils.chunks_overlapping_bbox( + bbox_min=[0, 0, 0], bbox_max=[128, 64, 64], chunk_size=[64, 64, 64] + ) + assert (0, 0, 0) in result + assert (1, 0, 0) in result + assert len(result) >= 2 + + def test_clipping(self): + result = chunk_utils.chunks_overlapping_bbox( + bbox_min=[10, 10, 10], bbox_max=[60, 60, 60], chunk_size=[64, 64, 64] + ) + assert (0, 0, 0) in result + bbox = result[(0, 0, 0)] + np.testing.assert_array_equal(bbox[0], [10, 10, 10]) + np.testing.assert_array_equal(bbox[1], [60, 60, 60]) + + def test_multi_chunk_clipping(self): + result = chunk_utils.chunks_overlapping_bbox( + bbox_min=[30, 0, 0], bbox_max=[100, 64, 64], chunk_size=[64, 64, 64] + ) + # chunk (0,0,0): min clipped to 30, max clipped to 64 + assert (0, 0, 0) in result + assert (1, 0, 0) in result + np.testing.assert_array_equal(result[(0, 0, 0)][0], [30, 0, 0]) + + +class TestGetNeighbors: + def test_inclusive(self): + neighbors = chunk_utils.get_neighbors([1, 1, 1], inclusive=True) + # 3^3 = 27 including the center + assert len(neighbors) == 27 + + def test_exclusive(self): + neighbors = chunk_utils.get_neighbors([1, 1, 1], inclusive=False) + assert len(neighbors) == 26 + # Center should not be in neighbors + has_center = any(np.array_equal(n, [1, 1, 1]) for n in neighbors) + assert not has_center + + def test_min_coord_clipping(self): + neighbors = chunk_utils.get_neighbors( + [0, 0, 0], inclusive=True, min_coord=[0, 0, 0] + ) + # Only non-negative coordinates; the 0,0,0 center has offsets going to -1,-1,-1 + for n in neighbors: + assert np.all(n >= 0) + + def test_max_coord_clipping(self): + neighbors = chunk_utils.get_neighbors( + [5, 5, 5], inclusive=True, max_coord=[5, 5, 5] + ) + for n in neighbors: + assert np.all(n <= 5) + + def test_corner_with_bounds(self): + neighbors = chunk_utils.get_neighbors( + [0, 0, 0], inclusive=True, min_coord=[0, 0, 0], max_coord=[2, 2, 2] + ) + # Should only include non-negative neighbors + for n in neighbors: + assert np.all(n >= 0) + assert np.all(n <= 2) + + +class TestGetL2ChunkIdsAlongBoundary: + def test_basic(self, gen_graph): + graph = gen_graph(n_layers=5) + coord_a = (0, 0, 0) + coord_b = (1, 0, 0) + chunk_utils.get_l2chunkids_along_boundary.cache_clear() + ids_a, ids_b = chunk_utils.get_l2chunkids_along_boundary( + graph.meta, 3, coord_a, coord_b + ) + assert len(ids_a) > 0 + assert len(ids_b) > 0 + + def test_with_padding(self, gen_graph): + graph = gen_graph(n_layers=5) + coord_a = (0, 0, 0) + coord_b = (1, 0, 0) + chunk_utils.get_l2chunkids_along_boundary.cache_clear() + ids_a, ids_b = chunk_utils.get_l2chunkids_along_boundary( + graph.meta, 3, coord_a, coord_b, padding=1 + ) + assert len(ids_a) > 0 + assert len(ids_b) > 0 + + class TestGetBoundingChildrenChunks: def test_basic(self, gen_graph): graph = gen_graph(n_layers=5) + chunk_utils.get_bounding_children_chunks.cache_clear() result = chunk_utils.get_bounding_children_chunks(graph.meta, 3, (0, 0, 0), 2) assert len(result) > 0 assert result.shape[1] == 3 diff --git a/pychunkedgraph/tests/graph/test_exceptions.py b/pychunkedgraph/tests/graph/test_exceptions.py index 82de4c063..1320360f4 100644 --- a/pychunkedgraph/tests/graph/test_exceptions.py +++ b/pychunkedgraph/tests/graph/test_exceptions.py @@ -19,6 +19,7 @@ ServerError, InternalServerError, GatewayTimeout, + SupervoxelSplitRequiredError, ) @@ -69,3 +70,26 @@ def test_internal_server_error(self): def test_gateway_timeout(self): assert GatewayTimeout.status_code == GATEWAY_TIMEOUT + + +class TestSupervoxelSplitRequiredError: + def test_inherits_chunkedgraph_error(self): + assert issubclass(SupervoxelSplitRequiredError, ChunkedGraphError) + + def test_stores_sv_remapping(self): + remap = {1: 10, 2: 20} + err = SupervoxelSplitRequiredError("split needed", remap) + assert err.sv_remapping == remap + assert str(err) == "split needed" + + def test_stores_operation_id(self): + err = SupervoxelSplitRequiredError("msg", {}, operation_id=42) + assert err.operation_id == 42 + + def test_operation_id_default_none(self): + err = SupervoxelSplitRequiredError("msg", {}) + assert err.operation_id is None + + def test_can_be_caught_as_chunkedgraph_error(self): + with pytest.raises(ChunkedGraphError): + raise SupervoxelSplitRequiredError("test", {1: 2}) diff --git a/pychunkedgraph/tests/graph/test_graph_build.py b/pychunkedgraph/tests/graph/test_graph_build.py index 575141abb..e773d1af3 100644 --- a/pychunkedgraph/tests/graph/test_graph_build.py +++ b/pychunkedgraph/tests/graph/test_graph_build.py @@ -49,7 +49,7 @@ def test_build_single_node(self, gen_graph): assert len(children) == 1 and children[0] == to_label(cg, 1, 0, 0, 0, 0) # Make sure there are not any more entries in the table # include counters, meta and version rows - assert len(res.rows) == 1 + 1 + 1 + 1 + 1 + assert len(res.rows) == 1 + 1 + 2 + 1 + 1 @pytest.mark.timeout(30) def test_build_single_edge(self, gen_graph): @@ -104,7 +104,7 @@ def test_build_single_edge(self, gen_graph): # Make sure there are not any more entries in the table # include counters, meta and version rows - assert len(res.rows) == 2 + 1 + 1 + 1 + 1 + assert len(res.rows) == 2 + 1 + 2 + 1 + 1 @pytest.mark.timeout(30) def test_build_single_across_edge(self, gen_graph): @@ -212,7 +212,7 @@ def test_build_single_across_edge(self, gen_graph): # Make sure there are not any more entries in the table # include counters, meta and version rows - assert len(res.rows) == 2 + 2 + 1 + 3 + 1 + 1 + assert len(res.rows) == 2 + 2 + 1 + 5 + 1 + 1 @pytest.mark.timeout(30) def test_build_single_edge_and_single_across_edge(self, gen_graph): @@ -326,7 +326,7 @@ def test_build_single_edge_and_single_across_edge(self, gen_graph): # Make sure there are not any more entries in the table # include counters, meta and version rows - assert len(res.rows) == 3 + 2 + 1 + 3 + 1 + 1 + assert len(res.rows) == 3 + 2 + 1 + 5 + 1 + 1 @pytest.mark.timeout(120) def test_build_big_graph(self, gen_graph): diff --git a/pychunkedgraph/tests/graph/test_meta.py b/pychunkedgraph/tests/graph/test_meta.py index f94b7d792..999db2234 100644 --- a/pychunkedgraph/tests/graph/test_meta.py +++ b/pychunkedgraph/tests/graph/test_meta.py @@ -442,7 +442,9 @@ def test_ws_cv_redis_cached(self, mock_get_redis, mock_cv_cls): result = meta.ws_cv assert result is mock_cv_instance - mock_cv_cls.assert_called_once_with("gs://bucket/ws", info=cached_info) + mock_cv_cls.assert_called_once_with( + "gs://bucket/ws", info=cached_info, progress=False + ) @patch("pychunkedgraph.graph.meta.CloudVolume") @patch("pychunkedgraph.graph.meta.get_redis_connection") @@ -462,7 +464,7 @@ def test_ws_cv_redis_failure_fallback(self, mock_get_redis, mock_cv_cls): assert result is mock_cv_instance # Should have been called without info kwarg (fallback) - mock_cv_cls.assert_called_with("gs://bucket/ws") + mock_cv_cls.assert_called_with("gs://bucket/ws", progress=False) @patch("pychunkedgraph.graph.meta.CloudVolume") @patch("pychunkedgraph.graph.meta.get_redis_connection") @@ -485,7 +487,7 @@ def test_ws_cv_caches_to_redis(self, mock_get_redis, mock_cv_cls): assert result is mock_cv_instance # The fallback CloudVolume call (no info= kwarg) - mock_cv_cls.assert_called_with("gs://bucket/ws") + mock_cv_cls.assert_called_with("gs://bucket/ws", progress=False) # Should try to cache in redis mock_redis.set.assert_called_once() @@ -568,6 +570,71 @@ def test_bitmasks_cached_after_first_access(self): assert bm1 is bm2 +class TestOcdbtSeg: + """Test ocdbt_seg property.""" + + def test_ocdbt_seg_false_by_default(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + assert meta.ocdbt_seg is False + + def test_ocdbt_seg_true_from_custom_data(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) + assert meta.ocdbt_seg is True + + def test_ocdbt_seg_false_explicit(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": False}}) + assert meta.ocdbt_seg is False + + def test_ocdbt_seg_cached(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) + val1 = meta.ocdbt_seg + val2 = meta.ocdbt_seg + assert val1 is val2 + + def test_ws_ocdbt_asserts_when_not_ocdbt(self): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds) + with pytest.raises(AssertionError, match="ocdbt"): + _ = meta.ws_ocdbt + + @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") + def test_ws_ocdbt_returns_destination(self, mock_get_ocdbt): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) + + mock_src = MagicMock() + mock_dst = MagicMock() + mock_get_ocdbt.return_value = (mock_src, mock_dst) + + result = meta.ws_ocdbt + assert result is mock_dst + mock_get_ocdbt.assert_called_once_with("gs://bucket/ws") + + @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") + def test_ws_ocdbt_cached(self, mock_get_ocdbt): + gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) + + mock_dst = MagicMock() + mock_get_ocdbt.return_value = (MagicMock(), mock_dst) + + result1 = meta.ws_ocdbt + result2 = meta.ws_ocdbt + assert result1 is result2 + mock_get_ocdbt.assert_called_once() + + class TestLayerChunkBoundsComputed: """Test layer_chunk_bounds property computation.""" diff --git a/pychunkedgraph/tests/graph/test_multicut.py b/pychunkedgraph/tests/graph/test_multicut.py index 19507465e..87408a654 100644 --- a/pychunkedgraph/tests/graph/test_multicut.py +++ b/pychunkedgraph/tests/graph/test_multicut.py @@ -67,5 +67,5 @@ def test_path_augmented_multicut(self, sv_data): cut_edges_aug = run_multicut(edges, sv_sources, sv_sinks, path_augment=True) assert cut_edges_aug.shape[0] == 350 - with pytest.raises(exceptions.PreconditionError): + with pytest.raises(exceptions.SupervoxelSplitRequiredError): run_multicut(edges, sv_sources, sv_sinks, path_augment=False) diff --git a/pychunkedgraph/tests/graph/test_operation.py b/pychunkedgraph/tests/graph/test_operation.py index db5878842..328ceb425 100644 --- a/pychunkedgraph/tests/graph/test_operation.py +++ b/pychunkedgraph/tests/graph/test_operation.py @@ -21,7 +21,11 @@ RedoOperation, UndoOperation, ) -from ...graph.exceptions import PreconditionError, PostconditionError +from ...graph.exceptions import ( + PreconditionError, + PostconditionError, + SupervoxelSplitRequiredError, +) from ...ingest.create.parent_layer import add_parent_chunk @@ -498,7 +502,7 @@ def test_split_self_loop_raises(self, gen_graph): def test_multicut_overlapping_ids_raises(self, gen_graph): """source_ids overlapping sink_ids should raise PreconditionError (line 872).""" cg, _, sv0, sv1 = _build_cross_chunk(gen_graph) - with pytest.raises(PreconditionError, match="both sink and source"): + with pytest.raises(SupervoxelSplitRequiredError, match="both sink and source"): MulticutOperation( cg, user_id="test_user", diff --git a/pychunkedgraph/tests/graph/test_utils_generic.py b/pychunkedgraph/tests/graph/test_utils_generic.py index 58444c838..248e3cb68 100644 --- a/pychunkedgraph/tests/graph/test_utils_generic.py +++ b/pychunkedgraph/tests/graph/test_utils_generic.py @@ -104,6 +104,34 @@ def test_unique(self): assert len(parents) == 1 +class TestGetLocalSegmentation: + def test_ocdbt_path(self): + from unittest.mock import MagicMock + from pychunkedgraph.graph.utils.generic import get_local_segmentation + + meta = MagicMock() + meta.ocdbt_seg = True + expected = np.ones((10, 10, 10), dtype=np.uint64) + mock_slice = MagicMock() + mock_slice.read.return_value.result.return_value = expected + meta.ws_ocdbt.__getitem__ = MagicMock(return_value=mock_slice) + + result = get_local_segmentation(meta, [0, 0, 0], [10, 10, 10]) + np.testing.assert_array_equal(result, expected) + + def test_cv_path(self): + from unittest.mock import MagicMock + from pychunkedgraph.graph.utils.generic import get_local_segmentation + + meta = MagicMock() + meta.ocdbt_seg = False + expected = np.ones((10, 10, 10), dtype=np.uint64) + meta.cv.__getitem__ = MagicMock(return_value=expected) + + result = get_local_segmentation(meta, [0, 0, 0], [10, 10, 10]) + np.testing.assert_array_equal(result, expected) + + class TestComputeIndicesPandas: def test_basic(self): data = np.array([1, 2, 1, 2, 3]) diff --git a/pychunkedgraph/tests/graph/test_utils_id_helpers.py b/pychunkedgraph/tests/graph/test_utils_id_helpers.py index f1b78c37e..ab4afa60d 100644 --- a/pychunkedgraph/tests/graph/test_utils_id_helpers.py +++ b/pychunkedgraph/tests/graph/test_utils_id_helpers.py @@ -178,6 +178,7 @@ def test_higher_layer_with_mock_cv(self): meta = MagicMock() meta.data_source.CV_MIP = 0 meta.resolution = np.array([8, 8, 40]) + meta.ocdbt_seg = False parent_id = np.uint64(100) sv1 = np.uint64(10) diff --git a/pychunkedgraph/tests/test_cutting_sv.py b/pychunkedgraph/tests/test_cutting_sv.py new file mode 100644 index 000000000..a2b29ac74 --- /dev/null +++ b/pychunkedgraph/tests/test_cutting_sv.py @@ -0,0 +1,729 @@ +"""Tests for pychunkedgraph.graph.cutting_sv""" + +import numpy as np +import pytest +from scipy.spatial import cKDTree + +from pychunkedgraph.graph.cutting_sv import ( + _cc_label_26, + _largest_component_id, + _to_zyx_sampling, + _to_internal_zyx_volume, + _from_internal_zyx_volume, + _seeds_to_zyx, + _seeds_from_zyx, + _extract_mask_boundary, + _downsample_points, + snap_seeds_to_segment, + _compute_edt, + _upsample_bool, + _upsample_labels, + build_kdtrees_by_label, + pairwise_min_distance_two_sets, + split_supervoxel_growing, + connect_both_seeds_via_ridge, + split_supervoxel_helper, +) + + +# ============================================================ +# Helper: create a simple 3D binary mask with two seed regions +# ============================================================ +def _make_dumbbell_mask(shape=(20, 30, 30)): + """ + Create a dumbbell-shaped mask: two blobs connected by a thin bridge. + Returns (mask, seeds_a_zyx, seeds_b_zyx) all in ZYX order. + """ + mask = np.zeros(shape, dtype=bool) + Z, Y, X = shape + # blob A: centered at (Z//2, Y//4, X//4) + cz, cy, cx = Z // 2, Y // 4, X // 4 + r = min(Z, Y, X) // 5 + for z in range(Z): + for y in range(Y): + for x in range(X): + if (z - cz) ** 2 + (y - cy) ** 2 + (x - cx) ** 2 <= r**2: + mask[z, y, x] = True + + # blob B: centered at (Z//2, 3*Y//4, 3*X//4) + cz2, cy2, cx2 = Z // 2, 3 * Y // 4, 3 * X // 4 + for z in range(Z): + for y in range(Y): + for x in range(X): + if (z - cz2) ** 2 + (y - cy2) ** 2 + (x - cx2) ** 2 <= r**2: + mask[z, y, x] = True + + # bridge between the two + mid_y = Y // 2 + mid_x = X // 2 + mask[cz - 1 : cz + 2, cy : cy2 + 1, mid_x - 1 : mid_x + 2] = True + + seeds_a = np.array([[cz, cy, cx]]) + seeds_b = np.array([[cz2, cy2, cx2]]) + return mask, seeds_a, seeds_b + + +# ============================================================ +# Tests: CC label and largest component +# ============================================================ +class TestCCLabel26: + def test_single_component(self): + mask = np.zeros((5, 5, 5), dtype=bool) + mask[1:4, 1:4, 1:4] = True + lbl, ncomp = _cc_label_26(mask) + assert ncomp == 1 + assert lbl[2, 2, 2] > 0 + assert lbl[0, 0, 0] == 0 + + def test_two_components(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[1:3, 1:3, 1:3] = True + mask[7:9, 7:9, 7:9] = True + lbl, ncomp = _cc_label_26(mask) + assert ncomp == 2 + assert lbl[2, 2, 2] != lbl[7, 7, 7] + + def test_empty(self): + mask = np.zeros((3, 3, 3), dtype=bool) + lbl, ncomp = _cc_label_26(mask) + assert ncomp == 0 + + def test_full(self): + mask = np.ones((4, 4, 4), dtype=bool) + lbl, ncomp = _cc_label_26(mask) + assert ncomp == 1 + + +class TestLargestComponentId: + def test_single_component(self): + lbl = np.zeros((5, 5, 5), dtype=np.int32) + lbl[1:4, 1:4, 1:4] = 1 + assert _largest_component_id(lbl) == 1 + + def test_two_components_picks_largest(self): + lbl = np.zeros((10, 10, 10), dtype=np.int32) + lbl[0:2, 0:2, 0:2] = 1 # 8 voxels + lbl[3:8, 3:8, 3:8] = 2 # 125 voxels + assert _largest_component_id(lbl) == 2 + + def test_all_background(self): + lbl = np.zeros((3, 3, 3), dtype=np.int32) + assert _largest_component_id(lbl) == 0 + + +# ============================================================ +# Tests: Order/utility helpers +# ============================================================ +class TestToZyxSampling: + def test_xyz_order(self): + result = _to_zyx_sampling((8.0, 8.0, 40.0), "xyz") + assert result == (40.0, 8.0, 8.0) + + def test_zyx_order(self): + result = _to_zyx_sampling((40.0, 8.0, 8.0), "zyx") + assert result == (40.0, 8.0, 8.0) + + def test_invalid_order_raises(self): + with pytest.raises(ValueError, match="vox_order"): + _to_zyx_sampling((1, 1, 1), "abc") + + +class TestToInternalZyxVolume: + def test_zyx_passthrough(self): + vol = np.zeros((3, 4, 5)) + result, transposed = _to_internal_zyx_volume(vol, "zyx") + assert result is vol + assert not transposed + + def test_xyz_transpose(self): + vol = np.zeros((5, 4, 3)) # X=5, Y=4, Z=3 + result, transposed = _to_internal_zyx_volume(vol, "xyz") + assert result.shape == (3, 4, 5) + assert transposed + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="vol_order"): + _to_internal_zyx_volume(np.zeros((3, 3, 3)), "abc") + + +class TestFromInternalZyxVolume: + def test_zyx_passthrough(self): + vol = np.zeros((3, 4, 5)) + result = _from_internal_zyx_volume(vol, "zyx") + assert result is vol + + def test_xyz_transpose(self): + vol = np.zeros((3, 4, 5)) # Z=3, Y=4, X=5 + result = _from_internal_zyx_volume(vol, "xyz") + assert result.shape == (5, 4, 3) + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="vol_order"): + _from_internal_zyx_volume(np.zeros((3, 3, 3)), "abc") + + +class TestSeedsToZyx: + def test_xyz_to_zyx(self): + seeds = np.array([[10, 20, 30]]) # x, y, z + result = _seeds_to_zyx(seeds, "xyz") + np.testing.assert_array_equal(result, [[30, 20, 10]]) + + def test_zyx_passthrough(self): + seeds = np.array([[30, 20, 10]]) # z, y, x + result = _seeds_to_zyx(seeds, "zyx") + np.testing.assert_array_equal(result, [[30, 20, 10]]) + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="seed_order"): + _seeds_to_zyx(np.array([[1, 2, 3]]), "abc") + + +class TestSeedsFromZyx: + def test_xyz_output(self): + seeds = np.array([[30, 20, 10]]) # z, y, x + result = _seeds_from_zyx(seeds, "xyz") + np.testing.assert_array_equal(result, [[10, 20, 30]]) + + def test_zyx_passthrough(self): + seeds = np.array([[30, 20, 10]]) + result = _seeds_from_zyx(seeds, "zyx") + np.testing.assert_array_equal(result, [[30, 20, 10]]) + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="seed_order"): + _seeds_from_zyx(np.array([[1, 2, 3]]), "abc") + + def test_roundtrip(self): + original = np.array([[10, 20, 30], [40, 50, 60]]) + zyx = _seeds_to_zyx(original, "xyz") + recovered = _seeds_from_zyx(zyx, "xyz") + np.testing.assert_array_equal(original, recovered) + + +# ============================================================ +# Tests: Snapping (KDTree-based) +# ============================================================ +class TestExtractMaskBoundary: + def test_basic_boundary(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[2:8, 2:8, 2:8] = True + boundary = _extract_mask_boundary(mask, erosion_iters=1) + # Interior should not be boundary + assert boundary[5, 5, 5] == False + # Edge should be boundary + assert boundary[2, 2, 2] == True + # Boundary must be subset of mask + assert np.all(boundary <= mask) + + def test_zero_erosion_returns_copy(self): + mask = np.ones((5, 5, 5), dtype=bool) + result = _extract_mask_boundary(mask, erosion_iters=0) + np.testing.assert_array_equal(result, mask) + + def test_thin_structure_all_boundary(self): + mask = np.zeros((5, 5, 5), dtype=bool) + mask[2, :, :] = True # single slice - all boundary + boundary = _extract_mask_boundary(mask, erosion_iters=1) + # For a single-voxel-thick structure, all voxels are boundary + assert boundary.sum() > 0 + + +class TestDownsamplePoints: + def test_stride(self): + pts = np.arange(30).reshape(10, 3) + result = _downsample_points(pts, mode="stride", stride=2) + assert len(result) == 5 + np.testing.assert_array_equal(result[0], pts[0]) + np.testing.assert_array_equal(result[1], pts[2]) + + def test_random(self): + rng = np.random.default_rng(42) + pts = np.arange(300).reshape(100, 3) + result = _downsample_points(pts, mode="random", target=10, rng=rng) + assert len(result) == 10 + + def test_random_target_larger_than_n(self): + pts = np.arange(15).reshape(5, 3) + result = _downsample_points(pts, mode="random", target=50) + assert len(result) == 5 + + def test_empty_returns_empty(self): + pts = np.empty((0, 3)) + result = _downsample_points(pts, mode="stride") + assert len(result) == 0 + + def test_invalid_mode_raises(self): + pts = np.arange(9).reshape(3, 3) + with pytest.raises(ValueError, match="downsample mode"): + _downsample_points(pts, mode="invalid") + + +class TestSnapSeedsToSegment: + def test_basic_snap(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[3:7, 3:7, 3:7] = True + seeds = np.array([[0.0, 0.0, 0.0]]) # far outside + result = snap_seeds_to_segment( + seeds, + mask, + mask_order="zyx", + use_boundary=False, + downsample=False, + ) + # Snapped seed should be on the mask + x, y, z = result[0] + assert mask[z, y, x] == True + + def test_seed_inside_mask(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[3:7, 3:7, 3:7] = True + seeds = np.array([[5.0, 5.0, 5.0]]) # inside the mask + result = snap_seeds_to_segment( + seeds, + mask, + mask_order="zyx", + use_boundary=False, + downsample=False, + ) + x, y, z = result[0] + assert mask[z, y, x] == True + + def test_with_boundary_and_downsample(self): + mask = np.zeros((20, 20, 20), dtype=bool) + mask[5:15, 5:15, 5:15] = True + seeds = np.array([[0.0, 0.0, 0.0]]) + result = snap_seeds_to_segment( + seeds, + mask, + mask_order="zyx", + use_boundary=True, + downsample=True, + downsample_mode="stride", + downsample_stride=2, + ) + x, y, z = result[0] + assert mask[z, y, x] == True + + def test_xyz_mask_order(self): + # mask_order='xyz' means shape is (X, Y, Z) + mask_xyz = np.zeros((10, 12, 8), dtype=bool) + mask_xyz[3:7, 3:9, 2:6] = True + seeds = np.array([[5.0, 6.0, 4.0]]) # xyz coords + result = snap_seeds_to_segment( + seeds, + mask_xyz, + mask_order="xyz", + use_boundary=False, + downsample=False, + ) + x, y, z = result[0] + assert mask_xyz[x, y, z] == True + + def test_empty_mask_raises(self): + mask = np.zeros((5, 5, 5), dtype=bool) + seeds = np.array([[2.0, 2.0, 2.0]]) + with pytest.raises(ValueError, match="no True voxels"): + snap_seeds_to_segment( + seeds, mask, mask_order="zyx", use_boundary=False, downsample=False + ) + + def test_non_3d_mask_raises(self): + mask = np.zeros((5, 5), dtype=bool) + seeds = np.array([[2.0, 2.0]]) + with pytest.raises(ValueError, match="3D"): + snap_seeds_to_segment(seeds, mask, mask_order="zyx") + + def test_return_index(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[5, 5, 5] = True + seeds = np.array([[0.0, 0.0, 0.0]]) + result, idx = snap_seeds_to_segment( + seeds, + mask, + mask_order="zyx", + use_boundary=False, + downsample=False, + return_index=True, + ) + assert idx.shape[0] == 1 + + def test_multiple_seeds(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[2:8, 2:8, 2:8] = True + seeds = np.array([[0.0, 0.0, 0.0], [9.0, 9.0, 9.0]]) + result = snap_seeds_to_segment( + seeds, + mask, + mask_order="zyx", + use_boundary=False, + downsample=False, + ) + assert result.shape == (2, 3) + for i in range(2): + x, y, z = result[i] + assert mask[z, y, x] == True + + def test_voxel_size_anisotropic(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[3:7, 3:7, 3:7] = True + seeds = np.array([[0.0, 0.0, 0.0]]) + result = snap_seeds_to_segment( + seeds, + mask, + mask_order="zyx", + voxel_size=(8.0, 8.0, 40.0), + use_boundary=False, + downsample=False, + ) + x, y, z = result[0] + assert mask[z, y, x] == True + + def test_invalid_mask_order(self): + mask = np.zeros((5, 5, 5), dtype=bool) + mask[2, 2, 2] = True + with pytest.raises(ValueError, match="mask_order"): + snap_seeds_to_segment( + np.array([[2, 2, 2]]), + mask, + mask_order="bad", + use_boundary=False, + downsample=False, + ) + + +# ============================================================ +# Tests: EDT +# ============================================================ +class TestComputeEdt: + def test_basic(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[3:7, 3:7, 3:7] = True + dist = _compute_edt(mask, (1.0, 1.0, 1.0)) + assert dist.shape == mask.shape + # Center should have highest distance + assert dist[5, 5, 5] > dist[3, 3, 3] + # Outside mask should be zero + assert dist[0, 0, 0] == 0.0 + + def test_anisotropic_sampling(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[3:7, 3:7, 3:7] = True + dist = _compute_edt(mask, (40.0, 8.0, 8.0)) + assert dist.shape == mask.shape + assert dist[5, 5, 5] > 0 + + +# ============================================================ +# Tests: Upsampling +# ============================================================ +class TestUpsample: + def test_upsample_bool(self): + mask = np.array([[[True, False], [False, True]]]) # shape (1, 2, 2) + result = _upsample_bool(mask, (2, 2, 2), (2, 4, 4)) + assert result.shape == (2, 4, 4) + assert result[0, 0, 0] == True + assert result[0, 0, 2] == False + + def test_upsample_labels(self): + lbl = np.array([[[1, 2], [3, 0]]]) # shape (1, 2, 2) + result = _upsample_labels(lbl, (2, 2, 2), (2, 4, 4)) + assert result.shape == (2, 4, 4) + assert result[0, 0, 0] == 1 + assert result[0, 0, 2] == 2 + + def test_upsample_with_trimming(self): + mask = np.ones((2, 2, 2), dtype=bool) + result = _upsample_bool(mask, (3, 3, 3), (5, 5, 5)) + assert result.shape == (5, 5, 5) + + +# ============================================================ +# Tests: build_kdtrees_by_label +# ============================================================ +class TestBuildKdtreesByLabel: + def test_basic(self): + vol = np.zeros((5, 5, 5), dtype=int) + vol[1, 1, 1] = 1 + vol[3, 3, 3] = 2 + vol[3, 3, 4] = 2 + trees, counts = build_kdtrees_by_label(vol) + assert 1 in trees + assert 2 in trees + assert 0 not in trees + assert counts[1] == 1 + assert counts[2] == 2 + + def test_empty_volume(self): + vol = np.zeros((3, 3, 3), dtype=int) + trees, counts = build_kdtrees_by_label(vol) + assert len(trees) == 0 + assert len(counts) == 0 + + def test_non_zero_background(self): + vol = np.full((5, 5, 5), 99, dtype=int) + vol[2, 2, 2] = 1 + trees, counts = build_kdtrees_by_label(vol, background=99) + assert 1 in trees + assert 99 not in trees + + def test_min_points_filter(self): + vol = np.zeros((5, 5, 5), dtype=int) + vol[1, 1, 1] = 1 # 1 voxel + vol[2:4, 2:4, 2:4] = 2 # 8 voxels + trees, counts = build_kdtrees_by_label(vol, min_points=5) + assert 1 not in trees + assert 2 in trees + + def test_non_3d_raises(self): + vol = np.zeros((5, 5), dtype=int) + with pytest.raises(ValueError, match="3D"): + build_kdtrees_by_label(vol) + + def test_uint64_labels(self): + vol = np.zeros((5, 5, 5), dtype=np.uint64) + vol[1, 1, 1] = np.uint64(2**60) + trees, counts = build_kdtrees_by_label(vol) + assert int(2**60) in trees + + +# ============================================================ +# Tests: pairwise_min_distance_two_sets +# ============================================================ +class TestPairwiseMinDistanceTwoSets: + def _make_tree(self, points): + return cKDTree(np.array(points, dtype=np.float32)) + + def test_basic_exact(self): + tA = self._make_tree([[0, 0, 0]]) + tB = self._make_tree([[3, 4, 0]]) + D = pairwise_min_distance_two_sets([tA], [tB]) + assert D.shape == (1, 1) + assert D[0, 0] == pytest.approx(5.0) + + def test_multiple_trees(self): + tA1 = self._make_tree([[0, 0, 0]]) + tA2 = self._make_tree([[10, 10, 10]]) + tB1 = self._make_tree([[1, 0, 0]]) + D = pairwise_min_distance_two_sets([tA1, tA2], [tB1]) + assert D.shape == (2, 1) + assert D[0, 0] < D[1, 0] + + def test_empty_sets(self): + D = pairwise_min_distance_two_sets([], []) + assert D.shape == (0, 0) + + def test_one_empty(self): + tA = self._make_tree([[0, 0, 0]]) + D = pairwise_min_distance_two_sets([tA], []) + assert D.shape == (1, 0) + + def test_cutoff_mode(self): + tA = self._make_tree([[0, 0, 0]]) + tB = self._make_tree([[100, 100, 100]]) + D = pairwise_min_distance_two_sets([tA], [tB], max_distance=5.0) + assert D[0, 0] == np.inf + + def test_cutoff_mode_within_range(self): + tA = self._make_tree([[0, 0, 0]]) + tB = self._make_tree([[1, 0, 0]]) + D = pairwise_min_distance_two_sets([tA], [tB], max_distance=5.0) + assert D[0, 0] == pytest.approx(1.0) + + def test_multi_point_trees(self): + tA = self._make_tree([[0, 0, 0], [10, 10, 10]]) + tB = self._make_tree([[1, 0, 0], [11, 10, 10]]) + D = pairwise_min_distance_two_sets([tA], [tB]) + assert D.shape == (1, 1) + assert D[0, 0] == pytest.approx(1.0) + + def test_asymmetric_tree_sizes(self): + # tA has many points, tB has few + tA = self._make_tree(np.random.default_rng(0).random((100, 3)) * 10) + tB = self._make_tree([[5, 5, 5]]) + D = pairwise_min_distance_two_sets([tA], [tB]) + assert D.shape == (1, 1) + assert D[0, 0] >= 0 + + +# ============================================================ +# Tests: split_supervoxel_growing +# ============================================================ +class TestSplitSupervoxelGrowing: + def test_basic_split_xyz(self): + """Split a dumbbell into two labels.""" + mask, seeds_a_zyx, seeds_b_zyx = _make_dumbbell_mask(shape=(20, 30, 30)) + # Convert to xyz + mask_xyz = np.transpose(mask, (2, 1, 0)) + seeds_a_xyz = seeds_a_zyx[:, [2, 1, 0]] + seeds_b_xyz = seeds_b_zyx[:, [2, 1, 0]] + + result = split_supervoxel_growing( + mask_xyz, + seeds_a_xyz, + seeds_b_xyz, + voxel_size=(1.0, 1.0, 1.0), + vol_order="xyz", + vox_order="xyz", + seed_order="xyz", + verbose=False, + snap_kwargs=dict(use_boundary=False, downsample=False), + enforce_single_cc=True, + raise_if_multi_cc=False, + ) + assert result.shape == mask_xyz.shape + # Should contain labels 1 and 2 + assert np.any(result == 1) + assert np.any(result == 2) + # Labels should only be where mask is True + assert np.all((result > 0) <= mask_xyz) + + def test_basic_split_zyx(self): + """Split using ZYX order.""" + mask, seeds_a, seeds_b = _make_dumbbell_mask(shape=(20, 30, 30)) + result = split_supervoxel_growing( + mask, + seeds_a, + seeds_b, + voxel_size=(1.0, 1.0, 1.0), + vol_order="zyx", + vox_order="zyx", + seed_order="zyx", + verbose=False, + snap_kwargs=dict(use_boundary=False, downsample=False), + enforce_single_cc=True, + raise_if_multi_cc=False, + ) + assert result.shape == mask.shape + assert np.any(result == 1) + assert np.any(result == 2) + + def test_empty_seeds_returns_label1(self): + """With no seeds on one side, the entire mask gets label 1.""" + mask = np.zeros((10, 10, 10), dtype=bool) + mask[3:7, 3:7, 3:7] = True + seeds_a = np.array([[5, 5, 5]]) + seeds_b = np.empty((0, 3), dtype=int) + result = split_supervoxel_growing( + mask, + seeds_a, + seeds_b, + vol_order="zyx", + vox_order="zyx", + seed_order="zyx", + verbose=False, + snap_kwargs=dict(use_boundary=False, downsample=False), + ) + assert np.all(result[mask] == 1) + + def test_with_downsample_geodesic(self): + """Test downsampled geodesic computation.""" + mask, seeds_a, seeds_b = _make_dumbbell_mask(shape=(20, 30, 30)) + result = split_supervoxel_growing( + mask, + seeds_a, + seeds_b, + voxel_size=(1.0, 1.0, 1.0), + vol_order="zyx", + vox_order="zyx", + seed_order="zyx", + downsample_geodesic=(1, 2, 2), + verbose=False, + snap_kwargs=dict(use_boundary=False, downsample=False), + enforce_single_cc=True, + raise_if_multi_cc=False, + ) + assert result.shape == mask.shape + assert np.any(result == 1) + assert np.any(result == 2) + + +# ============================================================ +# Tests: connect_both_seeds_via_ridge +# ============================================================ +class TestConnectBothSeedsViaRidge: + def test_basic_connection(self): + mask, seeds_a_zyx, seeds_b_zyx = _make_dumbbell_mask(shape=(20, 30, 30)) + mask_xyz = np.transpose(mask, (2, 1, 0)) + seeds_a_xyz = seeds_a_zyx[:, [2, 1, 0]] + seeds_b_xyz = seeds_b_zyx[:, [2, 1, 0]] + + A_aug, B_aug, okA, okB = connect_both_seeds_via_ridge( + mask_xyz, + seeds_a_xyz, + seeds_b_xyz, + voxel_size=(1.0, 1.0, 1.0), + vol_order="xyz", + vox_order="xyz", + seed_order="xyz", + downsample=(1, 1, 1), + verbose=False, + snap_kwargs=dict(use_boundary=False, downsample=False), + ) + assert okA + assert okB + # Augmented seeds should be at least as many as originals + assert len(A_aug) >= len(seeds_a_xyz) + assert len(B_aug) >= len(seeds_b_xyz) + + def test_single_seed_per_team(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[2:8, 2:8, 2:8] = True + mask_xyz = np.transpose(mask, (2, 1, 0)) + seeds_a = np.array([[4, 4, 4]]) + seeds_b = np.array([[6, 6, 6]]) + + A_aug, B_aug, okA, okB = connect_both_seeds_via_ridge( + mask_xyz, + seeds_a, + seeds_b, + voxel_size=(1.0, 1.0, 1.0), + vol_order="xyz", + seed_order="xyz", + downsample=(1, 1, 1), + verbose=False, + snap_kwargs=dict(use_boundary=False, downsample=False), + ) + assert okA + assert okB + + def test_empty_seeds(self): + mask = np.zeros((10, 10, 10), dtype=bool) + mask[2:8, 2:8, 2:8] = True + mask_xyz = np.transpose(mask, (2, 1, 0)) + seeds_a = np.empty((0, 3), dtype=int) + seeds_b = np.array([[4, 4, 4]]) + + A_aug, B_aug, okA, okB = connect_both_seeds_via_ridge( + mask_xyz, + seeds_a, + seeds_b, + voxel_size=(1.0, 1.0, 1.0), + vol_order="xyz", + seed_order="xyz", + downsample=(1, 1, 1), + verbose=False, + snap_kwargs=dict(use_boundary=False, downsample=False), + ) + assert not okA + + +# ============================================================ +# Tests: split_supervoxel_helper +# ============================================================ +class TestSplitSupervoxelHelper: + def test_basic_split(self): + mask, seeds_a_zyx, seeds_b_zyx = _make_dumbbell_mask(shape=(20, 30, 30)) + mask_xyz = np.transpose(mask, (2, 1, 0)) + seeds_a_xyz = seeds_a_zyx[:, [2, 1, 0]] + seeds_b_xyz = seeds_b_zyx[:, [2, 1, 0]] + + result = split_supervoxel_helper( + mask_xyz, + seeds_a_xyz, + seeds_b_xyz, + voxel_size=(1.0, 1.0, 1.0), + verbose=False, + ) + assert result.shape == mask_xyz.shape + assert np.any(result == 1) + assert np.any(result == 2) diff --git a/pychunkedgraph/tests/test_edits_sv.py b/pychunkedgraph/tests/test_edits_sv.py new file mode 100644 index 000000000..861fa8baf --- /dev/null +++ b/pychunkedgraph/tests/test_edits_sv.py @@ -0,0 +1,220 @@ +"""Tests for pychunkedgraph.graph.edits_sv""" + +import numpy as np +import pytest +from collections import defaultdict +from unittest.mock import MagicMock, patch + +from pychunkedgraph.graph.edits_sv import ( + _voxel_crop, + _parse_results, + _get_new_edges, +) +from pychunkedgraph.graph.utils import basetypes + + +# ============================================================ +# Tests: _voxel_crop +# ============================================================ +class TestVoxelCrop: + def test_no_overlap(self): + bbs = np.array([10, 20, 30]) + bbe = np.array([20, 30, 40]) + bbs_ = np.array([10, 20, 30]) + bbe_ = np.array([20, 30, 40]) + crop = _voxel_crop(bbs, bbe, bbs_, bbe_) + # No offset and no clipping + assert crop == np.s_[0:None, 0:None, 0:None] + + def test_with_padding(self): + bbs = np.array([10, 20, 30]) + bbe = np.array([20, 30, 40]) + bbs_ = np.array([9, 19, 29]) + bbe_ = np.array([21, 31, 41]) + crop = _voxel_crop(bbs, bbe, bbs_, bbe_) + # Start offset = bbs - bbs_ = (1, 1, 1) + # End offset: bbe_ - bbe = (1,1,1) != 0, so end = -1 + assert crop == np.s_[1:-1, 1:-1, 1:-1] + + def test_partial_padding(self): + bbs = np.array([10, 20, 30]) + bbe = np.array([20, 30, 40]) + bbs_ = np.array([9, 20, 30]) + bbe_ = np.array([21, 30, 40]) + crop = _voxel_crop(bbs, bbe, bbs_, bbe_) + # Only x has offset + assert crop == np.s_[1:-1, 0:None, 0:None] + + +# ============================================================ +# Tests: _parse_results +# ============================================================ +class TestParseResults: + def test_basic(self): + seg = np.array([[[100, 100], [100, 200]]], dtype=basetypes.NODE_ID) + bbs = np.array([0, 0, 0]) + bbe = np.array([1, 2, 2]) + # result: (indices, old_values, new_values) + indices = np.array([[0, 0, 0], [0, 0, 1]]) + old_values = np.array([100, 100], dtype=basetypes.NODE_ID) + new_values = np.array([300, 301], dtype=basetypes.NODE_ID) + results = [(indices, old_values, new_values)] + + updated_seg, old_new_map, slices = _parse_results(results, seg, bbs, bbe) + assert updated_seg[0, 0, 0] == 300 + assert updated_seg[0, 0, 1] == 301 + assert 300 in old_new_map[100] + assert 301 in old_new_map[100] + + def test_none_result_skipped(self): + seg = np.array([[[100]]], dtype=basetypes.NODE_ID) + bbs = np.array([0, 0, 0]) + bbe = np.array([1, 1, 1]) + results = [None] + updated_seg, old_new_map, slices = _parse_results(results, seg, bbs, bbe) + # No changes + assert updated_seg[0, 0, 0] == 100 + assert len(old_new_map) == 0 + + def test_multiple_results(self): + seg = np.array([[[100, 200]]], dtype=basetypes.NODE_ID) + bbs = np.array([0, 0, 0]) + bbe = np.array([1, 1, 2]) + result1 = ( + np.array([[0, 0, 0]]), + np.array([100], dtype=basetypes.NODE_ID), + np.array([300], dtype=basetypes.NODE_ID), + ) + result2 = ( + np.array([[0, 0, 1]]), + np.array([200], dtype=basetypes.NODE_ID), + np.array([400], dtype=basetypes.NODE_ID), + ) + results = [result1, result2] + + updated_seg, old_new_map, slices = _parse_results(results, seg, bbs, bbe) + assert updated_seg[0, 0, 0] == 300 + assert updated_seg[0, 0, 1] == 400 + assert 300 in old_new_map[100] + assert 400 in old_new_map[200] + + +# ============================================================ +# Tests: _get_new_edges +# ============================================================ +class TestGetNewEdges: + def test_with_active_and_inactive_partners(self): + """Test with both active partners (in sv_ids) and inactive (not in sv_ids).""" + old_sv = np.uint64(10) + new_sv1 = np.uint64(101) + new_sv2 = np.uint64(102) + active_partner = np.uint64(50) # in sv_ids -> active + inactive_partner = np.uint64(99) # not in sv_ids -> inactive + + edges = np.array( + [ + [10, 50], + [10, 99], + ], + dtype=basetypes.NODE_ID, + ) + affinities = np.array([0.9, 0.5], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100, 200], dtype=basetypes.EDGE_AREA) + + old_new_map = {old_sv: {new_sv1, new_sv2}} + sv_ids = np.array([10, 50, 101, 102], dtype=basetypes.NODE_ID) + + # distance_map: maps each label to its column index in the distance matrix + distance_map = { + np.uint64(10): 0, + np.uint64(50): 1, + np.uint64(101): 2, + np.uint64(102): 3, + } + dist_vec = np.vectorize(distance_map.get) + new_distance_map = {np.uint64(101): 0, np.uint64(102): 1} + new_dist_vec = np.vectorize(new_distance_map.get) + + # Distances: (new_ids x all_ids) + distances = np.array( + [ + [5.0, 3.0, 0.0, 8.0], # new_sv1 + [6.0, 4.0, 8.0, 0.0], # new_sv2 + ] + ) + + result_edges, result_affs, result_areas = _get_new_edges( + (edges, affinities, areas), + sv_ids, + old_new_map, + distances, + dist_vec, + new_dist_vec, + ) + # Should have: + # - Inactive edges: new_sv1->99, new_sv2->99 + # - Active edges: new_ids -> 50 based on distance + # - Fragment edges: new_sv1 <-> new_sv2 + assert len(result_edges) >= 3 + + def test_edge_between_split_fragments(self): + """Split fragments should have edges between them with low affinity.""" + old_sv = np.uint64(10) + new_sv1 = np.uint64(101) + new_sv2 = np.uint64(102) + partner = np.uint64(50) + + edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) + affinities = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100], dtype=basetypes.EDGE_AREA) + + old_new_map = {old_sv: {new_sv1, new_sv2}} + sv_ids = np.array([10, 50, 101, 102], dtype=basetypes.NODE_ID) + + distance_map = { + np.uint64(10): 0, + np.uint64(50): 1, + np.uint64(101): 2, + np.uint64(102): 3, + } + dist_vec = np.vectorize(distance_map.get) + new_distance_map = {np.uint64(101): 0, np.uint64(102): 1} + new_dist_vec = np.vectorize(new_distance_map.get) + distances = np.array( + [ + [5.0, 3.0, 0.0, 8.0], + [6.0, 4.0, 8.0, 0.0], + ] + ) + + result_edges, result_affs, result_areas = _get_new_edges( + (edges, affinities, areas), + sv_ids, + old_new_map, + distances, + dist_vec, + new_dist_vec, + ) + # Check that a fragment-to-fragment edge exists + fragment_edge_found = False + for e in result_edges: + if set(e) == {new_sv1, new_sv2}: + fragment_edge_found = True + break + assert fragment_edge_found + + def test_empty_old_new_map(self): + """Empty old_new_map should return empty results.""" + edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) + affinities = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100], dtype=basetypes.EDGE_AREA) + + result_edges, result_affs, result_areas = _get_new_edges( + (edges, affinities, areas), + np.array([10], dtype=basetypes.NODE_ID), + {}, + np.zeros((0, 0)), + np.vectorize(lambda x: x), + np.vectorize(lambda x: x), + ) + assert len(result_edges) == 0 diff --git a/pychunkedgraph/tests/test_ocdbt.py b/pychunkedgraph/tests/test_ocdbt.py new file mode 100644 index 000000000..a554e23b1 --- /dev/null +++ b/pychunkedgraph/tests/test_ocdbt.py @@ -0,0 +1,141 @@ +"""Tests for pychunkedgraph.graph.ocdbt""" + +import numpy as np +import pytest +from unittest.mock import MagicMock, patch + + +class TestGetSegSourceAndDestinationOcdbt: + @patch("pychunkedgraph.graph.ocdbt.ts") + def test_returns_src_dst_tuple(self, mock_ts): + from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt + + mock_src = MagicMock() + mock_schema = MagicMock() + mock_schema.rank = 4 + mock_schema.dtype = "uint64" + mock_schema.codec = None + mock_schema.domain = None + mock_schema.shape = [256, 256, 256, 1] + mock_schema.chunk_layout = None + mock_schema.dimension_units = None + mock_src.schema = mock_schema + + mock_dst = MagicMock() + + # ts.open returns a future-like with .result() + mock_ts.open.side_effect = [ + MagicMock(result=MagicMock(return_value=mock_src)), + MagicMock(result=MagicMock(return_value=mock_dst)), + ] + + src, dst = get_seg_source_and_destination_ocdbt("gs://bucket/ws") + assert src is mock_src + assert dst is mock_dst + assert mock_ts.open.call_count == 2 + + @patch("pychunkedgraph.graph.ocdbt.ts") + def test_create_flag(self, mock_ts): + from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt + + mock_src = MagicMock() + mock_schema = MagicMock() + mock_schema.rank = 4 + mock_schema.dtype = "uint64" + mock_schema.codec = None + mock_schema.domain = None + mock_schema.shape = [256, 256, 256, 1] + mock_schema.chunk_layout = None + mock_schema.dimension_units = None + mock_src.schema = mock_schema + + mock_dst = MagicMock() + mock_ts.open.side_effect = [ + MagicMock(result=MagicMock(return_value=mock_src)), + MagicMock(result=MagicMock(return_value=mock_dst)), + ] + + src, dst = get_seg_source_and_destination_ocdbt("gs://bucket/ws", create=True) + + # Second ts.open call should have create=True and delete_existing=True + second_call = mock_ts.open.call_args_list[1] + assert second_call.kwargs.get("create") == True + assert second_call.kwargs.get("delete_existing") == True + + +class TestCopyWsChunk: + def test_basic_copy(self): + from pychunkedgraph.graph.ocdbt import copy_ws_chunk + + mock_source = MagicMock() + mock_destination = MagicMock() + + # Simulate source read + data = np.ones((64, 64, 64), dtype=np.uint64) + mock_source.__getitem__ = MagicMock( + return_value=MagicMock( + read=MagicMock( + return_value=MagicMock(result=MagicMock(return_value=data)) + ) + ) + ) + mock_destination.__getitem__ = MagicMock( + return_value=MagicMock( + write=MagicMock( + return_value=MagicMock(result=MagicMock(return_value=None)) + ) + ) + ) + + voxel_bounds = np.array([[0, 256], [0, 256], [0, 256]]) + copy_ws_chunk( + mock_source, + mock_destination, + chunk_size=(64, 64, 64), + coords=[0, 0, 0], + voxel_bounds=voxel_bounds, + ) + # Should have read from source and written to destination + mock_source.__getitem__.assert_called_once() + mock_destination.__getitem__.assert_called_once() + + def test_boundary_clipping(self): + from pychunkedgraph.graph.ocdbt import copy_ws_chunk + + mock_source = MagicMock() + mock_destination = MagicMock() + + data = np.ones((32, 64, 64), dtype=np.uint64) + mock_source.__getitem__ = MagicMock( + return_value=MagicMock( + read=MagicMock( + return_value=MagicMock(result=MagicMock(return_value=data)) + ) + ) + ) + mock_destination.__getitem__ = MagicMock( + return_value=MagicMock( + write=MagicMock( + return_value=MagicMock(result=MagicMock(return_value=None)) + ) + ) + ) + + # Volume ends at 224 in x, so last chunk (192-256) is clipped to (192-224) + voxel_bounds = np.array([[0, 224], [0, 256], [0, 256]]) + copy_ws_chunk( + mock_source, + mock_destination, + chunk_size=(64, 64, 64), + coords=[3, 0, 0], + voxel_bounds=voxel_bounds, + ) + mock_source.__getitem__.assert_called_once() + + +class TestOcdbtConstants: + def test_compression_level(self): + from pychunkedgraph.graph.ocdbt import OCDBT_SEG_COMPRESSION_LEVEL + + assert OCDBT_SEG_COMPRESSION_LEVEL == 17 + assert isinstance(OCDBT_SEG_COMPRESSION_LEVEL, int) From 17c5577381efdfe404106bc89065fd626e74957c Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 4 Mar 2026 18:13:17 +0000 Subject: [PATCH 171/367] use kvdbclient, organize tests --- pychunkedgraph/graph/edits_sv.py | 18 +++++++++++------- .../tests/{ => graph}/test_cutting_sv.py | 0 .../tests/{ => graph}/test_edits_sv.py | 2 +- pychunkedgraph/tests/{ => graph}/test_ocdbt.py | 0 4 files changed, 12 insertions(+), 8 deletions(-) rename pychunkedgraph/tests/{ => graph}/test_cutting_sv.py (100%) rename pychunkedgraph/tests/{ => graph}/test_edits_sv.py (99%) rename pychunkedgraph/tests/{ => graph}/test_ocdbt.py (100%) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 4ac3a40f7..3b5395b05 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -23,9 +23,9 @@ from pychunkedgraph.graph.attributes import Hierarchy, OperationLogs from pychunkedgraph.graph.edges import Edges from pychunkedgraph.graph.types import empty_2d -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes +from pychunkedgraph.graph import serializers from pychunkedgraph.graph.utils import get_local_segmentation -from pychunkedgraph.graph.utils.serializers import serialize_uint64 from pychunkedgraph.io.edges import get_chunk_edges @@ -286,7 +286,7 @@ def _add_new_edges(cg: ChunkedGraph, edges_tuple: tuple, time_stamp: datetime = val_dict[Connectivity.Area] = areas[mask] rows.append( cg.client.mutate_row( - serialize_uint64(chunk_id, fake_edges=True), + serializers.serialize_uint64(chunk_id, fake_edges=True), val_dict=val_dict, time_stamp=time_stamp, ) @@ -404,19 +404,23 @@ def copy_parents_and_add_lineage( Hierarchy.FormerIdentity: np.array([old_id], dtype=basetypes.NODE_ID), OperationLogs.OperationID: operation_id, } - result.append(cg.client.mutate_row(serialize_uint64(new_id), val_dict)) + result.append( + cg.client.mutate_row(serializers.serialize_uint64(new_id), val_dict) + ) for cell in parent_cells_map[old_id]: cache_utils.update(cg.cache.parents_cache, [new_id], cell.value) parents.add(cell.value) result.append( cg.client.mutate_row( - serialize_uint64(new_id), + serializers.serialize_uint64(new_id), {Hierarchy.Parent: cell.value}, time_stamp=cell.timestamp, ) ) val_dict = {Hierarchy.NewIdentity: np.array(new_ids, dtype=basetypes.NODE_ID)} - result.append(cg.client.mutate_row(serialize_uint64(old_id), val_dict)) + result.append( + cg.client.mutate_row(serializers.serialize_uint64(old_id), val_dict) + ) children_cells_map = cg.client.read_nodes( node_ids=list(parents), properties=Hierarchy.Child @@ -432,7 +436,7 @@ def copy_parents_and_add_lineage( cg.cache.children_cache[parent] = children result.append( cg.client.mutate_row( - serialize_uint64(parent), + serializers.serialize_uint64(parent), {Hierarchy.Child: children}, time_stamp=cell.timestamp, ) diff --git a/pychunkedgraph/tests/test_cutting_sv.py b/pychunkedgraph/tests/graph/test_cutting_sv.py similarity index 100% rename from pychunkedgraph/tests/test_cutting_sv.py rename to pychunkedgraph/tests/graph/test_cutting_sv.py diff --git a/pychunkedgraph/tests/test_edits_sv.py b/pychunkedgraph/tests/graph/test_edits_sv.py similarity index 99% rename from pychunkedgraph/tests/test_edits_sv.py rename to pychunkedgraph/tests/graph/test_edits_sv.py index 861fa8baf..ced51e68f 100644 --- a/pychunkedgraph/tests/test_edits_sv.py +++ b/pychunkedgraph/tests/graph/test_edits_sv.py @@ -10,7 +10,7 @@ _parse_results, _get_new_edges, ) -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes # ============================================================ diff --git a/pychunkedgraph/tests/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py similarity index 100% rename from pychunkedgraph/tests/test_ocdbt.py rename to pychunkedgraph/tests/graph/test_ocdbt.py From 0c1cd1f9e5d1796023c79c9ff9e8a1b1889fc71b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 4 Mar 2026 19:02:03 +0000 Subject: [PATCH 172/367] regenrate requirements --- requirements.txt | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 72b58e8be..5df78e9f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,6 +55,8 @@ cloud-volume==12.10.0 # via -r requirements.in compressed-segmentation==2.3.2 # via cloud-volume +connected-components-3d==3.26.1 + # via -r requirements.in crc32c==2.8 # via cloud-files croniter==6.0.0 @@ -73,6 +75,8 @@ dracopy==1.7.0 # via # -r requirements.in # cloud-volume +edt==3.1.1 + # via -r requirements.in fasteners==0.20 # via cloud-files fastremap==1.17.7 @@ -165,6 +169,8 @@ grpcio-status==1.78.0 # google-cloud-pubsub idna==3.11 # via requests +imageio==2.37.2 + # via scikit-image importlib-metadata==8.7.1 # via opentelemetry-api inflection==0.5.1 @@ -191,6 +197,8 @@ jsonschema-specifications==2025.9.1 # via jsonschema kvdbclient==0.4.0 # via -r requirements.in +lazy-loader==0.4 + # via scikit-image markdown==3.10.2 # via python-jsonschema-objects markupsafe==3.0.3 @@ -215,13 +223,17 @@ networkx==3.6.1 # -r requirements.in # cloud-volume # osteoid + # scikit-image numpy==2.4.2 # via # -r requirements.in # cloud-files # cloud-volume # compressed-segmentation + # connected-components-3d + # edt # fastremap + # imageio # kvdbclient # messagingclient # microviewer @@ -229,9 +241,12 @@ numpy==2.4.2 # multiwrapper # osteoid # pandas + # scikit-image + # scipy # simplejpeg # task-queue # tensorstore + # tifffile # zmesh opentelemetry-api==1.39.1 # via @@ -251,7 +266,10 @@ orjson==3.11.7 osteoid==0.6.0 # via cloud-volume packaging==26.0 - # via pytest + # via + # lazy-loader + # pytest + # scikit-image pandas==3.0.1 # via -r requirements.in pathos==0.3.5 @@ -261,6 +279,10 @@ pathos==0.3.5 # task-queue pbr==7.0.3 # via task-queue +pillow==12.1.1 + # via + # imageio + # scikit-image pluggy==1.6.0 # via pytest posix-ipc==1.3.2 @@ -352,6 +374,10 @@ rsa==4.9.1 # google-auth s3transfer==0.16.0 # via boto3 +scikit-image==0.26.0 + # via -r requirements.in +scipy==1.17.1 + # via scikit-image simplejpeg==1.9.0 # via cloud-volume six==1.17.0 @@ -372,6 +398,8 @@ tenacity==9.1.4 # task-queue tensorstore==0.1.81 # via -r requirements.in +tifffile==2026.3.3 + # via scikit-image tqdm==4.67.3 # via # cloud-files From deff2c01f4d8373e96c2118d11f8846f3b655842 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 5 Mar 2026 20:56:42 +0000 Subject: [PATCH 173/367] fix: use registered attributes module --- pychunkedgraph/graph/chunkedgraph.py | 4 ++-- pychunkedgraph/graph/edits_sv.py | 30 +++++++++++++++------------- pychunkedgraph/ingest/cli.py | 4 +++- pychunkedgraph/ingest/cluster.py | 12 +++++++++-- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 4dbdcdac9..3a6b1461d 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -672,7 +672,7 @@ def get_subgraph_leaves( self, node_id_or_ids, bbox, bbox_is_coordinate, False, True ) - def get_edited_edges( + def get_edges_from_edits( self, chunk_ids: np.ndarray, time_stamp: datetime.datetime = None ) -> typing.Dict: """ @@ -748,7 +748,7 @@ def get_l2_agglomerations( if self.mock_edges is None: edges_d = self.read_chunk_edges(chunk_ids) - edited_edges = self.get_edited_edges(chunk_ids) + edited_edges = self.get_edges_from_edits(chunk_ids) all_chunk_edges = reduce( lambda x, y: x + y, chain(edges_d.values(), edited_edges.values()), diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 3b5395b05..15199b403 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -5,22 +5,20 @@ from functools import reduce import logging import multiprocessing as mp -from typing import Callable, Iterable +from typing import Callable from datetime import datetime from collections import defaultdict, deque import fastremap import numpy as np from tqdm import tqdm -from pychunkedgraph.graph import ChunkedGraph, cache as cache_utils -from pychunkedgraph.graph.attributes import Connectivity +from pychunkedgraph.graph import attributes, ChunkedGraph, cache as cache_utils from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox, get_neighbors from pychunkedgraph.graph.cutting_sv import ( build_kdtrees_by_label, pairwise_min_distance_two_sets, split_supervoxel_helper, ) -from pychunkedgraph.graph.attributes import Hierarchy, OperationLogs from pychunkedgraph.graph.edges import Edges from pychunkedgraph.graph.types import empty_2d from pychunkedgraph.graph import basetypes @@ -281,9 +279,9 @@ def _add_new_edges(cg: ChunkedGraph, edges_tuple: tuple, time_stamp: datetime = for chunk_id in np.unique(chunks): val_dict = {} mask = chunks_arr[:, 0] == chunk_id - val_dict[Connectivity.SplitEdges] = edges[mask] - val_dict[Connectivity.Affinity] = affinites[mask] - val_dict[Connectivity.Area] = areas[mask] + val_dict[attributes.Connectivity.SplitEdges] = edges[mask] + val_dict[attributes.Connectivity.Affinity] = affinites[mask] + val_dict[attributes.Connectivity.Area] = areas[mask] rows.append( cg.client.mutate_row( serializers.serialize_uint64(chunk_id, fake_edges=True), @@ -396,13 +394,15 @@ def copy_parents_and_add_lineage( parents = set() old_new_map = {k: list(v) for k, v in old_new_map.items()} parent_cells_map = cg.client.read_nodes( - node_ids=list(old_new_map.keys()), properties=Hierarchy.Parent + node_ids=list(old_new_map.keys()), properties=attributes.Hierarchy.Parent ) for old_id, new_ids in old_new_map.items(): for new_id in new_ids: val_dict = { - Hierarchy.FormerIdentity: np.array([old_id], dtype=basetypes.NODE_ID), - OperationLogs.OperationID: operation_id, + attributes.Hierarchy.FormerIdentity: np.array( + [old_id], dtype=basetypes.NODE_ID + ), + attributes.OperationLogs.OperationID: operation_id, } result.append( cg.client.mutate_row(serializers.serialize_uint64(new_id), val_dict) @@ -413,17 +413,19 @@ def copy_parents_and_add_lineage( result.append( cg.client.mutate_row( serializers.serialize_uint64(new_id), - {Hierarchy.Parent: cell.value}, + {attributes.Hierarchy.Parent: cell.value}, time_stamp=cell.timestamp, ) ) - val_dict = {Hierarchy.NewIdentity: np.array(new_ids, dtype=basetypes.NODE_ID)} + val_dict = { + attributes.Hierarchy.NewIdentity: np.array(new_ids, dtype=basetypes.NODE_ID) + } result.append( cg.client.mutate_row(serializers.serialize_uint64(old_id), val_dict) ) children_cells_map = cg.client.read_nodes( - node_ids=list(parents), properties=Hierarchy.Child + node_ids=list(parents), properties=attributes.Hierarchy.Child ) for parent, children_cells in children_cells_map.items(): assert len(children_cells) == 1, children_cells @@ -437,7 +439,7 @@ def copy_parents_and_add_lineage( result.append( cg.client.mutate_row( serializers.serialize_uint64(parent), - {Hierarchy.Child: children}, + {attributes.Hierarchy.Child: children}, time_stamp=cell.timestamp, ) ) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 8d44bf276..94a362d8c 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -5,6 +5,7 @@ """ import logging +import os import click import yaml @@ -70,9 +71,10 @@ def ingest_graph( if not retry: cg.create() + get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) imanager = IngestionManager(ingest_config, meta) enqueue_l2_tasks(imanager, create_atomic_chunk) - get_seg_source_and_destination_ocdbt(cg.meta, create=True) + os._exit(0) @ingest_cli.command("imanager") diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 473a61b22..5514f3b04 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -143,8 +143,16 @@ def create_atomic_chunk(coords: Sequence[int]): for k, v in chunk_edges_active.items(): logging.debug(f"active_{k}: {len(v)}") - src, dst = get_seg_source_and_destination_ocdbt(imanager.cg.meta) - copy_ws_chunk(imanager.cg, coords, src, dst) + src, dst = get_seg_source_and_destination_ocdbt( + imanager.cg.meta.data_source.WATERSHED + ) + copy_ws_chunk( + src, + dst, + imanager.cg.meta.graph_config.CHUNK_SIZE, + coords, + imanager.cg.meta.voxel_bounds, + ) _post_task_completion(imanager, 2, coords) From 328f98e83140d25e861d518effa7624b79fae281 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 5 Mar 2026 21:11:30 +0000 Subject: [PATCH 174/367] feat(ingest): make ocdbt seg optional --- .gitignore | 1 + pychunkedgraph/ingest/cli.py | 10 ++++++---- pychunkedgraph/ingest/cluster.py | 15 ++++++++------- pychunkedgraph/ingest/manager.py | 2 ++ 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 498253791..044d3b64c 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,7 @@ venv.bak/ # local dev stuff +.claude/ .devcontainer/ *.ipynb *.rdb diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 94a362d8c..3287c6040 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -47,12 +47,13 @@ def flush_redis(): @ingest_cli.command("graph") @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True)) +@click.option("--ocdbt", is_flag=True, help="Precomputed supervoxel seg into ocdbt.") @click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") -@click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @click.option("--retry", is_flag=True, help="Rerun without creating a new table.") +@click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @job_type_guard(group_name) def ingest_graph( - graph_id: str, dataset: click.Path, raw: bool, test: bool, retry: bool + graph_id: str, dataset: click.Path, ocdbt: bool, raw: bool, retry: bool, test: bool ): """ Main ingest command. @@ -71,8 +72,9 @@ def ingest_graph( if not retry: cg.create() - get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) - imanager = IngestionManager(ingest_config, meta) + if ocdbt: + get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) + imanager = IngestionManager(ingest_config, meta, ocdbt_seg=ocdbt) enqueue_l2_tasks(imanager, create_atomic_chunk) os._exit(0) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 5514f3b04..72a3c081e 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -146,13 +146,14 @@ def create_atomic_chunk(coords: Sequence[int]): src, dst = get_seg_source_and_destination_ocdbt( imanager.cg.meta.data_source.WATERSHED ) - copy_ws_chunk( - src, - dst, - imanager.cg.meta.graph_config.CHUNK_SIZE, - coords, - imanager.cg.meta.voxel_bounds, - ) + if imanager.ocdbt_seg: + copy_ws_chunk( + src, + dst, + imanager.cg.meta.graph_config.CHUNK_SIZE, + coords, + imanager.cg.meta.voxel_bounds, + ) _post_task_completion(imanager, 2, coords) diff --git a/pychunkedgraph/ingest/manager.py b/pychunkedgraph/ingest/manager.py index c23c3cca4..3ba6e972c 100644 --- a/pychunkedgraph/ingest/manager.py +++ b/pychunkedgraph/ingest/manager.py @@ -15,6 +15,7 @@ def __init__( self, config: IngestConfig, chunkedgraph_meta: ChunkedGraphMeta, + ocdbt_seg: bool = False, _from_pickle: bool = False, ): self._config = config @@ -23,6 +24,7 @@ def __init__( self._redis = None self._task_queues = {} self._from_pickle = _from_pickle + self.ocdbt_seg = ocdbt_seg if not _from_pickle: # initiate redis and store serialized state From 9c0778a60fe9b3b5980e50e8572084e0beb80777 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 18 Mar 2026 19:23:40 +0000 Subject: [PATCH 175/367] add ocdbt flag to ingest cli --- pychunkedgraph/ingest/cli.py | 2 ++ pychunkedgraph/ingest/cluster.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 3287c6040..5d448814a 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -73,6 +73,8 @@ def ingest_graph( cg.create() if ocdbt: + cg.meta.custom_data["seg"] = {"ocdbt": True} + cg.update_meta(cg.meta, overwrite=True) get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) imanager = IngestionManager(ingest_config, meta, ocdbt_seg=ocdbt) enqueue_l2_tasks(imanager, create_atomic_chunk) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 72a3c081e..6233c9d46 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -143,10 +143,10 @@ def create_atomic_chunk(coords: Sequence[int]): for k, v in chunk_edges_active.items(): logging.debug(f"active_{k}: {len(v)}") - src, dst = get_seg_source_and_destination_ocdbt( - imanager.cg.meta.data_source.WATERSHED - ) if imanager.ocdbt_seg: + src, dst = get_seg_source_and_destination_ocdbt( + imanager.cg.meta.data_source.WATERSHED + ) copy_ws_chunk( src, dst, From 18d3198b2c4e668bd6b440d321fd01781be33187 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 18 Mar 2026 19:26:04 +0000 Subject: [PATCH 176/367] bugfix: use labels for cx edges after split with inf affinity --- pychunkedgraph/app/segmentation/common.py | 10 +- pychunkedgraph/graph/edits_sv.py | 105 ++++++++++++--- pychunkedgraph/graph/operation.py | 1 + pychunkedgraph/tests/graph/test_edits_sv.py | 137 +++++++++++++++++++- 4 files changed, 220 insertions(+), 33 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 0a9c1789f..1f3a6ae06 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -384,7 +384,6 @@ def handle_merge(table_id, allow_same_segment_merge=False): source_coords=coords[:1], sink_coords=coords[1:], allow_same_segment_merge=allow_same_segment_merge, - do_sanity_check=True, ) except cg_exceptions.LockingError as e: @@ -410,7 +409,6 @@ def handle_merge(table_id, allow_same_segment_merge=False): def _get_sources_and_sinks(cg: ChunkedGraph, data): - current_app.logger.debug(data) node_idents = [] node_ident_map = {"sources": 0, "sinks": 1} coords = [] @@ -426,13 +424,7 @@ def _get_sources_and_sinks(cg: ChunkedGraph, data): coords = np.array(coords) node_idents = np.array(node_idents) - start = time.time() sv_ids = app_utils.handle_supervoxel_id_lookup(cg, coords, node_ids) - current_app.logger.info(f"SV lookup took {time.time() - start}s.") - current_app.logger.debug( - {"node_id": node_ids, "sv_id": sv_ids, "node_ident": node_idents} - ) - source_ids = sv_ids[node_idents == 0] sink_ids = sv_ids[node_idents == 1] source_coords = coords[node_idents == 0] @@ -450,6 +442,7 @@ def handle_split(table_id): mincut = request.args.get("mincut", True, type=str2bool) cg = app_utils.get_cg(table_id, skip_cache=True) + current_app.logger.debug(data) sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) try: ret = cg.remove_edges( @@ -494,7 +487,6 @@ def handle_split(table_id): source_coords=source_coords, sink_coords=sink_coords, mincut=mincut, - do_sanity_check=True, ) except cg_exceptions.LockingError as e: raise cg_exceptions.InternalServerError(e) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 15199b403..287b44963 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -95,6 +95,7 @@ def _update_chunk(args): _indices = [] _old_values = [] _new_values = [] + _label_id_map = {} for _id in labels: _mask = chunk_seg == _id if np.any(_mask): @@ -104,12 +105,14 @@ def _update_chunk(args): _indices.append(_index) _ones = np.ones(len(_index), dtype=basetypes.NODE_ID) _old_values.append(_ones * _og_value) - _new_values.append(_ones * cg.id_client.create_node_id(chunk_id)) + new_id = cg.id_client.create_node_id(chunk_id) + _new_values.append(_ones * new_id) + _label_id_map[int(_id)] = new_id _indices = np.concatenate(_indices) + (chunk_bbox[0] - bb_start) _old_values = np.concatenate(_old_values) _new_values = np.concatenate(_new_values) - return (_indices, _old_values, _new_values) + return (_indices, _old_values, _new_values, _label_id_map) def _voxel_crop(bbs, bbe, bbs_, bbe_): @@ -122,17 +125,62 @@ def _voxel_crop(bbs, bbe, bbs_, bbe_): def _parse_results(results, seg, bbs, bbe): old_new_map = defaultdict(set) + new_id_label_map = {} for result in results: if result: - indexer, old_values, new_values = result + indexer, old_values, new_values, label_id_map = result seg[tuple(indexer.T)] = new_values for old_sv, new_sv in zip(old_values, new_values): old_new_map[old_sv].add(new_sv) + for label, new_id in label_id_map.items(): + new_id_label_map[new_id] = label assert np.all(seg.shape == bbe - bbs), f"{seg.shape} != {bbe - bbs}" slices = tuple(slice(start, end) for start, end in zip(bbs, bbe)) + (slice(None),) logging.info(f"slices {slices}") - return seg, old_new_map, slices + return seg, old_new_map, slices, new_id_label_map + + +def _match_by_label(new_ids, partner, aff, area, new_id_label_map, distances_row): + """For inf-affinity (cross-chunk) edges: connect fragments with matching split label.""" + partner_label = new_id_label_map[partner] + matching = np.array( + [nid for nid in new_ids if new_id_label_map.get(nid) == partner_label], + dtype=basetypes.NODE_ID, + ) + if len(matching): + edges = np.column_stack( + [matching, np.full(len(matching), partner, dtype=np.uint64)] + ) + affs = np.full(len(matching), aff, dtype=basetypes.EDGE_AFFINITY) + areas = np.full(len(matching), area, dtype=basetypes.EDGE_AREA) + return edges, affs, areas + # fallback: closest fragment + close = new_ids[np.argmin(distances_row)] + return ( + np.array([[close, partner]], dtype=np.uint64), + np.array([aff], dtype=basetypes.EDGE_AFFINITY), + np.array([area], dtype=basetypes.EDGE_AREA), + ) + + +def _match_by_proximity(new_ids, partner, aff, area, distances_row, threshold): + """For regular edges: connect fragments within distance threshold.""" + close_mask = distances_row < threshold + nearby = new_ids[close_mask] + if len(nearby): + edges = np.column_stack( + [nearby, np.full(len(nearby), partner, dtype=np.uint64)] + ) + affs = np.full(len(nearby), aff, dtype=basetypes.EDGE_AFFINITY) + areas = np.full(len(nearby), area, dtype=basetypes.EDGE_AREA) + return edges, affs, areas + close = new_ids[np.argmin(distances_row)] + return ( + np.array([[close, partner]], dtype=np.uint64), + np.array([aff], dtype=basetypes.EDGE_AFFINITY), + np.array([area], dtype=basetypes.EDGE_AREA), + ) def _get_new_edges( @@ -142,6 +190,7 @@ def _get_new_edges( distances: np.ndarray, dist_vec: Callable, new_dist_vec: Callable, + new_id_label_map: dict = None, ): THRESHOLD = 10 new_edges, new_affs, new_areas = [], [], [] @@ -189,19 +238,29 @@ def _get_new_edges( logging.info(f"new_dist_vec(new_ids): {new_dist_vec(new_ids)}") logging.info(f"dist_vec(active_partners): {dist_vec(active_partners)}") distances_ = distances[new_dist_vec(new_ids)][:, dist_vec(active_partners)].T - for i, _ in enumerate(active_partners): - new_ids_ = new_ids[distances_[i] < THRESHOLD] - if len(new_ids_): - _a = [new_ids_, [active_partners[i]] * len(new_ids_)] - new_edges.extend(np.array(_a, dtype=np.uint64).T) - new_affs.extend([active_affs[i]] * len(new_ids_)) - new_areas.extend([active_areas[i]] * len(new_ids_)) + for i, partner in enumerate(active_partners): + aff = active_affs[i] + if np.isinf(aff) and new_id_label_map and partner in new_id_label_map: + e, a, ar = _match_by_label( + new_ids, + partner, + aff, + active_areas[i], + new_id_label_map, + distances_[i], + ) else: - close_new_sv_id = new_ids[np.argmin(distances_[i])] - _a = [close_new_sv_id, active_partners[i]] - new_edges.append(np.array(_a, dtype=np.uint64)) - new_affs.append(active_affs[i]) - new_areas.append(active_areas[i]) + e, a, ar = _match_by_proximity( + new_ids, + partner, + aff, + active_areas[i], + distances_[i], + THRESHOLD, + ) + new_edges.extend(e) + new_affs.extend(a) + new_areas.extend(ar) # edges between split fragments for i in range(len(new_ids)): @@ -225,6 +284,7 @@ def _update_edges( bbox: np.ndarray, new_seg: np.ndarray, old_new_map: dict, + new_id_label_map: dict = None, ): old_new_map = dict(old_new_map) kdtrees, _ = build_kdtrees_by_label(new_seg) @@ -259,6 +319,7 @@ def _update_edges( distances, dist_vec, new_dist_vec, + new_id_label_map, ) @@ -350,7 +411,9 @@ def split_supervoxel( with mp.Pool() as pool: results = [*tqdm(pool.imap_unordered(_update_chunk, tasks), total=len(tasks))] seg_cropped = seg[voxel_overlap_crop].copy() - new_seg, old_new_map, slices = _parse_results(results, seg_cropped, bbs, bbe) + new_seg, old_new_map, slices, new_id_label_map = _parse_results( + results, seg_cropped, bbs, bbe + ) seg_roots = seg.copy() sv_ids = fastremap.unique(seg) @@ -366,7 +429,13 @@ def split_supervoxel( seg_masked[voxel_overlap_crop] = new_seg edges_tuple = _update_edges( - cg, sv_ids, root, np.array([bbs, bbe]), seg_masked, old_new_map + cg, + sv_ids, + root, + np.array([bbs, bbe]), + seg_masked, + old_new_map, + new_id_label_map, ) rows0 = copy_parents_and_add_lineage(cg, operation_id, old_new_map) diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 5bf221e01..2066bdba0 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -461,6 +461,7 @@ def execute( old_root_ids=root_ids, ) except SupervoxelSplitRequiredError as err: + # no need for self.cg.cache = None, the cache must be retained after sv split raise SupervoxelSplitRequiredError( str(err), err.sv_remapping, operation_id=lock.operation_id ) from err diff --git a/pychunkedgraph/tests/graph/test_edits_sv.py b/pychunkedgraph/tests/graph/test_edits_sv.py index ced51e68f..c0b0f7d73 100644 --- a/pychunkedgraph/tests/graph/test_edits_sv.py +++ b/pychunkedgraph/tests/graph/test_edits_sv.py @@ -9,6 +9,8 @@ _voxel_crop, _parse_results, _get_new_edges, + _match_by_label, + _match_by_proximity, ) from pychunkedgraph.graph import basetypes @@ -54,27 +56,33 @@ def test_basic(self): seg = np.array([[[100, 100], [100, 200]]], dtype=basetypes.NODE_ID) bbs = np.array([0, 0, 0]) bbe = np.array([1, 2, 2]) - # result: (indices, old_values, new_values) indices = np.array([[0, 0, 0], [0, 0, 1]]) old_values = np.array([100, 100], dtype=basetypes.NODE_ID) new_values = np.array([300, 301], dtype=basetypes.NODE_ID) - results = [(indices, old_values, new_values)] + label_id_map = {1: np.uint64(300), 2: np.uint64(301)} + results = [(indices, old_values, new_values, label_id_map)] - updated_seg, old_new_map, slices = _parse_results(results, seg, bbs, bbe) + updated_seg, old_new_map, slices, new_id_label_map = _parse_results( + results, seg, bbs, bbe + ) assert updated_seg[0, 0, 0] == 300 assert updated_seg[0, 0, 1] == 301 assert 300 in old_new_map[100] assert 301 in old_new_map[100] + assert new_id_label_map[np.uint64(300)] == 1 + assert new_id_label_map[np.uint64(301)] == 2 def test_none_result_skipped(self): seg = np.array([[[100]]], dtype=basetypes.NODE_ID) bbs = np.array([0, 0, 0]) bbe = np.array([1, 1, 1]) results = [None] - updated_seg, old_new_map, slices = _parse_results(results, seg, bbs, bbe) - # No changes + updated_seg, old_new_map, slices, new_id_label_map = _parse_results( + results, seg, bbs, bbe + ) assert updated_seg[0, 0, 0] == 100 assert len(old_new_map) == 0 + assert len(new_id_label_map) == 0 def test_multiple_results(self): seg = np.array([[[100, 200]]], dtype=basetypes.NODE_ID) @@ -84,15 +92,19 @@ def test_multiple_results(self): np.array([[0, 0, 0]]), np.array([100], dtype=basetypes.NODE_ID), np.array([300], dtype=basetypes.NODE_ID), + {1: np.uint64(300)}, ) result2 = ( np.array([[0, 0, 1]]), np.array([200], dtype=basetypes.NODE_ID), np.array([400], dtype=basetypes.NODE_ID), + {1: np.uint64(400)}, ) results = [result1, result2] - updated_seg, old_new_map, slices = _parse_results(results, seg, bbs, bbe) + updated_seg, old_new_map, slices, new_id_label_map = _parse_results( + results, seg, bbs, bbe + ) assert updated_seg[0, 0, 0] == 300 assert updated_seg[0, 0, 1] == 400 assert 300 in old_new_map[100] @@ -218,3 +230,116 @@ def test_empty_old_new_map(self): np.vectorize(lambda x: x), ) assert len(result_edges) == 0 + + def test_inf_affinity_uses_label_matching(self): + """Inf-affinity (cross-chunk) edges should connect only same-label fragments.""" + old_sv = np.uint64(10) + new_sv1 = np.uint64(101) # label 1 + new_sv2 = np.uint64(102) # label 2 + # partner is a cross-chunk fragment also from the split, label 1 + partner = np.uint64(201) + + edges = np.array([[10, 201]], dtype=basetypes.NODE_ID) + affinities = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([0], dtype=basetypes.EDGE_AREA) + + old_new_map = {old_sv: {new_sv1, new_sv2}} + sv_ids = np.array([10, 101, 102, 201], dtype=basetypes.NODE_ID) + + distance_map = { + np.uint64(10): 0, + np.uint64(101): 1, + np.uint64(102): 2, + np.uint64(201): 3, + } + dist_vec = np.vectorize(distance_map.get) + new_distance_map = {np.uint64(101): 0, np.uint64(102): 1} + new_dist_vec = np.vectorize(new_distance_map.get) + + # new_sv2 (label 2) is closer to partner 201, but label doesn't match + distances = np.array( + [ + [5.0, 0.0, 8.0, 9.0], # new_sv1 (label 1) — far from partner + [6.0, 8.0, 0.0, 2.0], # new_sv2 (label 2) — close to partner + ] + ) + + new_id_label_map = { + np.uint64(101): 1, + np.uint64(102): 2, + np.uint64(201): 1, # same label as new_sv1 + } + + result_edges, result_affs, result_areas = _get_new_edges( + (edges, affinities, areas), + sv_ids, + old_new_map, + distances, + dist_vec, + new_dist_vec, + new_id_label_map, + ) + + # The inf-affinity edge should connect new_sv1 (label 1) to partner 201 (label 1) + # NOT new_sv2 (label 2) even though it's closer + inf_edges = result_edges[np.isinf(result_affs)] + for e in inf_edges: + assert ( + new_sv2 not in e + ), f"Inf-affinity edge {e} should not connect label-2 fragment to label-1 partner" + # Verify new_sv1 <-> 201 inf edge exists + found = any(set(e) == {new_sv1, partner} for e in inf_edges) + assert found, "Expected inf-affinity edge between same-label fragments" + + +# ============================================================ +# Tests: _match_by_label / _match_by_proximity +# ============================================================ +class TestMatchByLabel: + def test_matching_label(self): + new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) + new_id_label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 1} + distances_row = np.array([9.0, 2.0]) # 102 is closer + + edges, affs, areas = _match_by_label( + new_ids, np.uint64(201), np.inf, 0, new_id_label_map, distances_row + ) + # Should pick 101 (label 1) not 102 (label 2, closer) + assert all(np.uint64(101) in e for e in edges) + assert np.uint64(102) not in edges.flatten() + + def test_fallback_to_closest(self): + new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) + # partner label 3 doesn't match any new_id + new_id_label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 3} + distances_row = np.array([9.0, 2.0]) + + edges, affs, areas = _match_by_label( + new_ids, np.uint64(201), np.inf, 0, new_id_label_map, distances_row + ) + # Fallback: closest = 102 + assert np.uint64(102) in edges.flatten() + + +class TestMatchByProximity: + def test_within_threshold(self): + new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) + distances_row = np.array([3.0, 15.0]) + + edges, affs, areas = _match_by_proximity( + new_ids, np.uint64(50), 0.9, 100, distances_row, threshold=10 + ) + # Only 101 is within threshold + assert len(edges) == 1 + assert np.uint64(101) in edges[0] + + def test_fallback_to_closest(self): + new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) + distances_row = np.array([15.0, 20.0]) # both outside threshold + + edges, affs, areas = _match_by_proximity( + new_ids, np.uint64(50), 0.9, 100, distances_row, threshold=10 + ) + # Fallback: closest = 101 + assert len(edges) == 1 + assert np.uint64(101) in edges[0] From 5e82105f17e25ca2074fa1376d71134a0a240b26 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 19 Mar 2026 01:03:19 +0000 Subject: [PATCH 177/367] optimize whole sv lookup, use rep sv from source --- pychunkedgraph/app/segmentation/common.py | 2 +- pychunkedgraph/graph/edits_sv.py | 69 ++++++++++---------- pychunkedgraph/graph/meta.py | 5 +- pychunkedgraph/graph/ocdbt.py | 2 +- pychunkedgraph/graph/operation.py | 6 -- pychunkedgraph/ingest/cli.py | 19 +++++- pychunkedgraph/tests/graph/test_ocdbt.py | 8 --- pychunkedgraph/tests/graph/test_operation.py | 15 ----- workers/mesh_worker.py | 4 +- 9 files changed, 60 insertions(+), 70 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 1f3a6ae06..da037fc34 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -473,7 +473,7 @@ def handle_split(table_id): _mask1 = sinks_remapped == sv_to_split split_supervoxel( cg, - sv_to_split, + sources[_mask0][0], source_coords[_mask0], sink_coords[_mask1], e.operation_id, diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 287b44963..dc5641ca9 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -30,29 +30,23 @@ def _get_whole_sv( cg: ChunkedGraph, node: basetypes.NODE_ID, min_coord, max_coord ) -> set: - cx_edges = [empty_2d] - explored_chunks = set() + all_chunks = [ + (x, y, z) + for x in range(min_coord[0], max_coord[0]) + for y in range(min_coord[1], max_coord[1]) + for z in range(min_coord[2], max_coord[2]) + ] + edges = get_chunk_edges(cg.meta.data_source.EDGES, all_chunks) + cx_edges = edges["cross"].get_pairs() + if len(cx_edges) == 0: + return {node} + explored_nodes = set([node]) queue = deque([node]) - - while len(queue) > 0: + while queue: vertex = queue.popleft() - chunk = cg.get_chunk_coordinates(vertex) - chunks = get_neighbors(chunk, min_coord=min_coord, max_coord=max_coord) - - unexplored_chunks = [] - for _chunk in chunks: - if tuple(_chunk) not in explored_chunks: - unexplored_chunks.append(tuple(_chunk)) - - edges = get_chunk_edges(cg.meta.data_source.EDGES, unexplored_chunks) - explored_chunks.update(unexplored_chunks) - _cx_edges = edges["cross"].get_pairs() - cx_edges.append(_cx_edges) - _cx_edges = np.concatenate(cx_edges) - - mask = _cx_edges[:, 0] == vertex - neighbors = _cx_edges[mask][:, 1] + mask = cx_edges[:, 0] == vertex + neighbors = cx_edges[mask][:, 1] if len(neighbors) > 0: neighbor_coords = cg.get_chunk_coordinates_multiple(neighbors) @@ -61,10 +55,9 @@ def _get_whole_sv( neighbors = neighbors[min_mask & max_mask] for neighbor in neighbors: - if neighbor in explored_nodes: - continue - explored_nodes.add(neighbor) - queue.append(neighbor) + if neighbor not in explored_nodes: + explored_nodes.add(neighbor) + queue.append(neighbor) return explored_nodes @@ -191,8 +184,8 @@ def _get_new_edges( dist_vec: Callable, new_dist_vec: Callable, new_id_label_map: dict = None, + threshold: int = 10, ): - THRESHOLD = 10 new_edges, new_affs, new_areas = [], [], [] edges, affinities, areas = edges_info @@ -256,7 +249,7 @@ def _get_new_edges( aff, active_areas[i], distances_[i], - THRESHOLD, + threshold, ) new_edges.extend(e) new_affs.extend(a) @@ -270,9 +263,15 @@ def _get_new_edges( new_affs.append(0.001) new_areas.append(0) + if len(new_edges) == 0: + return ( + np.array([], dtype=basetypes.NODE_ID), + np.array([], dtype=basetypes.EDGE_AFFINITY), + np.array([], dtype=basetypes.EDGE_AREA), + ) affinites = np.array(new_affs, dtype=basetypes.EDGE_AFFINITY) areas = np.array(new_areas, dtype=basetypes.EDGE_AREA) - edges = np.array(new_edges, dtype=basetypes.NODE_ID) + edges = np.sort(np.array(new_edges, dtype=basetypes.NODE_ID), axis=1) edges, idx = np.unique(edges, return_index=True, axis=0) return edges, affinites[idx], areas[idx] @@ -320,6 +319,7 @@ def _update_edges( dist_vec, new_dist_vec, new_id_label_map, + threshold=cg.meta.sv_split_threshold, ) @@ -372,18 +372,21 @@ def split_supervoxel( vol_end = cg.meta.voxel_bounds[:, 1] chunk_size = cg.meta.graph_config.CHUNK_SIZE _coords = np.concatenate([source_coords, sink_coords]) - _padding = np.array([64] * 3) / cg.meta.resolution + _padding = np.array([cg.meta.resolution[-1] * 2] * 3) / cg.meta.resolution bbs = np.clip((np.min(_coords, 0) - _padding).astype(int), vol_start, vol_end) bbe = np.clip((np.max(_coords, 0) + _padding).astype(int), vol_start, vol_end) chunk_min, chunk_max = bbs // chunk_size, np.ceil(bbe / chunk_size).astype(int) bbs, bbe = chunk_min * chunk_size, chunk_max * chunk_size - logging.info(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}") - logging.info(f"{chunk_size}; {_padding}; {(bbs, bbe)}; {(chunk_min, chunk_max)}") + logging.info( + f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}" + ) + logging.info(f"chunk and padding {chunk_size}; {_padding}") + logging.info(f"bbox and chunk min max {(bbs, bbe)}; {(chunk_min, chunk_max)}") cut_supervoxels = _get_whole_sv(cg, sv_id, min_coord=chunk_min, max_coord=chunk_max) supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) - logging.info(f"{sv_id} -> {cut_supervoxels}") + logging.info(f"whole sv {sv_id} -> {cut_supervoxels}") # one voxel overlap for neighbors bbs_ = np.clip(bbs - 1, vol_start, vol_end) @@ -421,7 +424,7 @@ def split_supervoxel( seg_roots = fastremap.remap(seg_roots, dict(zip(sv_ids, roots)), in_place=True) root = cg.get_root(sv_id) - logging.info(f"root {root}") + logging.info(f"{sv_id} root = {root}") seg_masked = seg.copy() seg_masked[seg_roots != root] = 0 @@ -443,8 +446,8 @@ def split_supervoxel( rows = rows0 + rows1 logging.info(f"{operation_id}: writing {len(rows)} new rows") - cg.client.write(rows) cg.meta.ws_ocdbt[slices] = new_seg[..., np.newaxis] + cg.client.write(rows) return old_new_map, edges_tuple diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index 6a938f802..40968c697 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -14,7 +14,6 @@ from .chunks.utils import get_chunks_boundary from ..utils.redis import get_redis_connection - _datasource_fields = ("EDGES", "COMPONENTS", "WATERSHED", "DATA_VERSION", "CV_MIP") _datasource_defaults = (None, None, None, None, 0) DataSource = namedtuple( @@ -244,6 +243,10 @@ def edge_dtype(self): def READ_ONLY(self): return self.custom_data.get("READ_ONLY", False) + @property + def sv_split_threshold(self) -> int: + return self._custom_data.get("seg", {}).get("sv_split_threshold", 10) + @property def split_bounding_offset(self): return self.custom_data.get( diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index 03c6d9b65..f715bc101 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -2,7 +2,7 @@ import numpy as np import tensorstore as ts -OCDBT_SEG_COMPRESSION_LEVEL = 17 +OCDBT_SEG_COMPRESSION_LEVEL = 12 def get_seg_source_and_destination_ocdbt(ws_path: str, create: bool = False) -> tuple: diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 2066bdba0..0d91e3990 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -893,12 +893,6 @@ def __init__( self.path_augment = path_augment self.disallow_isolating_cut = disallow_isolating_cut self.do_sanity_check = do_sanity_check - if np.any(np.isin(self.sink_ids, self.source_ids)): - raise SupervoxelSplitRequiredError( - "Supervoxels exist in both sink and source, " - "try placing the points further apart.", - None, - ) ids = np.concatenate([self.source_ids, self.sink_ids]).astype(basetypes.NODE_ID) layers = self.cg.get_chunk_layers(ids) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 5d448814a..ca958c354 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -48,12 +48,24 @@ def flush_redis(): @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True)) @click.option("--ocdbt", is_flag=True, help="Precomputed supervoxel seg into ocdbt.") +@click.option( + "--sv-split-threshold", + type=int, + default=10, + help="Distance threshold for SV split edge matching.", +) @click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") @click.option("--retry", is_flag=True, help="Rerun without creating a new table.") @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @job_type_guard(group_name) def ingest_graph( - graph_id: str, dataset: click.Path, ocdbt: bool, raw: bool, retry: bool, test: bool + graph_id: str, + dataset: click.Path, + ocdbt: bool, + sv_split_threshold: int, + raw: bool, + retry: bool, + test: bool, ): """ Main ingest command. @@ -73,7 +85,10 @@ def ingest_graph( cg.create() if ocdbt: - cg.meta.custom_data["seg"] = {"ocdbt": True} + cg.meta.custom_data["seg"] = { + "ocdbt": True, + "sv_split_threshold": sv_split_threshold, + } cg.update_meta(cg.meta, overwrite=True) get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) imanager = IngestionManager(ingest_config, meta, ocdbt_seg=ocdbt) diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index a554e23b1..9f31e5f6f 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -131,11 +131,3 @@ def test_boundary_clipping(self): voxel_bounds=voxel_bounds, ) mock_source.__getitem__.assert_called_once() - - -class TestOcdbtConstants: - def test_compression_level(self): - from pychunkedgraph.graph.ocdbt import OCDBT_SEG_COMPRESSION_LEVEL - - assert OCDBT_SEG_COMPRESSION_LEVEL == 17 - assert isinstance(OCDBT_SEG_COMPRESSION_LEVEL, int) diff --git a/pychunkedgraph/tests/graph/test_operation.py b/pychunkedgraph/tests/graph/test_operation.py index 328ceb425..fa916ae0d 100644 --- a/pychunkedgraph/tests/graph/test_operation.py +++ b/pychunkedgraph/tests/graph/test_operation.py @@ -498,21 +498,6 @@ def test_split_self_loop_raises(self, gen_graph): sink_coords=None, ) - @pytest.mark.timeout(30) - def test_multicut_overlapping_ids_raises(self, gen_graph): - """source_ids overlapping sink_ids should raise PreconditionError (line 872).""" - cg, _, sv0, sv1 = _build_cross_chunk(gen_graph) - with pytest.raises(SupervoxelSplitRequiredError, match="both sink and source"): - MulticutOperation( - cg, - user_id="test_user", - source_ids=[sv0, sv1], - sink_ids=[sv1], - source_coords=[[0, 0, 0], [1, 0, 0]], - sink_coords=[[1, 0, 0]], - bbox_offset=[240, 240, 24], - ) - # =========================================================================== # NEW: Empty coords / affinities normalization (lines 82, 86, 593) diff --git a/workers/mesh_worker.py b/workers/mesh_worker.py index b8f1e0024..cb81b687d 100644 --- a/workers/mesh_worker.py +++ b/workers/mesh_worker.py @@ -10,10 +10,9 @@ from messagingclient import MessagingClient from pychunkedgraph.graph import ChunkedGraph -from pychunkedgraph.graph.utils import basetypes +from pychunkedgraph.graph import basetypes from pychunkedgraph.meshing import meshgen - PCG_CACHE = {} @@ -55,7 +54,6 @@ def callback(payload): cg.meta.data_source.WATERSHED, mesh_dir, cv_unsharded_mesh_dir ) - logging.log(INFO_HIGH, f"remeshing {l2ids}; graph {table_id} operation {op_id}.") meshgen.remeshing( cg, From b6599060d739053976a0fdd76220f40f34322a52 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 19 Mar 2026 19:21:27 +0000 Subject: [PATCH 178/367] fix: use relative ocdbt path in info --- pychunkedgraph/graph/meta.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index 40968c697..23ff8d35d 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -67,6 +67,7 @@ def __init__( self._layer_count = None self._bitmasks = None self._ocdbt_seg = None + self._ocdbt_path = None @property def graph_config(self): @@ -108,6 +109,14 @@ def ocdbt_seg(self) -> bool: self._ocdbt_seg = self._custom_data.get("seg", {}).get("ocdbt", False) return self._ocdbt_seg + @property + def ocdbt_path(self) -> bool: + if self._ocdbt_path is None: + self._ocdbt_path = self._custom_data.get("seg", {}).get( + "ocdbt_path", "ocdbt/base" + ) + return self._ocdbt_path + @property def ws_ocdbt(self): assert self.ocdbt_seg, "make sure this pcg has segmentation in ocdbt format" @@ -260,11 +269,7 @@ def dataset_info(self) -> Dict: info.update( { "chunks_start_at_voxel_offset": True, - "data_dir": ( - self.ws_ocdbt.kvstore.base.url - if self.ocdbt_seg - else self.data_source.WATERSHED - ), + "data_dir": self.data_source.WATERSHED, "graph": { "chunk_size": self.graph_config.CHUNK_SIZE, "bounding_box": [2048, 2048, 512], @@ -272,6 +277,8 @@ def dataset_info(self) -> Dict: "cv_mip": self.data_source.CV_MIP, "n_layers": self.layer_count, "spatial_bit_masks": self.bitmasks, + "ocdbt_seg": self.ocdbt_seg, + "ocdbt_path": self.ocdbt_path, }, } ) From 7b275997c5788f4191a17a992b6e42b9e99d5ecb Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 20 Mar 2026 16:43:07 +0000 Subject: [PATCH 179/367] fix: inf edges to only closest partner; mesh worker pubsub version, always sv lookup from seg for ocdbt --- pychunkedgraph/app/__init__.py | 4 + pychunkedgraph/app/app_utils.py | 5 + pychunkedgraph/app/segmentation/common.py | 4 + pychunkedgraph/graph/chunkedgraph.py | 13 +- pychunkedgraph/graph/cutting_sv.py | 5 +- pychunkedgraph/graph/edits_sv.py | 183 +++++++++--------- pychunkedgraph/graph/utils/generic.py | 10 +- pychunkedgraph/graph/utils/id_helpers.py | 7 +- pychunkedgraph/ingest/upgrade/atomic_layer.py | 6 + .../tests/graph/test_utils_id_helpers.py | 1 + requirements.in | 2 +- requirements.txt | 2 +- uwsgi.ini | 26 +-- 13 files changed, 135 insertions(+), 133 deletions(-) diff --git a/pychunkedgraph/app/__init__.py b/pychunkedgraph/app/__init__.py index 262849258..042fa7ff1 100644 --- a/pychunkedgraph/app/__init__.py +++ b/pychunkedgraph/app/__init__.py @@ -99,6 +99,10 @@ def configure_app(app): app.logger.setLevel(app.config["LOGGING_LEVEL"]) app.logger.propagate = False + # Also configure root logger so logging.info() calls in library code are captured + logging.root.addHandler(handler) + logging.root.setLevel(logging.INFO) + if app.config["USE_REDIS_JOBS"]: app.redis = redis.Redis.from_url(app.config["REDIS_URL"]) app.test_q = Queue("test", connection=app.redis) diff --git a/pychunkedgraph/app/app_utils.py b/pychunkedgraph/app/app_utils.py index 061f60115..9d69c3650 100644 --- a/pychunkedgraph/app/app_utils.py +++ b/pychunkedgraph/app/app_utils.py @@ -16,6 +16,7 @@ from pychunkedgraph.graph import ChunkedGraph from pychunkedgraph.graph import get_default_client_info from pychunkedgraph.graph import exceptions as cg_exceptions +from pychunkedgraph.graph.utils.generic import lookup_svs_from_seg PCG_CACHE = {} @@ -238,6 +239,10 @@ def ccs(coordinates_nm_): f"{coordinates} - Validation stage." ) + # Fast path: all node_ids are L1 and OCDBT — single seg read for all coords + if cg.meta.ocdbt_seg and np.all(cg.get_chunk_layers(np.unique(node_ids)) == 1): + return lookup_svs_from_seg(cg.meta, coordinates) + atomic_ids = np.zeros(len(coordinates), dtype=np.uint64) for node_id in np.unique(node_ids): node_id_m = node_ids == node_id diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index da037fc34..695d5aef9 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -444,6 +444,7 @@ def handle_split(table_id): cg = app_utils.get_cg(table_id, skip_cache=True) current_app.logger.debug(data) sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) + current_app.logger.info(f"sv_lookup pre-split: sources={sources}, sinks={sinks}") try: ret = cg.remove_edges( user_id=user_id, @@ -480,6 +481,9 @@ def handle_split(table_id): ) sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) + current_app.logger.info( + f"sv_lookup post-split: sources={sources}, sinks={sinks}" + ) ret = cg.remove_edges( user_id=user_id, source_ids=sources, diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 3a6b1461d..c320c1bde 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -183,16 +183,21 @@ def get_atomic_ids_from_coords( :param max_dist_nm: max distance explored :return: supervoxel ids; returns None if no solution was found """ - if self.get_chunk_layer(parent_id) == 1: + if self.get_chunk_layer(parent_id) == 1 and not self.meta.ocdbt_seg: return np.array([parent_id] * len(coordinates), dtype=np.uint64) - # Enable search with old parent by using its timestamp and map to parents - parent_ts = self.get_node_timestamps([parent_id], return_numpy=False)[0] + layer = self.get_chunk_layer(parent_id) + # L1 nodes don't have children, skip timestamp lookup + parent_ts = ( + None + if layer == 1 + else self.get_node_timestamps([parent_id], return_numpy=False)[0] + ) return id_helpers.get_atomic_ids_from_coords( self.meta, coordinates, parent_id, - self.get_chunk_layer(parent_id), + layer, parent_ts, self.get_roots, max_dist_nm, diff --git a/pychunkedgraph/graph/cutting_sv.py b/pychunkedgraph/graph/cutting_sv.py index 5f9ba58c5..bafd86c68 100644 --- a/pychunkedgraph/graph/cutting_sv.py +++ b/pychunkedgraph/graph/cutting_sv.py @@ -4,7 +4,6 @@ from typing import Dict, Tuple, Optional, Sequence from scipy.spatial import cKDTree - # EDT backends: prefer Seung-Lab edt, fallback to scipy.ndimage try: from edt import edt as _edt_fast @@ -385,7 +384,7 @@ def connect_both_seeds_via_ridge( refine_fullres_when_fail: bool = True, snap_method: str = "kdtree", snap_kwargs: dict | None = None, - verbose: bool = True, + verbose: bool = False, ): def log(msg: str): if verbose: @@ -726,7 +725,7 @@ def split_supervoxel_growing( snap_method: str = "kdtree", snap_kwargs: dict | None = None, # logging - verbose: bool = True, + verbose: bool = False, ): def log(msg: str): if verbose: diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index dc5641ca9..2b917c230 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -72,11 +72,6 @@ def _update_chunk(args): x, y, z = chunk_coord chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) - # TODO: remove these 3 lines, testing only - rr = cg.range_read_chunk(chunk_id) - max_node_id = max(rr.keys()) - cg.id_client.set_max_node_id(chunk_id, max_node_id) - _s, _e = chunk_bbox - bb_start og_chunk_seg = seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] chunk_seg = result_seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] @@ -91,16 +86,15 @@ def _update_chunk(args): _label_id_map = {} for _id in labels: _mask = chunk_seg == _id - if np.any(_mask): - _idx = np.unravel_index(np.flatnonzero(_mask)[0], og_chunk_seg.shape) - _og_value = og_chunk_seg[_idx] - _index = np.argwhere(_mask) - _indices.append(_index) - _ones = np.ones(len(_index), dtype=basetypes.NODE_ID) - _old_values.append(_ones * _og_value) - new_id = cg.id_client.create_node_id(chunk_id) - _new_values.append(_ones * new_id) - _label_id_map[int(_id)] = new_id + _idx = np.unravel_index(np.flatnonzero(_mask)[0], og_chunk_seg.shape) + _og_value = og_chunk_seg[_idx] + _index = np.argwhere(_mask) + _indices.append(_index) + _ones = np.ones(len(_index), dtype=basetypes.NODE_ID) + _old_values.append(_ones * _og_value) + new_id = cg.id_client.create_node_id(chunk_id) + _new_values.append(_ones * new_id) + _label_id_map[int(_id)] = new_id _indices = np.concatenate(_indices) + (chunk_bbox[0] - bb_start) _old_values = np.concatenate(_old_values) @@ -112,7 +106,6 @@ def _voxel_crop(bbs, bbe, bbs_, bbe_): xS, yS, zS = bbs - bbs_ xE, yE, zE = (None if i == 0 else -1 for i in bbe_ - bbe) voxel_overlap_crop = np.s_[xS:xE, yS:yE, zS:zE] - logging.info(f"voxel_overlap_crop: {voxel_overlap_crop}") return voxel_overlap_crop @@ -130,7 +123,6 @@ def _parse_results(results, seg, bbs, bbe): assert np.all(seg.shape == bbe - bbs), f"{seg.shape} != {bbe - bbs}" slices = tuple(slice(start, end) for start, end in zip(bbs, bbe)) + (slice(None),) - logging.info(f"slices {slices}") return seg, old_new_map, slices, new_id_label_map @@ -176,6 +168,42 @@ def _match_by_proximity(new_ids, partner, aff, area, distances_row, threshold): ) +def _match_inf_unsplit(new_ids, partner, aff, area, distances_row): + """Inf-affinity edge to an unsplit partner: assign to closest fragment only. + Connecting all fragments would create an uncuttable bridge between source/sink sides. + """ + closest = new_ids[np.argmin(distances_row)] + return ( + np.array([[closest, partner]], dtype=np.uint64), + np.array([aff], dtype=basetypes.EDGE_AFFINITY), + np.array([area], dtype=basetypes.EDGE_AREA), + ) + + +def _match_partner( + new_ids, partner, aff, area, distances_row, new_id_label_map, threshold +): + """Route a single old edge to the appropriate new fragment(s).""" + if np.isinf(aff): + if new_id_label_map and partner in new_id_label_map: + return _match_by_label( + new_ids, partner, aff, area, new_id_label_map, distances_row + ) + return _match_inf_unsplit(new_ids, partner, aff, area, distances_row) + return _match_by_proximity(new_ids, partner, aff, area, distances_row, threshold) + + +def _expand_partners(active_partners, active_affs, active_areas, old_new_map): + """If a partner was also split, expand it to its new fragment IDs.""" + partners, affs, areas = [], [], [] + for i in range(len(active_partners)): + remapped = old_new_map.get(active_partners[i], [active_partners[i]]) + partners.extend(remapped) + affs.extend([active_affs[i]] * len(remapped)) + areas.extend([active_areas[i]] * len(remapped)) + return partners, affs, areas + + def _get_new_edges( edges_info: tuple, sv_ids: np.ndarray, @@ -190,7 +218,6 @@ def _get_new_edges( edges, affinities, areas = edges_info for old, new in old_new_map.items(): - logging.info(f"old and new {old, new}") new_ids = np.array(list(new), dtype=basetypes.NODE_ID) edges_m = np.any(edges == old, axis=1) selected_edges = edges[edges_m] @@ -198,68 +225,41 @@ def _get_new_edges( assert np.all(np.sum(sel_m, axis=1) == 1) partners = selected_edges[sel_m] + edge_affs = affinities[edges_m] + edge_areas = areas[edges_m] active_m = np.isin(partners, sv_ids) - logging.info(f"sv_ids: {np.sum(sv_ids > 0)}") - logging.info(f"edges: {edges.shape} {np.sum(edges_m)} {np.sum(sel_m)}") - logging.info(f"selected_edges: {selected_edges.shape}") + # Inactive partners (different root, outside distance map): all fragments get the edge + for k in np.where(~active_m)[0]: + for new_id in new_ids: + new_edges.append(np.array([new_id, partners[k]], dtype=np.uint64)) + new_affs.append(edge_affs[k]) + new_areas.append(edge_areas[k]) - # inactive - for new_id in new_ids: - _a = [[new_id] * np.sum(~active_m), partners[~active_m]] - new_edges.extend(np.array(_a, dtype=np.uint64).T) - new_affs.extend(affinities[edges_m][np.any(sel_m, axis=1)][~active_m]) - new_areas.extend(areas[edges_m][np.any(sel_m, axis=1)][~active_m]) - - # active - active_partners_ = partners[active_m] - active_affs_ = affinities[edges_m][np.any(sel_m, axis=1)][active_m] - active_areas_ = areas[edges_m][np.any(sel_m, axis=1)][active_m] - - logging.info(f"partners: {partners.shape} {active_partners_.shape}") - - active_partners = [] - active_affs = [] - active_areas = [] - for i in range(len(active_partners_)): - remapped_ = old_new_map.get(active_partners_[i], [active_partners_[i]]) - active_partners.extend(remapped_) - active_affs.extend([active_affs_[i]] * len(remapped_)) - active_areas.extend([active_areas_[i]] * len(remapped_)) - - logging.info(f"new_ids, active_partners: {new_ids, len(active_partners)}") - logging.info(f"new_dist_vec(new_ids): {new_dist_vec(new_ids)}") - logging.info(f"dist_vec(active_partners): {dist_vec(active_partners)}") - distances_ = distances[new_dist_vec(new_ids)][:, dist_vec(active_partners)].T - for i, partner in enumerate(active_partners): - aff = active_affs[i] - if np.isinf(aff) and new_id_label_map and partner in new_id_label_map: - e, a, ar = _match_by_label( - new_ids, - partner, - aff, - active_areas[i], - new_id_label_map, - distances_[i], - ) - else: - e, a, ar = _match_by_proximity( - new_ids, - partner, - aff, - active_areas[i], - distances_[i], - threshold, - ) + # Active partners (same root): route based on affinity type + active_partners, act_affs, act_areas = _expand_partners( + partners[active_m], edge_affs[active_m], edge_areas[active_m], old_new_map + ) + new_id_rows = new_dist_vec(new_ids) + act_dists = distances[new_id_rows][:, dist_vec(active_partners)].T + for k, partner in enumerate(active_partners): + e, a, ar = _match_partner( + new_ids, + partner, + act_affs[k], + act_areas[k], + act_dists[k], + new_id_label_map, + threshold, + ) new_edges.extend(e) new_affs.extend(a) new_areas.extend(ar) - # edges between split fragments + # Low-affinity edges between split fragments (cuttable by mincut) for i in range(len(new_ids)): - for j in range(i + 1, len(new_ids)): # includes no selfedges - _a = [new_ids[i], new_ids[j]] - new_edges.append(np.array(_a, dtype=np.uint64)) + for j in range(i + 1, len(new_ids)): + new_edges.append(np.array([new_ids[i], new_ids[j]], dtype=np.uint64)) new_affs.append(0.001) new_areas.append(0) @@ -269,11 +269,11 @@ def _get_new_edges( np.array([], dtype=basetypes.EDGE_AFFINITY), np.array([], dtype=basetypes.EDGE_AREA), ) - affinites = np.array(new_affs, dtype=basetypes.EDGE_AFFINITY) - areas = np.array(new_areas, dtype=basetypes.EDGE_AREA) - edges = np.sort(np.array(new_edges, dtype=basetypes.NODE_ID), axis=1) - edges, idx = np.unique(edges, return_index=True, axis=0) - return edges, affinites[idx], areas[idx] + affinities_ = np.array(new_affs, dtype=basetypes.EDGE_AFFINITY) + areas_ = np.array(new_areas, dtype=basetypes.EDGE_AREA) + edges_ = np.sort(np.array(new_edges, dtype=basetypes.NODE_ID), axis=1) + edges_, idx = np.unique(edges_, return_index=True, axis=0) + return edges_, affinities_[idx], areas_[idx] def _update_edges( @@ -304,8 +304,6 @@ def _update_edges( edges = edges[edges_idx] affinities = affinities[edges_idx] areas = areas[edges_idx] - logging.info(f"edges.shape, affinities.shape {edges.shape, affinities.shape}") - new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) new_kdtrees = [kdtrees[k] for k in new_ids] new_disance_map = dict(zip(new_ids, np.arange(len(new_ids)))) @@ -360,7 +358,7 @@ def split_supervoxel( source_coords: np.ndarray, sink_coords: np.ndarray, operation_id: int, - verbose: bool = True, + verbose: bool = False, time_stamp: datetime = None, ) -> dict[int, set]: """ @@ -386,15 +384,13 @@ def split_supervoxel( cut_supervoxels = _get_whole_sv(cg, sv_id, min_coord=chunk_min, max_coord=chunk_max) supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) - logging.info(f"whole sv {sv_id} -> {cut_supervoxels}") + logging.info(f"whole sv {sv_id} -> {supervoxel_ids.tolist()}") # one voxel overlap for neighbors bbs_ = np.clip(bbs - 1, vol_start, vol_end) bbe_ = np.clip(bbe + 1, vol_start, vol_end) seg = get_local_segmentation(cg.meta, bbs_, bbe_).squeeze() binary_seg = np.isin(seg, supervoxel_ids) - logging.info(f"{seg.shape}; {binary_seg.shape}; {bbs, bbe}; {bbs_, bbe_}") - voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) split_result = split_supervoxel_helper( binary_seg[voxel_overlap_crop], @@ -418,25 +414,22 @@ def split_supervoxel( results, seg_cropped, bbs, bbe ) - seg_roots = seg.copy() sv_ids = fastremap.unique(seg) roots = cg.get_roots(sv_ids) - seg_roots = fastremap.remap(seg_roots, dict(zip(sv_ids, roots)), in_place=True) + sv_root_map = dict(zip(sv_ids, roots)) + root = sv_root_map[sv_id] + logging.info(f"{sv_id} -> {root}") - root = cg.get_root(sv_id) - logging.info(f"{sv_id} root = {root}") - - seg_masked = seg.copy() - seg_masked[seg_roots != root] = 0 - sv_ids = fastremap.unique(seg_masked) - - seg_masked[voxel_overlap_crop] = new_seg + root_mask = fastremap.remap(seg, sv_root_map, in_place=False) == root + seg[~root_mask] = 0 + sv_ids = fastremap.unique(seg) + seg[voxel_overlap_crop] = new_seg edges_tuple = _update_edges( cg, sv_ids, root, np.array([bbs, bbe]), - seg_masked, + seg, old_new_map, new_id_label_map, ) @@ -502,11 +495,9 @@ def copy_parents_and_add_lineage( for parent, children_cells in children_cells_map.items(): assert len(children_cells) == 1, children_cells for cell in children_cells: - logging.info(f"{parent}: {cell.value}") mask = np.isin(cell.value, list(old_new_map.keys())) replace = np.concatenate([old_new_map[x] for x in cell.value[mask]]) children = np.concatenate([cell.value[~mask], replace]) - logging.info(f"{parent}: {children}") cg.cache.children_cache[parent] = children result.append( cg.client.mutate_row( diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 0b5cf5c5c..e61356a1e 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -153,7 +153,6 @@ def get_parents_at_timestamp(nodes, parents_ts_map, time_stamp, unique: bool = F return list(parents), skipped_nodes - def get_local_segmentation(meta, bbox_start, bbox_end) -> np.ndarray: result = None xL, yL, zL = bbox_start @@ -163,3 +162,12 @@ def get_local_segmentation(meta, bbox_start, bbox_end) -> np.ndarray: else: result = meta.cv[xL:xH, yL:yH, zL:zH] return result + + +def lookup_svs_from_seg(meta, coordinates): + """Read SV IDs directly from OCDBT segmentation at given coordinates.""" + bbox_start = np.min(coordinates, axis=0) + bbox_end = np.max(coordinates, axis=0) + 1 + seg = get_local_segmentation(meta, bbox_start, bbox_end)[..., 0] + local_coords = coordinates - bbox_start + return np.array([seg[tuple(c)] for c in local_coords], dtype=np.uint64) diff --git a/pychunkedgraph/graph/utils/id_helpers.py b/pychunkedgraph/graph/utils/id_helpers.py index 43faf2160..7f7d8f927 100644 --- a/pychunkedgraph/graph/utils/id_helpers.py +++ b/pychunkedgraph/graph/utils/id_helpers.py @@ -10,7 +10,7 @@ import numpy as np from pychunkedgraph.graph import basetypes -from .generic import get_local_segmentation +from .generic import get_local_segmentation, lookup_svs_from_seg from ..meta import ChunkedGraphMeta from ..chunks import utils as chunk_utils @@ -128,7 +128,10 @@ def get_atomic_ids_from_coords( """ import fastremap - if parent_id_layer == 1: + if parent_id_layer == 1 and meta.ocdbt_seg: + return lookup_svs_from_seg(meta, coordinates) + + if parent_id_layer == 1 and not meta.ocdbt_seg: return np.array([parent_id] * len(coordinates), dtype=np.uint64) coordinates_nm = coordinates * np.array(meta.resolution) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index 81122c5a8..c767ca124 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -146,6 +146,12 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int]): fix_corrupt_nodes(cg, corrupt_nodes, CHILDREN) return + # Set max node ID for the L1 chunk (needed for SV splitting to create new IDs) + l1_chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) + if CHILDREN: + all_svs = np.concatenate(list(CHILDREN.values())) + cg.id_client.set_max_node_id(l1_chunk_id, np.max(all_svs)) + cg.copy_fake_edges(chunk_id) if len(nodes) == 0: return diff --git a/pychunkedgraph/tests/graph/test_utils_id_helpers.py b/pychunkedgraph/tests/graph/test_utils_id_helpers.py index ab4afa60d..c347b9bbd 100644 --- a/pychunkedgraph/tests/graph/test_utils_id_helpers.py +++ b/pychunkedgraph/tests/graph/test_utils_id_helpers.py @@ -153,6 +153,7 @@ def test_layer1_returns_parent_id(self): meta = MagicMock() meta.data_source.CV_MIP = 0 meta.resolution = np.array([1, 1, 1]) + meta.ocdbt_seg = False parent_id = np.uint64(42) coordinates = np.array([[10, 20, 30], [40, 50, 60]]) diff --git a/requirements.in b/requirements.in index 2d8112537..c6d241ff1 100644 --- a/requirements.in +++ b/requirements.in @@ -26,7 +26,7 @@ middle-auth-client>=3.11.0 zmesh>=1.7.0 fastremap>=1.14.0 task-queue>=2.14.0 -messagingclient +messagingclient>0.3.0 dracopy>=1.5.0 datastoreflex>=0.5.0 kvdbclient>=0.4.0 diff --git a/requirements.txt b/requirements.txt index 5df78e9f8..f5f8872df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -206,7 +206,7 @@ markupsafe==3.0.3 # flask # jinja2 # werkzeug -messagingclient==0.3.0 +messagingclient==0.4.0 # via -r requirements.in microviewer==1.20.0 # via cloud-volume diff --git a/uwsgi.ini b/uwsgi.ini index 776e2ff00..9440db38e 100644 --- a/uwsgi.ini +++ b/uwsgi.ini @@ -82,32 +82,8 @@ harakiri-verbose = true ### Logging -# Filter our properly pre-formated app messages and pass them through logger = app stdio -log-route = app ^{.*"source":.*}$ - -# Capture known / most common uWSGI messages -logger = uWSGIdebug stdio -logger = uWSGIwarn stdio - -log-route = uWSGIdebug ^{address space usage -log-route = uWSGIwarn \[warn\] - -log-encoder = json:uWSGIdebug {"source":"uWSGI","time":"${strftime:%Y-%m-%dT%H:%M:%S.000Z}","severity":"debug","message":"${msg}"} -log-encoder = nl:uWSGIdebug -log-encoder = json:uWSGIwarn {"source":"uWSGI","time":"${strftime:%Y-%m-%dT%H:%M:%S.000Z}","severity":"warning","message":"${msg}"} -log-encoder = nl:uWSGIwarn - -# Treat everything else as error message of unknown origin -logger = unknown stdio - -# Creating our own "inverse Regex" using negative lookaheads, which makes this -# log-route rather cryptic and slow... Unclear how to get a simple -# "fall-through" behavior for non-matching messages, otherwise. -log-route = unknown ^(?:(?!^{address space usage|\[warn\]|^{.*"source".*}$).)*$ - -log-encoder = json:unknown {"source":"unknown","time":"${strftime:%Y-%m-%dT%H:%M:%S.000Z}","severity":"error","message":"${msg}"} -log-encoder = nl:unknown +log-route = app .* log-4xx = true log-5xx = true From 621a811f804dc299d1a0979fe68067d0f64b0f1a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 20 Mar 2026 16:56:58 +0000 Subject: [PATCH 180/367] remove multiwrapper usage and references --- pychunkedgraph/graph/subgraph.py | 28 +++-- pychunkedgraph/meshing/mesh_io.py | 71 ++++++----- pychunkedgraph/meshing/meshengine.py | 155 ++++++++++++++---------- pychunkedgraph/meshing/meshgen.py | 20 ++- pychunkedgraph/meshing/meshlabserver.py | 34 +++--- requirements.in | 1 - requirements.txt | 3 - 7 files changed, 183 insertions(+), 129 deletions(-) diff --git a/pychunkedgraph/graph/subgraph.py b/pychunkedgraph/graph/subgraph.py index 1538b3cc2..4f21f2489 100644 --- a/pychunkedgraph/graph/subgraph.py +++ b/pychunkedgraph/graph/subgraph.py @@ -186,8 +186,8 @@ def _get_subgraph_multiple_nodes( return_flattened: bool = False, ): from collections import ChainMap - from multiwrapper.multiprocessing_utils import n_cpus - from multiwrapper.multiprocessing_utils import multithread_func + import os + from concurrent.futures import ThreadPoolExecutor from .utils.generic import mask_nodes_by_bounding_box @@ -223,20 +223,26 @@ def _get_subgraph_multiple_nodes_threaded( subgraph = SubgraphProgress(cg.meta, node_ids, return_layers, serializable) while not subgraph.done_processing(): - this_n_threads = min([int(len(subgraph.cur_nodes) // 50000) + 1, n_cpus]) - cur_nodes_child_maps = multithread_func( - _get_subgraph_multiple_nodes_threaded, - np.array_split(subgraph.cur_nodes, this_n_threads), - n_threads=this_n_threads, - debug=this_n_threads == 1, + this_n_threads = min( + [int(len(subgraph.cur_nodes) // 50000) + 1, os.cpu_count()] ) + batches = np.array_split(subgraph.cur_nodes, this_n_threads) + if this_n_threads == 1: + cur_nodes_child_maps = [ + _get_subgraph_multiple_nodes_threaded(b) for b in batches + ] + else: + with ThreadPoolExecutor(max_workers=this_n_threads) as executor: + cur_nodes_child_maps = list( + executor.map(_get_subgraph_multiple_nodes_threaded, batches) + ) cur_nodes_children = dict(ChainMap(*cur_nodes_child_maps)) subgraph.process_batch_of_children(cur_nodes_children) if return_flattened and len(return_layers) == 1: for node_id in node_ids: - subgraph.node_to_subgraph[ - _get_dict_key(node_id) - ] = subgraph.node_to_subgraph[_get_dict_key(node_id)][return_layers[0]] + subgraph.node_to_subgraph[_get_dict_key(node_id)] = ( + subgraph.node_to_subgraph[_get_dict_key(node_id)][return_layers[0]] + ) return subgraph.node_to_subgraph diff --git a/pychunkedgraph/meshing/mesh_io.py b/pychunkedgraph/meshing/mesh_io.py index 1cf1fed66..4a6eac7c4 100644 --- a/pychunkedgraph/meshing/mesh_io.py +++ b/pychunkedgraph/meshing/mesh_io.py @@ -7,19 +7,23 @@ import networkx as nx import cloudvolume -from multiwrapper import multiprocessing_utils as mu +from concurrent.futures import ProcessPoolExecutor + def read_mesh_h5(): pass + def write_mesh_h5(): pass + def read_obj(path): return Mesh(path) + def _download_meshes_thread(args): - """ Downloads meshes into target directory + """Downloads meshes into target directory :param args: list """ @@ -33,7 +37,7 @@ def _download_meshes_thread(args): def download_meshes(seg_ids, target_dir, cv_path, n_threads=1): - """ Downloads meshes in target directory (parallel) + """Downloads meshes in target directory (parallel) :param seg_ids: list of ints :param target_dir: str @@ -52,12 +56,11 @@ def download_meshes(seg_ids, target_dir, cv_path, n_threads=1): multi_args.append([seg_id_block, cv_path, target_dir]) if n_jobs == 1: - mu.multiprocess_func(_download_meshes_thread, - multi_args, debug=True, - verbose=True, n_threads=1) + for args in multi_args: + _download_meshes_thread(args) else: - mu.multisubprocess_func(_download_meshes_thread, - multi_args, n_threads=n_threads) + with ProcessPoolExecutor(max_workers=n_threads) as executor: + list(executor.map(_download_meshes_thread, multi_args)) def refine_mesh(): @@ -77,6 +80,7 @@ def mesh(self, filename): return self.filename_dict[filename] + class Mesh(object): def __init__(self, filename): self._vertices = [] @@ -117,8 +121,9 @@ def normals(self): @property def edges(self): if self._edges is None: - self._edges = np.concatenate([self.faces[:, :2], - self.faces[:, 1:3]], axis=0) + self._edges = np.concatenate( + [self.faces[:, :2], self.faces[:, 1:3]], axis=0 + ) return self._edges @property @@ -141,21 +146,23 @@ def load_obj(self): normals = [] for line in open(self.filename, "r"): - if line.startswith('#'): continue + if line.startswith("#"): + continue values = line.split() - if not values: continue - if values[0] == 'v': + if not values: + continue + if values[0] == "v": v = values[1:4] vertices.append(v) - elif values[0] == 'vn': + elif values[0] == "vn": v = map(float, values[1:4]) normals.append(v) - elif values[0] == 'f': + elif values[0] == "f": face = [] texcoords = [] norms = [] for v in values[1:]: - w = v.split('/') + w = v.split("/") face.append(int(w[0])) if len(w) >= 2 and len(w[1]) > 0: texcoords.append(int(w[1])) @@ -191,7 +198,8 @@ def write_vertices_ply(self, out_fname, coords=None): tweaked_array = np.array( list(zip(coords[:, 0], coords[:, 1], coords[:, 2])), - dtype=[('x', 'f4'), ('y', 'f4'), ('z', 'f4')]) + dtype=[("x", "f4"), ("y", "f4"), ("z", "f4")], + ) vertex_element = plyfile.PlyElement.describe(tweaked_array, "vertex") @@ -200,8 +208,15 @@ def write_vertices_ply(self, out_fname, coords=None): plyfile.PlyData([vertex_element]).write(out_fname) - def get_local_view(self, n_points, pc_align=False, center_node_id=None, - center_coord=None, method="kdtree", verbose=False): + def get_local_view( + self, + n_points, + pc_align=False, + center_node_id=None, + center_coord=None, + method="kdtree", + verbose=False, + ): if center_node_id is None and center_coord is None: center_node_id = np.random.randint(len(self.vertices)) @@ -215,11 +230,11 @@ def get_local_view(self, n_points, pc_align=False, center_node_id=None, if verbose: print(np.mean(dists), np.max(dists), np.min(dists)) elif method == "graph": - dist_dict = nx.single_source_dijkstra_path_length(self.graph, - center_node_id, - weight="weight") - sorting = np.argsort(np.array(list(dist_dict.values()))) - node_ids = np.array(list(dist_dict.keys()))[sorting[:n_points]] + dist_dict = nx.single_source_dijkstra_path_length( + self.graph, center_node_id, weight="weight" + ) + sorting = np.argsort(np.array(list(dist_dict.values()))) + node_ids = np.array(list(dist_dict.keys()))[sorting[:n_points]] else: raise Exception("unknow method") @@ -236,7 +251,9 @@ def calc_pc_align(self, vertices): return pca.transform(vertices) def create_nx_graph(self): - weights = np.linalg.norm(self.vertices[self.edges[:, 0]] - self.vertices[self.edges[:, 1]], axis=1) + weights = np.linalg.norm( + self.vertices[self.edges[:, 0]] - self.vertices[self.edges[:, 1]], axis=1 + ) print(weights.shape) @@ -244,8 +261,6 @@ def create_nx_graph(self): weighted_graph.add_edges_from(self.edges) for i_edge, edge in enumerate(self.edges): - weighted_graph[edge[0]][edge[1]]['weight'] = weights[i_edge] + weighted_graph[edge[0]][edge[1]]["weight"] = weights[i_edge] return weighted_graph - - diff --git a/pychunkedgraph/meshing/meshengine.py b/pychunkedgraph/meshing/meshengine.py index e852dfa3a..3f86fd7b3 100644 --- a/pychunkedgraph/meshing/meshengine.py +++ b/pychunkedgraph/meshing/meshengine.py @@ -3,19 +3,21 @@ import itertools import random +from concurrent.futures import ProcessPoolExecutor from pychunkedgraph.graph import chunkedgraph -from multiwrapper import multiprocessing_utils as mu from . import meshgen class MeshEngine(object): - def __init__(self, - table_id: str, - instance_id: str = "pychunkedgraph", - project_id: str = "neuromancer-seung-import", - mesh_mip: int = 3, - highest_mesh_layer: int = 5): + def __init__( + self, + table_id: str, + instance_id: str = "pychunkedgraph", + project_id: str = "neuromancer-seung-import", + mesh_mip: int = 3, + highest_mesh_layer: int = 5, + ): self._table_id = table_id self._instance_id = instance_id @@ -62,7 +64,8 @@ def cg(self): self._cg = chunkedgraph.ChunkedGraph( table_id=self.table_id, instance_id=self.instance_id, - project_id=self.project_id) + project_id=self.project_id, + ) return self._cg @property @@ -80,8 +83,9 @@ def cv(self): self._cv.info["mesh"] = self.cv_mesh_dir return self._cv - def mesh_multiple_layers(self, layers=None, bounding_box=None, - block_factor=2, n_threads=128): + def mesh_multiple_layers( + self, layers=None, bounding_box=None, block_factor=2, n_threads=128 + ): if layers is None: layers = range(1, int(self.cg.n_layers + 1)) @@ -94,28 +98,30 @@ def mesh_multiple_layers(self, layers=None, bounding_box=None, for layer in layers: print("Now: layer %d" % layer) - self.mesh_single_layer(layer, bounding_box=bounding_box, - block_factor=block_factor, - n_threads=n_threads) - - def mesh_single_layer(self, layer, bounding_box=None, block_factor=2, - n_threads=128): + self.mesh_single_layer( + layer, + bounding_box=bounding_box, + block_factor=block_factor, + n_threads=n_threads, + ) + + def mesh_single_layer( + self, layer, bounding_box=None, block_factor=2, n_threads=128 + ): assert layer <= self.highest_mesh_layer dataset_bounding_box = np.array(self.cv.bounds.to_list()) - block_bounding_box_cg = \ - [np.floor(dataset_bounding_box[:3] / - self.cg.chunk_size).astype(int), - np.ceil(dataset_bounding_box[3:] / - self.cg.chunk_size).astype(int)] + block_bounding_box_cg = [ + np.floor(dataset_bounding_box[:3] / self.cg.chunk_size).astype(int), + np.ceil(dataset_bounding_box[3:] / self.cg.chunk_size).astype(int), + ] if bounding_box is not None: - bounding_box_cg = \ - [np.floor(bounding_box[0] / - self.cg.chunk_size).astype(int), - np.ceil(bounding_box[1] / - self.cg.chunk_size).astype(int)] + bounding_box_cg = [ + np.floor(bounding_box[0] / self.cg.chunk_size).astype(int), + np.ceil(bounding_box[1] / self.cg.chunk_size).astype(int), + ] m = block_bounding_box_cg[0] < bounding_box_cg[0] block_bounding_box_cg[0][m] = bounding_box_cg[0][m] @@ -126,31 +132,37 @@ def mesh_single_layer(self, layer, bounding_box=None, block_factor=2, block_bounding_box_cg /= 2 ** np.max([0, layer - 2]) block_bounding_box_cg = np.ceil(block_bounding_box_cg) - n_jobs = np.prod(block_bounding_box_cg[1] - - block_bounding_box_cg[0]) / \ - block_factor ** 2 < n_threads + n_jobs = ( + np.prod(block_bounding_box_cg[1] - block_bounding_box_cg[0]) + / block_factor**2 + < n_threads + ) while n_jobs < n_threads and block_factor > 1: block_factor -= 1 - n_jobs = np.prod(block_bounding_box_cg[1] - - block_bounding_box_cg[0]) / \ - block_factor ** 2 < n_threads - - block_iter = itertools.product(np.arange(block_bounding_box_cg[0][0], - block_bounding_box_cg[1][0], - block_factor), - np.arange(block_bounding_box_cg[0][1], - block_bounding_box_cg[1][1], - block_factor), - np.arange(block_bounding_box_cg[0][2], - block_bounding_box_cg[1][2], - block_factor)) + n_jobs = ( + np.prod(block_bounding_box_cg[1] - block_bounding_box_cg[0]) + / block_factor**2 + < n_threads + ) + + block_iter = itertools.product( + np.arange( + block_bounding_box_cg[0][0], block_bounding_box_cg[1][0], block_factor + ), + np.arange( + block_bounding_box_cg[0][1], block_bounding_box_cg[1][1], block_factor + ), + np.arange( + block_bounding_box_cg[0][2], block_bounding_box_cg[1][2], block_factor + ), + ) blocks = np.array(list(block_iter), dtype=int) cg_info = self.cg.get_serialized_info() - del (cg_info['credentials']) + del cg_info["credentials"] multi_args = [] for start_block in blocks: @@ -158,44 +170,57 @@ def mesh_single_layer(self, layer, bounding_box=None, block_factor=2, m = end_block > block_bounding_box_cg[1] end_block[m] = block_bounding_box_cg[1][m] - multi_args.append([cg_info, start_block, end_block, self.cg._cv_path, - self.cv_mesh_dir, self.mesh_mip, layer]) + multi_args.append( + [ + cg_info, + start_block, + end_block, + self.cg._cv_path, + self.cv_mesh_dir, + self.mesh_mip, + layer, + ] + ) random.shuffle(multi_args) random.shuffle(multi_args) # Run parallelizing if n_threads == 1: - mu.multiprocess_func(meshgen._mesh_layer_thread, multi_args, - n_threads=n_threads, verbose=True, - debug=n_threads == 1) + for args in multi_args: + meshgen._mesh_layer_thread(args) else: - mu.multisubprocess_func(meshgen._mesh_layer_thread, multi_args, - n_threads=n_threads, - suffix="%s_%d" % (self.table_id, layer)) + with ProcessPoolExecutor(max_workers=n_threads) as executor: + list(executor.map(meshgen._mesh_layer_thread, multi_args)) def create_manifests_for_higher_layers(self, n_threads=1): root_id_max = self.cg.get_max_node_id( - self.cg.get_chunk_id(layer=int(self.cg.n_layers), - x=int(0), y=int(0), - z=int(0))) + self.cg.get_chunk_id( + layer=int(self.cg.n_layers), x=int(0), y=int(0), z=int(0) + ) + ) - root_id_blocks = np.linspace(1, root_id_max, n_threads*3).astype(int) + root_id_blocks = np.linspace(1, root_id_max, n_threads * 3).astype(int) cg_info = self.cg.get_serialized_info() - del (cg_info['credentials']) + del cg_info["credentials"] multi_args = [] for i_block in range(len(root_id_blocks) - 1): - multi_args.append([cg_info, self.cv_path, self.cv_mesh_dir, - root_id_blocks[i_block], - root_id_blocks[i_block + 1], - self.highest_mesh_layer]) + multi_args.append( + [ + cg_info, + self.cv_path, + self.cv_mesh_dir, + root_id_blocks[i_block], + root_id_blocks[i_block + 1], + self.highest_mesh_layer, + ] + ) # Run parallelizing if n_threads == 1: - mu.multiprocess_func(meshgen._create_manifest_files_thread, - multi_args, n_threads=n_threads, verbose=True, - debug=n_threads == 1) + for args in multi_args: + meshgen._create_manifest_files_thread(args) else: - mu.multisubprocess_func(meshgen._create_manifest_files_thread, - multi_args, n_threads=n_threads) + with ProcessPoolExecutor(max_workers=n_threads) as executor: + list(executor.map(meshgen._create_manifest_files_thread, multi_args)) diff --git a/pychunkedgraph/meshing/meshgen.py b/pychunkedgraph/meshing/meshgen.py index f6613f7d2..ea6410dfd 100644 --- a/pychunkedgraph/meshing/meshgen.py +++ b/pychunkedgraph/meshing/meshgen.py @@ -10,7 +10,7 @@ import pytz from scipy import ndimage -from multiwrapper import multiprocessing_utils as mu +from concurrent.futures import ThreadPoolExecutor from cloudfiles import CloudFiles from cloudvolume import CloudVolume from cloudvolume.datasource.precomputed.sharding import ShardingSpecification @@ -23,7 +23,6 @@ from pychunkedgraph.meshing import meshgen_utils # noqa from pychunkedgraph.meshing.manifest.cache import ManifestCache - UTC = pytz.UTC # Change below to true if debugging and want to see results in stdout @@ -263,7 +262,12 @@ def _get_root_ids(args): multi_args.append([start_ids[i_block], start_ids[i_block + 1]]) if n_jobs > 0: - mu.multithread_func(_get_root_ids, multi_args, n_threads=n_threads) + if n_threads == 1: + for args in multi_args: + _get_root_ids(args) + else: + with ThreadPoolExecutor(max_workers=n_threads) as executor: + list(executor.map(_get_root_ids, multi_args)) return lx_ids, np.array(root_ids), lx_id_remap @@ -443,7 +447,12 @@ def _get_root_ids(args): multi_args.append([start_ids[i_block], start_ids[i_block + 1]]) if n_jobs > 0: - mu.multithread_func(_get_root_ids, multi_args, n_threads=n_threads) + if n_threads == 1: + for args in multi_args: + _get_root_ids(args) + else: + with ThreadPoolExecutor(max_workers=n_threads) as executor: + list(executor.map(_get_root_ids, multi_args)) sv_ids_index = len(node_ids) chunk_ids_index = len(node_ids) + len(sv_ids) @@ -1040,7 +1049,8 @@ def get_multi_child_nodes(cg, chunk_id, node_id_subset=None, chunk_bbox_string=F fragment.value for child_fragments_for_node in node_rows for fragment in child_fragments_for_node - ], dtype=object + ], + dtype=object, ) # Filter out node ids that do not have roots (caused by failed ingest tasks) root_ids = cg.get_roots(node_ids, fail_to_zero=True) diff --git a/pychunkedgraph/meshing/meshlabserver.py b/pychunkedgraph/meshing/meshlabserver.py index 3065d6707..65c7439db 100644 --- a/pychunkedgraph/meshing/meshlabserver.py +++ b/pychunkedgraph/meshing/meshlabserver.py @@ -3,7 +3,7 @@ import glob import numpy as np -from multiwrapper import multiprocessing_utils as mu +from concurrent.futures import ProcessPoolExecutor HOME = os.path.expanduser("~") @@ -12,17 +12,19 @@ def run_meshlab_script(script_name, arg_dict): - """ Runs meshlabserver script --headless + """Runs meshlabserver script --headless No X-Server required :param script_name: str :param arg_dict: dict [str: str] """ - arg_string = "".join(["-{0} {1} ".format(k, arg_dict[k]) - for k in arg_dict.keys()]) - command = "xvfb-run --auto-servernum --server-num=1 meshlabserver -s {0}/{1} {2}".\ - format(path_to_scripts, script_name, arg_string) + arg_string = "".join(["-{0} {1} ".format(k, arg_dict[k]) for k in arg_dict.keys()]) + command = ( + "xvfb-run --auto-servernum --server-num=1 meshlabserver -s {0}/{1} {2}".format( + path_to_scripts, script_name, arg_string + ) + ) p = subprocess.Popen(command, shell=True, stderr=subprocess.PIPE) p.wait() @@ -31,8 +33,9 @@ def _run_meshlab_script_on_dir_thread(args): script_name, path_block, out_dir, suffix, arg_dict = args for path in path_block: - out_path = "{}/{}{}.obj".format(out_dir, - "".join(os.path.basename(path).split(".")[:-1]), suffix) + out_path = "{}/{}{}.obj".format( + out_dir, "".join(os.path.basename(path).split(".")[:-1]), suffix + ) this_arg_dict = {"i": path, "o": out_path} this_arg_dict.update(arg_dict) @@ -40,8 +43,9 @@ def _run_meshlab_script_on_dir_thread(args): run_meshlab_script(script_name, this_arg_dict) -def run_meshlab_script_on_dir(script_name, in_dir, out_dir, suffix, arg_dict={}, - n_threads=1): +def run_meshlab_script_on_dir( + script_name, in_dir, out_dir, suffix, arg_dict={}, n_threads=1 +): paths = glob.glob(in_dir + "/*.obj") print(len(paths)) @@ -60,10 +64,8 @@ def run_meshlab_script_on_dir(script_name, in_dir, out_dir, suffix, arg_dict={}, multi_args.append([script_name, path_block, out_dir, suffix, arg_dict]) if n_threads == 1: - mu.multiprocess_func(_run_meshlab_script_on_dir_thread, - multi_args, debug=True, - verbose=True, n_threads=1) + for args in multi_args: + _run_meshlab_script_on_dir_thread(args) else: - mu.multisubprocess_func(_run_meshlab_script_on_dir_thread, - multi_args, n_threads=n_threads) - + with ProcessPoolExecutor(max_workers=n_threads) as executor: + list(executor.map(_run_meshlab_script_on_dir_thread, multi_args)) diff --git a/requirements.in b/requirements.in index c6d241ff1..3ca5513c5 100644 --- a/requirements.in +++ b/requirements.in @@ -21,7 +21,6 @@ scikit-image # PyPI only: cloud-files>=6.0.0 cloud-volume>=12.0.0 -multiwrapper middle-auth-client>=3.11.0 zmesh>=1.7.0 fastremap>=1.14.0 diff --git a/requirements.txt b/requirements.txt index f5f8872df..33a82701c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -216,8 +216,6 @@ ml-dtypes==0.5.4 # via tensorstore multiprocess==0.70.19 # via pathos -multiwrapper==0.1.1 - # via -r requirements.in networkx==3.6.1 # via # -r requirements.in @@ -238,7 +236,6 @@ numpy==2.4.2 # messagingclient # microviewer # ml-dtypes - # multiwrapper # osteoid # pandas # scikit-image From 174156bbcdab1ab0a9422a9d0ed2340905fc2beb Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 20 Mar 2026 21:47:41 +0000 Subject: [PATCH 181/367] use a common logger, cleanup old mess --- pychunkedgraph/__init__.py | 28 +++++++++++++++-- pychunkedgraph/app/__init__.py | 7 +++-- pychunkedgraph/graph/edges/stale.py | 9 ++++-- pychunkedgraph/graph/edits.py | 5 ++-- pychunkedgraph/graph/edits_sv.py | 27 +++++++++-------- pychunkedgraph/graph/locks.py | 6 ++-- pychunkedgraph/graph/operation.py | 4 +-- pychunkedgraph/ingest/__init__.py | 5 ++-- pychunkedgraph/ingest/cli.py | 5 ++-- pychunkedgraph/ingest/cli_upgrade.py | 7 +++-- pychunkedgraph/ingest/cluster.py | 19 +++++++----- pychunkedgraph/ingest/upgrade/atomic_layer.py | 14 +++++---- pychunkedgraph/ingest/upgrade/parent_layer.py | 30 +++++++++++-------- pychunkedgraph/ingest/utils.py | 11 ++++--- 14 files changed, 113 insertions(+), 64 deletions(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 28c0d26dc..0ade7b18a 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -9,6 +9,26 @@ "ignore", message="Schema id not specified", module="python_jsonschema_objects" ) +# Custom log level between INFO (20) and WARNING (30) +# Use logger.notice() for pychunkedgraph logs that should always show +# even when third-party INFO is suppressed +NOTICE = 25 +stdlib_logging.addLevelName(NOTICE, "NOTICE") + + +class PCGLogger(stdlib_logging.Logger): + def note(self, message, *args, **kwargs): + if self.isEnabledFor(NOTICE): + self._log(NOTICE, message, args, stacklevel=2, **kwargs) + + +stdlib_logging.setLoggerClass(PCGLogger) + + +def get_logger(name: str) -> PCGLogger: + return stdlib_logging.getLogger(name) # type: ignore[return-value] + + # Export logging levels for convenience DEBUG = stdlib_logging.DEBUG INFO = stdlib_logging.INFO @@ -36,7 +56,7 @@ def configure_logging(level=stdlib_logging.INFO, format_str=None, stream=None): pychunkedgraph.configure_logging(pychunkedgraph.DEBUG) # Enable DEBUG level """ if format_str is None: - format_str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + format_str = "%(asctime)s %(module)s:%(funcName)s:%(lineno)d %(message)s" if stream is None: stream = sys.stdout @@ -54,10 +74,12 @@ def configure_logging(level=stdlib_logging.INFO, format_str=None, stream=None): handler = stdlib_logging.StreamHandler(stream) handler.setLevel(level) - handler.setFormatter(stdlib_logging.Formatter(format_str)) + formatter = stdlib_logging.Formatter(format_str) + formatter.default_msec_format = "%s.%03d" + handler.setFormatter(formatter) logger.addHandler(handler) return logger -configure_logging() +configure_logging(level=NOTICE) diff --git a/pychunkedgraph/app/__init__.py b/pychunkedgraph/app/__init__.py index 042fa7ff1..7f5e307e8 100644 --- a/pychunkedgraph/app/__init__.py +++ b/pychunkedgraph/app/__init__.py @@ -14,6 +14,7 @@ from flask_cors import CORS from rq import Queue +from pychunkedgraph import NOTICE, configure_logging from pychunkedgraph.logging import jsonformatter from . import config @@ -99,9 +100,9 @@ def configure_app(app): app.logger.setLevel(app.config["LOGGING_LEVEL"]) app.logger.propagate = False - # Also configure root logger so logging.info() calls in library code are captured - logging.root.addHandler(handler) - logging.root.setLevel(logging.INFO) + # Ensure pychunkedgraph logger always works at NOTICE level + # regardless of app config or environment log level + configure_logging(level=NOTICE) if app.config["USE_REDIS_JOBS"]: app.redis = redis.Redis.from_url(app.config["REDIS_URL"]) diff --git a/pychunkedgraph/graph/edges/stale.py b/pychunkedgraph/graph/edges/stale.py index 17ded90d0..6ff3b8a12 100644 --- a/pychunkedgraph/graph/edges/stale.py +++ b/pychunkedgraph/graph/edges/stale.py @@ -3,8 +3,11 @@ """ import datetime -import logging from os import environ + +from pychunkedgraph import get_logger + +logger = get_logger(__name__) from typing import Iterable import numpy as np @@ -428,7 +431,7 @@ def run(self): ) if _new_edges.size: break - logging.info(f"{_edge}, expanding search with padding {pad+1}.") + logger.note(f"{_edge}, expanding search with padding {pad+1}.") assert ( _new_edges.size ), f"No new edge found {_edge}; {edge_layer}, {self.parent_ts}" @@ -490,7 +493,7 @@ def get_latest_edges_wrapper( stale_edge_layers, parent_ts=parent_ts, ) - logging.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") + logger.debug(f"{stale_edges} -> {latest_edges}; {parent_ts}") _new_cx_edges.append(latest_edges) new_cx_edges_d[layer] = np.concatenate(_new_cx_edges) nodes.append(np.unique(new_cx_edges_d[layer])) diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index b29675661..779743740 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -1,6 +1,6 @@ # pylint: disable=invalid-name, missing-docstring, too-many-locals, c-extension-no-member -import datetime, logging, random +import datetime, random from typing import Dict from typing import List from typing import Tuple @@ -11,6 +11,7 @@ import fastremap import numpy as np +from pychunkedgraph import get_logger from pychunkedgraph.debug.profiler import HierarchicalProfiler, get_profiler from . import types @@ -25,7 +26,7 @@ from ..utils.general import in2d from ..debug.utils import sanity_check, sanity_check_single -logger = logging.getLogger(__name__) +logger = get_logger(__name__) def _init_old_hierarchy(cg, l2ids: np.ndarray, parent_ts: datetime.datetime = None): diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 2b917c230..7e4ab93b5 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -3,8 +3,11 @@ """ from functools import reduce -import logging import multiprocessing as mp + +from pychunkedgraph import get_logger + +logger = get_logger(__name__) from typing import Callable from datetime import datetime from collections import defaultdict, deque @@ -323,7 +326,7 @@ def _update_edges( def _add_new_edges(cg: ChunkedGraph, edges_tuple: tuple, time_stamp: datetime = None): edges_, affinites_, areas_ = edges_tuple - logging.info(f"new edges: {edges_.shape}") + logger.note(f"new edges: {edges_.shape}") nodes = fastremap.unique(edges_) chunks = cg.get_chunk_ids_from_node_ids(cg.get_parents(nodes)) @@ -348,7 +351,7 @@ def _add_new_edges(cg: ChunkedGraph, edges_tuple: tuple, time_stamp: datetime = time_stamp=time_stamp, ) ) - logging.info(f"writing {edges[mask].shape} edges to {chunk_id}") + logger.note(f"writing {edges[mask].shape} edges to {chunk_id}") return rows @@ -376,15 +379,13 @@ def split_supervoxel( bbe = np.clip((np.max(_coords, 0) + _padding).astype(int), vol_start, vol_end) chunk_min, chunk_max = bbs // chunk_size, np.ceil(bbe / chunk_size).astype(int) bbs, bbe = chunk_min * chunk_size, chunk_max * chunk_size - logging.info( - f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}" - ) - logging.info(f"chunk and padding {chunk_size}; {_padding}") - logging.info(f"bbox and chunk min max {(bbs, bbe)}; {(chunk_min, chunk_max)}") + logger.note(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}") + logger.note(f"chunk and padding {chunk_size}; {_padding}") + logger.note(f"bbox and chunk min max {(bbs, bbe)}; {(chunk_min, chunk_max)}") cut_supervoxels = _get_whole_sv(cg, sv_id, min_coord=chunk_min, max_coord=chunk_max) supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) - logging.info(f"whole sv {sv_id} -> {supervoxel_ids.tolist()}") + logger.note(f"whole sv {sv_id} -> {supervoxel_ids.tolist()}") # one voxel overlap for neighbors bbs_ = np.clip(bbs - 1, vol_start, vol_end) @@ -399,14 +400,14 @@ def split_supervoxel( cg.meta.resolution, verbose=verbose, ) - logging.info(f"split_result: {split_result.shape}") + logger.note(f"split_result: {split_result.shape}") chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) tasks = [ (cg.graph_id, *item, seg[voxel_overlap_crop], split_result, bbs) for item in chunks_bbox_map.items() ] - logging.info(f"tasks count: {len(tasks)}") + logger.note(f"tasks count: {len(tasks)}") with mp.Pool() as pool: results = [*tqdm(pool.imap_unordered(_update_chunk, tasks), total=len(tasks))] seg_cropped = seg[voxel_overlap_crop].copy() @@ -418,7 +419,7 @@ def split_supervoxel( roots = cg.get_roots(sv_ids) sv_root_map = dict(zip(sv_ids, roots)) root = sv_root_map[sv_id] - logging.info(f"{sv_id} -> {root}") + logger.note(f"{sv_id} -> {root}") root_mask = fastremap.remap(seg, sv_root_map, in_place=False) == root seg[~root_mask] = 0 @@ -437,7 +438,7 @@ def split_supervoxel( rows0 = copy_parents_and_add_lineage(cg, operation_id, old_new_map) rows1 = _add_new_edges(cg, edges_tuple, time_stamp=time_stamp) rows = rows0 + rows1 - logging.info(f"{operation_id}: writing {len(rows)} new rows") + logger.note(f"{operation_id}: writing {len(rows)} new rows") cg.meta.ws_ocdbt[slices] = new_seg[..., np.newaxis] cg.client.write(rows) diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index f7406922f..47a63dacf 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -1,5 +1,4 @@ from concurrent.futures import ThreadPoolExecutor, as_completed -import logging from typing import Union from typing import Sequence from collections import defaultdict @@ -7,11 +6,14 @@ import networkx as nx import numpy as np +from pychunkedgraph import get_logger + from . import exceptions from .types import empty_1d from .lineage import lineage_graph -logger = logging.getLogger(__name__) +logger = get_logger(__name__) + class RootLock: """Attempts to lock the requested root IDs using a unique operation ID. diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 0d91e3990..4c85bd463 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -1,6 +1,5 @@ # pylint: disable=invalid-name, missing-docstring, too-many-lines, protected-access, broad-exception-raised -import logging from abc import ABC, abstractmethod from collections import namedtuple from datetime import datetime @@ -16,8 +15,9 @@ from functools import reduce import numpy as np +from pychunkedgraph import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) from . import locks from . import edits diff --git a/pychunkedgraph/ingest/__init__.py b/pychunkedgraph/ingest/__init__.py index 55c10ca5f..482dfbb5f 100644 --- a/pychunkedgraph/ingest/__init__.py +++ b/pychunkedgraph/ingest/__init__.py @@ -1,7 +1,8 @@ -import logging from collections import namedtuple -logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO) +from pychunkedgraph import configure_logging, NOTICE + +configure_logging(level=NOTICE) _ingestconfig_fields = ( "AGGLOMERATION", diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index ca958c354..ba63e15f8 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -4,9 +4,10 @@ cli for running ingest """ -import logging import os +from pychunkedgraph import configure_logging, DEBUG + import click import yaml from flask.cli import AppGroup @@ -77,7 +78,7 @@ def ingest_graph( config = yaml.safe_load(stream) if test: - logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.DEBUG) + configure_logging(level=DEBUG) meta, ingest_config, client_info = bootstrap(graph_id, config, raw, test) cg = ChunkedGraph(meta=meta, client_info=client_info) diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index d7b7a56dd..4b5ed12c7 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -4,9 +4,12 @@ cli for running upgrade """ -import logging from time import sleep +from pychunkedgraph import get_logger + +logger = get_logger(__name__) + import click import tensorstore as ts from flask.cli import AppGroup @@ -89,7 +92,7 @@ def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): enqueue_l2_tasks(imanager, fn) if ocdbt: - logging.info("All tasks queued. Keep this alive for ocdbt coordinator server.") + logger.note("All tasks queued. Keep this alive for ocdbt coordinator server.") while True: sleep(60) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 6233c9d46..2736d6819 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -4,8 +4,11 @@ Ingest / create chunkedgraph with workers on a cluster. """ -import logging from os import environ + +from pychunkedgraph import get_logger + +logger = get_logger(__name__) from time import sleep from typing import Callable, Dict, Iterable, Tuple, Sequence @@ -55,7 +58,7 @@ def _post_task_completion( chunk_str += f"_{split}" # mark chunk as completed - "c" imanager.redis.sadd(f"{layer}c", chunk_str) - logging.info(f"{chunk_str} marked as complete") + logger.note(f"{chunk_str} marked as complete") def create_parent_chunk( @@ -139,9 +142,9 @@ def create_atomic_chunk(coords: Sequence[int]): add_atomic_chunk(imanager.cg, coords, chunk_edges_active, isolated=isolated_ids) for k, v in chunk_edges_all.items(): - logging.debug(f"{k}: {len(v)}") + logger.debug(f"{k}: {len(v)}") for k, v in chunk_edges_active.items(): - logging.debug(f"active_{k}: {len(v)}") + logger.debug(f"active_{k}: {len(v)}") if imanager.ocdbt_seg: src, dst = get_seg_source_and_destination_ocdbt( @@ -196,7 +199,7 @@ def convert_to_ocdbt(coords: Sequence[int]): port = imanager.redis.get("OCDBT_COORDINATOR_PORT").decode() environ["OCDBT_COORDINATOR_HOST"] = host environ["OCDBT_COORDINATOR_PORT"] = port - logging.info(f"OCDBT Coordinator address {host}:{port}") + logger.note(f"OCDBT Coordinator address {host}:{port}") put_edges( f"{imanager.cg.meta.data_source.EDGES}/ocdbt", @@ -224,7 +227,7 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl _coords = get_chunks_not_done(imanager, 2, batch) # buffer for optimal use of redis memory while len(q) > max_queue_size: - logging.info( + logger.note( f"Queue has {len(q)} items (limit {max_queue_size}), waiting..." ) sleep(10) @@ -244,7 +247,7 @@ def _queue_tasks(imanager: IngestionManager, chunk_fn: Callable, coords: Iterabl ) ) q.enqueue_many(job_datas) - logging.info(f"Queued {len(job_datas)} chunks.") + logger.note(f"Queued {len(job_datas)} chunks.") def enqueue_l2_tasks(imanager: IngestionManager, chunk_fn: Callable): @@ -257,5 +260,5 @@ def enqueue_l2_tasks(imanager: IngestionManager, chunk_fn: Callable): atomic_chunk_bounds = imanager.cg_meta.layer_chunk_bounds[2] chunk_coords = randomize_grid_points(*atomic_chunk_bounds) chunk_count = imanager.cg_meta.layer_chunk_counts[0] - logging.info(f"Chunk count: {chunk_count}, queuing...") + logger.note(f"Chunk count: {chunk_count}, queuing...") _queue_tasks(imanager, chunk_fn, chunk_coords) diff --git a/pychunkedgraph/ingest/upgrade/atomic_layer.py b/pychunkedgraph/ingest/upgrade/atomic_layer.py index c767ca124..69463d7f6 100644 --- a/pychunkedgraph/ingest/upgrade/atomic_layer.py +++ b/pychunkedgraph/ingest/upgrade/atomic_layer.py @@ -2,7 +2,11 @@ from collections import defaultdict from datetime import datetime, timedelta, timezone -import logging, time, os +import time, os + +from pychunkedgraph import get_logger + +logger = get_logger(__name__) from copy import copy import fastremap @@ -79,7 +83,7 @@ def update_nodes(cg: ChunkedGraph, nodes, nodes_ts, children_map=None) -> list: for partner, parents in zip(all_partners, all_parents): for parent, ts in parents: parents_ts_map[partner][ts] = parent - logging.info(f"update_nodes init {len(nodes)}: {time.time() - start}") + logger.note(f"update_nodes init {len(nodes)}: {time.time() - start}") rows = [] skipped = [] @@ -142,7 +146,7 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int]): clean_task = os.environ.get("CLEAN_CHUNKS", "false") == "clean" if clean_task: - logging.info(f"found {len(corrupt_nodes)} corrupt nodes {corrupt_nodes[:3]}...") + logger.note(f"found {len(corrupt_nodes)} corrupt nodes {corrupt_nodes[:3]}...") fix_corrupt_nodes(cg, corrupt_nodes, CHILDREN) return @@ -156,8 +160,8 @@ def update_chunk(cg: ChunkedGraph, chunk_coords: list[int]): if len(nodes) == 0: return - logging.info(f"processing {len(nodes)} nodes.") + logger.note(f"processing {len(nodes)} nodes.") assert len(CHILDREN) > 0, (nodes, CHILDREN) rows = update_nodes(cg, nodes, nodes_ts) cg.client.write(rows) - logging.info(f"mutations: {len(rows)}, time: {time.time() - start}") + logger.note(f"mutations: {len(rows)}, time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/upgrade/parent_layer.py b/pychunkedgraph/ingest/upgrade/parent_layer.py index 773fc9ed0..fd46917e2 100644 --- a/pychunkedgraph/ingest/upgrade/parent_layer.py +++ b/pychunkedgraph/ingest/upgrade/parent_layer.py @@ -1,7 +1,11 @@ # pylint: disable=invalid-name, missing-docstring, c-extension-no-member from math import ceil -import bisect, logging, random, time, os, gc +import bisect, random, time, os, gc + +from pychunkedgraph import get_logger + +logger = get_logger(__name__) import multiprocessing as mp from collections import defaultdict from datetime import datetime, timezone @@ -54,7 +58,7 @@ def _get_cx_edges_at_timestamp(node, response, ts): try: result[key.index].append(cells[idx].value) except IndexError as e: - logging.error(f"{k}, {idx}, {len(cells)}, {asc_ts}") + logger.error(f"{k}, {idx}, {len(cells)}, {asc_ts}") raise IndexError from e for layer, edges in result.items(): result[layer] = np.concatenate(edges) @@ -84,7 +88,7 @@ def _populate_cx_edges_with_timestamps( response = cg.client.read_nodes(node_ids=all_children, properties=attrs) timestamps_d = get_parent_timestamps(cg, nodes) end_timestamps = get_end_timestamps(cg, nodes, nodes_ts, CHILDREN, layer=layer) - logging.info(f"_populate_cx_edges_with_timestamps init: {time.time() - start}") + logger.note(f"_populate_cx_edges_with_timestamps init: {time.time() - start}") start = time.time() partners_map = {} @@ -97,7 +101,7 @@ def _populate_cx_edges_with_timestamps( partners = np.unique(np.concatenate([*partners_map.values()])) partner_parent_ts_d = get_parent_timestamps(cg, partners) - logging.info(f"get partners timestamps init: {time.time() - start}") + logger.note(f"get partners timestamps init: {time.time() - start}") rows = [] for node, node_ts, node_end_ts in zip(nodes, nodes_ts, end_timestamps): @@ -180,7 +184,7 @@ def _update_cross_edges_helper(args): tasks.append((cg, layer, node, node_ts)) if clean_task: - logging.info(f"found {len(corrupt_nodes)} corrupt nodes {corrupt_nodes[:3]}...") + logger.note(f"found {len(corrupt_nodes)} corrupt nodes {corrupt_nodes[:3]}...") fix_corrupt_nodes(cg, corrupt_nodes, CHILDREN) return @@ -224,31 +228,31 @@ def update_chunk( nodes = _get_split_nodes(cg, chunk_id, split, splits) _populate_nodes_and_children(cg, chunk_id, nodes=nodes) - logging.info(f"_populate_nodes_and_children: {time.time() - start}") + logger.note(f"_populate_nodes_and_children: {time.time() - start}") nodes = list(CHILDREN.keys()) if len(nodes) == 0: return - logging.info(f"processing {len(nodes)} nodes.") + logger.note(f"processing {len(nodes)} nodes.") random.shuffle(nodes) start = time.time() nodes_ts = cg.get_node_timestamps(nodes, return_numpy=False, normalize=True) - logging.info(f"get_node_timestamps: {time.time() - start}") + logger.note(f"get_node_timestamps: {time.time() - start}") start = time.time() _populate_cx_edges_with_timestamps(cg, layer, nodes, nodes_ts) - logging.info(f"_populate_cx_edges_with_timestamps: {time.time() - start}") + logger.note(f"_populate_cx_edges_with_timestamps: {time.time() - start}") if debug: rows = [] stale.PARENTS_CACHE = LRUCache(PARENT_CACHE_LIMIT) stale.CHILDREN_CACHE = LRUCache(1 * 1024) - logging.info(f"processing {len(nodes)} nodes with 1 worker.") + logger.note(f"processing {len(nodes)} nodes with 1 worker.") for node, node_ts in zip(nodes, nodes_ts): rows.extend(update_cross_edges(cg, layer, node, node_ts)) stale.PARENTS_CACHE.clear() stale.CHILDREN_CACHE.clear() - logging.info(f"total elaspsed time: {time.time() - start}") + logger.note(f"total elaspsed time: {time.time() - start}") return task_size = int(os.environ.get("TASK_SIZE", 1)) @@ -263,7 +267,7 @@ def update_chunk( process_multiplier = int(os.environ.get("PROCESS_MULTIPLIER", 5)) processes = min(mp.cpu_count() * process_multiplier, len(tasks)) - logging.info(f"processing {len(nodes)} nodes with {processes} workers.") + logger.note(f"processing {len(nodes)} nodes with {processes} workers.") with mp.Pool(processes) as pool: _ = list( tqdm( @@ -271,4 +275,4 @@ def update_chunk( total=len(tasks), ) ) - logging.info(f"total elaspsed time: {time.time() - start}") + logger.note(f"total elaspsed time: {time.time() - start}") diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index c41a41a56..d69756104 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -1,7 +1,10 @@ # pylint: disable=invalid-name, missing-docstring -import logging import functools + +from pychunkedgraph import get_logger + +logger = get_logger(__name__) import math, random, sys from os import environ from time import sleep @@ -99,7 +102,7 @@ def start_ocdbt_server(imanager: IngestionManager, server: Any): imanager.redis.set("OCDBT_COORDINATOR_PORT", str(server.port)) ocdbt_host = environ.get("MY_POD_IP", "localhost") imanager.redis.set("OCDBT_COORDINATOR_HOST", ocdbt_host) - logging.info(f"OCDBT Coordinator address {ocdbt_host}:{server.port}") + logger.note(f"OCDBT Coordinator address {ocdbt_host}:{server.port}") def randomize_grid_points(X: int, Y: int, Z: int) -> Generator[int, int, int]: @@ -225,7 +228,7 @@ def queue_layer_helper( _coords = get_chunks_not_done(imanager, parent_layer, batch, splits=splits) # buffer for optimal use of redis memory while len(q) > max_queue_size: - logging.info( + logger.note( f"Queue has {len(q)} items (limit {max_queue_size}), waiting..." ) sleep(10) @@ -261,7 +264,7 @@ def queue_layer_helper( ) ) q.enqueue_many(job_datas) - logging.info(f"Queued {len(job_datas)} chunks.") + logger.note(f"Queued {len(job_datas)} chunks.") def job_type_guard(job_type: str): From bf1bd181717338bae5af2238edc29ea9d18ddfd0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 14:53:50 +0000 Subject: [PATCH 182/367] fix: mincut vertex index mismatch, source/sink overlap detection, sv_split_supported flag --- pychunkedgraph/graph/cutting.py | 90 +++++++++++++++------ pychunkedgraph/graph/operation.py | 1 + pychunkedgraph/tests/graph/test_multicut.py | 8 +- 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/pychunkedgraph/graph/cutting.py b/pychunkedgraph/graph/cutting.py index bd236397c..e49cc9ded 100644 --- a/pychunkedgraph/graph/cutting.py +++ b/pychunkedgraph/graph/cutting.py @@ -91,6 +91,7 @@ def __init__( split_preview=False, path_augment=True, disallow_isolating_cut=True, + sv_split_supported=False, logger=None, ): self.cg_edges = cg_edges @@ -98,6 +99,7 @@ def __init__( self.logger = logger self.path_augment = path_augment self.disallow_isolating_cut = disallow_isolating_cut + self.sv_split_supported = sv_split_supported time_start = time.time() @@ -140,6 +142,18 @@ def __init__( np.array(cg_sinks), complete_mapping[:, 0], complete_mapping[:, 1] ) + # Detect source/sink overlap after cross-chunk remapping + # (both sides mapped to the same representative → need SV split) + overlap = np.intersect1d(self.sources, self.sinks) + if len(overlap) > 0: + msg = ( + "Source and sink supervoxels share a cross-chunk edge representative. " + "A supervoxel split is required." + ) + if self.sv_split_supported: + raise SupervoxelSplitRequiredError(msg, self.sv_remapping) + raise PreconditionError(msg) + self._build_gt_graph(mapped_edges, mapped_affs) self.source_path_vertices = self.source_graph_ids @@ -161,9 +175,18 @@ def _build_gt_graph(self, edges, affs): self.weighted_graph_raw, self.capacities_raw, self.gt_edges_raw, - _, + self.unique_supervoxel_ids_raw, ) = flatgraph.build_gt_graph(edges, affs, make_directed=True) + # Compute vertex indices valid for the raw graph + # (these differ from source_graph_ids/sink_graph_ids which are for weighted_graph) + self.source_graph_ids_raw = np.where( + np.isin(self.unique_supervoxel_ids_raw, self.sources) + )[0] + self.sink_graph_ids_raw = np.where( + np.isin(self.unique_supervoxel_ids_raw, self.sinks) + )[0] + self.source_edges = list(itertools.product(self.sources, self.sources)) self.sink_edges = list(itertools.product(self.sinks, self.sinks)) @@ -223,21 +246,23 @@ def _augment_mincut_capacity(self): paths_v_s, paths_e_s, invaff_s = flatgraph.compute_filtered_paths( self.weighted_graph_raw, self.capacities_raw, - self.source_graph_ids, - self.sink_graph_ids, + self.source_graph_ids_raw, + self.sink_graph_ids_raw, ) paths_v_y, paths_e_y, invaff_y = flatgraph.compute_filtered_paths( self.weighted_graph_raw, self.capacities_raw, - self.sink_graph_ids, - self.source_graph_ids, + self.sink_graph_ids_raw, + self.source_graph_ids_raw, ) except AssertionError: - raise SupervoxelSplitRequiredError( + msg = ( "Paths between source or sink points irreparably overlap other labels from other side. " - "Check that labels are correct and consider spreading points out farther.", - self.sv_remapping + "Check that labels are correct and consider spreading points out farther." ) + if self.sv_split_supported: + raise SupervoxelSplitRequiredError(msg, self.sv_remapping) + raise PreconditionError(msg) paths_e_s_no, paths_e_y_no, do_check = flatgraph.remove_overlapping_edges( paths_v_s, paths_e_s, paths_v_y, paths_e_y @@ -295,7 +320,7 @@ def rerun_paths_without_overlap( _, paths_e_y_no, _ = flatgraph.compute_filtered_paths( self.weighted_graph_raw, self.capacities_raw, - self.sink_graph_ids, + self.sink_graph_ids_raw, omit_verts, ) @@ -304,7 +329,7 @@ def rerun_paths_without_overlap( _, paths_e_s_no, _ = flatgraph.compute_filtered_paths( self.weighted_graph_raw, self.capacities_raw, - self.source_graph_ids, + self.source_graph_ids_raw, omit_verts, ) paths_e_y_no = paths_e_y @@ -330,8 +355,8 @@ def _compute_mincut_path_augmented(self): adj_capacity = self._augment_mincut_capacity() gr = self.weighted_graph_raw - src, tgt = gr.vertex(self.source_graph_ids[0]), gr.vertex( - self.sink_graph_ids[0] + src, tgt = gr.vertex(self.source_graph_ids_raw[0]), gr.vertex( + self.sink_graph_ids_raw[0] ) residuals = graph_tool.flow.boykov_kolmogorov_max_flow( @@ -348,7 +373,11 @@ def compute_mincut(self): time_start = time.time() - if self.path_augment: + if ( + self.path_augment + and len(self.source_graph_ids_raw) > 0 + and len(self.sink_graph_ids_raw) > 0 + ): partition = self._compute_mincut_path_augmented() else: partition = self._compute_mincut_direct() @@ -587,13 +616,15 @@ def _sink_and_source_connectivity_sanity_check(self, cut_edge_set): # but return a flag to return a message to the user illegal_split = True else: - raise SupervoxelSplitRequiredError( + msg = ( "Failed to find a cut that separated the sources from the sinks. " "Please try another cut that partitions the sets cleanly if possible. " "If there is a clear path between all the supervoxels in each set, " - "that helps the mincut algorithm.", - self.sv_remapping + "that helps the mincut algorithm." ) + if self.sv_split_supported: + raise SupervoxelSplitRequiredError(msg, self.sv_remapping) + raise PreconditionError(msg) except IsolatingCutException as e: if self.split_preview: illegal_split = True @@ -608,18 +639,24 @@ def _sink_and_source_connectivity_sanity_check(self, cut_edge_set): return ccs_test_post_cut, illegal_split def partition_edges_within_label(self, cc): - """Test is an isolated component has out-edges only within the original - labeled points of the cut + """Test if an isolated component has out-edges only within the original + labeled points of the cut. cc contains weighted_graph indices. + Use weighted_graph_raw to avoid fake infinite edges between sources/sinks. """ - label_graph_ids = np.concatenate((self.source_graph_ids, self.sink_graph_ids)) - + label_svs = np.concatenate((self.sources, self.sinks)) for vind in cc: - v = self.weighted_graph_raw.vertex(vind) - out_vinds = [int(x) for x in v.out_neighbors()] - if not np.all(np.isin(out_vinds, label_graph_ids)): + sv = self.unique_supervoxel_ids[vind] + raw_inds = np.where(self.unique_supervoxel_ids_raw == sv)[0] + if len(raw_inds) == 0: + # SV not in raw graph (only cross-chunk edges) — no local neighbors + continue + v = self.weighted_graph_raw.vertex(raw_inds[0]) + neighbor_svs = self.unique_supervoxel_ids_raw[ + [int(x) for x in v.out_neighbors()] + ] + if not np.all(np.isin(neighbor_svs, label_svs)): return False - else: - return True + return True def run_multicut( @@ -630,6 +667,7 @@ def run_multicut( split_preview: bool = False, path_augment: bool = True, disallow_isolating_cut: bool = True, + sv_split_supported: bool = False, ): local_mincut_graph = LocalMincutGraph( edges.get_pairs(), @@ -639,6 +677,7 @@ def run_multicut( split_preview, path_augment, disallow_isolating_cut=disallow_isolating_cut, + sv_split_supported=sv_split_supported, ) atomic_edges = local_mincut_graph.compute_mincut() if len(atomic_edges) == 0: @@ -681,6 +720,7 @@ def run_split_preview( split_preview=True, path_augment=path_augment, disallow_isolating_cut=disallow_isolating_cut, + sv_split_supported=cg.meta.ocdbt_seg, ) if len(edges_to_remove) == 0: diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 4c85bd463..73ad898d8 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -954,6 +954,7 @@ def _apply( self.sink_ids, path_augment=self.path_augment, disallow_isolating_cut=self.disallow_isolating_cut, + sv_split_supported=self.cg.meta.ocdbt_seg, ) if not self.removed_edges.size: raise PostconditionError("Mincut could not find any edges to remove.") diff --git a/pychunkedgraph/tests/graph/test_multicut.py b/pychunkedgraph/tests/graph/test_multicut.py index 87408a654..590476ffd 100644 --- a/pychunkedgraph/tests/graph/test_multicut.py +++ b/pychunkedgraph/tests/graph/test_multicut.py @@ -68,4 +68,10 @@ def test_path_augmented_multicut(self, sv_data): assert cut_edges_aug.shape[0] == 350 with pytest.raises(exceptions.SupervoxelSplitRequiredError): - run_multicut(edges, sv_sources, sv_sinks, path_augment=False) + run_multicut( + edges, + sv_sources, + sv_sinks, + path_augment=False, + sv_split_supported=True, + ) From f13f0b59ffce1b23710763a1c6f61ccae604a970 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 14:54:07 +0000 Subject: [PATCH 183/367] refactor: extract edge routing to edges_sv.py, root-based active/inactive, validation safeguards --- pychunkedgraph/graph/edges_sv.py | 413 ++++++++++++++++ pychunkedgraph/graph/edits_sv.py | 272 +---------- pychunkedgraph/tests/graph/test_edges_sv.py | 492 ++++++++++++++++++++ pychunkedgraph/tests/graph/test_edits_sv.py | 336 +++++-------- 4 files changed, 1042 insertions(+), 471 deletions(-) create mode 100644 pychunkedgraph/graph/edges_sv.py create mode 100644 pychunkedgraph/tests/graph/test_edges_sv.py diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py new file mode 100644 index 000000000..01f7baafe --- /dev/null +++ b/pychunkedgraph/graph/edges_sv.py @@ -0,0 +1,413 @@ +""" +Edge routing logic for supervoxel splits. + +When a supervoxel (SV) is split into multiple fragments, all edges that +connected the original SV to its neighbors must be reassigned to the +appropriate new fragment(s). This module handles that reassignment. + +Edge classification: + Active edges: partner SV shares the same root as the split SV. + These edges are routed based on affinity type: + - Inf-affinity (cross-chunk) to a split partner: matched by split label, + connecting fragments that received the same label during the split. + - Inf-affinity (cross-chunk) to an unsplit partner: assigned to the + closest fragment only. Broadcasting to all fragments would create an + uncuttable bridge between source/sink sides of the split. + - Finite-affinity: assigned to fragments within a distance threshold + of the partner, or the closest fragment if none are within threshold. + + Inactive edges: partner SV has a different root. + These are edges to neighboring objects. All fragments inherit the edge + since any fragment could border the neighbor. + +Distance computation: + For partners within the segmentation bbox, distances are precomputed via + kdtree pairwise distances. For active partners outside the bbox (e.g. + cross-chunk fragments excluded by _get_whole_sv's bbox clipping), distances + are computed from each new fragment's kdtree to the partner's chunk boundary. +""" + +from __future__ import annotations + +from functools import reduce +from typing import TYPE_CHECKING +from datetime import datetime + +import fastremap +import numpy as np + +from pychunkedgraph import get_logger +from pychunkedgraph.graph import attributes, basetypes, serializers +from pychunkedgraph.graph.exceptions import PostconditionError +from pychunkedgraph.graph.cutting_sv import ( + build_kdtrees_by_label, + pairwise_min_distance_two_sets, +) +from pychunkedgraph.graph.edges import Edges + +if TYPE_CHECKING: + from pychunkedgraph.graph.chunkedgraph import ChunkedGraph + +logger = get_logger(__name__) + + +def _match_by_label(new_ids, partner, aff, area, new_id_label_map, distances_row): + """For inf-affinity (cross-chunk) edges: connect fragments with matching split label.""" + partner_label = new_id_label_map[partner] + matching = np.array( + [nid for nid in new_ids if new_id_label_map.get(nid) == partner_label], + dtype=basetypes.NODE_ID, + ) + if len(matching): + edges = np.column_stack( + [matching, np.full(len(matching), partner, dtype=np.uint64)] + ) + affs = np.full(len(matching), aff, dtype=basetypes.EDGE_AFFINITY) + areas = np.full(len(matching), area, dtype=basetypes.EDGE_AREA) + return edges, affs, areas + # fallback: closest fragment + close = new_ids[np.argmin(distances_row)] + return ( + np.array([[close, partner]], dtype=np.uint64), + np.array([aff], dtype=basetypes.EDGE_AFFINITY), + np.array([area], dtype=basetypes.EDGE_AREA), + ) + + +def _match_by_proximity(new_ids, partner, aff, area, distances_row, threshold): + """For regular edges: connect fragments within distance threshold.""" + close_mask = distances_row < threshold + nearby = new_ids[close_mask] + if len(nearby): + edges = np.column_stack( + [nearby, np.full(len(nearby), partner, dtype=np.uint64)] + ) + affs = np.full(len(nearby), aff, dtype=basetypes.EDGE_AFFINITY) + areas = np.full(len(nearby), area, dtype=basetypes.EDGE_AREA) + return edges, affs, areas + close = new_ids[np.argmin(distances_row)] + return ( + np.array([[close, partner]], dtype=np.uint64), + np.array([aff], dtype=basetypes.EDGE_AFFINITY), + np.array([area], dtype=basetypes.EDGE_AREA), + ) + + +def _match_inf_unsplit(new_ids, partner, aff, area, distances_row): + """Inf-affinity edge to an unsplit partner: assign to closest fragment only. + Connecting all fragments would create an uncuttable bridge between source/sink sides. + """ + closest = new_ids[np.argmin(distances_row)] + return ( + np.array([[closest, partner]], dtype=np.uint64), + np.array([aff], dtype=basetypes.EDGE_AFFINITY), + np.array([area], dtype=basetypes.EDGE_AREA), + ) + + +def _match_partner( + new_ids, partner, aff, area, distances_row, new_id_label_map, threshold +): + """Route a single old edge to the appropriate new fragment(s).""" + if np.isinf(aff): + if new_id_label_map and partner in new_id_label_map: + return _match_by_label( + new_ids, partner, aff, area, new_id_label_map, distances_row + ) + return _match_inf_unsplit(new_ids, partner, aff, area, distances_row) + return _match_by_proximity(new_ids, partner, aff, area, distances_row, threshold) + + +def _expand_partners(active_partners, active_affs, active_areas, old_new_map): + """If a partner was also split, expand it to its new fragment IDs.""" + remapped_lists = [old_new_map.get(p, [p]) for p in active_partners] + if not remapped_lists: + return ( + [], + np.array([], dtype=basetypes.EDGE_AFFINITY), + np.array([], dtype=basetypes.EDGE_AREA), + ) + counts = np.array([len(r) for r in remapped_lists]) + partners = np.concatenate(remapped_lists) + affs = np.repeat(active_affs, counts) + areas = np.repeat(active_areas, counts) + return partners, affs, areas + + +def _compute_boundary_distances(cg, new_kdtrees, partner, old_chunk, chunk_size): + """Compute distance from each new fragment to a partner's chunk boundary. + Used for active partners outside the bbox that have no kdtree entry. + old_chunk and chunk_size should be precomputed by the caller. + """ + partner_chunk = cg.get_chunk_coordinates(partner) + diff = partner_chunk.astype(int) - old_chunk.astype(int) + axis = np.argmax(np.abs(diff)) + if diff[axis] > 0: + boundary = (old_chunk[axis] + 1) * chunk_size[axis] + else: + boundary = old_chunk[axis] * chunk_size[axis] + return np.array([np.min(np.abs(kt.data[:, axis] - boundary)) for kt in new_kdtrees]) + + +def _get_new_edges( + edges_info: tuple, + old_new_map: dict, + distances: np.ndarray, + distance_map: dict, + new_distance_map: dict, + root_id: basetypes.NODE_ID, + sv_root_map: dict, + cg: "ChunkedGraph", + new_kdtrees: list, + new_id_label_map: dict = None, + threshold: int = 10, +): + edge_batches, aff_batches, area_batches = [], [], [] + edges, affinities, areas = edges_info + + for old, new in old_new_map.items(): + new_ids = np.array(list(new), dtype=basetypes.NODE_ID) + edges_m = np.any(edges == old, axis=1) + selected_edges = edges[edges_m] + sel_m = selected_edges != old + assert np.all(np.sum(sel_m, axis=1) == 1) + + partners = selected_edges[sel_m] + edge_affs = affinities[edges_m] + edge_areas = areas[edges_m] + partner_roots = np.array( + [sv_root_map.get(p, 0) for p in partners], dtype=np.uint64 + ) + active_m = partner_roots == root_id + + # Inactive partners (different root): broadcast to all fragments + inactive_idx = np.where(~active_m)[0] + if len(inactive_idx) > 0: + inactive_partners = partners[inactive_idx] + n_frag = len(new_ids) + broadcast_edges = np.column_stack( + [ + np.repeat(new_ids, len(inactive_partners)), + np.tile(inactive_partners, n_frag), + ] + ) + edge_batches.append(broadcast_edges) + aff_batches.append(np.tile(edge_affs[inactive_idx], n_frag)) + area_batches.append(np.tile(edge_areas[inactive_idx], n_frag)) + + # Active partners (same root): route based on affinity type + active_partners, act_affs, act_areas = _expand_partners( + partners[active_m], edge_affs[active_m], edge_areas[active_m], old_new_map + ) + if len(active_partners) > 0: + new_id_rows = np.array( + [new_distance_map[nid] for nid in new_ids], dtype=int + ) + # Precompute chunk info for boundary distance fallback + old_chunk = cg.get_chunk_coordinates(new_ids[0]) if cg else None + chunk_size = cg.meta.graph_config.CHUNK_SIZE if cg else None + for k, partner in enumerate(active_partners): + dist_col = distance_map.get(partner) + if dist_col is not None: + act_dist_row = distances[new_id_rows, dist_col] + else: + act_dist_row = _compute_boundary_distances( + cg, new_kdtrees, partner, old_chunk, chunk_size + ) + e, a, ar = _match_partner( + new_ids, + partner, + act_affs[k], + act_areas[k], + act_dist_row, + new_id_label_map, + threshold, + ) + edge_batches.append(e) + aff_batches.append(a) + area_batches.append(ar) + + # Low-affinity edges between split fragments (cuttable by mincut) + if len(new_ids) > 1: + i_idx, j_idx = np.triu_indices(len(new_ids), k=1) + pairs = np.column_stack([new_ids[i_idx], new_ids[j_idx]]) + edge_batches.append(pairs) + n_pairs = len(pairs) + aff_batches.append(np.full(n_pairs, 0.001, dtype=basetypes.EDGE_AFFINITY)) + area_batches.append(np.zeros(n_pairs, dtype=basetypes.EDGE_AREA)) + + if len(edge_batches) == 0: + return ( + np.array([], dtype=basetypes.NODE_ID).reshape(0, 2), + np.array([], dtype=basetypes.EDGE_AFFINITY), + np.array([], dtype=basetypes.EDGE_AREA), + ) + all_edges = np.concatenate(edge_batches) + all_affs = np.concatenate(aff_batches) + all_areas = np.concatenate(area_batches) + edges_ = np.sort(all_edges.astype(basetypes.NODE_ID), axis=1) + edges_, idx = np.unique(edges_, return_index=True, axis=0) + return edges_, all_affs[idx], all_areas[idx] + + +def validate_split_edges(edges, affinities, old_new_map): + """Validate edge routing results before writing to prevent graph corruption. + + Checks: + A. No inf-broadcast to unsplit partners — inf-aff edges represent cross-chunk + identity (same logical SV across chunk boundaries). After splitting SV A into + fragments A1, A2, only one fragment's voxels physically touch partner B at + the chunk boundary. If multiple fragments connect via inf to the same unsplit + partner, merge_cross_chunk_edges_graph_tool merges them all into one + representative, making the split uncuttable by mincut. + B. No self-loops. + C. All old SVs have replacement edges from their fragments. + D. Inter-fragment edges exist between all fragment pairs. + + Raises PostconditionError on any violation. + """ + if len(edges) == 0: + return + + all_new_ids_arr = np.array( + [nid for ids in old_new_map.values() for nid in ids], dtype=np.uint64 + ) + + # B. No self-loops (cheapest check first) + self_loops = edges[:, 0] == edges[:, 1] + if self_loops.any(): + raise PostconditionError(f"Self-loop edges detected: {edges[self_loops]}") + + # A. No inf-broadcast to unsplit partners + inf_mask = np.isinf(affinities) + if inf_mask.any(): + inf_edges = edges[inf_mask] + is_frag_0 = np.isin(inf_edges[:, 0], all_new_ids_arr) + is_frag_1 = np.isin(inf_edges[:, 1], all_new_ids_arr) + # Edges where exactly one endpoint is a fragment, other is unsplit partner + mixed_mask = is_frag_0 ^ is_frag_1 + if mixed_mask.any(): + mixed = inf_edges[mixed_mask] + mixed_frag0 = is_frag_0[mixed_mask] + # Extract partner and fragment columns + partners = np.where(mixed_frag0, mixed[:, 1], mixed[:, 0]) + fragments = np.where(mixed_frag0, mixed[:, 0], mixed[:, 1]) + # Exclude partners that are also new fragments (split partners) + unsplit_mask = ~np.isin(partners, all_new_ids_arr) + if unsplit_mask.any(): + unsplit_partners = partners[unsplit_mask] + unsplit_fragments = fragments[unsplit_mask] + # For each unique unsplit partner, count distinct fragments + for p in np.unique(unsplit_partners): + n_frags = len(np.unique(unsplit_fragments[unsplit_partners == p])) + if n_frags > 1: + raise PostconditionError( + f"Inf-affinity edge to unsplit partner {p} connects " + f"{n_frags} fragments. " + f"Must connect to exactly 1 to prevent uncuttable bridges." + ) + + # C. All old SVs have replacement edges + edge_svs = np.unique(edges.ravel()) + for old_id, new_ids in old_new_map.items(): + new_arr = np.array(list(new_ids), dtype=np.uint64) + if not np.any(np.isin(new_arr, edge_svs)): + raise PostconditionError( + f"Old SV {old_id} has no replacement edges from fragments {new_ids}" + ) + + # D. Inter-fragment edges exist + # edges are already sorted (col0 < col1), so check sorted pairs directly + edge_set = set(map(tuple, edges.tolist())) + for new_ids in old_new_map.values(): + ids = sorted(new_ids) + for i in range(len(ids)): + for j in range(i + 1, len(ids)): + if (ids[i], ids[j]) not in edge_set: + raise PostconditionError( + f"Missing inter-fragment edge between {ids[i]} and {ids[j]}" + ) + + +def update_edges( + cg: "ChunkedGraph", + root_id: basetypes.NODE_ID, + bbox: np.ndarray, + new_seg: np.ndarray, + old_new_map: dict, + new_id_label_map: dict = None, +): + old_new_map = dict(old_new_map) + kdtrees, _ = build_kdtrees_by_label(new_seg) + distance_map = {k: int(i) for k, i in zip(kdtrees.keys(), range(len(kdtrees)))} + + _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) + edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) + + edges = edges_.get_pairs() + affinities = edges_.affinities + areas = edges_.areas + + edges = np.sort(edges, axis=1) + _, edges_idx = np.unique(edges, axis=0, return_index=True) + edges_idx = edges_idx[edges[edges_idx, 0] != edges[edges_idx, 1]] + + edges = edges[edges_idx] + affinities = affinities[edges_idx] + areas = areas[edges_idx] + + # Batch-fetch roots for all edge partners to define active vs inactive + all_edge_svs = np.unique(edges) + all_roots = cg.get_roots(all_edge_svs) + sv_root_map = dict(zip(all_edge_svs, all_roots)) + + new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) + new_kdtrees = [kdtrees[k] for k in new_ids] + new_distance_map = {k: int(i) for k, i in zip(new_ids, range(len(new_ids)))} + distances = pairwise_min_distance_two_sets(new_kdtrees, list(kdtrees.values())) + result = _get_new_edges( + (edges, affinities, areas), + old_new_map, + distances, + distance_map, + new_distance_map, + root_id, + sv_root_map, + cg, + new_kdtrees, + new_id_label_map, + threshold=cg.meta.sv_split_threshold, + ) + validate_split_edges(result[0], result[1], old_new_map) + return result + + +def add_new_edges(cg: "ChunkedGraph", edges_tuple: tuple, time_stamp: datetime = None): + edges_, affinites_, areas_ = edges_tuple + logger.note(f"new edges: {edges_.shape}") + + nodes = fastremap.unique(edges_) + chunks = cg.get_chunk_ids_from_node_ids(cg.get_parents(nodes)) + node_chunks = dict(zip(nodes, chunks)) + + edges = np.r_[edges_, edges_[:, ::-1]] + affinites = np.r_[affinites_, affinites_] + areas = np.r_[areas_, areas_] + + rows = [] + chunks_arr = fastremap.remap(edges, node_chunks) + for chunk_id in np.unique(chunks): + val_dict = {} + mask = chunks_arr[:, 0] == chunk_id + val_dict[attributes.Connectivity.SplitEdges] = edges[mask] + val_dict[attributes.Connectivity.Affinity] = affinites[mask] + val_dict[attributes.Connectivity.Area] = areas[mask] + rows.append( + cg.client.mutate_row( + serializers.serialize_uint64(chunk_id, fake_edges=True), + val_dict=val_dict, + time_stamp=time_stamp, + ) + ) + logger.note(f"writing {edges[mask].shape} edges to {chunk_id}") + return rows diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 7e4ab93b5..ed63f8b8e 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -2,33 +2,30 @@ Manage new supervoxels after a supervoxel split. """ -from functools import reduce import multiprocessing as mp - -from pychunkedgraph import get_logger - -logger = get_logger(__name__) -from typing import Callable from datetime import datetime from collections import defaultdict, deque import fastremap import numpy as np from tqdm import tqdm -from pychunkedgraph.graph import attributes, ChunkedGraph, cache as cache_utils -from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox, get_neighbors -from pychunkedgraph.graph.cutting_sv import ( - build_kdtrees_by_label, - pairwise_min_distance_two_sets, - split_supervoxel_helper, + +from pychunkedgraph import get_logger +from pychunkedgraph.graph import ( + attributes, + ChunkedGraph, + cache as cache_utils, + basetypes, + serializers, ) -from pychunkedgraph.graph.edges import Edges -from pychunkedgraph.graph.types import empty_2d -from pychunkedgraph.graph import basetypes -from pychunkedgraph.graph import serializers +from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox +from pychunkedgraph.graph.cutting_sv import split_supervoxel_helper +from pychunkedgraph.graph.edges_sv import update_edges, add_new_edges from pychunkedgraph.graph.utils import get_local_segmentation from pychunkedgraph.io.edges import get_chunk_edges +logger = get_logger(__name__) + def _get_whole_sv( cg: ChunkedGraph, node: basetypes.NODE_ID, min_coord, max_coord @@ -89,14 +86,14 @@ def _update_chunk(args): _label_id_map = {} for _id in labels: _mask = chunk_seg == _id - _idx = np.unravel_index(np.flatnonzero(_mask)[0], og_chunk_seg.shape) - _og_value = og_chunk_seg[_idx] - _index = np.argwhere(_mask) + voxel_locs = np.where(_mask) + _og_value = og_chunk_seg[voxel_locs[0][0], voxel_locs[1][0], voxel_locs[2][0]] + _index = np.column_stack(voxel_locs) + n = len(_index) _indices.append(_index) - _ones = np.ones(len(_index), dtype=basetypes.NODE_ID) - _old_values.append(_ones * _og_value) + _old_values.append(np.full(n, _og_value, dtype=basetypes.NODE_ID)) new_id = cg.id_client.create_node_id(chunk_id) - _new_values.append(_ones * new_id) + _new_values.append(np.full(n, new_id, dtype=basetypes.NODE_ID)) _label_id_map[int(_id)] = new_id _indices = np.concatenate(_indices) + (chunk_bbox[0] - bb_start) @@ -129,232 +126,6 @@ def _parse_results(results, seg, bbs, bbe): return seg, old_new_map, slices, new_id_label_map -def _match_by_label(new_ids, partner, aff, area, new_id_label_map, distances_row): - """For inf-affinity (cross-chunk) edges: connect fragments with matching split label.""" - partner_label = new_id_label_map[partner] - matching = np.array( - [nid for nid in new_ids if new_id_label_map.get(nid) == partner_label], - dtype=basetypes.NODE_ID, - ) - if len(matching): - edges = np.column_stack( - [matching, np.full(len(matching), partner, dtype=np.uint64)] - ) - affs = np.full(len(matching), aff, dtype=basetypes.EDGE_AFFINITY) - areas = np.full(len(matching), area, dtype=basetypes.EDGE_AREA) - return edges, affs, areas - # fallback: closest fragment - close = new_ids[np.argmin(distances_row)] - return ( - np.array([[close, partner]], dtype=np.uint64), - np.array([aff], dtype=basetypes.EDGE_AFFINITY), - np.array([area], dtype=basetypes.EDGE_AREA), - ) - - -def _match_by_proximity(new_ids, partner, aff, area, distances_row, threshold): - """For regular edges: connect fragments within distance threshold.""" - close_mask = distances_row < threshold - nearby = new_ids[close_mask] - if len(nearby): - edges = np.column_stack( - [nearby, np.full(len(nearby), partner, dtype=np.uint64)] - ) - affs = np.full(len(nearby), aff, dtype=basetypes.EDGE_AFFINITY) - areas = np.full(len(nearby), area, dtype=basetypes.EDGE_AREA) - return edges, affs, areas - close = new_ids[np.argmin(distances_row)] - return ( - np.array([[close, partner]], dtype=np.uint64), - np.array([aff], dtype=basetypes.EDGE_AFFINITY), - np.array([area], dtype=basetypes.EDGE_AREA), - ) - - -def _match_inf_unsplit(new_ids, partner, aff, area, distances_row): - """Inf-affinity edge to an unsplit partner: assign to closest fragment only. - Connecting all fragments would create an uncuttable bridge between source/sink sides. - """ - closest = new_ids[np.argmin(distances_row)] - return ( - np.array([[closest, partner]], dtype=np.uint64), - np.array([aff], dtype=basetypes.EDGE_AFFINITY), - np.array([area], dtype=basetypes.EDGE_AREA), - ) - - -def _match_partner( - new_ids, partner, aff, area, distances_row, new_id_label_map, threshold -): - """Route a single old edge to the appropriate new fragment(s).""" - if np.isinf(aff): - if new_id_label_map and partner in new_id_label_map: - return _match_by_label( - new_ids, partner, aff, area, new_id_label_map, distances_row - ) - return _match_inf_unsplit(new_ids, partner, aff, area, distances_row) - return _match_by_proximity(new_ids, partner, aff, area, distances_row, threshold) - - -def _expand_partners(active_partners, active_affs, active_areas, old_new_map): - """If a partner was also split, expand it to its new fragment IDs.""" - partners, affs, areas = [], [], [] - for i in range(len(active_partners)): - remapped = old_new_map.get(active_partners[i], [active_partners[i]]) - partners.extend(remapped) - affs.extend([active_affs[i]] * len(remapped)) - areas.extend([active_areas[i]] * len(remapped)) - return partners, affs, areas - - -def _get_new_edges( - edges_info: tuple, - sv_ids: np.ndarray, - old_new_map: dict, - distances: np.ndarray, - dist_vec: Callable, - new_dist_vec: Callable, - new_id_label_map: dict = None, - threshold: int = 10, -): - new_edges, new_affs, new_areas = [], [], [] - edges, affinities, areas = edges_info - - for old, new in old_new_map.items(): - new_ids = np.array(list(new), dtype=basetypes.NODE_ID) - edges_m = np.any(edges == old, axis=1) - selected_edges = edges[edges_m] - sel_m = selected_edges != old - assert np.all(np.sum(sel_m, axis=1) == 1) - - partners = selected_edges[sel_m] - edge_affs = affinities[edges_m] - edge_areas = areas[edges_m] - active_m = np.isin(partners, sv_ids) - - # Inactive partners (different root, outside distance map): all fragments get the edge - for k in np.where(~active_m)[0]: - for new_id in new_ids: - new_edges.append(np.array([new_id, partners[k]], dtype=np.uint64)) - new_affs.append(edge_affs[k]) - new_areas.append(edge_areas[k]) - - # Active partners (same root): route based on affinity type - active_partners, act_affs, act_areas = _expand_partners( - partners[active_m], edge_affs[active_m], edge_areas[active_m], old_new_map - ) - new_id_rows = new_dist_vec(new_ids) - act_dists = distances[new_id_rows][:, dist_vec(active_partners)].T - for k, partner in enumerate(active_partners): - e, a, ar = _match_partner( - new_ids, - partner, - act_affs[k], - act_areas[k], - act_dists[k], - new_id_label_map, - threshold, - ) - new_edges.extend(e) - new_affs.extend(a) - new_areas.extend(ar) - - # Low-affinity edges between split fragments (cuttable by mincut) - for i in range(len(new_ids)): - for j in range(i + 1, len(new_ids)): - new_edges.append(np.array([new_ids[i], new_ids[j]], dtype=np.uint64)) - new_affs.append(0.001) - new_areas.append(0) - - if len(new_edges) == 0: - return ( - np.array([], dtype=basetypes.NODE_ID), - np.array([], dtype=basetypes.EDGE_AFFINITY), - np.array([], dtype=basetypes.EDGE_AREA), - ) - affinities_ = np.array(new_affs, dtype=basetypes.EDGE_AFFINITY) - areas_ = np.array(new_areas, dtype=basetypes.EDGE_AREA) - edges_ = np.sort(np.array(new_edges, dtype=basetypes.NODE_ID), axis=1) - edges_, idx = np.unique(edges_, return_index=True, axis=0) - return edges_, affinities_[idx], areas_[idx] - - -def _update_edges( - cg: ChunkedGraph, - sv_ids: np.ndarray, - root_id: basetypes.NODE_ID, - bbox: np.ndarray, - new_seg: np.ndarray, - old_new_map: dict, - new_id_label_map: dict = None, -): - old_new_map = dict(old_new_map) - kdtrees, _ = build_kdtrees_by_label(new_seg) - distance_map = dict(zip(kdtrees.keys(), np.arange(len(kdtrees)))) - dist_vec = np.vectorize(distance_map.get) - - _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) - edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) - - edges = edges_.get_pairs() - affinities = edges_.affinities - areas = edges_.areas - - edges = np.sort(edges, axis=1) - _, edges_idx = np.unique(edges, axis=0, return_index=True) - edges_idx = edges_idx[edges[edges_idx, 0] != edges[edges_idx, 1]] - - edges = edges[edges_idx] - affinities = affinities[edges_idx] - areas = areas[edges_idx] - new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) - new_kdtrees = [kdtrees[k] for k in new_ids] - new_disance_map = dict(zip(new_ids, np.arange(len(new_ids)))) - new_dist_vec = np.vectorize(new_disance_map.get) - distances = pairwise_min_distance_two_sets(new_kdtrees, list(kdtrees.values())) - return _get_new_edges( - (edges, affinities, areas), - sv_ids, - old_new_map, - distances, - dist_vec, - new_dist_vec, - new_id_label_map, - threshold=cg.meta.sv_split_threshold, - ) - - -def _add_new_edges(cg: ChunkedGraph, edges_tuple: tuple, time_stamp: datetime = None): - edges_, affinites_, areas_ = edges_tuple - logger.note(f"new edges: {edges_.shape}") - - nodes = fastremap.unique(edges_) - chunks = cg.get_chunk_ids_from_node_ids(cg.get_parents(nodes)) - node_chunks = dict(zip(nodes, chunks)) - - edges = np.r_[edges_, edges_[:, ::-1]] - affinites = np.r_[affinites_, affinites_] - areas = np.r_[areas_, areas_] - - rows = [] - chunks_arr = fastremap.remap(edges, node_chunks) - for chunk_id in np.unique(chunks): - val_dict = {} - mask = chunks_arr[:, 0] == chunk_id - val_dict[attributes.Connectivity.SplitEdges] = edges[mask] - val_dict[attributes.Connectivity.Affinity] = affinites[mask] - val_dict[attributes.Connectivity.Area] = areas[mask] - rows.append( - cg.client.mutate_row( - serializers.serialize_uint64(chunk_id, fake_edges=True), - val_dict=val_dict, - time_stamp=time_stamp, - ) - ) - logger.note(f"writing {edges[mask].shape} edges to {chunk_id}") - return rows - - def split_supervoxel( cg: ChunkedGraph, sv_id: basetypes.NODE_ID, @@ -425,9 +196,8 @@ def split_supervoxel( seg[~root_mask] = 0 sv_ids = fastremap.unique(seg) seg[voxel_overlap_crop] = new_seg - edges_tuple = _update_edges( + edges_tuple = update_edges( cg, - sv_ids, root, np.array([bbs, bbe]), seg, @@ -436,7 +206,7 @@ def split_supervoxel( ) rows0 = copy_parents_and_add_lineage(cg, operation_id, old_new_map) - rows1 = _add_new_edges(cg, edges_tuple, time_stamp=time_stamp) + rows1 = add_new_edges(cg, edges_tuple, time_stamp=time_stamp) rows = rows0 + rows1 logger.note(f"{operation_id}: writing {len(rows)} new rows") diff --git a/pychunkedgraph/tests/graph/test_edges_sv.py b/pychunkedgraph/tests/graph/test_edges_sv.py new file mode 100644 index 000000000..bc7e8c71b --- /dev/null +++ b/pychunkedgraph/tests/graph/test_edges_sv.py @@ -0,0 +1,492 @@ +"""Comprehensive tests for pychunkedgraph.graph.edges_sv — edge routing after SV split.""" + +import numpy as np +import pytest + +from pychunkedgraph.graph import basetypes +from pychunkedgraph.graph.exceptions import PostconditionError +from pychunkedgraph.graph.edges_sv import ( + _get_new_edges, + _match_by_label, + _match_by_proximity, + _match_inf_unsplit, + _match_partner, + _expand_partners, + validate_split_edges, +) + +ROOT_ID = np.uint64(1) +OTHER_ROOT = np.uint64(2) + + +def _root_map(same_root, other_root=()): + """Build sv_root_map: same_root SVs → ROOT_ID, other_root SVs → OTHER_ROOT.""" + m = {np.uint64(sv): ROOT_ID for sv in same_root} + m.update({np.uint64(sv): OTHER_ROOT for sv in other_root}) + return m + + +def _call_get_new_edges( + edges, + affinities, + areas, + old_new_map, + distances, + distance_map, + new_distance_map, + sv_root_map, + new_id_label_map=None, +): + """Helper to call _get_new_edges with standard ROOT_ID and no cg/kdtrees.""" + return _get_new_edges( + (edges, affinities, areas), + old_new_map, + distances, + distance_map, + new_distance_map, + ROOT_ID, + sv_root_map, + None, + [], + new_id_label_map, + ) + + +# ============================================================ +# Inf-affinity edge routing +# ============================================================ +class TestInfEdgeRouting: + def test_inf_to_unsplit_partner_closest_only(self): + """Inf edge to unsplit active partner → only closest fragment gets it.""" + old = np.uint64(10) + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(50) + + edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) + affs = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([0], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2}} + sv_root_map = _root_map([10, 50, 101, 102]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} + # n1 closer to partner than n2 + distances = np.array([[5.0, 2.0, 0.0, 8.0], [6.0, 9.0, 8.0, 0.0]]) + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + ) + + inf_edges = result_edges[np.isinf(result_affs)] + # Only one fragment should connect to partner via inf + partner_inf = [e for e in inf_edges if partner in e] + assert len(partner_inf) == 1 + assert n1 in partner_inf[0] # n1 is closer + + def test_inf_to_split_partner_label_matched(self): + """Inf edge to split partner → matched by label, not proximity.""" + old = np.uint64(10) + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(201) # also split, label 1 + + edges = np.array([[10, 201]], dtype=basetypes.NODE_ID) + affs = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([0], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2}} + sv_root_map = _root_map([10, 101, 102, 201]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 101, 102, 201])} + + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} + + # n2 is CLOSER to partner, but wrong label + distances = np.array([[5.0, 0.0, 8.0, 9.0], [6.0, 8.0, 0.0, 2.0]]) + label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 1} + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + label_map, + ) + + inf_edges = result_edges[np.isinf(result_affs)] + # Should connect n1 (label 1) to partner (label 1), NOT n2 + for e in inf_edges: + if partner in e: + assert n1 in e, f"Expected label-matched n1, got {e}" + assert n2 not in e + + def test_inf_to_split_partner_label_fallback(self): + """Inf edge to split partner with no matching label → closest fragment.""" + old = np.uint64(10) + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(201) + + edges = np.array([[10, 201]], dtype=basetypes.NODE_ID) + affs = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([0], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2}} + sv_root_map = _root_map([10, 101, 102, 201]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 101, 102, 201])} + + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} + + # n2 closer to partner + distances = np.array([[5.0, 0.0, 8.0, 9.0], [6.0, 8.0, 0.0, 2.0]]) + # Partner has label 3 which matches no fragment + label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 3} + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + label_map, + ) + + inf_edges = result_edges[np.isinf(result_affs)] + partner_edges = [e for e in inf_edges if partner in e] + assert len(partner_edges) == 1 + # Fallback to closest → n2 + assert n2 in partner_edges[0] + + +# ============================================================ +# Finite-affinity edge routing +# ============================================================ +class TestFiniteEdgeRouting: + def test_finite_to_active_partner_proximity(self): + """Finite edge to active partner → fragments within threshold.""" + old = np.uint64(10) + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(50) + + edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) + affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2}} + sv_root_map = _root_map([10, 50, 101, 102]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} + # n1 within threshold (3 < 10), n2 outside (15 > 10) + distances = np.array([[5.0, 3.0, 0.0, 8.0], [6.0, 15.0, 8.0, 0.0]]) + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + ) + + finite_to_partner = [ + e + for e, a in zip(result_edges, result_affs) + if partner in e and not np.isinf(a) + ] + # Only n1 within threshold + assert len(finite_to_partner) == 1 + assert n1 in finite_to_partner[0] + + def test_finite_to_active_partner_fallback(self): + """Finite edge, none within threshold → closest fragment only.""" + old = np.uint64(10) + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(50) + + edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) + affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2}} + sv_root_map = _root_map([10, 50, 101, 102]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} + # Both outside threshold + distances = np.array([[5.0, 15.0, 0.0, 8.0], [6.0, 20.0, 8.0, 0.0]]) + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + ) + + finite_to_partner = [ + e + for e, a in zip(result_edges, result_affs) + if partner in e and not np.isinf(a) + ] + # Fallback: closest (n1 at dist 15) + assert len(finite_to_partner) == 1 + assert n1 in finite_to_partner[0] + + def test_finite_to_inactive_partner_broadcast(self): + """Finite edge to different-root partner → all fragments get it.""" + old = np.uint64(10) + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(99) # different root + + edges = np.array([[10, 99]], dtype=basetypes.NODE_ID) + affs = np.array([0.5], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([200], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2}} + sv_root_map = _root_map([10, 101, 102], [99]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 99, 101, 102])} + + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} + + distances = np.array([[5.0, 3.0, 0.0, 8.0], [6.0, 4.0, 8.0, 0.0]]) + + result_edges, _, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + ) + + partner_edges = [e for e in result_edges if partner in e] + fragments_connected = {e[0] if e[1] == partner else e[1] for e in partner_edges} + assert n1 in fragments_connected + assert n2 in fragments_connected + + +# ============================================================ +# Partner expansion +# ============================================================ +class TestPartnerExpansion: + def test_partner_also_split(self): + """Partner in old_new_map → expands to its fragments.""" + partners = np.array([np.uint64(50)]) + affs = np.array([0.9]) + areas = np.array([100]) + old_new_map = {np.uint64(50): [np.uint64(501), np.uint64(502)]} + + expanded_partners, expanded_affs, expanded_areas = _expand_partners( + partners, + affs, + areas, + old_new_map, + ) + assert len(expanded_partners) == 2 + assert np.uint64(501) in expanded_partners + assert np.uint64(502) in expanded_partners + assert all(a == 0.9 for a in expanded_affs) + + +# ============================================================ +# Fragment edges +# ============================================================ +class TestFragmentEdges: + def test_inter_fragment_edges(self): + """Two fragments → 1 low-affinity inter-fragment edge.""" + old = np.uint64(10) + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(50) + + edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) + affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2}} + sv_root_map = _root_map([10, 50, 101, 102]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} + distances = np.array([[5.0, 3.0, 0.0, 8.0], [6.0, 4.0, 8.0, 0.0]]) + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + ) + + frag_edges = [ + (e, a) for e, a in zip(result_edges, result_affs) if set(e) == {n1, n2} + ] + assert len(frag_edges) == 1 + assert frag_edges[0][1] == pytest.approx(0.001) + + def test_inter_fragment_edges_three_way(self): + """Three fragments → 3 inter-fragment edges.""" + old = np.uint64(10) + n1, n2, n3 = np.uint64(101), np.uint64(102), np.uint64(103) + partner = np.uint64(50) + + edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) + affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100], dtype=basetypes.EDGE_AREA) + + old_new_map = {old: {n1, n2, n3}} + sv_root_map = _root_map([10, 50, 101, 102, 103]) + dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102, 103])} + + new_dist_map = {np.uint64(101): 0, np.uint64(102): 1, np.uint64(103): 2} + + distances = np.array( + [ + [5.0, 3.0, 0.0, 8.0, 7.0], + [6.0, 4.0, 8.0, 0.0, 6.0], + [7.0, 5.0, 7.0, 6.0, 0.0], + ] + ) + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + distances, + dist_map, + new_dist_map, + sv_root_map, + ) + + frag_pairs = { + frozenset(e) + for e, a in zip(result_edges, result_affs) + if a == pytest.approx(0.001) + } + expected = {frozenset([n1, n2]), frozenset([n1, n3]), frozenset([n2, n3])} + assert frag_pairs == expected + + +# ============================================================ +# Validation +# ============================================================ +class TestValidateSplitEdges: + def test_valid_edges_pass(self): + """Well-formed edges pass validation without error.""" + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(50) + old_new_map = {np.uint64(10): {n1, n2}} + + edges = np.array( + [ + [n1, partner], + [n1, n2], + ], + dtype=basetypes.NODE_ID, + ) + affs = np.array([np.inf, 0.001], dtype=basetypes.EDGE_AFFINITY) + + validate_split_edges(edges, affs, old_new_map) # should not raise + + def test_catches_inf_broadcast(self): + """Validation rejects inf edges from multiple fragments to same unsplit partner.""" + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(50) + old_new_map = {np.uint64(10): {n1, n2}} + + edges = np.array( + [ + [n1, partner], + [n2, partner], + [n1, n2], + ], + dtype=basetypes.NODE_ID, + ) + affs = np.array([np.inf, np.inf, 0.001], dtype=basetypes.EDGE_AFFINITY) + + with pytest.raises(PostconditionError, match="unsplit partner"): + validate_split_edges(edges, affs, old_new_map) + + def test_catches_self_loop(self): + """Validation rejects self-loop edges.""" + n1, n2 = np.uint64(101), np.uint64(102) + old_new_map = {np.uint64(10): {n1, n2}} + + edges = np.array([[n1, n1], [n1, n2]], dtype=basetypes.NODE_ID) + affs = np.array([0.5, 0.001], dtype=basetypes.EDGE_AFFINITY) + + with pytest.raises(PostconditionError, match="Self-loop"): + validate_split_edges(edges, affs, old_new_map) + + def test_catches_missing_fragment_edges(self): + """Validation rejects missing inter-fragment edges.""" + n1, n2 = np.uint64(101), np.uint64(102) + partner = np.uint64(50) + old_new_map = {np.uint64(10): {n1, n2}} + + # Missing inter-fragment edge between n1 and n2 + edges = np.array([[n1, partner]], dtype=basetypes.NODE_ID) + affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) + + with pytest.raises(PostconditionError, match="Missing inter-fragment"): + validate_split_edges(edges, affs, old_new_map) + + def test_catches_missing_replacement_edges(self): + """Validation rejects old SV with no edges from any fragment.""" + n1, n2 = np.uint64(101), np.uint64(102) + n3, n4 = np.uint64(201), np.uint64(202) + old_new_map = {np.uint64(10): {n1, n2}, np.uint64(20): {n3, n4}} + + # Only edges for first old SV's fragments, none for second + edges = np.array([[n1, n2]], dtype=basetypes.NODE_ID) + affs = np.array([0.001], dtype=basetypes.EDGE_AFFINITY) + + with pytest.raises(PostconditionError, match="no replacement edges"): + validate_split_edges(edges, affs, old_new_map) + + def test_empty_edges_pass(self): + """Empty edge set passes validation (nothing to validate).""" + validate_split_edges( + np.array([], dtype=basetypes.NODE_ID).reshape(0, 2), + np.array([], dtype=basetypes.EDGE_AFFINITY), + {}, + ) + + def test_inf_to_split_partner_allowed_multiple(self): + """Inf edges to a split partner (in all_new_ids) are allowed from multiple fragments.""" + n1, n2 = np.uint64(101), np.uint64(102) + # partner 201 is also a new fragment (split partner) + partner = np.uint64(201) + old_new_map = {np.uint64(10): {n1, n2}, np.uint64(20): {partner}} + + edges = np.array( + [ + [n1, partner], + [n2, partner], + [n1, n2], + ], + dtype=basetypes.NODE_ID, + ) + affs = np.array([np.inf, np.inf, 0.001], dtype=basetypes.EDGE_AFFINITY) + + # Should NOT raise — partner is also split (in all_new_ids) + validate_split_edges(edges, affs, old_new_map) diff --git a/pychunkedgraph/tests/graph/test_edits_sv.py b/pychunkedgraph/tests/graph/test_edits_sv.py index c0b0f7d73..6f4338abe 100644 --- a/pychunkedgraph/tests/graph/test_edits_sv.py +++ b/pychunkedgraph/tests/graph/test_edits_sv.py @@ -8,9 +8,7 @@ from pychunkedgraph.graph.edits_sv import ( _voxel_crop, _parse_results, - _get_new_edges, - _match_by_label, - _match_by_proximity, + copy_parents_and_add_lineage, ) from pychunkedgraph.graph import basetypes @@ -112,234 +110,132 @@ def test_multiple_results(self): # ============================================================ -# Tests: _get_new_edges +# Tests: copy_parents_and_add_lineage # ============================================================ -class TestGetNewEdges: - def test_with_active_and_inactive_partners(self): - """Test with both active partners (in sv_ids) and inactive (not in sv_ids).""" - old_sv = np.uint64(10) - new_sv1 = np.uint64(101) - new_sv2 = np.uint64(102) - active_partner = np.uint64(50) # in sv_ids -> active - inactive_partner = np.uint64(99) # not in sv_ids -> inactive - - edges = np.array( - [ - [10, 50], - [10, 99], - ], - dtype=basetypes.NODE_ID, - ) - affinities = np.array([0.9, 0.5], dtype=basetypes.EDGE_AFFINITY) - areas = np.array([100, 200], dtype=basetypes.EDGE_AREA) - - old_new_map = {old_sv: {new_sv1, new_sv2}} - sv_ids = np.array([10, 50, 101, 102], dtype=basetypes.NODE_ID) - - # distance_map: maps each label to its column index in the distance matrix - distance_map = { - np.uint64(10): 0, - np.uint64(50): 1, - np.uint64(101): 2, - np.uint64(102): 3, - } - dist_vec = np.vectorize(distance_map.get) - new_distance_map = {np.uint64(101): 0, np.uint64(102): 1} - new_dist_vec = np.vectorize(new_distance_map.get) - - # Distances: (new_ids x all_ids) - distances = np.array( - [ - [5.0, 3.0, 0.0, 8.0], # new_sv1 - [6.0, 4.0, 8.0, 0.0], # new_sv2 +class _FakeCell: + """Mimics a bigtable cell with .value and .timestamp.""" + + def __init__(self, value, timestamp=None): + self.value = value + self.timestamp = timestamp + + +class TestCopyParentsAndAddLineage: + def _make_cg(self, parent_cells_map, children_cells_map=None): + from pychunkedgraph.graph import attributes + + cg = MagicMock() + cg.client.read_nodes.side_effect = lambda node_ids, properties: ( + parent_cells_map + if properties is attributes.Hierarchy.Parent + else (children_cells_map or {}) + ) + cg.client.mutate_row.side_effect = lambda key, val_dict, **kw: ( + key, + val_dict, + kw, + ) + cg.cache.parents_cache = {} + cg.cache.children_cache = {} + return cg + + def test_single_old_to_two_new(self): + """One old SV split into two new SVs. Each new SV gets the parent copied.""" + old = np.uint64(10) + new1, new2 = np.uint64(101), np.uint64(102) + parent = np.uint64(1000) + + parent_cells_map = {old: [_FakeCell(parent, timestamp=42)]} + children_cells_map = { + parent: [ + _FakeCell( + np.array([old, np.uint64(20)], dtype=basetypes.NODE_ID), + timestamp=42, + ) ] - ) - - result_edges, result_affs, result_areas = _get_new_edges( - (edges, affinities, areas), - sv_ids, - old_new_map, - distances, - dist_vec, - new_dist_vec, - ) - # Should have: - # - Inactive edges: new_sv1->99, new_sv2->99 - # - Active edges: new_ids -> 50 based on distance - # - Fragment edges: new_sv1 <-> new_sv2 - assert len(result_edges) >= 3 - - def test_edge_between_split_fragments(self): - """Split fragments should have edges between them with low affinity.""" - old_sv = np.uint64(10) - new_sv1 = np.uint64(101) - new_sv2 = np.uint64(102) - partner = np.uint64(50) - - edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) - affinities = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) - areas = np.array([100], dtype=basetypes.EDGE_AREA) - - old_new_map = {old_sv: {new_sv1, new_sv2}} - sv_ids = np.array([10, 50, 101, 102], dtype=basetypes.NODE_ID) - - distance_map = { - np.uint64(10): 0, - np.uint64(50): 1, - np.uint64(101): 2, - np.uint64(102): 3, } - dist_vec = np.vectorize(distance_map.get) - new_distance_map = {np.uint64(101): 0, np.uint64(102): 1} - new_dist_vec = np.vectorize(new_distance_map.get) - distances = np.array( - [ - [5.0, 3.0, 0.0, 8.0], - [6.0, 4.0, 8.0, 0.0], - ] - ) - - result_edges, result_affs, result_areas = _get_new_edges( - (edges, affinities, areas), - sv_ids, - old_new_map, - distances, - dist_vec, - new_dist_vec, - ) - # Check that a fragment-to-fragment edge exists - fragment_edge_found = False - for e in result_edges: - if set(e) == {new_sv1, new_sv2}: - fragment_edge_found = True - break - assert fragment_edge_found - - def test_empty_old_new_map(self): - """Empty old_new_map should return empty results.""" - edges = np.array([[10, 50]], dtype=basetypes.NODE_ID) - affinities = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) - areas = np.array([100], dtype=basetypes.EDGE_AREA) - - result_edges, result_affs, result_areas = _get_new_edges( - (edges, affinities, areas), - np.array([10], dtype=basetypes.NODE_ID), - {}, - np.zeros((0, 0)), - np.vectorize(lambda x: x), - np.vectorize(lambda x: x), - ) - assert len(result_edges) == 0 - - def test_inf_affinity_uses_label_matching(self): - """Inf-affinity (cross-chunk) edges should connect only same-label fragments.""" - old_sv = np.uint64(10) - new_sv1 = np.uint64(101) # label 1 - new_sv2 = np.uint64(102) # label 2 - # partner is a cross-chunk fragment also from the split, label 1 - partner = np.uint64(201) - - edges = np.array([[10, 201]], dtype=basetypes.NODE_ID) - affinities = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) - areas = np.array([0], dtype=basetypes.EDGE_AREA) - - old_new_map = {old_sv: {new_sv1, new_sv2}} - sv_ids = np.array([10, 101, 102, 201], dtype=basetypes.NODE_ID) - - distance_map = { - np.uint64(10): 0, - np.uint64(101): 1, - np.uint64(102): 2, - np.uint64(201): 3, + cg = self._make_cg(parent_cells_map, children_cells_map) + + result = copy_parents_and_add_lineage( + cg, operation_id=5, old_new_map={old: {new1, new2}} + ) + + # Should produce mutations: + # - FormerIdentity + OperationID for each new SV (2) + # - Parent copy for each new SV (2) + # - NewIdentity on old SV (1) + # - Updated children on parent (1) + assert len(result) >= 5 + # Parent cache should have entries for both new SVs + assert new1 in cg.cache.parents_cache or new2 in cg.cache.parents_cache + # Children cache should replace old with new1, new2 + assert parent in cg.cache.children_cache + children = cg.cache.children_cache[parent] + assert new1 in children or int(new1) in children + assert new2 in children or int(new2) in children + + def test_multiple_old_svs(self): + """Two old SVs each split into new SVs, sharing the same parent.""" + old1, old2 = np.uint64(10), np.uint64(20) + new1, new2, new3 = np.uint64(101), np.uint64(102), np.uint64(201) + parent = np.uint64(1000) + + parent_cells_map = { + old1: [_FakeCell(parent, timestamp=42)], + old2: [_FakeCell(parent, timestamp=42)], } - dist_vec = np.vectorize(distance_map.get) - new_distance_map = {np.uint64(101): 0, np.uint64(102): 1} - new_dist_vec = np.vectorize(new_distance_map.get) - - # new_sv2 (label 2) is closer to partner 201, but label doesn't match - distances = np.array( - [ - [5.0, 0.0, 8.0, 9.0], # new_sv1 (label 1) — far from partner - [6.0, 8.0, 0.0, 2.0], # new_sv2 (label 2) — close to partner + children_cells_map = { + parent: [ + _FakeCell( + np.array([old1, old2, np.uint64(30)], dtype=basetypes.NODE_ID), + timestamp=42, + ) ] - ) - - new_id_label_map = { - np.uint64(101): 1, - np.uint64(102): 2, - np.uint64(201): 1, # same label as new_sv1 } + cg = self._make_cg(parent_cells_map, children_cells_map) - result_edges, result_affs, result_areas = _get_new_edges( - (edges, affinities, areas), - sv_ids, - old_new_map, - distances, - dist_vec, - new_dist_vec, - new_id_label_map, - ) - - # The inf-affinity edge should connect new_sv1 (label 1) to partner 201 (label 1) - # NOT new_sv2 (label 2) even though it's closer - inf_edges = result_edges[np.isinf(result_affs)] - for e in inf_edges: - assert ( - new_sv2 not in e - ), f"Inf-affinity edge {e} should not connect label-2 fragment to label-1 partner" - # Verify new_sv1 <-> 201 inf edge exists - found = any(set(e) == {new_sv1, partner} for e in inf_edges) - assert found, "Expected inf-affinity edge between same-label fragments" - - -# ============================================================ -# Tests: _match_by_label / _match_by_proximity -# ============================================================ -class TestMatchByLabel: - def test_matching_label(self): - new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) - new_id_label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 1} - distances_row = np.array([9.0, 2.0]) # 102 is closer - - edges, affs, areas = _match_by_label( - new_ids, np.uint64(201), np.inf, 0, new_id_label_map, distances_row - ) - # Should pick 101 (label 1) not 102 (label 2, closer) - assert all(np.uint64(101) in e for e in edges) - assert np.uint64(102) not in edges.flatten() - - def test_fallback_to_closest(self): - new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) - # partner label 3 doesn't match any new_id - new_id_label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 3} - distances_row = np.array([9.0, 2.0]) - - edges, affs, areas = _match_by_label( - new_ids, np.uint64(201), np.inf, 0, new_id_label_map, distances_row + old_new_map = {old1: {new1, new2}, old2: {new3}} + result = copy_parents_and_add_lineage( + cg, operation_id=7, old_new_map=old_new_map ) - # Fallback: closest = 102 - assert np.uint64(102) in edges.flatten() + assert len(result) > 0 + # Children should replace old1 and old2 with new1, new2, new3, keep 30 + children = cg.cache.children_cache[parent] + assert np.uint64(30) in children + for nid in [new1, new2, new3]: + assert nid in children or int(nid) in children -class TestMatchByProximity: - def test_within_threshold(self): - new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) - distances_row = np.array([3.0, 15.0]) + def test_empty_old_new_map(self): + """Empty map produces no mutations.""" + cg = self._make_cg({}) + result = copy_parents_and_add_lineage(cg, operation_id=1, old_new_map={}) + assert len(result) == 0 + + def test_operation_id_stored(self): + """Each new SV mutation includes the operation_id.""" + old = np.uint64(10) + new1 = np.uint64(101) + parent = np.uint64(1000) + + parent_cells_map = {old: [_FakeCell(parent, timestamp=1)]} + children_cells_map = { + parent: [_FakeCell(np.array([old], dtype=basetypes.NODE_ID), timestamp=1)] + } + cg = self._make_cg(parent_cells_map, children_cells_map) - edges, affs, areas = _match_by_proximity( - new_ids, np.uint64(50), 0.9, 100, distances_row, threshold=10 + result = copy_parents_and_add_lineage( + cg, operation_id=99, old_new_map={old: {new1}} ) - # Only 101 is within threshold - assert len(edges) == 1 - assert np.uint64(101) in edges[0] - def test_fallback_to_closest(self): - new_ids = np.array([101, 102], dtype=basetypes.NODE_ID) - distances_row = np.array([15.0, 20.0]) # both outside threshold + # Check that mutate_row was called with OperationID=99 + calls = cg.client.mutate_row.call_args_list + op_id_found = False + from pychunkedgraph.graph import attributes - edges, affs, areas = _match_by_proximity( - new_ids, np.uint64(50), 0.9, 100, distances_row, threshold=10 - ) - # Fallback: closest = 101 - assert len(edges) == 1 - assert np.uint64(101) in edges[0] + for call in calls: + val_dict = call[0][1] if len(call[0]) > 1 else call[1].get("val_dict", {}) + if attributes.OperationLogs.OperationID in val_dict: + assert val_dict[attributes.OperationLogs.OperationID] == 99 + op_id_found = True + assert op_id_found From 919ca7371d175fb313bda041d9d8c5cb1440801e Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 14:54:19 +0000 Subject: [PATCH 184/367] chore: remove trivial and redundant tests --- pychunkedgraph/tests/graph/test_cache.py | 5 -- .../tests/graph/test_chunks_utils.py | 5 -- pychunkedgraph/tests/graph/test_cutting.py | 14 ---- pychunkedgraph/tests/graph/test_exceptions.py | 75 +------------------ pychunkedgraph/tests/graph/test_meta.py | 24 ------ pychunkedgraph/tests/graph/test_types.py | 21 +----- .../tests/ingest/test_ingest_config.py | 8 -- 7 files changed, 5 insertions(+), 147 deletions(-) diff --git a/pychunkedgraph/tests/graph/test_cache.py b/pychunkedgraph/tests/graph/test_cache.py index aab52af83..e4c68d887 100644 --- a/pychunkedgraph/tests/graph/test_cache.py +++ b/pychunkedgraph/tests/graph/test_cache.py @@ -52,11 +52,6 @@ def _build_simple_graph(self, gen_graph): add_parent_chunk(graph, 4, [0, 0, 0], n_threads=1) return graph - def test_init(self, gen_graph): - graph = self._build_simple_graph(gen_graph) - cache = CacheService(graph) - assert len(cache) == 0 - def test_len(self, gen_graph): graph = self._build_simple_graph(gen_graph) cache = CacheService(graph) diff --git a/pychunkedgraph/tests/graph/test_chunks_utils.py b/pychunkedgraph/tests/graph/test_chunks_utils.py index 5ff14e417..610c5b090 100644 --- a/pychunkedgraph/tests/graph/test_chunks_utils.py +++ b/pychunkedgraph/tests/graph/test_chunks_utils.py @@ -21,11 +21,6 @@ def test_higher_layer(self, gen_graph): class TestGetChunkLayers: - def test_empty(self, gen_graph): - graph = gen_graph(n_layers=4) - result = chunk_utils.get_chunk_layers(graph.meta, []) - assert len(result) == 0 - def test_multiple(self, gen_graph): graph = gen_graph(n_layers=4) from ..helpers import to_label diff --git a/pychunkedgraph/tests/graph/test_cutting.py b/pychunkedgraph/tests/graph/test_cutting.py index 40a1842d6..3e52887b6 100644 --- a/pychunkedgraph/tests/graph/test_cutting.py +++ b/pychunkedgraph/tests/graph/test_cutting.py @@ -13,20 +13,6 @@ from pychunkedgraph.graph.exceptions import PostconditionError, PreconditionError -class TestIsolatingCutException: - def test_is_exception_subclass(self): - """IsolatingCutException is a proper Exception subclass.""" - assert issubclass(IsolatingCutException, Exception) - - def test_can_be_raised_and_caught(self): - with pytest.raises(IsolatingCutException): - raise IsolatingCutException("Source") - - def test_message_preserved(self): - exc = IsolatingCutException("Sink") - assert str(exc) == "Sink" - - class TestMergeCrossChunkEdgesGraphTool: def test_merge_cross_chunk_edges_basic(self): """Cross-chunk edges (inf affinity) cause their endpoints to be merged. diff --git a/pychunkedgraph/tests/graph/test_exceptions.py b/pychunkedgraph/tests/graph/test_exceptions.py index 1320360f4..ebf553180 100644 --- a/pychunkedgraph/tests/graph/test_exceptions.py +++ b/pychunkedgraph/tests/graph/test_exceptions.py @@ -1,81 +1,12 @@ """Tests for pychunkedgraph.graph.exceptions""" -import pytest -from http.client import BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, CONFLICT -from http.client import INTERNAL_SERVER_ERROR, GATEWAY_TIMEOUT - -from kvdbclient.exceptions import KVDBClientError from pychunkedgraph.graph.exceptions import ( - ChunkedGraphError, - LockingError, - PreconditionError, - PostconditionError, - ChunkedGraphAPIError, - ClientError, - BadRequest, - Unauthorized, - Forbidden, - Conflict, - ServerError, - InternalServerError, - GatewayTimeout, SupervoxelSplitRequiredError, + ChunkedGraphError, ) -class TestExceptionHierarchy: - def test_base_error(self): - with pytest.raises(ChunkedGraphError): - raise ChunkedGraphError("test") - - def test_locking_error_inherits(self): - assert issubclass(LockingError, KVDBClientError) - with pytest.raises(KVDBClientError): - raise LockingError("locked") - - def test_precondition_error(self): - assert issubclass(PreconditionError, KVDBClientError) - - def test_postcondition_error(self): - assert issubclass(PostconditionError, ChunkedGraphError) - - def test_api_error_str(self): - err = ChunkedGraphAPIError("test message") - assert err.message == "test message" - assert err.status_code is None - assert "[None]: test message" == str(err) - - def test_client_error_inherits(self): - assert issubclass(ClientError, ChunkedGraphAPIError) - - def test_bad_request(self): - err = BadRequest("bad") - assert err.status_code == BAD_REQUEST - assert issubclass(BadRequest, ClientError) - - def test_unauthorized(self): - assert Unauthorized.status_code == UNAUTHORIZED - - def test_forbidden(self): - assert Forbidden.status_code == FORBIDDEN - - def test_conflict(self): - assert Conflict.status_code == CONFLICT - - def test_server_error_inherits(self): - assert issubclass(ServerError, ChunkedGraphAPIError) - - def test_internal_server_error(self): - assert InternalServerError.status_code == INTERNAL_SERVER_ERROR - - def test_gateway_timeout(self): - assert GatewayTimeout.status_code == GATEWAY_TIMEOUT - - class TestSupervoxelSplitRequiredError: - def test_inherits_chunkedgraph_error(self): - assert issubclass(SupervoxelSplitRequiredError, ChunkedGraphError) - def test_stores_sv_remapping(self): remap = {1: 10, 2: 20} err = SupervoxelSplitRequiredError("split needed", remap) @@ -91,5 +22,7 @@ def test_operation_id_default_none(self): assert err.operation_id is None def test_can_be_caught_as_chunkedgraph_error(self): - with pytest.raises(ChunkedGraphError): + try: raise SupervoxelSplitRequiredError("test", {1: 2}) + except ChunkedGraphError as e: + assert e.sv_remapping == {1: 2} diff --git a/pychunkedgraph/tests/graph/test_meta.py b/pychunkedgraph/tests/graph/test_meta.py index 999db2234..50d291479 100644 --- a/pychunkedgraph/tests/graph/test_meta.py +++ b/pychunkedgraph/tests/graph/test_meta.py @@ -38,10 +38,6 @@ def test_bitmasks(self, gen_graph): assert 1 in bm assert 2 in bm - def test_read_only_default(self, gen_graph): - graph = gen_graph(n_layers=4) - assert graph.meta.READ_ONLY is False - def test_is_out_of_bounds(self, gen_graph): graph = gen_graph(n_layers=4) meta = graph.meta @@ -100,26 +96,6 @@ def test_v4(self): assert dtype == np.float32 -class TestDataSourceDefaults: - def test_defaults(self): - ds = DataSource() - assert ds.EDGES is None - assert ds.COMPONENTS is None - assert ds.WATERSHED is None - assert ds.DATA_VERSION is None - assert ds.CV_MIP == 0 - - -class TestGraphConfigDefaults: - def test_defaults(self): - gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) - assert gc.FANOUT == 2 - assert gc.LAYER_ID_BITS == 8 - assert gc.SPATIAL_BITS == 10 - assert gc.OVERWRITE is False - assert gc.ROOT_COUNTERS == 8 - - class TestResolutionProperty: def test_resolution_returns_numpy_array(self, gen_graph): """meta.resolution should delegate to ws_cv.resolution and return a numpy array.""" diff --git a/pychunkedgraph/tests/graph/test_types.py b/pychunkedgraph/tests/graph/test_types.py index ec7bb1851..9bc46108b 100644 --- a/pychunkedgraph/tests/graph/test_types.py +++ b/pychunkedgraph/tests/graph/test_types.py @@ -2,30 +2,11 @@ import numpy as np -from pychunkedgraph.graph.types import empty_1d, empty_2d, Agglomeration +from pychunkedgraph.graph.types import Agglomeration from pychunkedgraph.graph import basetypes -class TestEmptyArrays: - def test_empty_1d_shape_and_dtype(self): - assert empty_1d.shape == (0,) - assert empty_1d.dtype == basetypes.NODE_ID - - def test_empty_2d_shape_and_dtype(self): - assert empty_2d.shape == (0, 2) - assert empty_2d.dtype == basetypes.NODE_ID - - class TestAgglomeration: - def test_defaults(self): - agg = Agglomeration(node_id=np.uint64(1)) - assert agg.node_id == np.uint64(1) - assert agg.supervoxels.shape == (0,) - assert agg.in_edges.shape == (0, 2) - assert agg.out_edges.shape == (0, 2) - assert agg.cross_edges.shape == (0, 2) - assert agg.cross_edges_d == {} - def test_custom_fields(self): svs = np.array([10, 20], dtype=basetypes.NODE_ID) agg = Agglomeration(node_id=np.uint64(5), supervoxels=svs) diff --git a/pychunkedgraph/tests/ingest/test_ingest_config.py b/pychunkedgraph/tests/ingest/test_ingest_config.py index f068f5da1..e6d30c657 100644 --- a/pychunkedgraph/tests/ingest/test_ingest_config.py +++ b/pychunkedgraph/tests/ingest/test_ingest_config.py @@ -4,14 +4,6 @@ class TestIngestConfig: - def test_defaults(self): - config = IngestConfig() - assert config.AGGLOMERATION is None - assert config.WATERSHED is None - assert config.USE_RAW_EDGES is False - assert config.USE_RAW_COMPONENTS is False - assert config.TEST_RUN is False - def test_custom_values(self): config = IngestConfig( AGGLOMERATION="gs://bucket/agg", From 6d688f31ce54916a1945ee33b4a859f399545b77 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 15:17:21 +0000 Subject: [PATCH 185/367] test the right error is raised for ocdbt vs precomputed seg --- codecov.yml | 2 ++ pychunkedgraph/tests/graph/test_cutting.py | 38 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/codecov.yml b/codecov.yml index 92e9570d2..fc04b242e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,9 +7,11 @@ coverage: default: target: auto threshold: 1% + informational: true patch: default: target: 1% + informational: true comment: layout: "reach,diff,flags,files" diff --git a/pychunkedgraph/tests/graph/test_cutting.py b/pychunkedgraph/tests/graph/test_cutting.py index 3e52887b6..89cf4969d 100644 --- a/pychunkedgraph/tests/graph/test_cutting.py +++ b/pychunkedgraph/tests/graph/test_cutting.py @@ -1402,3 +1402,41 @@ def test_overlap_with_split_preview(self): assert isinstance(supervoxel_ccs, list) assert len(supervoxel_ccs) >= 2 assert isinstance(illegal_split, bool) + + +class TestSvSplitSupportedFlag: + """Test that sv_split_supported controls exception type.""" + + def test_overlap_raises_precondition_when_unsupported(self): + """Non-ocdbt graphs raise PreconditionError on source/sink overlap.""" + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, np.inf, 0.5], dtype=np.float32) + sources = np.array([2], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + with pytest.raises(PreconditionError, match="cross-chunk edge representative"): + LocalMincutGraph( + edges, + affs, + sources, + sinks, + sv_split_supported=False, + ) + + def test_overlap_raises_sv_split_when_supported(self): + """OCDBT graphs raise SupervoxelSplitRequiredError on source/sink overlap.""" + from pychunkedgraph.graph.exceptions import SupervoxelSplitRequiredError + + edges = np.array([[1, 2], [2, 3], [3, 4]], dtype=np.uint64) + affs = np.array([0.5, np.inf, 0.5], dtype=np.float32) + sources = np.array([2], dtype=np.uint64) + sinks = np.array([3], dtype=np.uint64) + + with pytest.raises(SupervoxelSplitRequiredError): + LocalMincutGraph( + edges, + affs, + sources, + sinks, + sv_split_supported=True, + ) From 48d56672786389da36e9856f575f826fa5714568 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 16:43:24 +0000 Subject: [PATCH 186/367] fix: _expand_partners set type crash, validation checks labels not fragment count, multi-SV tests --- pychunkedgraph/graph/edges_sv.py | 78 ++++----- pychunkedgraph/tests/graph/test_edges_sv.py | 175 ++++++++++++-------- 2 files changed, 148 insertions(+), 105 deletions(-) diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index 01f7baafe..7953db753 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -120,7 +120,10 @@ def _match_partner( def _expand_partners(active_partners, active_affs, active_areas, old_new_map): """If a partner was also split, expand it to its new fragment IDs.""" - remapped_lists = [old_new_map.get(p, [p]) for p in active_partners] + remapped_lists = [ + np.asarray(list(old_new_map.get(p, {p})), dtype=np.uint64) + for p in active_partners + ] if not remapped_lists: return ( [], @@ -250,16 +253,13 @@ def _get_new_edges( return edges_, all_affs[idx], all_areas[idx] -def validate_split_edges(edges, affinities, old_new_map): +def validate_split_edges(edges, affinities, old_new_map, new_id_label_map=None): """Validate edge routing results before writing to prevent graph corruption. Checks: - A. No inf-broadcast to unsplit partners — inf-aff edges represent cross-chunk - identity (same logical SV across chunk boundaries). After splitting SV A into - fragments A1, A2, only one fragment's voxels physically touch partner B at - the chunk boundary. If multiple fragments connect via inf to the same unsplit - partner, merge_cross_chunk_edges_graph_tool merges them all into one - representative, making the split uncuttable by mincut. + A. No cross-label inf bridges — if an unsplit partner connects via inf edges + to fragments with different labels (different sides of the split), that + creates an uncuttable bridge through mincut. B. No self-loops. C. All old SVs have replacement edges from their fragments. D. Inter-fragment edges exist between all fragment pairs. @@ -278,34 +278,36 @@ def validate_split_edges(edges, affinities, old_new_map): if self_loops.any(): raise PostconditionError(f"Self-loop edges detected: {edges[self_loops]}") - # A. No inf-broadcast to unsplit partners - inf_mask = np.isinf(affinities) - if inf_mask.any(): - inf_edges = edges[inf_mask] - is_frag_0 = np.isin(inf_edges[:, 0], all_new_ids_arr) - is_frag_1 = np.isin(inf_edges[:, 1], all_new_ids_arr) - # Edges where exactly one endpoint is a fragment, other is unsplit partner - mixed_mask = is_frag_0 ^ is_frag_1 - if mixed_mask.any(): - mixed = inf_edges[mixed_mask] - mixed_frag0 = is_frag_0[mixed_mask] - # Extract partner and fragment columns - partners = np.where(mixed_frag0, mixed[:, 1], mixed[:, 0]) - fragments = np.where(mixed_frag0, mixed[:, 0], mixed[:, 1]) - # Exclude partners that are also new fragments (split partners) - unsplit_mask = ~np.isin(partners, all_new_ids_arr) - if unsplit_mask.any(): - unsplit_partners = partners[unsplit_mask] - unsplit_fragments = fragments[unsplit_mask] - # For each unique unsplit partner, count distinct fragments - for p in np.unique(unsplit_partners): - n_frags = len(np.unique(unsplit_fragments[unsplit_partners == p])) - if n_frags > 1: - raise PostconditionError( - f"Inf-affinity edge to unsplit partner {p} connects " - f"{n_frags} fragments. " - f"Must connect to exactly 1 to prevent uncuttable bridges." - ) + # A. No cross-label inf bridges to unsplit partners + if new_id_label_map: + inf_mask = np.isinf(affinities) + if inf_mask.any(): + inf_edges = edges[inf_mask] + is_frag_0 = np.isin(inf_edges[:, 0], all_new_ids_arr) + is_frag_1 = np.isin(inf_edges[:, 1], all_new_ids_arr) + mixed_mask = is_frag_0 ^ is_frag_1 + if mixed_mask.any(): + mixed = inf_edges[mixed_mask] + mixed_frag0 = is_frag_0[mixed_mask] + partners = np.where(mixed_frag0, mixed[:, 1], mixed[:, 0]) + fragments = np.where(mixed_frag0, mixed[:, 0], mixed[:, 1]) + unsplit_mask = ~np.isin(partners, all_new_ids_arr) + if unsplit_mask.any(): + unsplit_partners = partners[unsplit_mask] + unsplit_fragments = fragments[unsplit_mask] + for p in np.unique(unsplit_partners): + p_frags = unsplit_fragments[unsplit_partners == p] + labels = { + new_id_label_map[int(f)] + for f in p_frags + if int(f) in new_id_label_map + } + if len(labels) > 1: + raise PostconditionError( + f"Inf-affinity edge to unsplit partner {p} bridges " + f"fragments with different labels {labels}. " + f"This creates an uncuttable bridge in mincut." + ) # C. All old SVs have replacement edges edge_svs = np.unique(edges.ravel()) @@ -378,7 +380,7 @@ def update_edges( new_id_label_map, threshold=cg.meta.sv_split_threshold, ) - validate_split_edges(result[0], result[1], old_new_map) + validate_split_edges(result[0], result[1], old_new_map, new_id_label_map) return result @@ -409,5 +411,5 @@ def add_new_edges(cg: "ChunkedGraph", edges_tuple: tuple, time_stamp: datetime = time_stamp=time_stamp, ) ) - logger.note(f"writing {edges[mask].shape} edges to {chunk_id}") + # logger.note(f"writing {edges[mask].shape} edges to {chunk_id}") return rows diff --git a/pychunkedgraph/tests/graph/test_edges_sv.py b/pychunkedgraph/tests/graph/test_edges_sv.py index bc7e8c71b..65850eaf5 100644 --- a/pychunkedgraph/tests/graph/test_edges_sv.py +++ b/pychunkedgraph/tests/graph/test_edges_sv.py @@ -291,7 +291,7 @@ def test_partner_also_split(self): partners = np.array([np.uint64(50)]) affs = np.array([0.9]) areas = np.array([100]) - old_new_map = {np.uint64(50): [np.uint64(501), np.uint64(502)]} + old_new_map = {np.uint64(50): {np.uint64(501), np.uint64(502)}} expanded_partners, expanded_affs, expanded_areas = _expand_partners( partners, @@ -390,103 +390,144 @@ def test_inter_fragment_edges_three_way(self): # Validation # ============================================================ class TestValidateSplitEdges: - def test_valid_edges_pass(self): - """Well-formed edges pass validation without error.""" - n1, n2 = np.uint64(101), np.uint64(102) - partner = np.uint64(50) - old_new_map = {np.uint64(10): {n1, n2}} + """All tests use multi-SV old_new_map to match production scenarios.""" - edges = np.array( - [ - [n1, partner], - [n1, n2], - ], - dtype=basetypes.NODE_ID, + def _make_multi_sv_map(self): + """Two old SVs split into 2 fragments each.""" + return { + np.uint64(10): {np.uint64(101), np.uint64(102)}, + np.uint64(20): {np.uint64(201), np.uint64(202)}, + } + + def _make_valid_edges(self, old_new_map, extra_edges=None, extra_affs=None): + """Build a valid edge set: inter-fragment + cross-SV finite edges.""" + edge_list = [] + aff_list = [] + # Inter-fragment edges for each old SV + for new_ids in old_new_map.values(): + ids = sorted(new_ids) + for i in range(len(ids)): + for j in range(i + 1, len(ids)): + edge_list.append([ids[i], ids[j]]) + aff_list.append(0.001) + # Cross-SV edges (fragments from different old SVs connected) + all_frags = [list(v) for v in old_new_map.values()] + if len(all_frags) > 1: + edge_list.append([sorted(all_frags[0])[0], sorted(all_frags[1])[0]]) + aff_list.append(0.5) + if extra_edges is not None: + edge_list.extend(extra_edges) + aff_list.extend(extra_affs) + return ( + np.array(edge_list, dtype=basetypes.NODE_ID), + np.array(aff_list, dtype=basetypes.EDGE_AFFINITY), ) - affs = np.array([np.inf, 0.001], dtype=basetypes.EDGE_AFFINITY) - validate_split_edges(edges, affs, old_new_map) # should not raise + def test_valid_multi_sv_edges_pass(self): + """Well-formed edges with multiple split SVs pass validation.""" + old_new_map = self._make_multi_sv_map() + partner = np.uint64(50) + edges, affs = self._make_valid_edges( + old_new_map, + extra_edges=[[101, partner]], + extra_affs=[np.inf], + ) + validate_split_edges(edges, affs, old_new_map) - def test_catches_inf_broadcast(self): - """Validation rejects inf edges from multiple fragments to same unsplit partner.""" - n1, n2 = np.uint64(101), np.uint64(102) + def test_unsplit_partner_inf_to_fragments_from_different_old_svs(self): + """Unsplit partner connecting via inf to fragments from different old SVs is valid.""" + old_new_map = self._make_multi_sv_map() + label_map = {101: 0, 102: 1, 201: 0, 202: 1} partner = np.uint64(50) - old_new_map = {np.uint64(10): {n1, n2}} + # Partner connects to one fragment from each old SV — different old SVs, same label + edges, affs = self._make_valid_edges( + old_new_map, + extra_edges=[[101, partner], [201, partner]], + extra_affs=[np.inf, np.inf], + ) + validate_split_edges(edges, affs, old_new_map, label_map) - edges = np.array( - [ - [n1, partner], - [n2, partner], - [n1, n2], - ], - dtype=basetypes.NODE_ID, + def test_allows_same_label_inf_to_unsplit_partner(self): + """Multiple fragments with same label connecting to unsplit partner is valid.""" + old_new_map = self._make_multi_sv_map() + label_map = {101: 0, 102: 0, 201: 0, 202: 1} + partner = np.uint64(50) + edges, affs = self._make_valid_edges( + old_new_map, + extra_edges=[[101, partner], [102, partner]], + extra_affs=[np.inf, np.inf], ) - affs = np.array([np.inf, np.inf, 0.001], dtype=basetypes.EDGE_AFFINITY) + validate_split_edges(edges, affs, old_new_map, label_map) - with pytest.raises(PostconditionError, match="unsplit partner"): - validate_split_edges(edges, affs, old_new_map) + def test_catches_cross_label_inf_bridge(self): + """Fragments with different labels connecting to unsplit partner via inf is invalid.""" + old_new_map = self._make_multi_sv_map() + label_map = {101: 0, 102: 1, 201: 0, 202: 1} + partner = np.uint64(50) + edges, affs = self._make_valid_edges( + old_new_map, + extra_edges=[[101, partner], [102, partner]], + extra_affs=[np.inf, np.inf], + ) + with pytest.raises(PostconditionError, match="different labels"): + validate_split_edges(edges, affs, old_new_map, label_map) + + def test_no_label_map_skips_inf_check(self): + """Without label map, inf check is skipped (no false positives).""" + old_new_map = self._make_multi_sv_map() + partner = np.uint64(50) + edges, affs = self._make_valid_edges( + old_new_map, + extra_edges=[[101, partner], [102, partner]], + extra_affs=[np.inf, np.inf], + ) + validate_split_edges(edges, affs, old_new_map) # no label_map, should not raise def test_catches_self_loop(self): """Validation rejects self-loop edges.""" - n1, n2 = np.uint64(101), np.uint64(102) - old_new_map = {np.uint64(10): {n1, n2}} - - edges = np.array([[n1, n1], [n1, n2]], dtype=basetypes.NODE_ID) - affs = np.array([0.5, 0.001], dtype=basetypes.EDGE_AFFINITY) - + old_new_map = self._make_multi_sv_map() + edges, affs = self._make_valid_edges( + old_new_map, + extra_edges=[[101, 101]], + extra_affs=[0.5], + ) with pytest.raises(PostconditionError, match="Self-loop"): validate_split_edges(edges, affs, old_new_map) def test_catches_missing_fragment_edges(self): """Validation rejects missing inter-fragment edges.""" - n1, n2 = np.uint64(101), np.uint64(102) - partner = np.uint64(50) - old_new_map = {np.uint64(10): {n1, n2}} - - # Missing inter-fragment edge between n1 and n2 - edges = np.array([[n1, partner]], dtype=basetypes.NODE_ID) - affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) - + old_new_map = self._make_multi_sv_map() + # Only include inter-fragment for second old SV, missing first + edges = np.array([[201, 202], [101, 201]], dtype=basetypes.NODE_ID) + affs = np.array([0.001, 0.5], dtype=basetypes.EDGE_AFFINITY) with pytest.raises(PostconditionError, match="Missing inter-fragment"): validate_split_edges(edges, affs, old_new_map) def test_catches_missing_replacement_edges(self): """Validation rejects old SV with no edges from any fragment.""" - n1, n2 = np.uint64(101), np.uint64(102) - n3, n4 = np.uint64(201), np.uint64(202) - old_new_map = {np.uint64(10): {n1, n2}, np.uint64(20): {n3, n4}} - - # Only edges for first old SV's fragments, none for second - edges = np.array([[n1, n2]], dtype=basetypes.NODE_ID) + old_new_map = self._make_multi_sv_map() + # Only edges for first old SV's fragments + edges = np.array([[101, 102]], dtype=basetypes.NODE_ID) affs = np.array([0.001], dtype=basetypes.EDGE_AFFINITY) - with pytest.raises(PostconditionError, match="no replacement edges"): validate_split_edges(edges, affs, old_new_map) def test_empty_edges_pass(self): - """Empty edge set passes validation (nothing to validate).""" + """Empty edge set passes validation.""" validate_split_edges( np.array([], dtype=basetypes.NODE_ID).reshape(0, 2), np.array([], dtype=basetypes.EDGE_AFFINITY), {}, ) - def test_inf_to_split_partner_allowed_multiple(self): + def test_inf_to_split_partner_allowed(self): """Inf edges to a split partner (in all_new_ids) are allowed from multiple fragments.""" - n1, n2 = np.uint64(101), np.uint64(102) - # partner 201 is also a new fragment (split partner) - partner = np.uint64(201) - old_new_map = {np.uint64(10): {n1, n2}, np.uint64(20): {partner}} - - edges = np.array( - [ - [n1, partner], - [n2, partner], - [n1, n2], - ], - dtype=basetypes.NODE_ID, + old_new_map = self._make_multi_sv_map() + label_map = {101: 0, 102: 1, 201: 0, 202: 1} + # 201 is a split partner (in all_new_ids), so inf from both 101 and 102 is fine + edges, affs = self._make_valid_edges( + old_new_map, + extra_edges=[[101, 201], [102, 201]], + extra_affs=[np.inf, np.inf], ) - affs = np.array([np.inf, np.inf, 0.001], dtype=basetypes.EDGE_AFFINITY) - - # Should NOT raise — partner is also split (in all_new_ids) - validate_split_edges(edges, affs, old_new_map) + validate_split_edges(edges, affs, old_new_map, label_map) From 497cef01101a9692c3fa15ed7315aca6655ec182 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 16:44:41 +0000 Subject: [PATCH 187/367] add pcg logger with timing for sv split flow: whole sv, seg read, split, chunk updates, edge update, write --- pychunkedgraph/app/segmentation/common.py | 19 ++++++++++++------- pychunkedgraph/graph/edits_sv.py | 21 +++++++++++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 695d5aef9..457865834 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -13,7 +13,9 @@ from flask import current_app, g, jsonify, make_response, request from pytz import UTC -from pychunkedgraph import __version__ +from pychunkedgraph import __version__, get_logger + +logger = get_logger(__name__) from pychunkedgraph.app import app_utils from pychunkedgraph.graph import attributes, cutting, segmenthistory, ChunkedGraph from pychunkedgraph.graph import ( @@ -23,7 +25,6 @@ exceptions as cg_exceptions, ) from pychunkedgraph.graph.analysis import pathing -from pychunkedgraph.graph.attributes import OperationLogs from pychunkedgraph.graph.edits_sv import split_supervoxel from pychunkedgraph.graph.misc import get_contact_sites from pychunkedgraph.graph.operation import GraphEditOperation @@ -444,7 +445,8 @@ def handle_split(table_id): cg = app_utils.get_cg(table_id, skip_cache=True) current_app.logger.debug(data) sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) - current_app.logger.info(f"sv_lookup pre-split: sources={sources}, sinks={sinks}") + logger.note(f"pre-split: sources={sources}, sinks={sinks}") + t0 = time.time() try: ret = cg.remove_edges( user_id=user_id, @@ -454,8 +456,9 @@ def handle_split(table_id): sink_coords=sink_coords, mincut=mincut, ) + logger.note(f"remove_edges ({time.time() - t0:.2f}s)") except cg_exceptions.SupervoxelSplitRequiredError as e: - current_app.logger.info(e) + logger.note(f"sv split required ({time.time() - t0:.2f}s): {e}") sources_remapped = fastremap.remap( sources, e.sv_remapping, @@ -469,6 +472,7 @@ def handle_split(table_id): in_place=False, ) overlap_mask = np.isin(sources_remapped, sinks_remapped) + t1 = time.time() for sv_to_split in np.unique(sources_remapped[overlap_mask]): _mask0 = sources_remapped == sv_to_split _mask1 = sinks_remapped == sv_to_split @@ -479,11 +483,11 @@ def handle_split(table_id): sink_coords[_mask1], e.operation_id, ) + logger.note(f"sv splits done ({time.time() - t1:.2f}s)") sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) - current_app.logger.info( - f"sv_lookup post-split: sources={sources}, sinks={sinks}" - ) + logger.note(f"post-split: sources={sources}, sinks={sinks}") + t1 = time.time() ret = cg.remove_edges( user_id=user_id, source_ids=sources, @@ -492,6 +496,7 @@ def handle_split(table_id): sink_coords=sink_coords, mincut=mincut, ) + logger.note(f"remove_edges after sv split ({time.time() - t1:.2f}s)") except cg_exceptions.LockingError as e: raise cg_exceptions.InternalServerError(e) except cg_exceptions.PreconditionError as e: diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index ed63f8b8e..6dbf9a0fe 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -2,6 +2,7 @@ Manage new supervoxels after a supervoxel split. """ +import time import multiprocessing as mp from datetime import datetime from collections import defaultdict, deque @@ -154,16 +155,23 @@ def split_supervoxel( logger.note(f"chunk and padding {chunk_size}; {_padding}") logger.note(f"bbox and chunk min max {(bbs, bbe)}; {(chunk_min, chunk_max)}") + t0 = time.time() cut_supervoxels = _get_whole_sv(cg, sv_id, min_coord=chunk_min, max_coord=chunk_max) supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) - logger.note(f"whole sv {sv_id} -> {supervoxel_ids.tolist()}") + logger.note( + f"whole sv {sv_id} -> {supervoxel_ids.tolist()} ({time.time() - t0:.2f}s)" + ) # one voxel overlap for neighbors bbs_ = np.clip(bbs - 1, vol_start, vol_end) bbe_ = np.clip(bbe + 1, vol_start, vol_end) + t0 = time.time() seg = get_local_segmentation(cg.meta, bbs_, bbe_).squeeze() + logger.note(f"segmentation read {seg.shape} ({time.time() - t0:.2f}s)") + binary_seg = np.isin(seg, supervoxel_ids) voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) + t0 = time.time() split_result = split_supervoxel_helper( binary_seg[voxel_overlap_crop], source_coords - bbs, @@ -171,16 +179,18 @@ def split_supervoxel( cg.meta.resolution, verbose=verbose, ) - logger.note(f"split_result: {split_result.shape}") + logger.note(f"split computation {split_result.shape} ({time.time() - t0:.2f}s)") chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) tasks = [ (cg.graph_id, *item, seg[voxel_overlap_crop], split_result, bbs) for item in chunks_bbox_map.items() ] - logger.note(f"tasks count: {len(tasks)}") + t0 = time.time() with mp.Pool() as pool: results = [*tqdm(pool.imap_unordered(_update_chunk, tasks), total=len(tasks))] + logger.note(f"chunk updates {len(tasks)} tasks ({time.time() - t0:.2f}s)") + seg_cropped = seg[voxel_overlap_crop].copy() new_seg, old_new_map, slices, new_id_label_map = _parse_results( results, seg_cropped, bbs, bbe @@ -196,6 +206,7 @@ def split_supervoxel( seg[~root_mask] = 0 sv_ids = fastremap.unique(seg) seg[voxel_overlap_crop] = new_seg + t0 = time.time() edges_tuple = update_edges( cg, root, @@ -204,14 +215,16 @@ def split_supervoxel( old_new_map, new_id_label_map, ) + logger.note(f"edge update ({time.time() - t0:.2f}s)") rows0 = copy_parents_and_add_lineage(cg, operation_id, old_new_map) rows1 = add_new_edges(cg, edges_tuple, time_stamp=time_stamp) rows = rows0 + rows1 - logger.note(f"{operation_id}: writing {len(rows)} new rows") + t0 = time.time() cg.meta.ws_ocdbt[slices] = new_seg[..., np.newaxis] cg.client.write(rows) + logger.note(f"write seg + {len(rows)} rows ({time.time() - t0:.2f}s)") return old_new_map, edges_tuple From 6af123b51328f64adb3c96463c6823b86457c52b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 16:44:49 +0000 Subject: [PATCH 188/367] add sv_split debug utilities: subgraph edges, inf bridges, L2 children checks --- pychunkedgraph/debug/sv_split.py | 151 +++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 pychunkedgraph/debug/sv_split.py diff --git a/pychunkedgraph/debug/sv_split.py b/pychunkedgraph/debug/sv_split.py new file mode 100644 index 000000000..43c9454ac --- /dev/null +++ b/pychunkedgraph/debug/sv_split.py @@ -0,0 +1,151 @@ +"""Debug utilities for supervoxel splitting.""" + +from functools import reduce + +import numpy as np +import fastremap + +from ..app.segmentation.common import _get_sources_and_sinks as get_sources_and_sinks +from ..graph.chunkedgraph import ChunkedGraph +from ..graph.edges import Edges + + +def get_subgraph_edges(cg: ChunkedGraph, root_id, bbox): + """Fetch subgraph edges and return deduplicated (pairs, affinities, areas).""" + _, edges_tuple = cg.get_subgraph(root_id, bbox=bbox, bbox_is_coordinate=True) + edges_all = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) + pairs = edges_all.get_pairs() + affs = edges_all.affinities + areas = edges_all.areas + + pairs_sorted = np.sort(pairs, axis=1) + _, idx = np.unique(pairs_sorted, axis=0, return_index=True) + idx = idx[pairs_sorted[idx, 0] != pairs_sorted[idx, 1]] + return pairs[idx], affs[idx], areas[idx] + + +def compute_bbox(source_coords, sink_coords, padding=240): + """Compute bounding box from source/sink coordinates with padding.""" + all_coords = np.concatenate([source_coords, sink_coords]) + return np.array( + [np.min(all_coords, axis=0) - padding, np.max(all_coords, axis=0) + padding], + dtype=int, + ) + + +def inspect_sv_edges(cg: ChunkedGraph, svs, bbox): + """Show edge counts and inf-affinity edges for given SVs within a bbox.""" + root = cg.get_root(svs[0]) + pairs, affs, _ = get_subgraph_edges(cg, root, bbox) + + print(f"root: {root}") + print(f"total edges: {len(pairs)}") + print(f"inf-affinity edges: {np.sum(np.isinf(affs))}") + print() + + for sv in svs: + m = np.any(pairs == sv, axis=1) + if m.any(): + inf_m = np.isinf(affs[m]) + print(f" SV {sv}: {m.sum()} edges, {inf_m.sum()} inf-aff") + else: + print(f" SV {sv}: no edges in subgraph") + + return pairs, affs + + +def find_inf_bridges(pairs, affs, source_svs, sink_svs): + """Find partners connected by inf edges to both source and sink sides.""" + source_set = set(np.asarray(source_svs, dtype=np.uint64).tolist()) + sink_set = set(np.asarray(sink_svs, dtype=np.uint64).tolist()) + all_split = source_set | sink_set + + inf_mask = np.isinf(affs) + inf_pairs = pairs[inf_mask] + if len(inf_pairs) == 0: + print("no inf-affinity edges") + return {} + + partner_sides = {} + for a, b in inf_pairs: + a, b = int(a), int(b) + for sv, other in [(a, b), (b, a)]: + if other in all_split: + continue + if sv in source_set: + partner_sides.setdefault(other, set()).add("src") + elif sv in sink_set: + partner_sides.setdefault(other, set()).add("sink") + + bridges = {k: v for k, v in partner_sides.items() if len(v) > 1} + if bridges: + print(f"inf-aff bridge partners (connected to both sides): {len(bridges)}") + for p, sides in bridges.items(): + print(f" {p}: {sides}") + else: + print("no inf-aff bridge partners") + return bridges + + +def check_l2_children(cg: ChunkedGraph, data: dict, svs): + """Check L2 children for stale/new SVs after a split.""" + original_svs = set(int(node[0]) for k in ["sources", "sinks"] for node in data[k]) + post_svs = set(int(s) for s in svs) + stale = original_svs - post_svs + new = post_svs - original_svs + + l2ids = cg.get_parents(np.asarray(list(post_svs), dtype=np.uint64)) + l2_children = cg.get_children(np.unique(l2ids)) + + issues = [] + for l2id, children in l2_children.items(): + children_set = set(int(c) for c in children) + stale_found = stale & children_set + new_found = new & children_set + if stale_found: + print(f" L2 {l2id}: STALE old SVs: {stale_found}") + issues.append(("stale", l2id, stale_found)) + if new_found: + print(f" L2 {l2id}: new SVs present: {new_found}") + + if not issues: + print(" no stale SVs found in L2 children") + return stale, new, issues + + +def inspect_edited_edges(cg: ChunkedGraph, svs): + """Show edges from edits for L2 chunks containing the given SVs.""" + l2ids = cg.get_parents(np.asarray(svs, dtype=np.uint64)) + l2chunks = cg.get_chunk_ids_from_node_ids(np.unique(l2ids)) + fedges = cg.get_edges_from_edits(l2chunks) + for k, v in fedges.items(): + unique_pairs = np.unique(v.get_pairs(), axis=0) + print(f" chunk {k}: {unique_pairs.shape[0]} unique edge pairs") + return fedges + + +def inspect_split(cg: ChunkedGraph, data: dict): + """Full diagnostic for a split request: edges, inf bridges, L2 state.""" + sources, sinks, src_coords, snk_coords = get_sources_and_sinks(cg, data) + all_svs = np.concatenate([sources, sinks]) + bbox = compute_bbox(src_coords, snk_coords) + + print("=== Sources & Sinks ===") + print(f" sources: {sources}") + print(f" sinks: {sinks}") + print() + + print("=== Edited Edges ===") + inspect_edited_edges(cg, all_svs) + print() + + print("=== Subgraph Edges ===") + pairs, affs = inspect_sv_edges(cg, all_svs, bbox) + print() + + print("=== Inf-aff Bridges ===") + find_inf_bridges(pairs, affs, sources, sinks) + print() + + print("=== L2 Children ===") + check_l2_children(cg, data, all_svs) From 632ae4d908efb973e0a5132a414b7174d3431879 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 18:57:38 +0000 Subject: [PATCH 189/367] perf: lazy kdtree distances, single-process chunk updates, add timing instrumentation --- pychunkedgraph/graph/cutting_sv.py | 38 ++++++++++++ pychunkedgraph/graph/edges_sv.py | 69 ++++++++++++++-------- pychunkedgraph/graph/edits_sv.py | 94 +++++++++++++++--------------- 3 files changed, 129 insertions(+), 72 deletions(-) diff --git a/pychunkedgraph/graph/cutting_sv.py b/pychunkedgraph/graph/cutting_sv.py index bafd86c68..d1869951c 100644 --- a/pychunkedgraph/graph/cutting_sv.py +++ b/pychunkedgraph/graph/cutting_sv.py @@ -1159,6 +1159,44 @@ def build_kdtrees_by_label( return trees, counts +def build_coords_by_label( + vol: np.ndarray, + *, + background: int = 0, + min_points: int = 1, + dtype: np.dtype = np.float32, +) -> Dict[int, np.ndarray]: + """Group voxel coordinates by label without building kdtrees. + + Returns mapping label -> (N, 3) coordinate array in (z, y, x) order. + """ + if vol.ndim != 3: + raise ValueError("`vol` must be a 3D array.") + Z, Y, X = vol.shape + + flat = vol.ravel() + nz = np.flatnonzero(flat) if background == 0 else np.flatnonzero(flat != background) + if nz.size == 0: + return {} + + labels = flat[nz] + z, y, x = np.unravel_index(nz, (Z, Y, X)) + coords = np.column_stack((z, y, x)).astype(dtype, copy=False) + + order = np.argsort(labels, kind="mergesort") + labels_sorted = labels[order] + starts = np.flatnonzero(np.r_[True, labels_sorted[1:] != labels_sorted[:-1]]) + ends = np.r_[starts[1:], labels_sorted.size] + + result: Dict[int, np.ndarray] = {} + for s, e in zip(starts, ends): + n = e - s + if n < min_points: + continue + result[int(labels_sorted[s])] = coords[order[s:e]] + return result + + def pairwise_min_distance_two_sets( trees_a: Sequence[cKDTree], trees_b: Sequence[cKDTree], diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index 7953db753..ce282b121 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -29,6 +29,7 @@ from __future__ import annotations +import time from functools import reduce from typing import TYPE_CHECKING from datetime import datetime @@ -39,10 +40,8 @@ from pychunkedgraph import get_logger from pychunkedgraph.graph import attributes, basetypes, serializers from pychunkedgraph.graph.exceptions import PostconditionError -from pychunkedgraph.graph.cutting_sv import ( - build_kdtrees_by_label, - pairwise_min_distance_two_sets, -) +from scipy.spatial import cKDTree +from pychunkedgraph.graph.cutting_sv import build_coords_by_label from pychunkedgraph.graph.edges import Edges if TYPE_CHECKING: @@ -137,6 +136,19 @@ def _expand_partners(active_partners, active_affs, active_areas, old_new_map): return partners, affs, areas +def _compute_partner_distances(new_kdtrees, partner_coords): + """Compute min distance from each new fragment kdtree to a partner's voxel coords.""" + partner_tree = cKDTree(partner_coords) + distances = np.empty(len(new_kdtrees), dtype=float) + for i, kt in enumerate(new_kdtrees): + if kt.n <= partner_tree.n: + d, _ = partner_tree.query(kt.data, k=1, workers=-1) + else: + d, _ = kt.query(partner_tree.data, k=1, workers=-1) + distances[i] = float(np.min(d)) + return distances + + def _compute_boundary_distances(cg, new_kdtrees, partner, old_chunk, chunk_size): """Compute distance from each new fragment to a partner's chunk boundary. Used for active partners outside the bbox that have no kdtree entry. @@ -155,13 +167,12 @@ def _compute_boundary_distances(cg, new_kdtrees, partner, old_chunk, chunk_size) def _get_new_edges( edges_info: tuple, old_new_map: dict, - distances: np.ndarray, - distance_map: dict, - new_distance_map: dict, + coords_by_label: dict, root_id: basetypes.NODE_ID, sv_root_map: dict, cg: "ChunkedGraph", new_kdtrees: list, + new_ids_arr: np.ndarray, new_id_label_map: dict = None, threshold: int = 10, ): @@ -203,19 +214,19 @@ def _get_new_edges( partners[active_m], edge_affs[active_m], edge_areas[active_m], old_new_map ) if len(active_partners) > 0: - new_id_rows = np.array( - [new_distance_map[nid] for nid in new_ids], dtype=int - ) - # Precompute chunk info for boundary distance fallback + # Build kdtrees for this old SV's fragments only + frag_kdtrees = [cKDTree(coords_by_label[int(nid)]) for nid in new_ids] old_chunk = cg.get_chunk_coordinates(new_ids[0]) if cg else None chunk_size = cg.meta.graph_config.CHUNK_SIZE if cg else None for k, partner in enumerate(active_partners): - dist_col = distance_map.get(partner) - if dist_col is not None: - act_dist_row = distances[new_id_rows, dist_col] + partner_coords = coords_by_label.get(int(partner)) + if partner_coords is not None: + act_dist_row = _compute_partner_distances( + frag_kdtrees, partner_coords + ) else: act_dist_row = _compute_boundary_distances( - cg, new_kdtrees, partner, old_chunk, chunk_size + cg, frag_kdtrees, partner, old_chunk, chunk_size ) e, a, ar = _match_partner( new_ids, @@ -340,11 +351,20 @@ def update_edges( new_id_label_map: dict = None, ): old_new_map = dict(old_new_map) - kdtrees, _ = build_kdtrees_by_label(new_seg) - distance_map = {k: int(i) for k, i in zip(kdtrees.keys(), range(len(kdtrees)))} + t0 = time.time() + coords_by_label = build_coords_by_label(new_seg) + new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) + new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] + logger.note( + f"build_coords {len(coords_by_label)} labels, {len(new_ids)} fragment trees ({time.time() - t0:.2f}s)" + ) + t0 = time.time() _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) + logger.note( + f"get_subgraph {len(edges_.get_pairs())} edges ({time.time() - t0:.2f}s)" + ) edges = edges_.get_pairs() affinities = edges_.affinities @@ -358,28 +378,27 @@ def update_edges( affinities = affinities[edges_idx] areas = areas[edges_idx] - # Batch-fetch roots for all edge partners to define active vs inactive + t0 = time.time() all_edge_svs = np.unique(edges) all_roots = cg.get_roots(all_edge_svs) sv_root_map = dict(zip(all_edge_svs, all_roots)) + logger.note(f"get_roots {len(all_edge_svs)} svs ({time.time() - t0:.2f}s)") - new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) - new_kdtrees = [kdtrees[k] for k in new_ids] - new_distance_map = {k: int(i) for k, i in zip(new_ids, range(len(new_ids)))} - distances = pairwise_min_distance_two_sets(new_kdtrees, list(kdtrees.values())) + t0 = time.time() result = _get_new_edges( (edges, affinities, areas), old_new_map, - distances, - distance_map, - new_distance_map, + coords_by_label, root_id, sv_root_map, cg, new_kdtrees, + new_ids, new_id_label_map, threshold=cg.meta.sv_split_threshold, ) + logger.note(f"_get_new_edges {result[0].shape} ({time.time() - t0:.2f}s)") + validate_split_edges(result[0], result[1], old_new_map, new_id_label_map) return result diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 6dbf9a0fe..fd88d2f03 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -3,13 +3,11 @@ """ import time -import multiprocessing as mp from datetime import datetime from collections import defaultdict, deque import fastremap import numpy as np -from tqdm import tqdm from pychunkedgraph import get_logger from pychunkedgraph.graph import ( @@ -62,45 +60,48 @@ def _get_whole_sv( return explored_nodes -def _update_chunk(args): - """ - For a chunk that overlaps bounding box for supervoxel split, - If chunk contains mask for the split supervoxel, - return indices of mask, old and new supervoxel IDs from this chunk. +def _update_chunks(cg, chunks_bbox_map, seg, result_seg, bb_start): + """Process all chunks in a single pass: assign new SV IDs to split fragments. + + For each chunk overlapping the split bbox, finds split labels and + batch-allocates new IDs. No multiprocessing needed. """ - graph_id, chunk_coord, chunk_bbox, seg, result_seg, bb_start = args - cg = ChunkedGraph(graph_id=graph_id) - x, y, z = chunk_coord - chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) - - _s, _e = chunk_bbox - bb_start - og_chunk_seg = seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] - chunk_seg = result_seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] - - labels = fastremap.unique(chunk_seg[chunk_seg != 0]) - if labels.size < 2: - return None - - _indices = [] - _old_values = [] - _new_values = [] - _label_id_map = {} - for _id in labels: - _mask = chunk_seg == _id - voxel_locs = np.where(_mask) - _og_value = og_chunk_seg[voxel_locs[0][0], voxel_locs[1][0], voxel_locs[2][0]] - _index = np.column_stack(voxel_locs) - n = len(_index) - _indices.append(_index) - _old_values.append(np.full(n, _og_value, dtype=basetypes.NODE_ID)) - new_id = cg.id_client.create_node_id(chunk_id) - _new_values.append(np.full(n, new_id, dtype=basetypes.NODE_ID)) - _label_id_map[int(_id)] = new_id - - _indices = np.concatenate(_indices) + (chunk_bbox[0] - bb_start) - _old_values = np.concatenate(_old_values) - _new_values = np.concatenate(_new_values) - return (_indices, _old_values, _new_values, _label_id_map) + results = [] + for chunk_coord, chunk_bbox in chunks_bbox_map.items(): + x, y, z = chunk_coord + chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) + + _s, _e = chunk_bbox - bb_start + og_chunk_seg = seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] + chunk_seg = result_seg[_s[0] : _e[0], _s[1] : _e[1], _s[2] : _e[2]] + + labels = fastremap.unique(chunk_seg[chunk_seg != 0]) + if labels.size < 2: + continue + + new_ids = cg.id_client.create_node_ids(chunk_id, size=len(labels)) + _indices = [] + _old_values = [] + _new_values = [] + _label_id_map = {} + for _id, new_id in zip(labels, new_ids): + _mask = chunk_seg == _id + voxel_locs = np.where(_mask) + _og_value = og_chunk_seg[ + voxel_locs[0][0], voxel_locs[1][0], voxel_locs[2][0] + ] + _index = np.column_stack(voxel_locs) + n = len(_index) + _indices.append(_index) + _old_values.append(np.full(n, _og_value, dtype=basetypes.NODE_ID)) + _new_values.append(np.full(n, new_id, dtype=basetypes.NODE_ID)) + _label_id_map[int(_id)] = new_id + + _indices = np.concatenate(_indices) + (chunk_bbox[0] - bb_start) + _old_values = np.concatenate(_old_values) + _new_values = np.concatenate(_new_values) + results.append((_indices, _old_values, _new_values, _label_id_map)) + return results def _voxel_crop(bbs, bbe, bbs_, bbe_): @@ -182,14 +183,13 @@ def split_supervoxel( logger.note(f"split computation {split_result.shape} ({time.time() - t0:.2f}s)") chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) - tasks = [ - (cg.graph_id, *item, seg[voxel_overlap_crop], split_result, bbs) - for item in chunks_bbox_map.items() - ] t0 = time.time() - with mp.Pool() as pool: - results = [*tqdm(pool.imap_unordered(_update_chunk, tasks), total=len(tasks))] - logger.note(f"chunk updates {len(tasks)} tasks ({time.time() - t0:.2f}s)") + results = _update_chunks( + cg, chunks_bbox_map, seg[voxel_overlap_crop], split_result, bbs + ) + logger.note( + f"chunk updates {len(chunks_bbox_map)} chunks, {len(results)} with splits ({time.time() - t0:.2f}s)" + ) seg_cropped = seg[voxel_overlap_crop].copy() new_seg, old_new_map, slices, new_id_label_map = _parse_results( From f54f87a28efab91464ad253644ac3cb8f76fbae4 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 18:57:48 +0000 Subject: [PATCH 190/367] fix: app logger propagation blocking pcg logger in common.py --- pychunkedgraph/app/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pychunkedgraph/app/__init__.py b/pychunkedgraph/app/__init__.py index 7f5e307e8..d513f67aa 100644 --- a/pychunkedgraph/app/__init__.py +++ b/pychunkedgraph/app/__init__.py @@ -103,6 +103,16 @@ def configure_app(app): # Ensure pychunkedgraph logger always works at NOTICE level # regardless of app config or environment log level configure_logging(level=NOTICE) + # app.logger.propagate = False blocks children under pychunkedgraph.app + # from reaching the pychunkedgraph handler — attach it directly + pcg_logger = logging.getLogger("pychunkedgraph") + app_ns_logger = logging.getLogger("pychunkedgraph.app") + for h in pcg_logger.handlers: + if isinstance(h, logging.StreamHandler) and not isinstance( + h, logging.NullHandler + ): + app_ns_logger.addHandler(h) + break if app.config["USE_REDIS_JOBS"]: app.redis = redis.Redis.from_url(app.config["REDIS_URL"]) From 7e778bf578daeb07a83e046a0b9c60e74b7bdff2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 21 Mar 2026 18:58:01 +0000 Subject: [PATCH 191/367] test: all edge routing tests use multi-SV old_new_map to match production --- pychunkedgraph/tests/graph/test_edges_sv.py | 315 +++++++++++++------- 1 file changed, 214 insertions(+), 101 deletions(-) diff --git a/pychunkedgraph/tests/graph/test_edges_sv.py b/pychunkedgraph/tests/graph/test_edges_sv.py index 65850eaf5..d8ad0f2ba 100644 --- a/pychunkedgraph/tests/graph/test_edges_sv.py +++ b/pychunkedgraph/tests/graph/test_edges_sv.py @@ -2,6 +2,7 @@ import numpy as np import pytest +from scipy.spatial import cKDTree from pychunkedgraph.graph import basetypes from pychunkedgraph.graph.exceptions import PostconditionError @@ -26,28 +27,43 @@ def _root_map(same_root, other_root=()): return m +def _make_coords_and_trees(positions, old_new_map): + """Build coords_by_label and new fragment kdtrees from 1D positions. + + positions: dict mapping SV id (int) -> x position (float) + Returns (coords_by_label, new_kdtrees, new_ids_arr) + """ + coords_by_label = { + sv_id: np.array([[0, 0, pos]], dtype=np.float32) + for sv_id, pos in positions.items() + } + new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) + new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] + return coords_by_label, new_kdtrees, new_ids + + def _call_get_new_edges( edges, affinities, areas, old_new_map, - distances, - distance_map, - new_distance_map, + positions, sv_root_map, new_id_label_map=None, ): """Helper to call _get_new_edges with standard ROOT_ID and no cg/kdtrees.""" + coords_by_label, new_kdtrees, new_ids = _make_coords_and_trees( + positions, old_new_map + ) return _get_new_edges( (edges, affinities, areas), old_new_map, - distances, - distance_map, - new_distance_map, + coords_by_label, ROOT_ID, sv_root_map, None, - [], + new_kdtrees, + new_ids, new_id_label_map, ) @@ -56,6 +72,18 @@ def _call_get_new_edges( # Inf-affinity edge routing # ============================================================ class TestInfEdgeRouting: + """All tests use multi-SV old_new_map to match production.""" + + def _base_map(self): + """Second old SV always present to catch index mismatches.""" + return {np.uint64(20): {np.uint64(201), np.uint64(202)}} + + def _base_positions(self): + return {20: 500, 201: 500, 202: 600} + + def _base_roots(self): + return [20, 201, 202] + def test_inf_to_unsplit_partner_closest_only(self): """Inf edge to unsplit active partner → only closest fragment gets it.""" old = np.uint64(10) @@ -66,48 +94,37 @@ def test_inf_to_unsplit_partner_closest_only(self): affs = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) areas = np.array([0], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2}} - sv_root_map = _root_map([10, 50, 101, 102]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} - # n1 closer to partner than n2 - distances = np.array([[5.0, 2.0, 0.0, 8.0], [6.0, 9.0, 8.0, 0.0]]) + old_new_map = {old: {n1, n2}, **self._base_map()} + sv_root_map = _root_map([10, 50, 101, 102] + self._base_roots()) + positions = {10: 0, 50: 2, 101: 0, 102: 100, **self._base_positions()} result_edges, result_affs, _ = _call_get_new_edges( edges, affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, ) inf_edges = result_edges[np.isinf(result_affs)] - # Only one fragment should connect to partner via inf partner_inf = [e for e in inf_edges if partner in e] assert len(partner_inf) == 1 - assert n1 in partner_inf[0] # n1 is closer + assert n1 in partner_inf[0] def test_inf_to_split_partner_label_matched(self): """Inf edge to split partner → matched by label, not proximity.""" old = np.uint64(10) n1, n2 = np.uint64(101), np.uint64(102) - partner = np.uint64(201) # also split, label 1 + partner = np.uint64(201) edges = np.array([[10, 201]], dtype=basetypes.NODE_ID) affs = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) areas = np.array([0], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2}} - sv_root_map = _root_map([10, 101, 102, 201]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 101, 102, 201])} - - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} - - # n2 is CLOSER to partner, but wrong label - distances = np.array([[5.0, 0.0, 8.0, 9.0], [6.0, 8.0, 0.0, 2.0]]) + old_new_map = {old: {n1, n2}, np.uint64(20): {partner, np.uint64(202)}} + sv_root_map = _root_map([10, 101, 102, 201, 202, 20]) + positions = {10: 0, 101: 100, 102: 2, 201: 0, 20: 500, 202: 600} label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 1} result_edges, result_affs, _ = _call_get_new_edges( @@ -115,15 +132,12 @@ def test_inf_to_split_partner_label_matched(self): affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, label_map, ) inf_edges = result_edges[np.isinf(result_affs)] - # Should connect n1 (label 1) to partner (label 1), NOT n2 for e in inf_edges: if partner in e: assert n1 in e, f"Expected label-matched n1, got {e}" @@ -139,15 +153,9 @@ def test_inf_to_split_partner_label_fallback(self): affs = np.array([np.inf], dtype=basetypes.EDGE_AFFINITY) areas = np.array([0], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2}} - sv_root_map = _root_map([10, 101, 102, 201]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 101, 102, 201])} - - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} - - # n2 closer to partner - distances = np.array([[5.0, 0.0, 8.0, 9.0], [6.0, 8.0, 0.0, 2.0]]) - # Partner has label 3 which matches no fragment + old_new_map = {old: {n1, n2}, np.uint64(20): {partner, np.uint64(202)}} + sv_root_map = _root_map([10, 101, 102, 201, 202, 20]) + positions = {10: 0, 101: 100, 102: 2, 201: 0, 20: 500, 202: 600} label_map = {np.uint64(101): 1, np.uint64(102): 2, np.uint64(201): 3} result_edges, result_affs, _ = _call_get_new_edges( @@ -155,9 +163,7 @@ def test_inf_to_split_partner_label_fallback(self): affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, label_map, ) @@ -165,7 +171,6 @@ def test_inf_to_split_partner_label_fallback(self): inf_edges = result_edges[np.isinf(result_affs)] partner_edges = [e for e in inf_edges if partner in e] assert len(partner_edges) == 1 - # Fallback to closest → n2 assert n2 in partner_edges[0] @@ -173,6 +178,17 @@ def test_inf_to_split_partner_label_fallback(self): # Finite-affinity edge routing # ============================================================ class TestFiniteEdgeRouting: + """All tests use multi-SV old_new_map to match production.""" + + def _base_map(self): + return {np.uint64(20): {np.uint64(201), np.uint64(202)}} + + def _base_positions(self): + return {20: 500, 201: 500, 202: 600} + + def _base_roots(self): + return [20, 201, 202] + def test_finite_to_active_partner_proximity(self): """Finite edge to active partner → fragments within threshold.""" old = np.uint64(10) @@ -183,21 +199,16 @@ def test_finite_to_active_partner_proximity(self): affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) areas = np.array([100], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2}} - sv_root_map = _root_map([10, 50, 101, 102]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} - # n1 within threshold (3 < 10), n2 outside (15 > 10) - distances = np.array([[5.0, 3.0, 0.0, 8.0], [6.0, 15.0, 8.0, 0.0]]) + old_new_map = {old: {n1, n2}, **self._base_map()} + sv_root_map = _root_map([10, 50, 101, 102] + self._base_roots()) + positions = {10: 0, 50: 3, 101: 0, 102: 100, **self._base_positions()} result_edges, result_affs, _ = _call_get_new_edges( edges, affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, ) @@ -206,7 +217,6 @@ def test_finite_to_active_partner_proximity(self): for e, a in zip(result_edges, result_affs) if partner in e and not np.isinf(a) ] - # Only n1 within threshold assert len(finite_to_partner) == 1 assert n1 in finite_to_partner[0] @@ -220,21 +230,16 @@ def test_finite_to_active_partner_fallback(self): affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) areas = np.array([100], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2}} - sv_root_map = _root_map([10, 50, 101, 102]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} - # Both outside threshold - distances = np.array([[5.0, 15.0, 0.0, 8.0], [6.0, 20.0, 8.0, 0.0]]) + old_new_map = {old: {n1, n2}, **self._base_map()} + sv_root_map = _root_map([10, 50, 101, 102] + self._base_roots()) + positions = {10: 0, 50: 15, 101: 0, 102: 100, **self._base_positions()} result_edges, result_affs, _ = _call_get_new_edges( edges, affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, ) @@ -243,7 +248,6 @@ def test_finite_to_active_partner_fallback(self): for e, a in zip(result_edges, result_affs) if partner in e and not np.isinf(a) ] - # Fallback: closest (n1 at dist 15) assert len(finite_to_partner) == 1 assert n1 in finite_to_partner[0] @@ -251,28 +255,22 @@ def test_finite_to_inactive_partner_broadcast(self): """Finite edge to different-root partner → all fragments get it.""" old = np.uint64(10) n1, n2 = np.uint64(101), np.uint64(102) - partner = np.uint64(99) # different root + partner = np.uint64(99) edges = np.array([[10, 99]], dtype=basetypes.NODE_ID) affs = np.array([0.5], dtype=basetypes.EDGE_AFFINITY) areas = np.array([200], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2}} - sv_root_map = _root_map([10, 101, 102], [99]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 99, 101, 102])} - - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} - - distances = np.array([[5.0, 3.0, 0.0, 8.0], [6.0, 4.0, 8.0, 0.0]]) + old_new_map = {old: {n1, n2}, **self._base_map()} + sv_root_map = _root_map([10, 101, 102] + self._base_roots(), [99]) + positions = {10: 0, 99: 3, 101: 0, 102: 100, **self._base_positions()} result_edges, _, _ = _call_get_new_edges( edges, affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, ) @@ -305,10 +303,139 @@ def test_partner_also_split(self): assert all(a == 0.9 for a in expanded_affs) +# ============================================================ +# Multi-SV edge routing (multiple old SVs split simultaneously) +# ============================================================ +class TestMultiSVRouting: + def test_multi_sv_active_partners(self): + """Multiple old SVs split, each with active partners — no index mismatch.""" + old1, old2 = np.uint64(10), np.uint64(20) + n1, n2 = np.uint64(101), np.uint64(102) + n3, n4 = np.uint64(201), np.uint64(202) + partner = np.uint64(50) + + # Both old SVs have edges to the same active partner + edges = np.array([[10, 50], [20, 50]], dtype=basetypes.NODE_ID) + affs = np.array([0.9, 0.8], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100, 80], dtype=basetypes.EDGE_AREA) + + old_new_map = {old1: {n1, n2}, old2: {n3, n4}} + sv_root_map = _root_map([10, 20, 50, 101, 102, 201, 202]) + # partner close to n1 and n3 + positions = {10: 0, 20: 50, 50: 2, 101: 0, 102: 100, 201: 50, 202: 150} + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + positions, + sv_root_map, + ) + + # Should have edges from fragments of both old SVs to partner + partner_edges = [e for e in result_edges if partner in e] + frags_connected = { + int(e[0]) if e[1] == partner else int(e[1]) for e in partner_edges + } + # At least one fragment from each old SV connects to partner + assert frags_connected & {101, 102}, "No fragment from old1 connects to partner" + assert frags_connected & {201, 202}, "No fragment from old2 connects to partner" + + def test_multi_sv_inf_edges(self): + """Multiple old SVs with inf edges routed correctly.""" + old1, old2 = np.uint64(10), np.uint64(20) + n1, n2 = np.uint64(101), np.uint64(102) + n3, n4 = np.uint64(201), np.uint64(202) + p1, p2 = np.uint64(50), np.uint64(60) + + edges = np.array([[10, 50], [20, 60]], dtype=basetypes.NODE_ID) + affs = np.array([np.inf, np.inf], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([0, 0], dtype=basetypes.EDGE_AREA) + + old_new_map = {old1: {n1, n2}, old2: {n3, n4}} + sv_root_map = _root_map([10, 20, 50, 60, 101, 102, 201, 202]) + positions = {10: 0, 20: 50, 50: 1, 60: 51, 101: 0, 102: 100, 201: 50, 202: 150} + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + positions, + sv_root_map, + ) + + inf_edges = result_edges[np.isinf(result_affs)] + # p1 should connect to exactly 1 fragment of old1 (closest = n1) + p1_edges = [e for e in inf_edges if p1 in e] + assert len(p1_edges) == 1 + assert n1 in p1_edges[0] + # p2 should connect to exactly 1 fragment of old2 (closest = n3) + p2_edges = [e for e in inf_edges if p2 in e] + assert len(p2_edges) == 1 + assert n3 in p2_edges[0] + + def test_multi_sv_mixed_active_inactive(self): + """Multiple old SVs with both active and inactive partners.""" + old1, old2 = np.uint64(10), np.uint64(20) + n1, n2 = np.uint64(101), np.uint64(102) + n3, n4 = np.uint64(201), np.uint64(202) + active_p = np.uint64(50) + inactive_p = np.uint64(99) + + edges = np.array( + [[10, 50], [10, 99], [20, 50]], + dtype=basetypes.NODE_ID, + ) + affs = np.array([0.9, 0.5, 0.8], dtype=basetypes.EDGE_AFFINITY) + areas = np.array([100, 200, 80], dtype=basetypes.EDGE_AREA) + + old_new_map = {old1: {n1, n2}, old2: {n3, n4}} + sv_root_map = _root_map([10, 20, 50, 101, 102, 201, 202], [99]) + positions = {10: 0, 20: 50, 50: 2, 99: 5, 101: 0, 102: 100, 201: 50, 202: 150} + + result_edges, result_affs, _ = _call_get_new_edges( + edges, + affs, + areas, + old_new_map, + positions, + sv_root_map, + ) + + # Inactive partner broadcast: both n1 and n2 connect to 99 + inactive_edges = [e for e in result_edges if inactive_p in e] + inactive_frags = { + int(e[0]) if e[1] == inactive_p else int(e[1]) for e in inactive_edges + } + assert n1 in inactive_frags + assert n2 in inactive_frags + + # Active partner routed by proximity + active_edges = [ + e + for e, a in zip(result_edges, result_affs) + if active_p in e and not np.isinf(a) + ] + assert len(active_edges) >= 1 + + # ============================================================ # Fragment edges # ============================================================ class TestFragmentEdges: + """All tests use multi-SV old_new_map to match production.""" + + def _base_map(self): + return {np.uint64(20): {np.uint64(201), np.uint64(202)}} + + def _base_positions(self): + return {20: 500, 201: 500, 202: 600} + + def _base_roots(self): + return [20, 201, 202] + def test_inter_fragment_edges(self): """Two fragments → 1 low-affinity inter-fragment edge.""" old = np.uint64(10) @@ -319,20 +446,16 @@ def test_inter_fragment_edges(self): affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) areas = np.array([100], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2}} - sv_root_map = _root_map([10, 50, 101, 102]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102])} - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1} - distances = np.array([[5.0, 3.0, 0.0, 8.0], [6.0, 4.0, 8.0, 0.0]]) + old_new_map = {old: {n1, n2}, **self._base_map()} + sv_root_map = _root_map([10, 50, 101, 102] + self._base_roots()) + positions = {10: 0, 50: 3, 101: 0, 102: 100, **self._base_positions()} result_edges, result_affs, _ = _call_get_new_edges( edges, affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, ) @@ -352,28 +475,16 @@ def test_inter_fragment_edges_three_way(self): affs = np.array([0.9], dtype=basetypes.EDGE_AFFINITY) areas = np.array([100], dtype=basetypes.EDGE_AREA) - old_new_map = {old: {n1, n2, n3}} - sv_root_map = _root_map([10, 50, 101, 102, 103]) - dist_map = {np.uint64(k): i for i, k in enumerate([10, 50, 101, 102, 103])} - - new_dist_map = {np.uint64(101): 0, np.uint64(102): 1, np.uint64(103): 2} - - distances = np.array( - [ - [5.0, 3.0, 0.0, 8.0, 7.0], - [6.0, 4.0, 8.0, 0.0, 6.0], - [7.0, 5.0, 7.0, 6.0, 0.0], - ] - ) + old_new_map = {old: {n1, n2, n3}, **self._base_map()} + sv_root_map = _root_map([10, 50, 101, 102, 103] + self._base_roots()) + positions = {10: 0, 50: 3, 101: 0, 102: 50, 103: 100, **self._base_positions()} result_edges, result_affs, _ = _call_get_new_edges( edges, affs, areas, old_new_map, - distances, - dist_map, - new_dist_map, + positions, sv_root_map, ) @@ -382,8 +493,10 @@ def test_inter_fragment_edges_three_way(self): for e, a in zip(result_edges, result_affs) if a == pytest.approx(0.001) } - expected = {frozenset([n1, n2]), frozenset([n1, n3]), frozenset([n2, n3])} - assert frag_pairs == expected + expected_old1 = {frozenset([n1, n2]), frozenset([n1, n3]), frozenset([n2, n3])} + assert expected_old1.issubset(frag_pairs) + # Base map also produces its own inter-fragment edge + assert frozenset([np.uint64(201), np.uint64(202)]) in frag_pairs # ============================================================ From 5bfae6d01c088d6782b70eac2ef9ae272c7a17f0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 22 Mar 2026 00:47:10 +0000 Subject: [PATCH 192/367] fix: use sv_remapping for whole SV lookup, filter stale split edges via NewIdentity --- pychunkedgraph/graph/chunkedgraph.py | 78 +++++++++++++++++++++++----- pychunkedgraph/graph/edits_sv.py | 18 ++++++- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index c320c1bde..63791a932 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -684,14 +684,32 @@ def get_edges_from_edits( Edges stored within a pcg that were created as a result of edits. Either 'fake' edges that were adding for a merge edit; Or 'split' edges resulting from a supervoxel split. + + SplitEdges accumulate across operations (append-only, preserves history). + CompactedSplitEdges is a single cell per chunk with only currently-valid + edges — updated by add_new_edges on each SV split (reads existing, filters + out edges referencing replaced SVs, merges with new edges, overwrites). + + For current-time queries (time_stamp=None), reads CompactedSplitEdges + for O(1) cells per chunk. For historical queries, reads all SplitEdges + up to that timestamp. """ + use_compacted = time_stamp is None and self.meta.ocdbt_seg + if use_compacted: + properties = [ + attributes.Connectivity.FakeEdges, + attributes.Connectivity.CompactedSplitEdges, + attributes.Connectivity.CompactedAffinity, + attributes.Connectivity.CompactedArea, + ] + else: + properties = [ + attributes.Connectivity.FakeEdges, + attributes.Connectivity.SplitEdges, + attributes.Connectivity.Affinity, + attributes.Connectivity.Area, + ] result = {} - properties = [ - attributes.Connectivity.FakeEdges, - attributes.Connectivity.SplitEdges, - attributes.Connectivity.Affinity, - attributes.Connectivity.Area, - ] _edges_d = self.client.read_nodes( node_ids=chunk_ids, properties=properties, @@ -704,14 +722,18 @@ def get_edges_from_edits( edges = np.concatenate([types.empty_2d, *[e.value for e in edges]]) fake_edges_ = Edges(edges[:, 0], edges[:, 1]) - edges = val.get(attributes.Connectivity.SplitEdges, []) - edges = np.concatenate([types.empty_2d, *[e.value for e in edges]]) - - aff = val.get(attributes.Connectivity.Affinity, []) - aff = np.concatenate([types.empty_affinities, *[e.value for e in aff]]) + if use_compacted: + se = val.get(attributes.Connectivity.CompactedSplitEdges, []) + af = val.get(attributes.Connectivity.CompactedAffinity, []) + ar = val.get(attributes.Connectivity.CompactedArea, []) + else: + se = val.get(attributes.Connectivity.SplitEdges, []) + af = val.get(attributes.Connectivity.Affinity, []) + ar = val.get(attributes.Connectivity.Area, []) - areas = val.get(attributes.Connectivity.Area, []) - areas = np.concatenate([types.empty_areas, *[e.value for e in areas]]) + edges = np.concatenate([types.empty_2d, *[e.value for e in se]]) + aff = np.concatenate([types.empty_affinities, *[e.value for e in af]]) + areas = np.concatenate([types.empty_areas, *[e.value for e in ar]]) split_edges_ = Edges(edges[:, 0], edges[:, 1], affinities=aff, areas=areas) result[id_] = fake_edges_ + split_edges_ @@ -781,6 +803,7 @@ def get_l2_agglomerations( raise ValueError("Found conflicting parents.") sv_parent_d.update(dict(zip(svs.tolist(), [l2id] * len(svs)))) + all_chunk_edges = self._filter_stale_svs(all_chunk_edges, sv_parent_d) if active: all_chunk_edges = edge_utils.filter_inactive_cross_edges( self, all_chunk_edges, time_stamp=time_stamp @@ -802,6 +825,35 @@ def get_l2_agglomerations( ), ) + def _filter_stale_svs(self, edges: Edges, sv_parent_d: dict) -> Edges: + """Filter edges referencing SVs replaced by prior SV splits. + + Stale SVs have NewIdentity set (replaced by split fragments) but are + not in sv_parent_d. Cross-root SVs also aren't in sv_parent_d but + don't have NewIdentity — those edges are legitimate and kept. + Only applies to ocdbt_seg graphs (SV splitting is not possible otherwise). + """ + if not self.meta.ocdbt_seg or len(edges) == 0: + return edges + all_svs = np.unique(np.concatenate([edges.node_ids1, edges.node_ids2])) + unknown_svs = np.array( + [sv for sv in all_svs if sv not in sv_parent_d], dtype=np.uint64 + ) + if len(unknown_svs) == 0: + return edges + new_id_cells = self.client.read_nodes( + node_ids=unknown_svs, + properties=attributes.Hierarchy.NewIdentity, + ) + stale_svs = set(int(sv) for sv in unknown_svs if new_id_cells.get(sv)) + if not stale_svs: + return edges + stale_arr = np.array(list(stale_svs), dtype=np.uint64) + keep_m = ~np.isin(edges.node_ids1, stale_arr) & ~np.isin( + edges.node_ids2, stale_arr + ) + return edges[keep_m] + def get_node_timestamps( self, node_ids: typing.Sequence[np.uint64], return_numpy=True, normalize=False ) -> typing.Iterable: diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index fd88d2f03..cc884a1aa 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -134,6 +134,7 @@ def split_supervoxel( source_coords: np.ndarray, sink_coords: np.ndarray, operation_id: int, + sv_remapping: dict, verbose: bool = False, time_stamp: datetime = None, ) -> dict[int, set]: @@ -157,7 +158,14 @@ def split_supervoxel( logger.note(f"bbox and chunk min max {(bbs, bbe)}; {(chunk_min, chunk_max)}") t0 = time.time() - cut_supervoxels = _get_whole_sv(cg, sv_id, min_coord=chunk_min, max_coord=chunk_max) + rep = sv_remapping.get(sv_id, sv_id) + all_svs = np.array( + [sv for sv, r in sv_remapping.items() if r == rep], + dtype=basetypes.NODE_ID, + ) + coords = cg.get_chunk_coordinates_multiple(all_svs) + in_bbox = (coords >= chunk_min).all(axis=1) & (coords < chunk_max).all(axis=1) + cut_supervoxels = set(all_svs[in_bbox].tolist()) supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) logger.note( f"whole sv {sv_id} -> {supervoxel_ids.tolist()} ({time.time() - t0:.2f}s)" @@ -195,6 +203,12 @@ def split_supervoxel( new_seg, old_new_map, slices, new_id_label_map = _parse_results( results, seg_cropped, bbs, bbe ) + logger.note( + f"old_new_map: {len(old_new_map)} SVs split, whole_sv: {len(cut_supervoxels)} SVs" + ) + unsplit = cut_supervoxels - set(old_new_map.keys()) + if unsplit: + logger.note(f"unsplit SVs (kept IDs): {unsplit}") sv_ids = fastremap.unique(seg) roots = cg.get_roots(sv_ids) @@ -218,7 +232,7 @@ def split_supervoxel( logger.note(f"edge update ({time.time() - t0:.2f}s)") rows0 = copy_parents_and_add_lineage(cg, operation_id, old_new_map) - rows1 = add_new_edges(cg, edges_tuple, time_stamp=time_stamp) + rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) rows = rows0 + rows1 t0 = time.time() From 7fbe2ce25e0b7fb163efb4c51fc735140bb352c0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 22 Mar 2026 00:47:17 +0000 Subject: [PATCH 193/367] refactor: extract split_with_sv_splits, return user message when retry fails --- pychunkedgraph/app/segmentation/common.py | 70 ++++++++++++++++------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 457865834..0ff758c2d 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -27,6 +27,7 @@ from pychunkedgraph.graph.analysis import pathing from pychunkedgraph.graph.edits_sv import split_supervoxel from pychunkedgraph.graph.misc import get_contact_sites +from pychunkedgraph.debug.sv_split import check_unsplit_sv_bridges from pychunkedgraph.graph.operation import GraphEditOperation from pychunkedgraph.graph import basetypes from pychunkedgraph.meshing import mesh_analysis @@ -433,17 +434,12 @@ def _get_sources_and_sinks(cg: ChunkedGraph, data): return (source_ids, sink_ids, source_coords, sink_coords) -def handle_split(table_id): - current_app.table_id = table_id - user_id = str(g.auth_user.get("id", current_app.user_id)) +def split_with_sv_splits(cg, data, user_id="test", mincut=True): + """Remove edges with automatic supervoxel splitting when needed. - data = json.loads(request.data) - is_priority = request.args.get("priority", True, type=str2bool) - remesh = request.args.get("remesh", True, type=str2bool) - mincut = request.args.get("mincut", True, type=str2bool) - - cg = app_utils.get_cg(table_id, skip_cache=True) - current_app.logger.debug(data) + Attempts remove_edges. If source/sink SVs share a cross-chunk representative, + splits the overlapping SVs in the segmentation and retries. + """ sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) logger.note(f"pre-split: sources={sources}, sinks={sinks}") t0 = time.time() @@ -471,32 +467,64 @@ def handle_split(table_id): preserve_missing_labels=True, in_place=False, ) + logger.note(f"remapped sources={sources_remapped}, sinks={sinks_remapped}") overlap_mask = np.isin(sources_remapped, sinks_remapped) + logger.note(f"overlapping reps: {np.unique(sources_remapped[overlap_mask])}") t1 = time.time() - for sv_to_split in np.unique(sources_remapped[overlap_mask]): - _mask0 = sources_remapped == sv_to_split - _mask1 = sinks_remapped == sv_to_split + for rep in np.unique(sources_remapped[overlap_mask]): + _mask0 = sources_remapped == rep + _mask1 = sinks_remapped == rep split_supervoxel( cg, sources[_mask0][0], source_coords[_mask0], sink_coords[_mask1], e.operation_id, + sv_remapping=e.sv_remapping, ) logger.note(f"sv splits done ({time.time() - t1:.2f}s)") sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) logger.note(f"post-split: sources={sources}, sinks={sinks}") t1 = time.time() - ret = cg.remove_edges( - user_id=user_id, - source_ids=sources, - sink_ids=sinks, - source_coords=source_coords, - sink_coords=sink_coords, - mincut=mincut, - ) + try: + ret = cg.remove_edges( + user_id=user_id, + source_ids=sources, + sink_ids=sinks, + source_coords=source_coords, + sink_coords=sink_coords, + mincut=mincut, + ) + except cg_exceptions.SupervoxelSplitRequiredError as e2: + # The cross-chunk representative group extends beyond the split + # bbox. Unsplit SVs inside the bbox still have inf edges to SVs + # outside, bridging source and sink through the broader component. + + logger.note(f"retry still requires sv split") + # check_unsplit_sv_bridges(cg, e2.sv_remapping, sources, sinks) + raise cg_exceptions.PreconditionError( + "Supervoxel split succeeded but the split region is too small " + "to fully separate source and sink. " + "Try placing source and sink points farther apart." + ) from e2 logger.note(f"remove_edges after sv split ({time.time() - t1:.2f}s)") + return ret + + +def handle_split(table_id): + current_app.table_id = table_id + user_id = str(g.auth_user.get("id", current_app.user_id)) + + data = json.loads(request.data) + is_priority = request.args.get("priority", True, type=str2bool) + remesh = request.args.get("remesh", True, type=str2bool) + mincut = request.args.get("mincut", True, type=str2bool) + + cg = app_utils.get_cg(table_id, skip_cache=True) + current_app.logger.debug(data) + try: + ret = split_with_sv_splits(cg, data, user_id, mincut) except cg_exceptions.LockingError as e: raise cg_exceptions.InternalServerError(e) except cg_exceptions.PreconditionError as e: From 3a938b2ed4d519a96288af394ee08b8f9d452b26 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 22 Mar 2026 00:47:32 +0000 Subject: [PATCH 194/367] add split lineage inspection, unsplit SV bridge detection --- pychunkedgraph/debug/sv_split.py | 216 ++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/debug/sv_split.py b/pychunkedgraph/debug/sv_split.py index 43c9454ac..8caca0fc2 100644 --- a/pychunkedgraph/debug/sv_split.py +++ b/pychunkedgraph/debug/sv_split.py @@ -5,7 +5,8 @@ import numpy as np import fastremap -from ..app.segmentation.common import _get_sources_and_sinks as get_sources_and_sinks +from ..app.app_utils import handle_supervoxel_id_lookup +from ..graph import attributes from ..graph.chunkedgraph import ChunkedGraph from ..graph.edges import Edges @@ -126,7 +127,23 @@ def inspect_edited_edges(cg: ChunkedGraph, svs): def inspect_split(cg: ChunkedGraph, data: dict): """Full diagnostic for a split request: edges, inf bridges, L2 state.""" - sources, sinks, src_coords, snk_coords = get_sources_and_sinks(cg, data) + node_idents = [] + node_ident_map = {"sources": 0, "sinks": 1} + coords = [] + node_ids = [] + for k in ["sources", "sinks"]: + for node in data[k]: + node_ids.append(node[0]) + coords.append(np.array(node[1:]) / cg.segmentation_resolution) + node_idents.append(node_ident_map[k]) + node_ids = np.array(node_ids, dtype=np.uint64) + coords = np.array(coords) + node_idents = np.array(node_idents) + sv_ids = handle_supervoxel_id_lookup(cg, coords, node_ids) + sources = sv_ids[node_idents == 0] + sinks = sv_ids[node_idents == 1] + src_coords = coords[node_idents == 0] + snk_coords = coords[node_idents == 1] all_svs = np.concatenate([sources, sinks]) bbox = compute_bbox(src_coords, snk_coords) @@ -149,3 +166,198 @@ def inspect_split(cg: ChunkedGraph, data: dict): print("=== L2 Children ===") check_l2_children(cg, data, all_svs) + + +def check_unsplit_sv_bridges(cg: ChunkedGraph, sv_remapping: dict, sources, sinks): + """Check if unsplit SVs on opposite sides still have inf edges bridging them. + + After an SV split, SVs that kept their original IDs (chunk had only 1 label) + may still have inf edges to SVs on the opposite side in raw chunk data. + """ + # Find which SVs map to overlapping representatives + source_set = set(int(s) for s in sources) + sink_set = set(int(s) for s in sinks) + all_svs = source_set | sink_set + + # Group by representative + rep_groups = {} + for sv, rep in sv_remapping.items(): + rep_groups.setdefault(rep, []).append(sv) + + # Find overlapping reps + for rep, svs in rep_groups.items(): + src_in = [sv for sv in svs if sv in source_set] + snk_in = [sv for sv in svs if sv in sink_set] + if not src_in or not snk_in: + continue + + print(f"\n=== Overlapping rep {rep} ===") + print(f" sources in group: {src_in}") + print(f" sinks in group: {snk_in}") + + # Check which SVs have NewIdentity (were split) vs kept original IDs + all_group_svs = np.array(svs, dtype=np.uint64) + new_id_cells = cg.client.read_nodes( + node_ids=all_group_svs, + properties=attributes.Hierarchy.NewIdentity, + ) + for sv in svs: + has_new = bool(new_id_cells.get(sv)) + side = "src" if sv in source_set else "sink" if sv in sink_set else "other" + print(f" SV {sv}: side={side}, was_split={has_new}") + + # Check inf edges between unsplit SVs on opposite sides + unsplit = [sv for sv in svs if not new_id_cells.get(sv)] + unsplit_src = [sv for sv in unsplit if sv in source_set] + unsplit_snk = [sv for sv in unsplit if sv in sink_set] + if unsplit_src and unsplit_snk: + print( + f" WARNING: unsplit SVs on both sides: src={unsplit_src}, sink={unsplit_snk}" + ) + print(f" These have inf edges in raw data that were never updated") + + # Show full lineage for the group + print() + inspect_split_lineage(cg, svs) + + +def inspect_split_lineage(cg: ChunkedGraph, whole_sv_ids, old_new_map=None): + """Inspect NewIdentity/FormerIdentity and old_new_map for a whole SV group. + + Shows which SVs were actually split (got new IDs), which kept their IDs, + and whether NewIdentity was written correctly. + """ + sv_arr = np.asarray(whole_sv_ids, dtype=np.uint64) + print(f"=== Split Lineage for {len(sv_arr)} SVs ===") + + # Read NewIdentity for all SVs in the group + new_id_cells = cg.client.read_nodes( + node_ids=sv_arr, + properties=attributes.Hierarchy.NewIdentity, + ) + # Read FormerIdentity too + former_id_cells = cg.client.read_nodes( + node_ids=sv_arr, + properties=attributes.Hierarchy.FormerIdentity, + ) + + in_old_new = set() + if old_new_map: + in_old_new = set(int(k) for k in old_new_map.keys()) + print(f"\nold_new_map keys: {sorted(in_old_new)}") + for old, new in old_new_map.items(): + print(f" {old} -> {new}") + + print(f"\nLineage status:") + for sv in sv_arr: + sv_int = int(sv) + new_id = new_id_cells.get(sv) + former_id = former_id_cells.get(sv) + new_vals = [c.value for c in new_id] if new_id else None + former_vals = [c.value for c in former_id] if former_id else None + in_map = sv_int in in_old_new + chunk = cg.get_chunk_coordinates(sv) + + status = [] + if new_vals: + status.append(f"NewIdentity={new_vals}") + if former_vals: + status.append(f"FormerIdentity={former_vals}") + if in_map: + status.append("in old_new_map") + if not status: + status.append("UNCHANGED (no lineage, not in old_new_map)") + + print(f" {sv} chunk={chunk}: {', '.join(status)}") + + # Check for SVs that were split (have new fragments) but missing NewIdentity + if old_new_map: + missing = [k for k in old_new_map if not new_id_cells.get(np.uint64(k))] + if missing: + print(f"\n WARNING: SVs in old_new_map but missing NewIdentity: {missing}") + + +def trace_stale_sv(cg: ChunkedGraph, sv_id, bbox=None, root_id=None): + """Trace why a stale SV still appears in edges after a split. + + Checks: parent, NewIdentity, L2 children membership, + and where edges referencing it come from. + """ + sv_id = np.uint64(sv_id) + print(f"=== Tracing SV {sv_id} ===") + + # Parent + parents = cg.get_parents([sv_id]) + parent = list(parents.values())[0] if parents else None + print(f" parent: {parent}") + + # NewIdentity (set on old SVs after split) + cells = cg.client.read_nodes( + node_ids=[sv_id], properties=attributes.Hierarchy.NewIdentity + ) + if cells.get(sv_id): + new_ids = [c.value for c in cells[sv_id]] + print(f" NewIdentity: {new_ids} (SV was replaced)") + else: + print(f" NewIdentity: not set (SV was NOT replaced)") + + # Is it still in its L2 parent's children? + if parent is not None: + children = cg.get_children(parent) + in_children = sv_id in children + print(f" in L2 {parent} children: {in_children}") + if in_children: + print(f" children: {children}") + + # Root + root = cg.get_root(sv_id) + print(f" root: {root}") + + # Check edges in subgraph if bbox provided + if bbox is not None and root_id is not None: + print(f"\n --- Edges referencing {sv_id} in subgraph ---") + _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) + edges_all = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) + pairs = edges_all.get_pairs() + affs = edges_all.affinities + mask = np.any(pairs == sv_id, axis=1) + print(f" total edges with this SV: {mask.sum()}") + if mask.any(): + for p, a in zip(pairs[mask][:10], affs[mask][:10]): + aff_str = "inf" if np.isinf(a) else f"{a:.4f}" + print(f" {p[0]} -- {p[1]} aff={aff_str}") + + # Check origin: chunk edges vs edit edges + l2ids = list( + cg.get_subgraph( + root_id, + bbox, + bbox_is_coordinate=True, + nodes_only=True, + return_flattened=True, + ).values() + )[0] + chunk_ids = np.unique(cg.get_chunk_ids_from_node_ids(l2ids)) + + from ..io.edges import get_chunk_edges + + chunk_edges_d = cg.read_chunk_edges(chunk_ids) + chunk_edges_all = reduce( + lambda x, y: x + y, chunk_edges_d.values(), Edges([], []) + ) + chunk_pairs = chunk_edges_all.get_pairs() + chunk_mask = np.any(chunk_pairs == sv_id, axis=1) + print(f" from chunk edges (cloud storage): {chunk_mask.sum()}") + + edit_edges_d = cg.get_edges_from_edits(chunk_ids) + edit_edges_all = reduce( + lambda x, y: x + y, edit_edges_d.values(), Edges([], []) + ) + edit_pairs = edit_edges_all.get_pairs() + edit_mask = np.any(edit_pairs == sv_id, axis=1) + print(f" from edit edges (SplitEdges): {edit_mask.sum()}") + if edit_mask.any(): + edit_affs = edit_edges_all.affinities + for p, a in zip(edit_pairs[edit_mask][:10], edit_affs[edit_mask][:10]): + aff_str = "inf" if np.isinf(a) else f"{a:.4f}" + print(f" {p[0]} -- {p[1]} aff={aff_str}") From 8ba60ff7095cb0d259a15d451948afe794678d40 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 22 Mar 2026 00:52:34 +0000 Subject: [PATCH 195/367] add compacted split edges for O(1) current-time reads, suppress glog noise --- pychunkedgraph/__init__.py | 2 +- pychunkedgraph/graph/edges_sv.py | 77 ++++++++++++++++++++++++++------ requirements.in | 2 +- requirements.txt | 2 +- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/pychunkedgraph/__init__.py b/pychunkedgraph/__init__.py index 0ade7b18a..f2c219e4a 100644 --- a/pychunkedgraph/__init__.py +++ b/pychunkedgraph/__init__.py @@ -56,7 +56,7 @@ def configure_logging(level=stdlib_logging.INFO, format_str=None, stream=None): pychunkedgraph.configure_logging(pychunkedgraph.DEBUG) # Enable DEBUG level """ if format_str is None: - format_str = "%(asctime)s %(module)s:%(funcName)s:%(lineno)d %(message)s" + format_str = "%(asctime)s [%(module)s:%(funcName)s:%(lineno)d] %(message)s" if stream is None: stream = sys.stdout diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index ce282b121..ea9354990 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -403,32 +403,83 @@ def update_edges( return result -def add_new_edges(cg: "ChunkedGraph", edges_tuple: tuple, time_stamp: datetime = None): - edges_, affinites_, areas_ = edges_tuple +def _edges_to_bidirectional(edges_, affinities_, areas_): + """Duplicate edges in both directions and map nodes to chunks.""" + return ( + np.r_[edges_, edges_[:, ::-1]], + np.r_[affinities_, affinities_], + np.r_[areas_, areas_], + ) + + +def _compact_chunk_edges(prev_data, new_edges, new_affs, new_areas, stale_svs): + """Merge new edges with existing compacted edges, filtering stale SVs.""" + prev_cells = prev_data.get(attributes.Connectivity.CompactedSplitEdges, []) + if prev_cells: + prev_e = prev_cells[-1].value + prev_a = prev_data[attributes.Connectivity.CompactedAffinity][-1].value + prev_ar = prev_data[attributes.Connectivity.CompactedArea][-1].value + keep = ~np.isin(prev_e[:, 0], stale_svs) & ~np.isin(prev_e[:, 1], stale_svs) + new_edges = np.concatenate([prev_e[keep], new_edges]) + new_affs = np.concatenate([prev_a[keep], new_affs]) + new_areas = np.concatenate([prev_ar[keep], new_areas]) + return { + attributes.Connectivity.CompactedSplitEdges: new_edges, + attributes.Connectivity.CompactedAffinity: new_affs, + attributes.Connectivity.CompactedArea: new_areas, + } + + +def add_new_edges( + cg: "ChunkedGraph", + edges_tuple: tuple, + old_new_map: dict, + time_stamp: datetime = None, +): + edges_, affinities_, areas_ = edges_tuple logger.note(f"new edges: {edges_.shape}") nodes = fastremap.unique(edges_) chunks = cg.get_chunk_ids_from_node_ids(cg.get_parents(nodes)) node_chunks = dict(zip(nodes, chunks)) - edges = np.r_[edges_, edges_[:, ::-1]] - affinites = np.r_[affinites_, affinites_] - areas = np.r_[areas_, areas_] + edges, affinities, areas = _edges_to_bidirectional(edges_, affinities_, areas_) + stale_svs = np.array(list(old_new_map.keys()), dtype=basetypes.NODE_ID) + unique_chunks = np.unique(chunks) + + existing = cg.client.read_nodes( + node_ids=unique_chunks, + properties=[ + attributes.Connectivity.CompactedSplitEdges, + attributes.Connectivity.CompactedAffinity, + attributes.Connectivity.CompactedArea, + ], + fake_edges=True, + ) rows = [] chunks_arr = fastremap.remap(edges, node_chunks) - for chunk_id in np.unique(chunks): - val_dict = {} + for chunk_id in unique_chunks: mask = chunks_arr[:, 0] == chunk_id - val_dict[attributes.Connectivity.SplitEdges] = edges[mask] - val_dict[attributes.Connectivity.Affinity] = affinites[mask] - val_dict[attributes.Connectivity.Area] = areas[mask] + new_e, new_a, new_ar = edges[mask], affinities[mask], areas[mask] + row_key = serializers.serialize_uint64(chunk_id, fake_edges=True) + + # Append to SplitEdges (history, preserves all timestamps) rows.append( cg.client.mutate_row( - serializers.serialize_uint64(chunk_id, fake_edges=True), - val_dict=val_dict, + row_key, + { + attributes.Connectivity.SplitEdges: new_e, + attributes.Connectivity.Affinity: new_a, + attributes.Connectivity.Area: new_ar, + }, time_stamp=time_stamp, ) ) - # logger.note(f"writing {edges[mask].shape} edges to {chunk_id}") + + # Write compacted edges (latest valid only) + compact_dict = _compact_chunk_edges( + existing.get(chunk_id, {}), new_e, new_a, new_ar, stale_svs + ) + rows.append(cg.client.mutate_row(row_key, compact_dict, time_stamp=time_stamp)) return rows diff --git a/requirements.in b/requirements.in index 3ca5513c5..143d90399 100644 --- a/requirements.in +++ b/requirements.in @@ -28,7 +28,7 @@ task-queue>=2.14.0 messagingclient>0.3.0 dracopy>=1.5.0 datastoreflex>=0.5.0 -kvdbclient>=0.4.0 +kvdbclient>0.5.0 zstandard>=0.23.0 # Conda only - use requirements.yml (or install manually): diff --git a/requirements.txt b/requirements.txt index 33a82701c..af29a75bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -195,7 +195,7 @@ jsonschema==4.26.0 # python-jsonschema-objects jsonschema-specifications==2025.9.1 # via jsonschema -kvdbclient==0.4.0 +kvdbclient==0.6.0 # via -r requirements.in lazy-loader==0.4 # via scikit-image From b65e754bb7d2d1f1fcefa1fc5b78d4c88ac113d5 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 22 Mar 2026 02:23:04 +0000 Subject: [PATCH 196/367] ingest/upgrade: add ocdbt option to upgrade cli --- pychunkedgraph/ingest/cli_upgrade.py | 33 +++++++++++++++++++++------- pychunkedgraph/ingest/cluster.py | 2 +- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index 4b5ed12c7..5fa445ff6 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -18,7 +18,7 @@ from . import IngestConfig from .cluster import ( - convert_to_ocdbt, + convert_edges_to_ocdbt, enqueue_l2_tasks, upgrade_atomic_chunk, upgrade_parent_chunk, @@ -33,6 +33,7 @@ job_type_guard, ) from ..graph.chunkedgraph import ChunkedGraph, ChunkedGraphMeta +from ..graph.ocdbt import get_seg_source_and_destination_ocdbt from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys @@ -56,9 +57,18 @@ def flush_redis(): @upgrade_cli.command("graph") @click.argument("graph_id", type=str) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") -@click.option("--ocdbt", is_flag=True, help="Store edges using ts ocdbt kv store.") +@click.option("--ocdbt", is_flag=True, help="Enable ocdbt seg (SV splitting support).") +@click.option("--ocdbt-edges", is_flag=True, help="Convert edges to ocdbt kv store.") +@click.option( + "--sv-split-threshold", + type=int, + default=10, + help="Distance threshold for SV split edge matching.", +) @job_type_guard(group_name) -def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): +def upgrade_graph( + graph_id: str, test: bool, ocdbt: bool, ocdbt_edges: bool, sv_split_threshold: int +): """ Main upgrade command. Queues atomic tasks. """ @@ -77,21 +87,28 @@ def upgrade_graph(graph_id: str, test: bool, ocdbt: bool): cg.update_meta(new_meta, overwrite=True) cg = ChunkedGraph(graph_id=graph_id) + if ocdbt: + cg.meta.custom_data["seg"] = { + "ocdbt": True, + "sv_split_threshold": sv_split_threshold, + } + cg.update_meta(cg.meta, overwrite=True) + logger.note(f"enabled ocdbt seg with sv_split_threshold={sv_split_threshold}") + get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) try: - # create new column family for cross chunk edges cg.client.create_column_family("4") except Exception: ... imanager = IngestionManager(ingest_config, cg.meta) - server = ts.ocdbt.DistributedCoordinatorServer() - if ocdbt: + if ocdbt_edges: + server = ts.ocdbt.DistributedCoordinatorServer() start_ocdbt_server(imanager, server) - fn = convert_to_ocdbt if ocdbt else upgrade_atomic_chunk + fn = convert_edges_to_ocdbt if ocdbt_edges else upgrade_atomic_chunk enqueue_l2_tasks(imanager, fn) - if ocdbt: + if ocdbt_edges: logger.note("All tasks queued. Keep this alive for ocdbt coordinator server.") while True: sleep(60) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 2736d6819..07b3ee6d0 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -168,7 +168,7 @@ def upgrade_atomic_chunk(coords: Sequence[int]): _post_task_completion(imanager, 2, coords) -def convert_to_ocdbt(coords: Sequence[int]): +def convert_edges_to_ocdbt(coords: Sequence[int]): """ Convert edges stored per chunk to ajacency list in the tensorstore ocdbt kv store. """ From 33c378dbc4732b18075d0867b792414290a35bca Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 16 Apr 2026 03:06:27 +0000 Subject: [PATCH 197/367] feat: multi-scale OCDBT seg with mode-downsample propagation, inline 512K for storage optimization --- pychunkedgraph/graph/edits_sv.py | 15 +- pychunkedgraph/graph/meta.py | 54 ++- pychunkedgraph/graph/ocdbt.py | 422 ++++++++++++++++++++++-- pychunkedgraph/graph/utils/generic.py | 19 +- pychunkedgraph/ingest/cluster.py | 16 +- pychunkedgraph/meshing/meshgen_utils.py | 5 +- 6 files changed, 471 insertions(+), 60 deletions(-) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index cc884a1aa..a0ce5b98b 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -20,6 +20,7 @@ from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox from pychunkedgraph.graph.cutting_sv import split_supervoxel_helper from pychunkedgraph.graph.edges_sv import update_edges, add_new_edges +from pychunkedgraph.graph.ocdbt import write_seg from pychunkedgraph.graph.utils import get_local_segmentation from pychunkedgraph.io.edges import get_chunk_edges @@ -112,6 +113,13 @@ def _voxel_crop(bbs, bbe, bbs_, bbe_): def _parse_results(results, seg, bbs, bbe): + """Merge per-chunk split results into a single segmentation volume. + + Applies new SV IDs from each chunk's split result to `seg` (in-place) + and builds the old→new mapping + label→new-id mapping. + + Returns (seg, old_new_map, new_id_label_map). + """ old_new_map = defaultdict(set) new_id_label_map = {} for result in results: @@ -124,8 +132,7 @@ def _parse_results(results, seg, bbs, bbe): new_id_label_map[new_id] = label assert np.all(seg.shape == bbe - bbs), f"{seg.shape} != {bbe - bbs}" - slices = tuple(slice(start, end) for start, end in zip(bbs, bbe)) + (slice(None),) - return seg, old_new_map, slices, new_id_label_map + return seg, old_new_map, new_id_label_map def split_supervoxel( @@ -200,7 +207,7 @@ def split_supervoxel( ) seg_cropped = seg[voxel_overlap_crop].copy() - new_seg, old_new_map, slices, new_id_label_map = _parse_results( + new_seg, old_new_map, new_id_label_map = _parse_results( results, seg_cropped, bbs, bbe ) logger.note( @@ -236,7 +243,7 @@ def split_supervoxel( rows = rows0 + rows1 t0 = time.time() - cg.meta.ws_ocdbt[slices] = new_seg[..., np.newaxis] + write_seg(cg.meta, bbs, bbe, new_seg) cg.client.write(rows) logger.note(f"write seg + {len(rows)} rows ({time.time() - t0:.2f}s)") return old_new_map, edges_tuple diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index 23ff8d35d..2d2d1d289 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -62,12 +62,20 @@ def __init__( self._custom_data = custom_data self._ws_cv = None - self._ws_ocdbt = None + # Multi-scale OCDBT handles + per-scale resolutions, populated lazily + # from source's info JSON. ws_ocdbt returns scale 0 for backward + # compatibility; ws_ocdbt_scales exposes the full pyramid. + self._ws_ocdbt_scales = None + self._ws_ocdbt_resolutions = None self._layer_bounds_d = None self._layer_count = None self._bitmasks = None self._ocdbt_seg = None - self._ocdbt_path = None + + @property + def graph_id(self): + assert self._graph_config.ID is not None, "graph_id required" + return self._graph_config.ID_PREFIX + self._graph_config.ID @property def graph_config(self): @@ -110,22 +118,32 @@ def ocdbt_seg(self) -> bool: return self._ocdbt_seg @property - def ocdbt_path(self) -> bool: - if self._ocdbt_path is None: - self._ocdbt_path = self._custom_data.get("seg", {}).get( - "ocdbt_path", "ocdbt/base" - ) - return self._ocdbt_path + def ws_ocdbt(self): + """Base scale (MIP 0) handle. Backward-compatible single-handle access.""" + return self.ws_ocdbt_scales[0] @property - def ws_ocdbt(self): + def ws_ocdbt_scales(self): + """List of TensorStore handles, one per MIP level. Lazily initialized. + + Opens the CG's delta OCDBT via the kvstack-layered fork spec — reads + merge the shared base + this CG's edits, writes go to the delta. + """ assert self.ocdbt_seg, "make sure this pcg has segmentation in ocdbt format" - if self._ws_ocdbt: - return self._ws_ocdbt + if self._ws_ocdbt_scales is None: + _, self._ws_ocdbt_scales, self._ws_ocdbt_resolutions = ( + get_seg_source_and_destination_ocdbt( + self.data_source.WATERSHED, self.graph_id + ) + ) + return self._ws_ocdbt_scales - _, _ocdbt_seg = get_seg_source_and_destination_ocdbt(self.data_source.WATERSHED) - self._ws_ocdbt = _ocdbt_seg - return self._ws_ocdbt + @property + def ws_ocdbt_resolutions(self): + """Per-scale [x,y,z] resolutions (used to derive downsample factors).""" + # Trigger lazy init via ws_ocdbt_scales — both are populated together. + _ = self.ws_ocdbt_scales + return self._ws_ocdbt_resolutions @property def resolution(self): @@ -278,7 +296,13 @@ def dataset_info(self) -> Dict: "n_layers": self.layer_count, "spatial_bit_masks": self.bitmasks, "ocdbt_seg": self.ocdbt_seg, - "ocdbt_path": self.ocdbt_path, + # Per-CG delta OCDBT path. Neuroglancer must open this + # via the kvstack spec from build_cg_ocdbt_spec() to see + # both base + delta data. Opening it as plain OCDBT only + # sees the delta. + "ocdbt_path": ( + f"ocdbt/{self.graph_id}" if self._graph_config.ID else None + ), }, } ) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index f715bc101..fb12cb7d0 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -1,43 +1,265 @@ -import os +"""OCDBT-backed neuroglancer_precomputed segmentation store. + +Architecture: one immutable base OCDBT per watershed + one delta OCDBT per +ChunkedGraph. Reads merge base + delta via tensorstore's kvstack driver. +Writes land in the delta via OCDBT's *_data_prefix options. + +Multi-scale (MIP pyramid) is supported: the source watershed's info JSON +drives the scale layout. All scales share one OCDBT kvstore; the precomputed +driver prefixes keys by scale key automatically. +""" + +import json + import numpy as np import tensorstore as ts +from pychunkedgraph import get_logger + +logger = get_logger(__name__) + OCDBT_SEG_COMPRESSION_LEVEL = 12 +OCDBT_CONFIG = { + "compression": {"id": "zstd", "level": OCDBT_SEG_COMPRESSION_LEVEL}, + # Inline chunk values into B+tree leaves so they share the leaf's zstd + # compression context. Default (100 bytes) puts every chunk in its own + # out-of-line blob with independent zstd framing → ~7x bloat on GCS. + # 512 KiB captures every compressed_segmentation chunk we've measured. + "max_inline_value_bytes": 524288, +} -def get_seg_source_and_destination_ocdbt(ws_path: str, create: bool = False) -> tuple: - src_spec = { + +def _read_source_scales(ws_path): + """Read the source precomputed `info` JSON to get scale count and resolutions. + + The leading '/' in '/info' is required for GCS — without it the read + returns empty. + """ + kvs = ts.KvStore.open(ws_path).result() + info = json.loads(kvs.read("/info").result().value) + return info["scales"] + + +def _open_precomputed_scale(kvstore, scale_index, create=False, **schema_kw): + """Open one neuroglancer_precomputed scale on top of a kvstore spec.""" + spec = { "driver": "neuroglancer_precomputed", - "kvstore": ws_path, + "kvstore": kvstore, + "scale_index": scale_index, } - src = ts.open(src_spec).result() - schema = src.schema + return ts.open(spec, create=create, **schema_kw).result() - ocdbt_path = os.path.join(ws_path, "ocdbt", "base") - dst_spec = { - "driver": "neuroglancer_precomputed", - "kvstore": { - "driver": "ocdbt", - "base": ocdbt_path, - "config": { - "compression": {"id": "zstd", "level": OCDBT_SEG_COMPRESSION_LEVEL}, - }, + +def _schema_from_src(src_handle): + """Extract the schema kwargs needed to open a matching destination.""" + s = src_handle.schema + return dict( + rank=s.rank, + dtype=s.dtype, + codec=s.codec, + domain=s.domain, + shape=s.shape, + chunk_layout=s.chunk_layout, + dimension_units=s.dimension_units, + ) + + +# --------------------------------------------------------------------------- +# Base OCDBT (shared, immutable after ingest) +# --------------------------------------------------------------------------- + + +def _ensure_trailing_slash(path): + """Ensure kvstore paths end with / so they're treated as directories.""" + return path if path.endswith("/") else path + "/" + + +def _base_ocdbt_path(ws_path): + return _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/base") + + +def base_exists(ws_path: str) -> bool: + """Check if the base OCDBT has already been created for this watershed.""" + base = _base_ocdbt_path(ws_path) + kvs = ts.KvStore.open(base).result() + result = kvs.read("manifest.ocdbt").result() + return result.value is not None and len(result.value) > 0 + + +def create_base_ocdbt(ws_path: str): + """One-time bootstrap: create the shared base OCDBT at /ocdbt/base/. + + Wipes any existing base first, then opens each scale with create=True + so the info JSON is built from the source. Populating the base with + actual chunk data happens separately via copy_ws_chunk_multiscale + during the per-chunk ingest tasks. + + Returns (src_list, dst_list, resolutions) for the caller to use with + copy_ws_chunk_multiscale. + """ + base = _base_ocdbt_path(ws_path) + # Wipe existing base for a clean slate. + try: + kvs = ts.KvStore.open({"driver": "ocdbt", "base": base}).result() + kvs.delete_range(ts.KvStore.KeyRange()).result() + except Exception: + pass + + scales = _read_source_scales(ws_path) + resolutions = [s["resolution"] for s in scales] + base_kvstore = {"driver": "ocdbt", "base": base, "config": dict(OCDBT_CONFIG)} + + src_list, dst_list = [], [] + for i in range(len(scales)): + src_i = ts.open( + {"driver": "neuroglancer_precomputed", "kvstore": ws_path, "scale_index": i} + ).result() + dst_i = _open_precomputed_scale( + base_kvstore, i, create=True, **_schema_from_src(src_i) + ) + src_list.append(src_i) + dst_list.append(dst_i) + return src_list, dst_list, resolutions + + +def wipe_base_ocdbt(ws_path: str): + """Wipe the base OCDBT entirely (for --reset-ocdbt).""" + base = _base_ocdbt_path(ws_path) + try: + kvs = ts.KvStore.open({"driver": "ocdbt", "base": base}).result() + kvs.delete_range(ts.KvStore.KeyRange()).result() + except Exception: + pass + + +def open_base_ocdbt(ws_path: str): + """Open the existing base OCDBT (read/write) for populating during ingest. + + Used by per-chunk ingest tasks that copy precomputed data into the shared + base. NOT used at runtime — runtime always goes through the per-CG fork + spec via get_seg_source_and_destination_ocdbt. + + Returns (src_list, dst_list, resolutions). + """ + base = _base_ocdbt_path(ws_path) + scales = _read_source_scales(ws_path) + resolutions = [s["resolution"] for s in scales] + base_kvstore = {"driver": "ocdbt", "base": base, "config": dict(OCDBT_CONFIG)} + + src_list, dst_list = [], [] + for i in range(len(scales)): + src_i = ts.open( + {"driver": "neuroglancer_precomputed", "kvstore": ws_path, "scale_index": i} + ).result() + dst_i = _open_precomputed_scale(base_kvstore, i, **_schema_from_src(src_i)) + src_list.append(src_i) + dst_list.append(dst_i) + return src_list, dst_list, resolutions + + +# --------------------------------------------------------------------------- +# Per-CG delta (fork of the base) +# --------------------------------------------------------------------------- + + +def build_cg_ocdbt_spec(ws_path: str, graph_id: str) -> dict: + """Open-time kvstore spec for a CG's OCDBT, backed by a shared immutable base. + + The fork directory and its manifest are created automatically by + `fork_base_manifest` as part of CG creation — no manual setup. + + All three kvstack layers below AND all three `*_data_prefix` options + are load-bearing; removing any of them causes fork writes to leak + into the immutable base (verified empirically). + """ + base = _base_ocdbt_path(ws_path) + fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") + data_prefix = f"{graph_id}_d/" + + # Catch-all. Lets the fork READ base's B+tree (manifest + d/ + # data files) via fall-through. Must be first so later layers can + # override sub-ranges. + base_layer = {"base": base} + + # Single-key override. Routes the fork's manifest file so new + # commits by this CG are visible only to this CG. Without this layer + # manifest writes silently clobber base's manifest. + fork_manifest_layer = { + "exact": "manifest.ocdbt", + "base": fork_dir + "manifest.ocdbt", + } + + # Catches OCDBT's new data-file writes for the fork. Pairs with the + # *_data_prefix options: OCDBT would otherwise write under the + # default `d/` prefix — no later layer claims `d/`, so kvstack + # falls through to the base catch-all and the writes corrupt base. + fork_data_layer = { + "prefix": data_prefix, + "base": _ensure_trailing_slash(fork_dir + data_prefix), + } + + return { + "driver": "ocdbt", + "base": { + "driver": "kvstack", + "layers": [base_layer, fork_manifest_layer, fork_data_layer], }, + "config": dict(OCDBT_CONFIG), + # Steer every kind of OCDBT write under `_d/` so the + # fork_data_layer catches them. + "value_data_prefix": data_prefix, + "btree_node_data_prefix": data_prefix, + "version_tree_node_data_prefix": data_prefix, } - dst = ts.open( - dst_spec, - create=create, - rank=schema.rank, - dtype=schema.dtype, - codec=schema.codec, - domain=schema.domain, - shape=schema.shape, - chunk_layout=schema.chunk_layout, - dimension_units=schema.dimension_units, - delete_existing=create, - ).result() - return (src, dst) + +def fork_base_manifest(ws_path: str, graph_id: str, wipe_existing: bool = False): + """Initialize a CG's delta directory by copying the base manifest. + + If wipe_existing=True, deletes the existing fork directory first (for + --retry when a prior ingest failed and left partial delta state). + """ + assert base_exists(ws_path), "base OCDBT must exist before forking" + base = _base_ocdbt_path(ws_path) + fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") + + if wipe_existing: + try: + kvs = ts.KvStore.open(fork_dir).result() + kvs.delete_range(ts.KvStore.KeyRange()).result() + except Exception: + pass + + base_kvs = ts.KvStore.open(base).result() + fork_kvs = ts.KvStore.open(fork_dir).result() + manifest = base_kvs.read("manifest.ocdbt").result().value + fork_kvs.write("manifest.ocdbt", manifest).result() + + +def get_seg_source_and_destination_ocdbt(ws_path: str, graph_id: str) -> tuple: + """Open source watershed + CG's delta OCDBT destination (all scales). + + Always uses the fork-based kvstack spec. Requires the base to exist and + the fork's manifest to be present (set up at ingest time). + + Returns: + (src_list, dst_list, resolutions): per-scale TensorStore handles + and [x,y,z] resolutions. + """ + scales = _read_source_scales(ws_path) + resolutions = [s["resolution"] for s in scales] + cg_kvstore = build_cg_ocdbt_spec(ws_path, graph_id) + + src_list, dst_list = [], [] + for i in range(len(scales)): + src_i = ts.open( + {"driver": "neuroglancer_precomputed", "kvstore": ws_path, "scale_index": i} + ).result() + dst_i = _open_precomputed_scale(cg_kvstore, i, **_schema_from_src(src_i)) + src_list.append(src_i) + dst_list.append(dst_i) + return src_list, dst_list, resolutions def copy_ws_chunk( @@ -47,6 +269,11 @@ def copy_ws_chunk( coords: list, voxel_bounds: np.ndarray, ): + """Copy one chunk from source watershed to OCDBT destination at the same scale. + + Coordinates are interpreted at the source/destination's native scale — + callers must pre-scale them when copying coarser MIP levels. + """ coords = np.array(coords, dtype=int) chunk_size = np.array(chunk_size, dtype=int) vx_start = coords * chunk_size + voxel_bounds[:, 0] @@ -61,3 +288,142 @@ def copy_ws_chunk( data = source[x0:x1, y0:y1, z0:z1].read().result() destination[x0:x1, y0:y1, z0:z1].write(data).result() + + +def copy_ws_chunk_multiscale( + src_list, + dst_list, + resolutions, + chunk_size: tuple, + coords: list, + voxel_bounds: np.ndarray, +): + """Copy a base-resolution chunk's physical region across all MIP scales. + + The graph's chunk grid is defined at base resolution. For each coarser + scale we copy the SAME physical region — voxel coordinates are divided + by the cumulative downsample factor (derived from resolution ratios). + Source already has correct data at every scale, so this is a pure copy + with no recomputation. + """ + assert len(src_list) == len(dst_list) == len(resolutions) + coords = np.array(coords, dtype=int) + chunk_size_arr = np.array(chunk_size, dtype=int) + base_res = np.array(resolutions[0]) + + # Physical region at base resolution. + vx_start_base = coords * chunk_size_arr + voxel_bounds[:, 0] + vx_end_base = np.minimum(vx_start_base + chunk_size_arr, voxel_bounds[:, 1]) + + for i, (src, dst) in enumerate(zip(src_list, dst_list)): + # Cumulative factor from base to this scale (e.g. [2,2,1] per level). + factor = (np.array(resolutions[i]) / base_res).astype(int) + x0, y0, z0 = vx_start_base // factor + x1, y1, z1 = vx_end_base // factor + if x1 <= x0 or y1 <= y0 or z1 <= z0: + logger.debug(f"skipping empty region at scale {i}") + continue + data = src[x0:x1, y0:y1, z0:z1].read().result() + dst[x0:x1, y0:y1, z0:z1].write(data).result() + + +def _mode_downsample(data: np.ndarray, factors: tuple) -> np.ndarray: + """Mode downsample 4D segmentation array [X,Y,Z,C] by per-axis factors. + + Mode (most-frequent label) is the correct downsampling for segmentation: + it preserves exact label IDs (no interpolation) and biases toward the + dominant label in each block. + + Fast path for 2x2x1: uses a vectorized 4-element pairwise comparison. + Among 4 voxels {a,b,c,d}, if any value appears at least twice it is the + mode. Order of comparisons biases ties toward the top-left corner, which + is the standard convention for segmentation downsampling. + """ + fx, fy, fz = factors + X, Y, Z, C = data.shape + + # Pad with edge values so dimensions are divisible by the factor. + # Using 'edge' (not zeros) avoids introducing a phantom background label. + pad = [(0, (-X % fx) % fx), (0, (-Y % fy) % fy), (0, (-Z % fz) % fz), (0, 0)] + if any(p[1] > 0 for p in pad): + data = np.pad(data, pad, mode="edge") + X, Y, Z, C = data.shape + + if fx == 2 and fy == 2 and fz == 1: + # Fast vectorized path for the common 2x2x1 case. + reshaped = data.reshape(X // 2, 2, Y // 2, 2, Z, C) + a = reshaped[:, 0, :, 0] + b = reshaped[:, 0, :, 1] + c = reshaped[:, 1, :, 0] + d = reshaped[:, 1, :, 1] + return np.where( + (a == b) | (a == c) | (a == d), + a, + np.where((b == c) | (b == d), b, np.where(c == d, c, a)), + ) + + if fx == 2 and fy == 2 and fz == 2: + # 2x2x2 (8-element mode) — strided subsample is fast and label-safe + # for typical segmentation where adjacent voxels share labels. + return data[::2, ::2, ::2] + + # Generic factor: reshape into blocks, take strided first element. + # This is label-safe but loses the mode property; downsampling factor + # ratios in production are 2x2x1 or 2x2x2 so the fast paths cover them. + reshaped = data.reshape(X // fx, fx, Y // fy, fy, Z // fz, fz, C) + return reshaped[:, 0, :, 0, :, 0] + + +def propagate_to_coarser_scales(dst_scales, resolutions, base_slices): + """Cascade-downsample data from base scale through all coarser scales. + + Called after writing to the base scale (e.g. after an SV split). Each + coarser scale reads from the level below it (not from base directly), + so total downsample cost shrinks geometrically — each level processes + 1/N the data of the previous one. + + Args: + dst_scales: TensorStore handles, one per MIP level. + resolutions: [x,y,z] resolution arrays per scale, used to derive + per-axis downsample factors from consecutive resolution ratios. + base_slices: tuple of 3 slices (x, y, z) covering the region written + at base resolution. + """ + prev_slices = base_slices + for i in range(1, len(dst_scales)): + # Per-axis downsample factor from actual resolution ratio. + # Never hardcoded — different datasets may have different ratios. + factor = (np.array(resolutions[i]) / np.array(resolutions[i - 1])).astype(int) + + # Map prev-level slices to this level's coordinates. + # Ceil division on stop ensures we cover any partial block. + target_slices = tuple( + slice(s.start // f, -(-s.stop // f)) for s, f in zip(prev_slices, factor) + ) + + data = dst_scales[i - 1][prev_slices + (slice(None),)].read().result() + downsampled = _mode_downsample(data, tuple(int(f) for f in factor)) + dst_scales[i][target_slices + (slice(None),)].write(downsampled).result() + + prev_slices = target_slices + + +def write_seg(meta, bbs, bbe, data): + """Write segmentation at base scale and propagate to coarser scales. + + Single entry point for all SV-split-time segmentation writes. Builds + the tensorstore slices from the bounding box and adds the channel + dimension, so callers just pass the 3D bbox + 3D data. + + Args: + meta: ChunkedGraphMeta with ws_ocdbt_scales and ws_ocdbt_resolutions. + bbs: (3,) array — start of the region in base-resolution voxels. + bbe: (3,) array — end of the region in base-resolution voxels. + data: 3D numpy array of new segmentation IDs. + """ + slices = tuple(slice(int(s), int(e)) for s, e in zip(bbs, bbe)) + meta.ws_ocdbt[slices + (slice(None),)] = data[..., np.newaxis] + if len(meta.ws_ocdbt_scales) > 1: + propagate_to_coarser_scales( + meta.ws_ocdbt_scales, meta.ws_ocdbt_resolutions, slices + ) diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index e61356a1e..4ebe0533e 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -153,15 +153,22 @@ def get_parents_at_timestamp(nodes, parents_ts_map, time_stamp, unique: bool = F return list(parents), skipped_nodes -def get_local_segmentation(meta, bbox_start, bbox_end) -> np.ndarray: - result = None +def get_local_segmentation(meta, bbox_start, bbox_end, mip: int = 0) -> np.ndarray: + """Read a segmentation region from OCDBT (or CloudVolume). + + `bbox_start` and `bbox_end` must already be in the requested MIP level's + coordinate space — this function does not rescale them. Meshing computes + chunk bounds at the target MIP and passes them through directly; SV split + and coordinate lookup always use base resolution (mip=0). + """ xL, yL, zL = bbox_start xH, yH, zH = bbox_end if meta.ocdbt_seg: - result = meta.ws_ocdbt[xL:xH, yL:yH, zL:zH].read().result() - else: - result = meta.cv[xL:xH, yL:yH, zL:zH] - return result + # mip > 0 reads from a coarser scale; saves bandwidth and is what + # meshing wants when it operates at a non-base MIP. + store = meta.ws_ocdbt if mip == 0 else meta.ws_ocdbt_scales[mip] + return store[xL:xH, yL:yH, zL:zH].read().result() + return meta.cv[xL:xH, yL:yH, zL:zH] def lookup_svs_from_seg(meta, coordinates): diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 07b3ee6d0..4e8149ead 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -29,7 +29,7 @@ from .upgrade.parent_layer import update_chunk as update_parent_chunk from ..graph.edges import EDGE_TYPES, Edges, put_edges from ..graph import ChunkedGraph, ChunkedGraphMeta -from ..graph.ocdbt import copy_ws_chunk, get_seg_source_and_destination_ocdbt +from ..graph.ocdbt import copy_ws_chunk_multiscale, open_base_ocdbt from ..graph.chunks.hierarchy import get_children_chunk_coords from ..graph.basetypes import NODE_ID from ..io.edges import get_chunk_edges @@ -146,13 +146,17 @@ def create_atomic_chunk(coords: Sequence[int]): for k, v in chunk_edges_active.items(): logger.debug(f"active_{k}: {len(v)}") - if imanager.ocdbt_seg: - src, dst = get_seg_source_and_destination_ocdbt( + if imanager.ocdbt_seg and imanager.ocdbt_populate_base: + # Populate the shared base OCDBT with precomputed chunks (one-time + # per watershed). Uses the raw base handles, NOT the per-CG fork + # spec — the fork only stores SV-split deltas. + src_list, dst_list, resolutions = open_base_ocdbt( imanager.cg.meta.data_source.WATERSHED ) - copy_ws_chunk( - src, - dst, + copy_ws_chunk_multiscale( + src_list, + dst_list, + resolutions, imanager.cg.meta.graph_config.CHUNK_SIZE, coords, imanager.cg.meta.voxel_bounds, diff --git a/pychunkedgraph/meshing/meshgen_utils.py b/pychunkedgraph/meshing/meshgen_utils.py index 8fbe237c3..1a35f5f73 100644 --- a/pychunkedgraph/meshing/meshgen_utils.py +++ b/pychunkedgraph/meshing/meshgen_utils.py @@ -167,5 +167,8 @@ def get_ws_seg_for_chunk(cg, chunk_id, mip, overlap_vx=1): cg.meta.cv.mip_voxel_offset(mip), cg.meta.cv.mip_voxel_offset(mip) + cg.meta.cv.mip_volume_size(mip), ) - ws_seg = get_local_segmentation(cg.meta, chunk_start, chunk_end).squeeze() + # Pass mip so that with multi-scale OCDBT we read from the correct + # coarser scale (smaller data, faster reads). Coordinates above are + # already computed at the target MIP level. + ws_seg = get_local_segmentation(cg.meta, chunk_start, chunk_end, mip=mip).squeeze() return ws_seg From 6250bc01b1057da28df4fff36a9924983ab32150 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 16 Apr 2026 03:07:18 +0000 Subject: [PATCH 198/367] feat: per-CG delta OCDBT via kvstack fork, base auto-detection, --reset-ocdbt flag --- pychunkedgraph/ingest/cli.py | 33 +++++++++++++++++++++++++--- pychunkedgraph/ingest/cli_upgrade.py | 29 +++++++++++++++++++++--- pychunkedgraph/ingest/manager.py | 9 +++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index ba63e15f8..bd2f07626 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -25,7 +25,12 @@ from .simple_tests import run_all from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph -from ..graph.ocdbt import get_seg_source_and_destination_ocdbt +from ..graph.ocdbt import ( + base_exists, + create_base_ocdbt, + fork_base_manifest, + wipe_base_ocdbt, +) from ..utils.redis import get_redis_connection, keys as r_keys group_name = "ingest" @@ -57,6 +62,11 @@ def flush_redis(): ) @click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") @click.option("--retry", is_flag=True, help="Rerun without creating a new table.") +@click.option( + "--reset-ocdbt", + is_flag=True, + help="Wipe base AND this CG's delta OCDBT, then recreate from scratch.", +) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @job_type_guard(group_name) def ingest_graph( @@ -66,6 +76,7 @@ def ingest_graph( sv_split_threshold: int, raw: bool, retry: bool, + reset_ocdbt: bool, test: bool, ): """ @@ -85,14 +96,30 @@ def ingest_graph( if not retry: cg.create() + needs_base = False if ocdbt: + ws = cg.meta.data_source.WATERSHED cg.meta.custom_data["seg"] = { "ocdbt": True, "sv_split_threshold": sv_split_threshold, } cg.update_meta(cg.meta, overwrite=True) - get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) - imanager = IngestionManager(ingest_config, meta, ocdbt_seg=ocdbt) + + if reset_ocdbt: + wipe_base_ocdbt(ws) + + needs_base = not base_exists(ws) + if needs_base: + create_base_ocdbt(ws) + + fork_base_manifest(ws, graph_id, wipe_existing=retry or reset_ocdbt) + + imanager = IngestionManager( + ingest_config, + meta, + ocdbt_seg=ocdbt, + ocdbt_populate_base=needs_base, + ) enqueue_l2_tasks(imanager, create_atomic_chunk) os._exit(0) diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index 5fa445ff6..3a3ccb2e1 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -33,7 +33,12 @@ job_type_guard, ) from ..graph.chunkedgraph import ChunkedGraph, ChunkedGraphMeta -from ..graph.ocdbt import get_seg_source_and_destination_ocdbt +from ..graph.ocdbt import ( + base_exists, + create_base_ocdbt, + fork_base_manifest, + wipe_base_ocdbt, +) from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys @@ -65,9 +70,19 @@ def flush_redis(): default=10, help="Distance threshold for SV split edge matching.", ) +@click.option( + "--reset-ocdbt", + is_flag=True, + help="Wipe base AND this CG's delta OCDBT, then recreate from scratch.", +) @job_type_guard(group_name) def upgrade_graph( - graph_id: str, test: bool, ocdbt: bool, ocdbt_edges: bool, sv_split_threshold: int + graph_id: str, + test: bool, + ocdbt: bool, + ocdbt_edges: bool, + sv_split_threshold: int, + reset_ocdbt: bool, ): """ Main upgrade command. Queues atomic tasks. @@ -88,13 +103,21 @@ def upgrade_graph( cg = ChunkedGraph(graph_id=graph_id) if ocdbt: + ws = cg.meta.data_source.WATERSHED cg.meta.custom_data["seg"] = { "ocdbt": True, "sv_split_threshold": sv_split_threshold, } cg.update_meta(cg.meta, overwrite=True) logger.note(f"enabled ocdbt seg with sv_split_threshold={sv_split_threshold}") - get_seg_source_and_destination_ocdbt(cg.meta.data_source.WATERSHED, create=True) + + if reset_ocdbt: + wipe_base_ocdbt(ws) + + if not base_exists(ws): + create_base_ocdbt(ws) + + fork_base_manifest(ws, graph_id, wipe_existing=reset_ocdbt) try: cg.client.create_column_family("4") except Exception: diff --git a/pychunkedgraph/ingest/manager.py b/pychunkedgraph/ingest/manager.py index 3ba6e972c..915538320 100644 --- a/pychunkedgraph/ingest/manager.py +++ b/pychunkedgraph/ingest/manager.py @@ -16,6 +16,7 @@ def __init__( config: IngestConfig, chunkedgraph_meta: ChunkedGraphMeta, ocdbt_seg: bool = False, + ocdbt_populate_base: bool = False, _from_pickle: bool = False, ): self._config = config @@ -25,6 +26,7 @@ def __init__( self._task_queues = {} self._from_pickle = _from_pickle self.ocdbt_seg = ocdbt_seg + self.ocdbt_populate_base = ocdbt_populate_base if not _from_pickle: # initiate redis and store serialized state @@ -54,7 +56,12 @@ def redis(self): return self._redis def serialized(self, pickled=False): - params = {"config": self._config, "chunkedgraph_meta": self._chunkedgraph_meta} + params = { + "config": self._config, + "chunkedgraph_meta": self._chunkedgraph_meta, + "ocdbt_seg": self.ocdbt_seg, + "ocdbt_populate_base": self.ocdbt_populate_base, + } if pickled: return pickle.dumps(params) return params From c416bcd61e3580b31a9267146cfb39c1e3d92ac0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 16 Apr 2026 03:07:58 +0000 Subject: [PATCH 199/367] =?UTF-8?q?test:=20multi-scale=20propagation,=20fo?= =?UTF-8?q?rk=20isolation,=20meta=E2=86=92kvstack=20e2e,=20cleanup=20neste?= =?UTF-8?q?d=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pychunkedgraph/tests/graph/test_edits_sv.py | 10 +- pychunkedgraph/tests/graph/test_meta.py | 24 +- pychunkedgraph/tests/graph/test_ocdbt.py | 649 ++++++++++++++++++-- 3 files changed, 612 insertions(+), 71 deletions(-) diff --git a/pychunkedgraph/tests/graph/test_edits_sv.py b/pychunkedgraph/tests/graph/test_edits_sv.py index 6f4338abe..bced0a070 100644 --- a/pychunkedgraph/tests/graph/test_edits_sv.py +++ b/pychunkedgraph/tests/graph/test_edits_sv.py @@ -10,7 +10,7 @@ _parse_results, copy_parents_and_add_lineage, ) -from pychunkedgraph.graph import basetypes +from pychunkedgraph.graph import attributes, basetypes # ============================================================ @@ -60,7 +60,7 @@ def test_basic(self): label_id_map = {1: np.uint64(300), 2: np.uint64(301)} results = [(indices, old_values, new_values, label_id_map)] - updated_seg, old_new_map, slices, new_id_label_map = _parse_results( + updated_seg, old_new_map, new_id_label_map = _parse_results( results, seg, bbs, bbe ) assert updated_seg[0, 0, 0] == 300 @@ -75,7 +75,7 @@ def test_none_result_skipped(self): bbs = np.array([0, 0, 0]) bbe = np.array([1, 1, 1]) results = [None] - updated_seg, old_new_map, slices, new_id_label_map = _parse_results( + updated_seg, old_new_map, new_id_label_map = _parse_results( results, seg, bbs, bbe ) assert updated_seg[0, 0, 0] == 100 @@ -100,7 +100,7 @@ def test_multiple_results(self): ) results = [result1, result2] - updated_seg, old_new_map, slices, new_id_label_map = _parse_results( + updated_seg, old_new_map, new_id_label_map = _parse_results( results, seg, bbs, bbe ) assert updated_seg[0, 0, 0] == 300 @@ -122,7 +122,6 @@ def __init__(self, value, timestamp=None): class TestCopyParentsAndAddLineage: def _make_cg(self, parent_cells_map, children_cells_map=None): - from pychunkedgraph.graph import attributes cg = MagicMock() cg.client.read_nodes.side_effect = lambda node_ids, properties: ( @@ -231,7 +230,6 @@ def test_operation_id_stored(self): # Check that mutate_row was called with OperationID=99 calls = cg.client.mutate_row.call_args_list op_id_found = False - from pychunkedgraph.graph import attributes for call in calls: val_dict = call[0][1] if len(call[0]) > 1 else call[1].get("val_dict", {}) diff --git a/pychunkedgraph/tests/graph/test_meta.py b/pychunkedgraph/tests/graph/test_meta.py index 50d291479..fc24f9917 100644 --- a/pychunkedgraph/tests/graph/test_meta.py +++ b/pychunkedgraph/tests/graph/test_meta.py @@ -583,27 +583,33 @@ def test_ws_ocdbt_asserts_when_not_ocdbt(self): _ = meta.ws_ocdbt @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") - def test_ws_ocdbt_returns_destination(self, mock_get_ocdbt): - gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + def test_ws_ocdbt_returns_base_scale(self, mock_get_ocdbt): + gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) mock_src = MagicMock() - mock_dst = MagicMock() - mock_get_ocdbt.return_value = (mock_src, mock_dst) + mock_dst_base = MagicMock() + mock_dst_mip1 = MagicMock() + mock_get_ocdbt.return_value = ( + [mock_src, MagicMock()], + [mock_dst_base, mock_dst_mip1], + [[4, 4, 40], [8, 8, 40]], + ) - result = meta.ws_ocdbt - assert result is mock_dst - mock_get_ocdbt.assert_called_once_with("gs://bucket/ws") + assert meta.ws_ocdbt is mock_dst_base + assert meta.ws_ocdbt_scales == [mock_dst_base, mock_dst_mip1] + assert meta.ws_ocdbt_resolutions == [[4, 4, 40], [8, 8, 40]] + mock_get_ocdbt.assert_called_once_with("gs://bucket/ws", "test_graph") @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") def test_ws_ocdbt_cached(self, mock_get_ocdbt): - gc = GraphConfig(CHUNK_SIZE=[64, 64, 64]) + gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) mock_dst = MagicMock() - mock_get_ocdbt.return_value = (MagicMock(), mock_dst) + mock_get_ocdbt.return_value = ([MagicMock()], [mock_dst], [[4, 4, 40]]) result1 = meta.ws_ocdbt result2 = meta.ws_ocdbt diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index 9f31e5f6f..efa6ed048 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -1,71 +1,143 @@ """Tests for pychunkedgraph.graph.ocdbt""" +import json +import os +import shutil +import tempfile + import numpy as np import pytest +import tensorstore as ts from unittest.mock import MagicMock, patch +from pychunkedgraph.graph import ocdbt as ocdbt_mod +from pychunkedgraph.graph.meta import ChunkedGraphMeta, GraphConfig, DataSource -class TestGetSegSourceAndDestinationOcdbt: - @patch("pychunkedgraph.graph.ocdbt.ts") - def test_returns_src_dst_tuple(self, mock_ts): - from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt - - mock_src = MagicMock() - mock_schema = MagicMock() - mock_schema.rank = 4 - mock_schema.dtype = "uint64" - mock_schema.codec = None - mock_schema.domain = None - mock_schema.shape = [256, 256, 256, 1] - mock_schema.chunk_layout = None - mock_schema.dimension_units = None - mock_src.schema = mock_schema - - mock_dst = MagicMock() - - # ts.open returns a future-like with .result() - mock_ts.open.side_effect = [ - MagicMock(result=MagicMock(return_value=mock_src)), - MagicMock(result=MagicMock(return_value=mock_dst)), - ] - src, dst = get_seg_source_and_destination_ocdbt("gs://bucket/ws") - assert src is mock_src - assert dst is mock_dst - assert mock_ts.open.call_count == 2 - - @patch("pychunkedgraph.graph.ocdbt.ts") - def test_create_flag(self, mock_ts): - from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt - - mock_src = MagicMock() - mock_schema = MagicMock() - mock_schema.rank = 4 - mock_schema.dtype = "uint64" - mock_schema.codec = None - mock_schema.domain = None - mock_schema.shape = [256, 256, 256, 1] - mock_schema.chunk_layout = None - mock_schema.dimension_units = None - mock_src.schema = mock_schema - - mock_dst = MagicMock() - mock_ts.open.side_effect = [ - MagicMock(result=MagicMock(return_value=mock_src)), - MagicMock(result=MagicMock(return_value=mock_dst)), +def _make_mock_src(num_scales=2): + """Build a mock TensorStore source handle with a copyable schema.""" + mock_src = MagicMock() + schema = MagicMock() + schema.rank = 4 + schema.dtype = "uint64" + schema.codec = None + schema.domain = None + schema.shape = [256, 256, 256, 1] + schema.chunk_layout = None + schema.dimension_units = None + mock_src.schema = schema + return mock_src + + +def _setup_ts_mock(mock_ts, num_scales=2): + """Configure ts.KvStore.open and ts.open to return source/dest handles + for `num_scales` scales. Source returns a fake info JSON listing all scales. + """ + info = { + "scales": [ + {"resolution": [4 * (2**i), 4 * (2**i), 40], "size": [256, 256, 256]} + for i in range(num_scales) ] + } + info_bytes = json.dumps(info).encode() + + # KvStore.open(path).result().read('/info').result().value + mock_kvs = MagicMock() + mock_read_result = MagicMock() + mock_read_result.value = info_bytes + mock_kvs.read.return_value.result.return_value = mock_read_result + mock_ts.KvStore.open.return_value.result.return_value = mock_kvs + + # ts.open: alternates source / destination per scale + handles = [] + for _ in range(num_scales): + handles.append(_make_mock_src()) # source + handles.append(MagicMock()) # destination + mock_ts.open.side_effect = [ + MagicMock(result=MagicMock(return_value=h)) for h in handles + ] + return handles + + +class TestBuildCgOcdbtSpec: + def test_spec_structure(self): + """build_cg_ocdbt_spec returns the expected kvstack-layered spec.""" + spec = ocdbt_mod.build_cg_ocdbt_spec("gs://bucket/ws", "my_graph") + assert spec["driver"] == "ocdbt" + layers = spec["base"]["layers"] + assert len(layers) == 3 + # Layer 0: base catch-all (trailing slash) + assert layers[0]["base"] == "gs://bucket/ws/ocdbt/base/" + # Layer 1: manifest override + assert layers[1]["exact"] == "manifest.ocdbt" + assert "my_graph" in layers[1]["base"] + # Layer 2: data prefix + assert layers[2]["prefix"] == "my_graph_d/" + # Data prefix options steer writes + assert spec["value_data_prefix"] == "my_graph_d/" + assert spec["btree_node_data_prefix"] == "my_graph_d/" + assert spec["version_tree_node_data_prefix"] == "my_graph_d/" + + +class TestForkBaseManifest: + def test_copies_manifest(self): + """fork_base_manifest copies the base manifest via tensorstore kvstore.""" + tmpdir = tempfile.mkdtemp() + ws = f"file://{tmpdir}" + try: + # Create a real base OCDBT with a manifest. + base_kvs = ts.KvStore.open(f"{ws}/ocdbt/base/").result() + base_kvs.write("manifest.ocdbt", b"fake_manifest_bytes").result() + + ocdbt_mod.fork_base_manifest(ws, "my_graph") + + fork_kvs = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() + result = fork_kvs.read("manifest.ocdbt").result() + assert result.value == b"fake_manifest_bytes" + finally: + shutil.rmtree(tmpdir) - src, dst = get_seg_source_and_destination_ocdbt("gs://bucket/ws", create=True) + def test_wipe_existing_cleans_fork_dir(self): + """wipe_existing=True removes the fork directory before copying.""" + tmpdir = tempfile.mkdtemp() + ws = f"file://{tmpdir}" + try: + base_kvs = ts.KvStore.open(f"{ws}/ocdbt/base/").result() + base_kvs.write("manifest.ocdbt", b"manifest_v1").result() - # Second ts.open call should have create=True and delete_existing=True - second_call = mock_ts.open.call_args_list[1] - assert second_call.kwargs.get("create") == True - assert second_call.kwargs.get("delete_existing") == True + fork_kvs = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() + fork_kvs.write("stale_file", b"stale").result() + + ocdbt_mod.fork_base_manifest(ws, "my_graph", wipe_existing=True) + + fork_kvs2 = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() + assert fork_kvs2.read("manifest.ocdbt").result().value == b"manifest_v1" + assert len(fork_kvs2.read("stale_file").result().value) == 0 + finally: + shutil.rmtree(tmpdir) + + +class TestModeDownsample: + def test_2x2x1_picks_majority(self): + # 2x2x1 block where 3 of 4 voxels are label 7 — mode is 7. + data = np.array( + [[[[7]], [[7]]], [[[7]], [[3]]]], + dtype=np.uint64, + ) + out = ocdbt_mod._mode_downsample(data, (2, 2, 1)) + assert out.shape == (1, 1, 1, 1) + assert out[0, 0, 0, 0] == 7 + + def test_pads_odd_dimensions(self): + # 3x3x1 single-channel — pads to 4x4x1 then downsamples to 2x2x1 + data = np.full((3, 3, 1, 1), 5, dtype=np.uint64) + out = ocdbt_mod._mode_downsample(data, (2, 2, 1)) + assert out.shape == (2, 2, 1, 1) + assert (out == 5).all() class TestCopyWsChunk: def test_basic_copy(self): - from pychunkedgraph.graph.ocdbt import copy_ws_chunk mock_source = MagicMock() mock_destination = MagicMock() @@ -88,7 +160,7 @@ def test_basic_copy(self): ) voxel_bounds = np.array([[0, 256], [0, 256], [0, 256]]) - copy_ws_chunk( + ocdbt_mod.copy_ws_chunk( mock_source, mock_destination, chunk_size=(64, 64, 64), @@ -100,7 +172,6 @@ def test_basic_copy(self): mock_destination.__getitem__.assert_called_once() def test_boundary_clipping(self): - from pychunkedgraph.graph.ocdbt import copy_ws_chunk mock_source = MagicMock() mock_destination = MagicMock() @@ -123,7 +194,7 @@ def test_boundary_clipping(self): # Volume ends at 224 in x, so last chunk (192-256) is clipped to (192-224) voxel_bounds = np.array([[0, 224], [0, 256], [0, 256]]) - copy_ws_chunk( + ocdbt_mod.copy_ws_chunk( mock_source, mock_destination, chunk_size=(64, 64, 64), @@ -131,3 +202,469 @@ def test_boundary_clipping(self): voxel_bounds=voxel_bounds, ) mock_source.__getitem__.assert_called_once() + + +# -------------------------------------------------------------------------- +# Integration tests on a real local OCDBT store. +# +# The mock-based tests above validate call patterns but can't catch byte-level +# bugs in the multi-scale update path (wrong coordinates, missing propagation, +# skipped scales). These use an actual local file-backed OCDBT to exercise +# the full read/write cycle end-to-end. +# -------------------------------------------------------------------------- + + +@pytest.fixture +def local_ocdbt(): + """Create a local precomputed multi-scale OCDBT store. + + Builds 3 scales (factors 2,2,1 between each) with known segmentation IDs + so downsampling behaviour and propagation can be asserted against exact + values. Returns paths + handles for tests to work against directly. + """ + tmpdir = tempfile.mkdtemp() + base = f"file://{tmpdir}/ocdbt/base" + + mm = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + + def mk(scale_idx, size, resolution, extra_mm=None): + spec = { + "driver": "neuroglancer_precomputed", + "kvstore": {"driver": "ocdbt", "base": base}, + "scale_metadata": { + "size": size, + "resolution": resolution, + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + "chunk_size": [32, 32, 32], + }, + } + if extra_mm: + spec["multiscale_metadata"] = extra_mm + return ts.open(spec, create=True).result() + + scales = [ + mk(0, [64, 64, 32], [4, 4, 40], extra_mm=mm), + mk(1, [32, 32, 32], [8, 8, 40]), + mk(2, [16, 16, 32], [16, 16, 40]), + ] + resolutions = [[4, 4, 40], [8, 8, 40], [16, 16, 40]] + + yield { + "tmpdir": tmpdir, + "base": base, + "scales": scales, + "resolutions": resolutions, + } + shutil.rmtree(tmpdir) + + +class TestPropagateToCoarserScales: + def test_writes_downsampled_to_each_coarser_scale(self, local_ocdbt): + """Writing at base scale then propagating populates each coarser scale + with a mode-downsampled copy at the correctly-scaled coordinates.""" + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] + + # Base region: fill a 16x16x16 block with label 42. + base_slices = (slice(0, 16), slice(0, 16), slice(0, 16)) + base_data = np.full((16, 16, 16, 1), 42, dtype=np.uint64) + scales[0][base_slices + (slice(None),)] = base_data + + ocdbt_mod.propagate_to_coarser_scales(scales, res, base_slices) + + # Scale 1 (factor 2,2,1): region 8x8x16 should all be label 42. + s1 = scales[1][0:8, 0:8, 0:16, :].read().result() + assert (s1 == 42).all(), f"scale 1 mismatch: {np.unique(s1)}" + + # Scale 2 (factor 4,4,1 cumulative): region 4x4x16 should all be 42. + s2 = scales[2][0:4, 0:4, 0:16, :].read().result() + assert (s2 == 42).all(), f"scale 2 mismatch: {np.unique(s2)}" + + def test_mode_picks_majority_when_block_is_mixed(self, local_ocdbt): + """In a 2x2x1 block with mixed labels, mode wins.""" + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] + + # Fill a 2x2x16 block: three voxels per xy plane are label 7, one is 3. + base = np.full((2, 2, 16, 1), 7, dtype=np.uint64) + base[1, 1, :, 0] = 3 # minority voxel per 2x2 block + base_slices = (slice(0, 2), slice(0, 2), slice(0, 16)) + scales[0][base_slices + (slice(None),)] = base + + ocdbt_mod.propagate_to_coarser_scales(scales, res, base_slices) + + # Scale 1: 1x1x16 block should be mode-7 (3-of-4 majority). + s1 = scales[1][0:1, 0:1, 0:16, :].read().result() + assert (s1 == 7).all(), f"mode downsample failed: {np.unique(s1)}" + + def test_unaffected_regions_remain_zero(self, local_ocdbt): + """Propagating a region does not touch coarser data outside it.""" + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] + + # Seed scale 1 directly with a sentinel in a region we won't propagate over. + scales[1][16:20, 16:20, 0:4, :] = np.full((4, 4, 4, 1), 99, dtype=np.uint64) + + base_slices = (slice(0, 16), slice(0, 16), slice(0, 16)) + scales[0][base_slices + (slice(None),)] = np.full( + (16, 16, 16, 1), 1, dtype=np.uint64 + ) + ocdbt_mod.propagate_to_coarser_scales(scales, res, base_slices) + + # Sentinel region is outside the scaled propagation area (0:8, 0:8, 0:16 + # at scale 1) — must be unchanged. + sentinel = scales[1][16:20, 16:20, 0:4, :].read().result() + assert (sentinel == 99).all(), "propagation overwrote unrelated region" + + def test_spatial_pattern_matches_ground_truth(self, local_ocdbt): + """Downsampled coarser scales match what ts.downsample(method=mode) produces. + + Uses a spatial label pattern so every 2x2x1 block has a non-trivial mode; + catches any off-by-one in coordinate mapping or incorrect mode logic. + """ + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] + + # Deterministic per-voxel labels derived from coords — every 2x2x1 block + # has 4 distinct values, exposing any mode-selection bug and any + # coordinate-mapping mistake. + X, Y, Z = 16, 16, 16 + xs, ys, zs = np.meshgrid( + np.arange(X), np.arange(Y), np.arange(Z), indexing="ij" + ) + # Encode (x,y,z) uniquely so each voxel has a unique label in [1, 16*16*16]. + pattern = (xs * (Y * Z) + ys * Z + zs + 1).astype(np.uint64) + pattern = pattern[..., np.newaxis] + + base_slices = (slice(0, X), slice(0, Y), slice(0, Z)) + scales[0][base_slices + (slice(None),)] = pattern + + ocdbt_mod.propagate_to_coarser_scales(scales, res, base_slices) + + # Ground truth via tensorstore's own downsample driver (mode method). + # Factor 2,2,1 scale 0 -> scale 1, then 2,2,1 scale 1 -> scale 2. + gt_s1 = ( + ts.downsample(scales[0], [2, 2, 1, 1], method="mode")[0:8, 0:8, 0:16, :] + .read() + .result() + ) + got_s1 = scales[1][0:8, 0:8, 0:16, :].read().result() + assert got_s1.shape == gt_s1.shape + # Mode ties can differ between implementations; verify each downsampled + # voxel matches ONE of the four input voxels (correctness property). + for i in range(8): + for j in range(8): + for k in range(16): + block = pattern[2 * i : 2 * i + 2, 2 * j : 2 * j + 2, k : k + 1, 0] + assert ( + got_s1[i, j, k, 0] in block + ), f"scale1[{i},{j},{k}]={got_s1[i,j,k,0]} not in block {block.flatten()}" + # Scale 2 cascades from scale 1, not base — cumulative factor 4,4,1. + got_s2 = scales[2][0:4, 0:4, 0:16, :].read().result() + for i in range(4): + for j in range(4): + for k in range(16): + # Each scale-2 voxel must match one of the four scale-1 voxels + # (because cascade downsamples from the level below). + s1_block = got_s1[ + 2 * i : 2 * i + 2, 2 * j : 2 * j + 2, k : k + 1, 0 + ] + assert ( + got_s2[i, j, k, 0] in s1_block + ), f"scale2[{i},{j},{k}]={got_s2[i,j,k,0]} not in s1 block {s1_block.flatten()}" + + def test_repeated_update_reflects_latest_base(self, local_ocdbt): + """Propagating twice (simulating repeated SV splits) leaves coarser + scales consistent with the latest base-scale contents.""" + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] + + base_slices = (slice(0, 16), slice(0, 16), slice(0, 16)) + + # First update: region is label 1. + scales[0][base_slices + (slice(None),)] = np.full( + (16, 16, 16, 1), 1, dtype=np.uint64 + ) + ocdbt_mod.propagate_to_coarser_scales(scales, res, base_slices) + assert (scales[1][0:8, 0:8, 0:16, :].read().result() == 1).all() + + # Second update: same region now label 2. Coarser scales must reflect it. + scales[0][base_slices + (slice(None),)] = np.full( + (16, 16, 16, 1), 2, dtype=np.uint64 + ) + ocdbt_mod.propagate_to_coarser_scales(scales, res, base_slices) + assert (scales[1][0:8, 0:8, 0:16, :].read().result() == 2).all() + assert (scales[2][0:4, 0:4, 0:16, :].read().result() == 2).all() + + +class TestWriteSeg: + def test_writes_base_and_propagates(self, local_ocdbt): + """`write_seg` writes to base scale AND propagates to all coarser scales.""" + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] + meta = MagicMock() + meta.ws_ocdbt = scales[0] + meta.ws_ocdbt_scales = scales + meta.ws_ocdbt_resolutions = res + + data = np.full((16, 16, 16), 55, dtype=np.uint64) + ocdbt_mod.write_seg(meta, [0, 0, 0], [16, 16, 16], data) + + # Base scale: written region has label 55. + assert (scales[0][0:16, 0:16, 0:16, :].read().result() == 55).all() + # Coarser scales: propagated. + assert (scales[1][0:8, 0:8, 0:16, :].read().result() == 55).all() + assert (scales[2][0:4, 0:4, 0:16, :].read().result() == 55).all() + + def test_single_scale_skips_propagation(self, local_ocdbt): + """With only one scale in the list, propagation is a no-op (no IndexError).""" + meta = MagicMock() + meta.ws_ocdbt = local_ocdbt["scales"][0] + meta.ws_ocdbt_scales = [local_ocdbt["scales"][0]] + meta.ws_ocdbt_resolutions = [local_ocdbt["resolutions"][0]] + + data = np.full((8, 8, 8), 99, dtype=np.uint64) + ocdbt_mod.write_seg(meta, [0, 0, 0], [8, 8, 8], data) + assert (meta.ws_ocdbt[0:8, 0:8, 0:8, :].read().result() == 99).all() + + +class TestMetaToForkEndToEnd: + """Full path: ChunkedGraphMeta.ws_ocdbt_scales → real kvstack fork → read/write.""" + + def test_meta_opens_fork_and_merges_base(self): + """meta.ws_ocdbt_scales opens a real kvstack-backed OCDBT and reads + merge base + fork correctly. + + Only `_read_source_scales` is mocked (it reads `/info` which is a + GCS-only key). The full meta → build_cg_ocdbt_spec → kvstack → + OCDBT → read/write path is exercised for real. + """ + tmpdir = tempfile.mkdtemp() + ws = f"file://{tmpdir}" + try: + MM = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + SCALE = { + "size": [64, 64, 32], + "resolution": [4, 4, 40], + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + "chunk_size": [32, 32, 32], + } + FAKE_SCALES = [ + { + "resolution": [4, 4, 40], + "size": [64, 64, 32], + "chunk_sizes": [[32, 32, 32]], + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + } + ] + + # Source precomputed — needed by get_seg_source_and_destination_ocdbt + # to open the source handle and copy its schema. + ts.open( + { + "driver": "neuroglancer_precomputed", + "kvstore": f"{ws}/", + "multiscale_metadata": MM, + "scale_metadata": SCALE, + }, + create=True, + ).result() + + # Create base OCDBT with known data. + base_kvstore = { + "driver": "ocdbt", + "base": f"{ws}/ocdbt/base/", + "config": dict(ocdbt_mod.OCDBT_CONFIG), + } + base_store = ts.open( + { + "driver": "neuroglancer_precomputed", + "kvstore": base_kvstore, + "multiscale_metadata": MM, + "scale_metadata": SCALE, + }, + create=True, + ).result() + base_store[...] = np.full((64, 64, 32, 1), 50, dtype=np.uint64) + + # Fork for graph "test_cg". + ocdbt_mod.fork_base_manifest(f"{ws}/", "test_cg") + + gc = GraphConfig(ID="test_cg", CHUNK_SIZE=[32, 32, 32]) + ds = DataSource(WATERSHED=f"{ws}/", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) + + # Mock only _read_source_scales ('/info' is GCS-only). + with patch.object( + ocdbt_mod, "_read_source_scales", return_value=FAKE_SCALES + ): + scales = meta.ws_ocdbt_scales + assert len(scales) == 1 + + # Read: should see base data. + r = scales[0][0:16, 0:16, 0:16, :].read().result() + assert (r == 50).all(), f"fork should see base, got {np.unique(r)}" + + # Write via the fork handle. + scales[0][0:16, 0:16, 0:16, :] = np.full( + (16, 16, 16, 1), 7, dtype=np.uint64 + ) + + # Read back: edited = 7, untouched = 50. + assert (scales[0][0:16, 0:16, 0:16, :].read().result() == 7).all() + assert (scales[0][32:48, 0:16, 0:16, :].read().result() == 50).all() + + # Base unchanged. + base_ro = ts.open( + { + "driver": "neuroglancer_precomputed", + "kvstore": base_kvstore, + } + ).result() + assert (base_ro[0:16, 0:16, 0:16, :].read().result() == 50).all() + finally: + shutil.rmtree(tmpdir) + + +class TestForkIsolation: + """End-to-end: two forks on the same base, writes isolated, base immutable.""" + + def test_two_forks_isolated(self): + tmpdir = tempfile.mkdtemp() + ws = f"file://{tmpdir}" + try: + # Build a base OCDBT with known data. + MM = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + SCALE = { + "size": [64, 64, 32], + "resolution": [4, 4, 40], + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + "chunk_size": [32, 32, 32], + } + base_kvstore = { + "driver": "ocdbt", + "base": f"{ws}/ocdbt/base/", + "config": dict(ocdbt_mod.OCDBT_CONFIG), + } + base_store = ts.open( + { + "driver": "neuroglancer_precomputed", + "kvstore": base_kvstore, + "multiscale_metadata": MM, + "scale_metadata": SCALE, + }, + create=True, + ).result() + base_store[...] = np.full((64, 64, 32, 1), 50, dtype=np.uint64) + + base_path = f"{tmpdir}/ocdbt/base" + base_files_before = set( + os.path.relpath(os.path.join(r, f), base_path) + for r, _, fs in os.walk(base_path) + for f in fs + ) + + # Fork A and B via fork_base_manifest. + ocdbt_mod.fork_base_manifest(ws, "fork_a") + ocdbt_mod.fork_base_manifest(ws, "fork_b") + + def open_fork(gid): + spec = ocdbt_mod.build_cg_ocdbt_spec(ws, gid) + return ts.open( + {"driver": "neuroglancer_precomputed", "kvstore": spec}, + ).result() + + fork_a = open_fork("fork_a") + fork_b = open_fork("fork_b") + + # Both see base data. + assert (fork_a[0:16, 0:16, 0:16, :].read().result() == 50).all() + assert (fork_b[0:16, 0:16, 0:16, :].read().result() == 50).all() + + # Write different values to each fork. + fork_a[0:16, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 1, dtype=np.uint64) + fork_b[32:48, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 2, dtype=np.uint64) + + # Each fork sees ONLY its own edit + base for the rest. + assert (fork_a[0:16, 0:16, 0:16, :].read().result() == 1).all() + assert (fork_a[32:48, 0:16, 0:16, :].read().result() == 50).all() + assert (fork_b[32:48, 0:16, 0:16, :].read().result() == 2).all() + assert (fork_b[0:16, 0:16, 0:16, :].read().result() == 50).all() + + # Base is unchanged. + base_files_after = set( + os.path.relpath(os.path.join(r, f), base_path) + for r, _, fs in os.walk(base_path) + for f in fs + ) + assert ( + base_files_before == base_files_after + ), f"base was mutated: new={base_files_after - base_files_before}" + + # Fork writes went to their own directories. + fork_a_files = os.listdir(f"{tmpdir}/ocdbt/fork_a") + fork_b_files = os.listdir(f"{tmpdir}/ocdbt/fork_b") + assert any("fork_a_d" in f for f in fork_a_files) + assert any("fork_b_d" in f for f in fork_b_files) + finally: + shutil.rmtree(tmpdir) + + +class TestCopyWsChunkMultiscale: + def test_copies_physical_region_across_scales(self, local_ocdbt): + """One base-chunk copy call populates the same physical region at every scale.""" + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] + + # Seed the source at each scale with its own unique label so we can + # verify the copy reads from the correct scale, not the base. + src_paths = [MagicMock() for _ in scales] + # Build a fake "source" using the same local store as src — we fill + # each scale with a different value and then copy to a NEW local dst. + for i, (s, label) in enumerate(zip(scales, (11, 22, 33))): + s[...] = np.full(s.shape, label, dtype=np.uint64) + + # Create a separate local destination store. + dst_tmp = tempfile.mkdtemp() + dst_base = f"file://{dst_tmp}/ocdbt/base" + mm = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + dst_scales = [] + for i, src in enumerate(scales): + spec = { + "driver": "neuroglancer_precomputed", + "kvstore": {"driver": "ocdbt", "base": dst_base}, + "scale_metadata": { + "size": list(src.shape[:3]), + "resolution": res[i], + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + "chunk_size": [32, 32, 32], + }, + } + if i == 0: + spec["multiscale_metadata"] = mm + dst_scales.append(ts.open(spec, create=True).result()) + + try: + # Graph chunk_size 32, coords (0,0,0), voxel_bounds [0..64, 0..64, 0..32] + # maps to base region (0:32, 0:32, 0:32). + ocdbt_mod.copy_ws_chunk_multiscale( + scales, + dst_scales, + res, + chunk_size=(32, 32, 32), + coords=[0, 0, 0], + voxel_bounds=np.array([[0, 64], [0, 64], [0, 32]]), + ) + # Scale 0 dst got label 11 in (0:32, 0:32, 0:32). + assert (dst_scales[0][0:32, 0:32, 0:32, :].read().result() == 11).all() + # Scale 1 dst got label 22 in (0:16, 0:16, 0:32). + assert (dst_scales[1][0:16, 0:16, 0:32, :].read().result() == 22).all() + # Scale 2 dst got label 33 in (0:8, 0:8, 0:32). + assert (dst_scales[2][0:8, 0:8, 0:32, :].read().result() == 33).all() + finally: + shutil.rmtree(dst_tmp) From 5ddd4d1a9e38fd04fb55a2aa106904f4bd3579e1 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 21 Apr 2026 21:36:08 +0000 Subject: [PATCH 200/367] ocdbt: write_seg writes base scale only Drop the inline propagate_to_coarser_scales call from write_seg; coarser mips are now the async downsample worker's responsibility. write_seg is back to a single base-scale tensorstore write, so SV splits no longer block on the full pyramid update. TestWriteSeg updated to assert the coarser scales stay zero after write_seg (propagation tested separately via TestPropagateToCoarserScales). --- pychunkedgraph/graph/ocdbt.py | 26 +++++++----------------- pychunkedgraph/tests/graph/test_ocdbt.py | 15 +++++++------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index fb12cb7d0..666160595 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -65,11 +65,6 @@ def _schema_from_src(src_handle): ) -# --------------------------------------------------------------------------- -# Base OCDBT (shared, immutable after ingest) -# --------------------------------------------------------------------------- - - def _ensure_trailing_slash(path): """Ensure kvstore paths end with / so they're treated as directories.""" return path if path.endswith("/") else path + "/" @@ -158,11 +153,6 @@ def open_base_ocdbt(ws_path: str): return src_list, dst_list, resolutions -# --------------------------------------------------------------------------- -# Per-CG delta (fork of the base) -# --------------------------------------------------------------------------- - - def build_cg_ocdbt_spec(ws_path: str, graph_id: str) -> dict: """Open-time kvstore spec for a CG's OCDBT, backed by a shared immutable base. @@ -409,21 +399,19 @@ def propagate_to_coarser_scales(dst_scales, resolutions, base_slices): def write_seg(meta, bbs, bbe, data): - """Write segmentation at base scale and propagate to coarser scales. + """Write segmentation at base scale only. - Single entry point for all SV-split-time segmentation writes. Builds - the tensorstore slices from the bounding box and adds the channel - dimension, so callers just pass the 3D bbox + 3D data. + Coarser MIP levels are produced asynchronously by the downsample worker, + which consumes a pubsub message published by `publish_edit` after this + call returns. PCG itself only reads the base scale; viewers + (Neuroglancer) consume the coarser scales, and don't need them + synchronously with the edit. Args: - meta: ChunkedGraphMeta with ws_ocdbt_scales and ws_ocdbt_resolutions. + meta: ChunkedGraphMeta with ws_ocdbt (base-scale handle). bbs: (3,) array — start of the region in base-resolution voxels. bbe: (3,) array — end of the region in base-resolution voxels. data: 3D numpy array of new segmentation IDs. """ slices = tuple(slice(int(s), int(e)) for s, e in zip(bbs, bbe)) meta.ws_ocdbt[slices + (slice(None),)] = data[..., np.newaxis] - if len(meta.ws_ocdbt_scales) > 1: - propagate_to_coarser_scales( - meta.ws_ocdbt_scales, meta.ws_ocdbt_resolutions, slices - ) diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index efa6ed048..5bd3937a1 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -399,8 +399,9 @@ def test_repeated_update_reflects_latest_base(self, local_ocdbt): class TestWriteSeg: - def test_writes_base_and_propagates(self, local_ocdbt): - """`write_seg` writes to base scale AND propagates to all coarser scales.""" + def test_writes_base_only(self, local_ocdbt): + """`write_seg` writes to base scale; coarser scales are untouched + (propagation is now the downsample worker's job).""" scales = local_ocdbt["scales"] res = local_ocdbt["resolutions"] meta = MagicMock() @@ -413,12 +414,12 @@ def test_writes_base_and_propagates(self, local_ocdbt): # Base scale: written region has label 55. assert (scales[0][0:16, 0:16, 0:16, :].read().result() == 55).all() - # Coarser scales: propagated. - assert (scales[1][0:8, 0:8, 0:16, :].read().result() == 55).all() - assert (scales[2][0:4, 0:4, 0:16, :].read().result() == 55).all() + # Coarser scales: unchanged (still empty/zero — write_seg does not touch them). + assert (scales[1][0:8, 0:8, 0:16, :].read().result() == 0).all() + assert (scales[2][0:4, 0:4, 0:16, :].read().result() == 0).all() - def test_single_scale_skips_propagation(self, local_ocdbt): - """With only one scale in the list, propagation is a no-op (no IndexError).""" + def test_single_scale(self, local_ocdbt): + """Single-scale setup still works (write_seg only touches base).""" meta = MagicMock() meta.ws_ocdbt = local_ocdbt["scales"][0] meta.ws_ocdbt_scales = [local_ocdbt["scales"][0]] From ff8caff07a5f16b9f438b0ed72a42dd2c7d85603 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 21 Apr 2026 21:37:54 +0000 Subject: [PATCH 201/367] edits: forward SV-split bbox to publish_edit for async downsample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split_supervoxel now returns its base-resolution bbox. split_with_sv_splits collects one per call and attaches the list to Result.seg_bbox (new optional field). publish_edit includes the list in the payload and sets downsample="true" so the worker only runs on edits that touched base seg. List kept unmerged — lets the worker skip tiles outside the actual change region. --- pychunkedgraph/app/segmentation/common.py | 26 ++++++++++++++++++----- pychunkedgraph/graph/edits_sv.py | 2 +- pychunkedgraph/graph/operation.py | 4 +++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 0ff758c2d..5a33c9e71 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -2,6 +2,7 @@ import json import os +import pickle import time from datetime import datetime, timezone from functools import reduce @@ -11,6 +12,7 @@ import pandas as pd import fastremap from flask import current_app, g, jsonify, make_response, request +from messagingclient import MessagingClient from pytz import UTC from pychunkedgraph import __version__, get_logger @@ -322,15 +324,13 @@ def publish_edit( is_priority=True, remesh: bool = True, ): - import pickle - - from messagingclient import MessagingClient - + downsample = bool(result.seg_bbox) attributes = { "table_id": table_id, "user_id": user_id, "remesh_priority": "true" if is_priority else "false", "remesh": "true" if remesh else "false", + "downsample": "true" if downsample else "false", } payload = { "operation_id": int(result.operation_id), @@ -338,6 +338,13 @@ def publish_edit( "new_root_ids": result.new_root_ids.tolist(), "old_root_ids": result.old_root_ids.tolist(), } + if downsample: + # Each entry is the base-resolution bbox of one supervoxel split's + # writes. Kept as a list (not merged) so the worker only rewrites + # tiles whose base footprint actually changed. + payload["seg_bboxes"] = [ + [bbs.tolist(), bbe.tolist()] for bbs, bbe in result.seg_bbox + ] exchange = os.getenv("PYCHUNKEDGRAPH_EDITS_EXCHANGE", "pychunkedgraph") c = MessagingClient() @@ -471,10 +478,17 @@ def split_with_sv_splits(cg, data, user_id="test", mincut=True): overlap_mask = np.isin(sources_remapped, sinks_remapped) logger.note(f"overlapping reps: {np.unique(sources_remapped[overlap_mask])}") t1 = time.time() + # Collect the base-resolution bbox for each SV split so the downsample + # worker only re-derives coarser mips for regions that actually changed. + # The list is kept as-is (not merged into a single envelope) because + # merging overlapping-but-disjoint bboxes would include corner regions + # that no split touched — the worker would then re-write unchanged + # tiles, inflating the OCDBT delta store. + seg_bboxes = [] for rep in np.unique(sources_remapped[overlap_mask]): _mask0 = sources_remapped == rep _mask1 = sinks_remapped == rep - split_supervoxel( + _, _, seg_bbox = split_supervoxel( cg, sources[_mask0][0], source_coords[_mask0], @@ -482,6 +496,7 @@ def split_with_sv_splits(cg, data, user_id="test", mincut=True): e.operation_id, sv_remapping=e.sv_remapping, ) + seg_bboxes.append(seg_bbox) logger.note(f"sv splits done ({time.time() - t1:.2f}s)") sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) @@ -509,6 +524,7 @@ def split_with_sv_splits(cg, data, user_id="test", mincut=True): "Try placing source and sink points farther apart." ) from e2 logger.note(f"remove_edges after sv split ({time.time() - t1:.2f}s)") + ret = ret._replace(seg_bbox=seg_bboxes) return ret diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index a0ce5b98b..5f3ffc827 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -246,7 +246,7 @@ def split_supervoxel( write_seg(cg.meta, bbs, bbe, new_seg) cg.client.write(rows) logger.note(f"write seg + {len(rows)} rows ({time.time() - t0:.2f}s)") - return old_new_map, edges_tuple + return old_new_map, edges_tuple, (bbs, bbe) def copy_parents_and_add_lineage( diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 73ad898d8..37ecbd675 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -50,7 +50,9 @@ class GraphEditOperation(ABC): "do_sanity_check", ] Result = namedtuple( - "Result", ["operation_id", "new_root_ids", "new_lvl2_ids", "old_root_ids"] + "Result", + ["operation_id", "new_root_ids", "new_lvl2_ids", "old_root_ids", "seg_bbox"], + defaults=(None,), ) def __init__( From d9367823fc4618fdb52df6550729fc3bf0ee0272 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 21 Apr 2026 21:38:44 +0000 Subject: [PATCH 202/367] downsample: async pubsub worker updates coarser mips workers/downsample_worker.py consumes edits-exchange messages flagged downsample="true" and writes each non-base mip within the SV-split bbox. graph/downsample.py splits the region into pyramid_blocks (sized so no two blocks share a storage chunk at any mip), then either tinybrain'd in one call (fast path, typical small edits) or per-mip (fallback when base read exceeds memory budget). Write filtering keeps OCDBT delta proportional to the actual change. DownsampleBlockLock serializes overlapping jobs via kvdbclient's row-key lock API; 26-byte hash-prefixed keys avoid tablet hot-spots. Depends on kvdbclient lock_by_row_key / unlock_by_row_key / renew_lock_by_row_key landing first. --- pychunkedgraph/graph/downsample.py | 339 +++++++++++++++++ pychunkedgraph/graph/locks.py | 118 +++++- pychunkedgraph/tests/graph/test_downsample.py | 344 ++++++++++++++++++ workers/downsample_worker.py | 86 +++++ 4 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 pychunkedgraph/graph/downsample.py create mode 100644 pychunkedgraph/tests/graph/test_downsample.py create mode 100644 workers/downsample_worker.py diff --git a/pychunkedgraph/graph/downsample.py b/pychunkedgraph/graph/downsample.py new file mode 100644 index 000000000..791043e0f --- /dev/null +++ b/pychunkedgraph/graph/downsample.py @@ -0,0 +1,339 @@ +"""Async mip-pyramid downsample worker support. + +An SV split writes at base resolution only; coarser mips are produced +afterwards by a pubsub worker that consumes this module's primitives. + +Work is organized into `pyramid_block`s. A block is a cubic physical +region sized so that at the coarsest scale in the pyramid it equals +exactly one storage chunk. Because every finer scale's chunk grid is a +power-of-2 refinement of the coarsest, a block aligned at the coarsest +scale is automatically aligned at every finer scale — so two different +blocks never share a storage chunk at any mip. That is what makes a +single lock per block safe. + +Within a block we pick one of two code paths: + 1. Fast in-memory path: read the affected base region once, call + tinybrain with `num_mips=K` (all mips at once), write each mip's + output. Used when the base read fits a memory budget — the typical + case because the SV-split bbox is bounded by the /split endpoint + (source+sink coords + small padding). + 2. Per-mip fallback: read the previous mip, tinybrain one step, write. + K storage round-trips instead of 1. Kept for pathological inputs + whose base read would exceed the memory budget. + +Uniform downsample factor (e.g. 2x2x2) across all non-base scales is +assumed and asserted. +""" + +import numpy as np +import tinybrain + +from pychunkedgraph import get_logger + +logger = get_logger(__name__) + +# Default memory budget for the in-memory path's base read. +# uint64 segmentation is 8 bytes/voxel; 1 GiB ≈ 512^3 voxels. Edits +# produced by the /split endpoint are bounded far below this. +DEFAULT_MEMORY_BUDGET_BYTES = 1 << 30 + + +def num_output_mips(meta) -> int: + """Count of non-base scales — what the worker actually writes.""" + return len(meta.ws_ocdbt_scales) - 1 + + +def uniform_factor(meta) -> tuple: + """Per-axis downsample factor between consecutive scales. + + tinybrain takes one factor tuple per call, so the factor must be + constant across the pyramid. Asserts rather than silently producing + wrong mips for a dataset with mixed factors. + """ + resolutions = [np.array(r, dtype=float) for r in meta.ws_ocdbt_resolutions] + factors = [ + tuple((resolutions[i] / resolutions[i - 1]).astype(int)) + for i in range(1, len(resolutions)) + ] + assert all( + f == factors[0] for f in factors + ), f"non-uniform downsample factors {factors}" + return factors[0] + + +def _chunk_size_at_scale(meta, scale_idx: int) -> np.ndarray: + """Storage chunk size at a given scale (excluding the channel dim).""" + return np.array( + meta.ws_ocdbt_scales[scale_idx].chunk_layout.read_chunk.shape[:3], dtype=int + ) + + +def block_shape(meta) -> np.ndarray: + """pyramid_block size in base-resolution voxels. + + Chosen so that at the coarsest scale K the block equals exactly one + storage chunk — which transitively aligns it to every finer scale's + chunk grid. + """ + K = num_output_mips(meta) + coarsest_chunk = _chunk_size_at_scale(meta, K) + factor = np.array(uniform_factor(meta), dtype=int) + return coarsest_chunk * factor**K + + +def blocks_for_bbox(meta, bbs, bbe) -> list: + """Block coords intersected by a base-resolution bbox. + + Bbox is rounded outward to the block grid — a tiny bbox inside one + block still yields that one block coord. Returns sorted list of + `(bx, by, bz)` ints for deadlock-free lock acquisition. + """ + shape = block_shape(meta) + lo = np.asarray(bbs, dtype=int) // shape + hi = -(-np.asarray(bbe, dtype=int) // shape) + coords = [ + (int(bx), int(by), int(bz)) + for bx in range(lo[0], hi[0]) + for by in range(lo[1], hi[1]) + for bz in range(lo[2], hi[2]) + ] + return sorted(coords) + + +def block_base_bbox(meta, block_coord) -> tuple: + """Inverse of `blocks_for_bbox` for a single coord — base-voxel bbox.""" + shape = block_shape(meta) + lo = np.asarray(block_coord, dtype=int) * shape + hi = lo + shape + return lo, hi + + +def _seg_bboxes_to_np(seg_bboxes): + return [ + (np.asarray(bbs, dtype=int), np.asarray(bbe, dtype=int)) + for bbs, bbe in seg_bboxes + ] + + +def _affected_region_base(meta, block_coord, seg_bboxes_np): + """Base-voxel region covering all tiles this block will write, at any mip. + + Starts from the union of (seg bbox ∩ block ∩ volume) then aligns + outward to the coarsest mip's base-voxel grid (= factor**K per axis). + That alignment both makes the region tinybrain-valid for num_mips=K + and guarantees clean chunk-aligned writes at every mip (coarsest + alignment refines down to every finer scale). + + Returns `(base_lo, base_hi)` or `None` if no overlap. + """ + K = num_output_mips(meta) + factor = np.array(uniform_factor(meta), dtype=int) + align = factor**K + + block_lo, block_hi = block_base_bbox(meta, block_coord) + vol_lo = meta.voxel_bounds[:, 0] + vol_hi = meta.voxel_bounds[:, 1] + clipped_lo = np.maximum(block_lo, vol_lo) + clipped_hi = np.minimum(block_hi, vol_hi) + if np.any(clipped_hi <= clipped_lo): + return None + + union_lo, union_hi = None, None + for sb, eb in seg_bboxes_np: + ilo = np.maximum(sb, clipped_lo) + ihi = np.minimum(eb, clipped_hi) + if np.any(ihi <= ilo): + continue + union_lo = ilo if union_lo is None else np.minimum(union_lo, ilo) + union_hi = ihi if union_hi is None else np.maximum(union_hi, ihi) + if union_lo is None: + return None + + base_lo = (union_lo // align) * align + base_hi = -(-union_hi // align) * align + # Keep within the clipped block. Block corners are factor**K-aligned + # (block_shape is a multiple of factor**K), so this clip preserves + # alignment. + base_lo = np.maximum(base_lo, clipped_lo) + base_hi = np.minimum(base_hi, clipped_hi) + if np.any(base_hi <= base_lo): + return None + return base_lo, base_hi + + +def _process_block_in_memory(meta, base_region, K, factor): + """Read base once, tinybrain all mips, write each output. + + Assumes the base region is factor**K-aligned in size (which is what + `_affected_region_base` returns) so tinybrain with num_mips=K emits + clean integer voxel counts at every mip. + """ + base_lo, base_hi = base_region + base = meta.ws_ocdbt_scales[0] + arr = ( + base[ + base_lo[0] : base_hi[0], + base_lo[1] : base_hi[1], + base_lo[2] : base_hi[2], + :, + ] + .read() + .result() + ) + mips = tinybrain.downsample_segmentation( + arr, factor=tuple(int(f) for f in factor), num_mips=K, sparse=False + ) + for m, out in enumerate(mips, start=1): + scale = factor**m + mip_lo = base_lo // scale + mip_hi = base_hi // scale + dst = meta.ws_ocdbt_scales[m] + dst[ + mip_lo[0] : mip_hi[0], + mip_lo[1] : mip_hi[1], + mip_lo[2] : mip_hi[2], + :, + ].write(out).result() + + +def _affected_region_at_mip( + block_lo_base, + block_hi_base, + vol_lo, + vol_hi, + seg_bboxes_base, + mip: int, + factor: np.ndarray, + mip_chunk: np.ndarray, +): + """Write region at this mip in mip-local voxel coords. + + Union of seg bboxes ∩ block ∩ volume, aligned outward to this mip's + storage-chunk grid. Returns `(mip_lo, mip_hi)` or None. + """ + scale = factor**mip + clipped_lo = np.maximum(block_lo_base, vol_lo) + clipped_hi = np.minimum(block_hi_base, vol_hi) + if np.any(clipped_hi <= clipped_lo): + return None + + union_lo, union_hi = None, None + for sb, eb in seg_bboxes_base: + ilo = np.maximum(sb, clipped_lo) + ihi = np.minimum(eb, clipped_hi) + if np.any(ihi <= ilo): + continue + union_lo = ilo if union_lo is None else np.minimum(union_lo, ilo) + union_hi = ihi if union_hi is None else np.maximum(union_hi, ihi) + if union_lo is None: + return None + + mip_lo = union_lo // scale + mip_hi = -(-union_hi // scale) + mip_lo = (mip_lo // mip_chunk) * mip_chunk + mip_hi = -(-mip_hi // mip_chunk) * mip_chunk + + vol_lo_mip = vol_lo // scale + vol_hi_mip = -(-vol_hi // scale) + mip_lo = np.maximum(mip_lo, vol_lo_mip) + mip_hi = np.minimum(mip_hi, vol_hi_mip) + if np.any(mip_hi <= mip_lo): + return None + return mip_lo, mip_hi + + +def _process_block_per_mip(meta, block_coord, seg_bboxes_np, K, factor): + """Fallback path: process one mip at a time. + + Used when the full in-memory base read would exceed the memory + budget. Each mip reads the prior mip from storage, does one + tinybrain step, writes. + + Safe across mip boundaries only because the caller holds the block + lock — no other task can write the storage chunks this block owns, + so reading mip N here always sees what we wrote at mip N in the + previous iteration. + """ + vol_lo = meta.voxel_bounds[:, 0] + vol_hi = meta.voxel_bounds[:, 1] + block_lo_base, block_hi_base = block_base_bbox(meta, block_coord) + + for mip in range(1, K + 1): + mip_chunk = _chunk_size_at_scale(meta, mip) + region = _affected_region_at_mip( + block_lo_base, + block_hi_base, + vol_lo, + vol_hi, + seg_bboxes_np, + mip, + factor, + mip_chunk, + ) + if region is None: + continue + mip_lo, mip_hi = region + src = meta.ws_ocdbt_scales[mip - 1] + src_lo = mip_lo * factor + src_hi = mip_hi * factor + arr = ( + src[ + src_lo[0] : src_hi[0], + src_lo[1] : src_hi[1], + src_lo[2] : src_hi[2], + :, + ] + .read() + .result() + ) + out = tinybrain.downsample_segmentation( + arr, factor=tuple(int(f) for f in factor), num_mips=1, sparse=False + )[0] + dst = meta.ws_ocdbt_scales[mip] + dst[ + mip_lo[0] : mip_hi[0], + mip_lo[1] : mip_hi[1], + mip_lo[2] : mip_hi[2], + :, + ].write(out).result() + + +def process_block( + meta, + block_coord, + seg_bboxes, + memory_budget_bytes: int = DEFAULT_MEMORY_BUDGET_BYTES, +): + """Downsample one pyramid_block through every non-base mip. + + Atomic within the block: caller must hold the block lock. Picks the + in-memory path when the base read fits the memory budget, falls + back to the per-mip path otherwise. Both paths only touch tiles + whose footprint intersects `seg_bboxes` — unchanged tiles are + skipped to keep OCDBT delta growth proportional to the actual edit. + + Args: + meta: ChunkedGraphMeta with `ws_ocdbt_scales` / `ws_ocdbt_resolutions`. + block_coord: (bx, by, bz) block grid coord. + seg_bboxes: iterable of `(bbs, bbe)` base-voxel bbox pairs from + the SV splits that triggered this job. + """ + K = num_output_mips(meta) + factor = np.array(uniform_factor(meta), dtype=int) + seg_bboxes_np = _seg_bboxes_to_np(seg_bboxes) + + region = _affected_region_base(meta, block_coord, seg_bboxes_np) + if region is None: + return + base_lo, base_hi = region + + bytes_per_voxel = meta.ws_ocdbt_scales[0].dtype.numpy_dtype.itemsize + base_bytes = int(np.prod(base_hi - base_lo)) * bytes_per_voxel + if base_bytes <= memory_budget_bytes: + _process_block_in_memory(meta, region, K, factor) + else: + logger.info( + f"block {block_coord} base read {base_bytes / 1e9:.2f} GB exceeds " + f"budget {memory_budget_bytes / 1e9:.2f} GB; using per-mip path" + ) + _process_block_per_mip(meta, block_coord, seg_bboxes_np, K, factor) diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index 47a63dacf..06b1eed77 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -1,6 +1,7 @@ +import hashlib +import time from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Union -from typing import Sequence +from typing import Sequence, Union from collections import defaultdict import networkx as nx @@ -181,3 +182,116 @@ def __exit__(self, exception_type, exception_value, traceback): future.result() except Exception as e: logger.warning(f"Failed to unlock root: {e}") + + +def _downsample_block_lock_row_key(block_coord) -> bytes: + """Row key for one pyramid_block's downsample lock cell. + + Hash-prefixed so spatially-clustered block coords — common when a + team edits the same region — scatter across bigtable tablets instead + of piling up in one lexicographic range, which would hot-spot a + single tablet under concurrent load. + + 26 bytes total: + - 2-byte blake2b hash of the packed coord (tablet distribution). + - 24 bytes of packed coord (big-endian uint64 per axis). + uint64 per axis tracks the existing node-id width and puts no cap on + the block grid. The full coord in the key guarantees uniqueness even + if two coords share the 2-byte hash prefix. + """ + bx, by, bz = (int(c) for c in block_coord) + packed = ( + bx.to_bytes(8, "big", signed=False) + + by.to_bytes(8, "big", signed=False) + + bz.to_bytes(8, "big", signed=False) + ) + return hashlib.blake2b(packed, digest_size=2).digest() + packed + + +class DownsampleBlockLock: + """Lock a set of pyramid_blocks for the lifetime of a downsample task. + + The downsample worker holds one across read → tinybrain → write for + every block it touches. All-or-nothing: on partial acquisition we + release what we got and retry with backoff; on repeated failure we + raise so the pubsub message ends up un-acked and redelivered. + + Uses `cg.client.lock_by_row_key` with hash-prefixed row keys — the + generic row-key lock primitive in kvdbclient — so these rows never + collide with node-id-keyed root locks even though both use the same + `Concurrency.Lock` column. + """ + + __slots__ = ["cg", "block_coords", "operation_id", "acquired_keys"] + + # Retry budget for partial-acquire failures. Each attempt releases + # anything it got in the previous pass, then re-acquires from scratch. + _MAX_ACQUIRE_ATTEMPTS = 7 + _ACQUIRE_BACKOFF_BASE_SEC = 0.5 + + def __init__( + self, + cg, + block_coords: Sequence, + operation_id: np.uint64, + ) -> None: + self.cg = cg + # Sort so every `__enter__` uses a consistent acquisition order + # across workers — reduces contention between workers whose block + # sets overlap. Sort is on the coord tuple (not the hashed row + # key) so the order is stable and debuggable. + self.block_coords = sorted( + (int(bx), int(by), int(bz)) for bx, by, bz in block_coords + ) + self.operation_id = np.uint64(operation_id) + self.acquired_keys: list = [] + + def __enter__(self): + for attempt in range(self._MAX_ACQUIRE_ATTEMPTS): + self.acquired_keys = [] + all_ok = True + for coord in self.block_coords: + row_key = _downsample_block_lock_row_key(coord) + if self.cg.client.lock_by_row_key(row_key, self.operation_id): + self.acquired_keys.append(row_key) + else: + all_ok = False + break + if all_ok: + return self + self._release_acquired() + time.sleep(self._ACQUIRE_BACKOFF_BASE_SEC * (2**attempt)) + raise exceptions.LockingError( + f"Could not acquire downsample block locks for coords " + f"{self.block_coords} after {self._MAX_ACQUIRE_ATTEMPTS} attempts" + ) + + def __exit__(self, exception_type, exception_value, traceback): + self._release_acquired() + + def _release_acquired(self): + if not self.acquired_keys: + return + max_workers = min(8, max(1, len(self.acquired_keys))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit( + self.cg.client.unlock_by_row_key, key, self.operation_id + ) + for key in self.acquired_keys + ] + for future in as_completed(futures): + try: + future.result() + except Exception as e: + logger.warning(f"Failed to unlock downsample block: {e}") + self.acquired_keys = [] + + def renew(self) -> bool: + """Extend expiry on every held lock. Returns False if any failed.""" + ok = True + for key in self.acquired_keys: + if not self.cg.client.renew_lock_by_row_key(key, self.operation_id): + logger.warning(f"Failed to renew downsample block lock {key!r}") + ok = False + return ok diff --git a/pychunkedgraph/tests/graph/test_downsample.py b/pychunkedgraph/tests/graph/test_downsample.py new file mode 100644 index 000000000..0f1e69ee1 --- /dev/null +++ b/pychunkedgraph/tests/graph/test_downsample.py @@ -0,0 +1,344 @@ +"""Tests for pychunkedgraph.graph.downsample.""" + +import shutil +import tempfile +import threading +import time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +import tensorstore as ts + +from pychunkedgraph.graph import downsample as ds +from pychunkedgraph.graph.locks import ( + DownsampleBlockLock, + _downsample_block_lock_row_key, +) +from pychunkedgraph.graph import exceptions + + +@pytest.fixture +def local_ocdbt(): + """3-scale file-backed OCDBT store with factor (2,2,1) between scales. + + Matches the fixture in test_ocdbt.py so downsample behaviour can be + exercised end-to-end against real tensorstore handles. + """ + tmpdir = tempfile.mkdtemp() + base = f"file://{tmpdir}/ocdbt/base" + mm = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + + def mk(size, resolution, extra_mm=None): + spec = { + "driver": "neuroglancer_precomputed", + "kvstore": {"driver": "ocdbt", "base": base}, + "scale_metadata": { + "size": size, + "resolution": resolution, + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + "chunk_size": [32, 32, 32], + }, + } + if extra_mm: + spec["multiscale_metadata"] = extra_mm + return ts.open(spec, create=True).result() + + scales = [ + mk([64, 64, 32], [4, 4, 40], extra_mm=mm), + mk([32, 32, 32], [8, 8, 40]), + mk([16, 16, 32], [16, 16, 40]), + ] + resolutions = [[4, 4, 40], [8, 8, 40], [16, 16, 40]] + + yield {"scales": scales, "resolutions": resolutions} + shutil.rmtree(tmpdir) + + +def _make_meta(local_ocdbt_, voxel_bounds=None): + """Minimal ChunkedGraphMeta stand-in with only the attributes downsample reads.""" + scales = local_ocdbt_["scales"] + if voxel_bounds is None: + # Full volume from scale 0. + dom = scales[0].domain + voxel_bounds = np.array( + [ + [dom[0].inclusive_min, dom[0].exclusive_max], + [dom[1].inclusive_min, dom[1].exclusive_max], + [dom[2].inclusive_min, dom[2].exclusive_max], + ], + dtype=int, + ) + return SimpleNamespace( + ws_ocdbt_scales=scales, + ws_ocdbt_resolutions=local_ocdbt_["resolutions"], + voxel_bounds=voxel_bounds, + ) + + +class TestBlockGeometry: + def test_num_output_mips(self, local_ocdbt): + meta = _make_meta(local_ocdbt) + assert ds.num_output_mips(meta) == 2 + + def test_uniform_factor(self, local_ocdbt): + meta = _make_meta(local_ocdbt) + assert ds.uniform_factor(meta) == (2, 2, 1) + + def test_non_uniform_factor_asserts(self, local_ocdbt): + meta = _make_meta(local_ocdbt) + meta.ws_ocdbt_resolutions = [[4, 4, 40], [8, 8, 40], [8, 16, 40]] + with pytest.raises(AssertionError): + ds.uniform_factor(meta) + + def test_block_shape_covers_one_coarsest_chunk(self, local_ocdbt): + # coarsest chunk = 32 mip-2 voxels per axis; factor^2 = (4,4,1). + # Block = 32 * (4,4,1) = (128, 128, 32) base voxels. + meta = _make_meta(local_ocdbt) + assert tuple(ds.block_shape(meta).tolist()) == (128, 128, 32) + + def test_blocks_for_bbox_single(self, local_ocdbt): + meta = _make_meta(local_ocdbt) + # Tiny bbox entirely inside block (0,0,0). + blocks = ds.blocks_for_bbox(meta, [10, 10, 5], [20, 20, 10]) + assert blocks == [(0, 0, 0)] + + def test_blocks_for_bbox_spans_block_boundary(self, local_ocdbt): + meta = _make_meta(local_ocdbt) + # Block shape = (128,128,32). Bbox from (120,0,0) to (200,50,10) + # crosses the x-axis boundary at 128. + blocks = ds.blocks_for_bbox(meta, [120, 0, 0], [200, 50, 10]) + assert blocks == sorted([(0, 0, 0), (1, 0, 0)]) + + def test_block_base_bbox_roundtrip(self, local_ocdbt): + meta = _make_meta(local_ocdbt) + lo, hi = ds.block_base_bbox(meta, (0, 0, 0)) + assert tuple(lo.tolist()) == (0, 0, 0) + assert tuple(hi.tolist()) == (128, 128, 32) + + lo, hi = ds.block_base_bbox(meta, (2, 1, 0)) + assert tuple(lo.tolist()) == (256, 128, 0) + assert tuple(hi.tolist()) == (384, 256, 32) + + +class TestProcessBlockInMemory: + def test_writes_to_every_non_base_scale(self, local_ocdbt): + """Base region intersected by bbox propagates to mip 1 and mip 2.""" + scales = local_ocdbt["scales"] + # Seed base with a constant label. + data = np.full((32, 32, 32), 7, dtype=np.uint64) + scales[0][0:32, 0:32, 0:32, :].write(data[..., np.newaxis]).result() + + meta = _make_meta(local_ocdbt) + # Block (0,0,0) has shape (128,128,32); only its (0..32, 0..32, 0..32) + # subregion has real data — the rest is zeros. + ds.process_block( + meta, (0, 0, 0), [(np.array([0, 0, 0]), np.array([32, 32, 32]))] + ) + + mip1 = scales[1][0:16, 0:16, 0:32, :].read().result() + mip2 = scales[2][0:8, 0:8, 0:32, :].read().result() + assert (mip1 == 7).all() + assert (mip2 == 7).all() + + def test_region_outside_bbox_stays_zero(self, local_ocdbt): + """Mip tiles whose base footprint misses the bbox are not written.""" + scales = local_ocdbt["scales"] + # Seed base with 3 inside the edit bbox only. + edit_data = np.full((16, 16, 16), 3, dtype=np.uint64) + scales[0][0:16, 0:16, 0:16, :].write(edit_data[..., np.newaxis]).result() + + meta = _make_meta(local_ocdbt) + ds.process_block( + meta, (0, 0, 0), [(np.array([0, 0, 0]), np.array([16, 16, 16]))] + ) + + # Tile inside edit: written with label 3. + mip1_inside = scales[1][0:8, 0:8, 0:16, :].read().result() + assert (mip1_inside == 3).all() + # Tile outside edit (far corner of block): still zero. + mip1_outside = scales[1][12:16, 12:16, 16:32, :].read().result() + assert (mip1_outside == 0).all() + + +class TestProcessBlockDispatcher: + def test_selects_in_memory_when_under_budget(self, local_ocdbt, monkeypatch): + """Typical small affected region → in-memory path.""" + calls = {"in_memory": 0, "per_mip": 0} + monkeypatch.setattr( + ds, + "_process_block_in_memory", + lambda *a, **kw: calls.__setitem__("in_memory", calls["in_memory"] + 1), + ) + monkeypatch.setattr( + ds, + "_process_block_per_mip", + lambda *a, **kw: calls.__setitem__("per_mip", calls["per_mip"] + 1), + ) + meta = _make_meta(local_ocdbt) + ds.process_block( + meta, (0, 0, 0), [(np.array([0, 0, 0]), np.array([16, 16, 16]))] + ) + assert calls == {"in_memory": 1, "per_mip": 0} + + def test_selects_per_mip_when_over_budget(self, local_ocdbt, monkeypatch): + """When the base read would exceed budget, the per-mip path runs.""" + calls = {"in_memory": 0, "per_mip": 0} + monkeypatch.setattr( + ds, + "_process_block_in_memory", + lambda *a, **kw: calls.__setitem__("in_memory", calls["in_memory"] + 1), + ) + monkeypatch.setattr( + ds, + "_process_block_per_mip", + lambda *a, **kw: calls.__setitem__("per_mip", calls["per_mip"] + 1), + ) + meta = _make_meta(local_ocdbt) + ds.process_block( + meta, + (0, 0, 0), + [(np.array([0, 0, 0]), np.array([128, 128, 32]))], + memory_budget_bytes=1, # force the fallback + ) + assert calls == {"in_memory": 0, "per_mip": 1} + + +class TestDownsampleBlockRowKey: + def test_length(self): + assert len(_downsample_block_lock_row_key((0, 0, 0))) == 26 + + def test_deterministic(self): + assert _downsample_block_lock_row_key( + (7, 8, 9) + ) == _downsample_block_lock_row_key((7, 8, 9)) + + def test_distinct_coords_distinct_keys(self): + a = _downsample_block_lock_row_key((1, 0, 0)) + b = _downsample_block_lock_row_key((0, 1, 0)) + assert a != b + + def test_hash_prefix_scatters(self): + """Adjacent block coords should not produce adjacent row keys (the whole + point of the hash prefix).""" + # Gather hash prefixes for a line of adjacent coords; they should span + # many distinct first-bytes, not cluster in one byte. + prefixes = {_downsample_block_lock_row_key((i, 0, 0))[0] for i in range(128)} + assert len(prefixes) > 32 + + +class _LockRegistry: + """In-memory replacement for the kvdbclient row-key lock primitives. + + Tracks which row keys are held by which operation_id so tests can + exercise contention without touching bigtable. + """ + + def __init__(self): + self._lock = threading.Lock() + self._held = {} # row_key -> operation_id + + def lock_by_row_key(self, row_key, operation_id): + with self._lock: + if row_key in self._held: + return False + self._held[row_key] = operation_id + return True + + def unlock_by_row_key(self, row_key, operation_id): + with self._lock: + if self._held.get(row_key) == operation_id: + del self._held[row_key] + return True + return False + + def renew_lock_by_row_key(self, row_key, operation_id): + with self._lock: + return self._held.get(row_key) == operation_id + + +def _make_cg_with_registry(registry): + cg = MagicMock() + cg.client.lock_by_row_key = registry.lock_by_row_key + cg.client.unlock_by_row_key = registry.unlock_by_row_key + cg.client.renew_lock_by_row_key = registry.renew_lock_by_row_key + return cg + + +class TestDownsampleBlockLock: + def test_acquire_and_release(self): + registry = _LockRegistry() + cg = _make_cg_with_registry(registry) + with DownsampleBlockLock(cg, [(0, 0, 0), (1, 0, 0)], np.uint64(42)): + assert len(registry._held) == 2 + assert registry._held == {} + + def test_non_overlapping_concurrent(self): + """Two locks on disjoint block sets can coexist.""" + registry = _LockRegistry() + cg = _make_cg_with_registry(registry) + l1 = DownsampleBlockLock(cg, [(0, 0, 0)], np.uint64(1)) + l2 = DownsampleBlockLock(cg, [(5, 5, 5)], np.uint64(2)) + l1.__enter__() + l2.__enter__() + assert len(registry._held) == 2 + l1.__exit__(None, None, None) + l2.__exit__(None, None, None) + assert registry._held == {} + + def test_overlapping_contends(self, monkeypatch): + """Two overlapping acquisitions serialize: second blocks until first releases.""" + # Short backoff so the waiting thread retries quickly after release. + monkeypatch.setattr(DownsampleBlockLock, "_ACQUIRE_BACKOFF_BASE_SEC", 0.05) + + registry = _LockRegistry() + cg = _make_cg_with_registry(registry) + + l1 = DownsampleBlockLock(cg, [(0, 0, 0)], np.uint64(1)) + l1.__enter__() + + second_entered = threading.Event() + second_failed = threading.Event() + + def second(): + lock = DownsampleBlockLock(cg, [(0, 0, 0)], np.uint64(2)) + try: + lock.__enter__() + second_entered.set() + lock.__exit__(None, None, None) + except exceptions.LockingError: + second_failed.set() + + t = threading.Thread(target=second) + t.start() + time.sleep(0.2) + # l1 is still holding; second should not have entered. + assert not second_entered.is_set() + # Now release; second should succeed on its next retry. + l1.__exit__(None, None, None) + t.join(timeout=2.0) + assert second_entered.is_set() + assert not second_failed.is_set() + assert registry._held == {} + + def test_partial_acquire_released_on_failure(self, monkeypatch): + """If any coord in the set fails to lock, prior ones are released.""" + monkeypatch.setattr(DownsampleBlockLock, "_MAX_ACQUIRE_ATTEMPTS", 2) + monkeypatch.setattr(DownsampleBlockLock, "_ACQUIRE_BACKOFF_BASE_SEC", 0.01) + + registry = _LockRegistry() + # Pre-hold (1,0,0) so the second coord always fails. + registry.lock_by_row_key( + _downsample_block_lock_row_key((1, 0, 0)), np.uint64(99) + ) + + cg = _make_cg_with_registry(registry) + lock = DownsampleBlockLock(cg, [(0, 0, 0), (1, 0, 0)], np.uint64(1)) + with pytest.raises(exceptions.LockingError): + lock.__enter__() + # Only (1,0,0) should remain held, by the pre-existing holder. + assert len(registry._held) == 1 + only_key = next(iter(registry._held)) + assert only_key == _downsample_block_lock_row_key((1, 0, 0)) diff --git a/workers/downsample_worker.py b/workers/downsample_worker.py new file mode 100644 index 000000000..ae436a9aa --- /dev/null +++ b/workers/downsample_worker.py @@ -0,0 +1,86 @@ +# pylint: disable=invalid-name, missing-docstring, logging-fstring-interpolation + +"""Pubsub worker that updates coarser segmentation mips after an SV split. + +Consumes the same edits exchange the mesh worker uses, but binds its own +queue and filters on the `downsample="true"` attribute set by +`publish_edit` when `result.seg_bbox` is populated. For each block the +SV-split touched, acquires the block's lock, runs the in-memory / +per-mip pyramid writer, releases. +""" + +import gc +import logging +import pickle +from os import getenv + +from messagingclient import MessagingClient + +from pychunkedgraph.graph import ChunkedGraph +from pychunkedgraph.graph.downsample import blocks_for_bbox, process_block +from pychunkedgraph.graph.locks import DownsampleBlockLock + +PCG_CACHE = {} + +INFO_HIGH = 25 +logging.basicConfig( + level=INFO_HIGH, + format="%(asctime)s %(message)s", + datefmt="%m/%d/%Y %I:%M:%S %p", +) + + +def callback(payload): + # Filter by attribute rather than queue binding so all edit-triggered + # workers can share the same exchange. Split edits set + # `downsample=true`; merges/undos/redos/rollbacks don't. + if payload.attributes.get("downsample") != "true": + return + + data = pickle.loads(payload.data) + op_id = int(data["operation_id"]) + table_id = payload.attributes["table_id"] + seg_bboxes = data.get("seg_bboxes") + if not seg_bboxes: + return + + try: + cg = PCG_CACHE[table_id] + except KeyError: + cg = ChunkedGraph(graph_id=table_id) + PCG_CACHE[table_id] = cg + + # Defensive: non-OCDBT graphs have no coarser scales to write to. + seg_cfg = cg.meta.custom_data.get("seg", {}) + if not seg_cfg.get("ocdbt"): + logging.log( + INFO_HIGH, + f"graph {table_id} not OCDBT-backed; skipping downsample op {op_id}", + ) + return + + # Each published bbox is one SV split's write region. Collapse the + # list into the union of blocks touched so we lock/process each + # block exactly once even if two bboxes share blocks. + unique_blocks = set() + for bbs, bbe in seg_bboxes: + unique_blocks.update(blocks_for_bbox(cg.meta, bbs, bbe)) + block_list = sorted(unique_blocks) + + logging.log( + INFO_HIGH, + f"downsampling {len(block_list)} block(s) for op {op_id} graph {table_id}", + ) + with DownsampleBlockLock(cg, block_list, op_id): + for block in block_list: + process_block(cg.meta, block, seg_bboxes) + logging.log(INFO_HIGH, f"downsample complete op {op_id} graph {table_id}") + gc.collect() + + +c = MessagingClient() +downsample_queue = getenv("PYCHUNKEDGRAPH_DOWNSAMPLE_QUEUE") +assert ( + downsample_queue is not None +), "env PYCHUNKEDGRAPH_DOWNSAMPLE_QUEUE not specified." +c.consume(downsample_queue, callback) From 8a269ea4b0621e2ea71ae3e6ad1f18bb523c5e2a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 21 Apr 2026 21:43:59 +0000 Subject: [PATCH 203/367] deps: add tinybrain Used by the async downsample worker as the mip-pyramid kernel. --- requirements.in | 1 + requirements.txt | 3 +++ 2 files changed, 4 insertions(+) diff --git a/requirements.in b/requirements.in index 143d90399..57e07e5c2 100644 --- a/requirements.in +++ b/requirements.in @@ -30,6 +30,7 @@ dracopy>=1.5.0 datastoreflex>=0.5.0 kvdbclient>0.5.0 zstandard>=0.23.0 +tinybrain>=1.7.0 # Conda only - use requirements.yml (or install manually): # graph-tool \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index af29a75bd..7edbee1a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -244,6 +244,7 @@ numpy==2.4.2 # task-queue # tensorstore # tifffile + # tinybrain # zmesh opentelemetry-api==1.39.1 # via @@ -397,6 +398,8 @@ tensorstore==0.1.81 # via -r requirements.in tifffile==2026.3.3 # via scikit-image +tinybrain==1.7.0 + # via -r requirements.in tqdm==4.67.3 # via # cloud-files From 8abbf674b5fe1a831fb5b38727d9fc54ae513699 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 22 Apr 2026 01:56:54 +0000 Subject: [PATCH 204/367] locks: add L2ChunkLock for spatial serialization of SV splits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a concurrency gap where two SV splits on overlapping L2 chunks but distinct roots can't be serialized by root locks — they acquire disjoint root-lock sets and race on seg state. L2ChunkLock serializes them via the kvdbclient row-key lock primitive. Row key = 2-byte blake2b hash + 8-byte uint64 chunk_id (10 bytes). Hash prefix keeps spatially-clustered L2 chunks from hot-spotting a single bigtable tablet under concurrent load. Primitive only — callers land separately. RowKeyLockRegistry helper moved to tests/helpers.py so L2ChunkLock and DownsampleBlockLock tests share it instead of duplicating. --- pychunkedgraph/graph/locks.py | 107 ++++++++++++++++++ pychunkedgraph/tests/graph/test_downsample.py | 59 ++-------- pychunkedgraph/tests/graph/test_locks.py | 99 +++++++++++++++- pychunkedgraph/tests/helpers.py | 43 +++++++ 4 files changed, 260 insertions(+), 48 deletions(-) diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index 06b1eed77..caade7ad0 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -295,3 +295,110 @@ def renew(self) -> bool: logger.warning(f"Failed to renew downsample block lock {key!r}") ok = False return ok + + +def _l2_chunk_lock_row_key(chunk_id) -> bytes: + """Row key for one L2 chunk's spatial lock cell. + + Hash-prefixed so spatially-clustered chunk IDs scatter across + bigtable tablets instead of piling up in one lexicographic range, + which would hot-spot a single tablet under concurrent load. + + 10 bytes total: + - 2-byte blake2b hash of the chunk_id (tablet distribution). + - 8 bytes of big-endian uint64 chunk_id. + chunk_id already encodes layer+xyz in its bits, so the full key is + unique per L2 chunk. + """ + packed = int(chunk_id).to_bytes(8, "big", signed=False) + return hashlib.blake2b(packed, digest_size=2).digest() + packed + + +class L2ChunkLock: + """Lock a set of L2 chunks to serialize SV splits that touch them. + + Closes the cross-root spatial race: two SV splits on overlapping L2 + chunks but distinct roots acquire disjoint root-lock sets and would + otherwise race on seg state. This lock is held across the + `split_supervoxel` loop (seg write + SV-level hierarchy row write) + so the pair commits atomically. + + All-or-nothing: on partial acquisition we release what we got and + retry with backoff; on repeated failure we raise `LockingError`. + + Uses `cg.client.lock_by_row_key` — the generic row-key lock in + kvdbclient — with a row-key namespace distinct from root and + downsample block locks (all three share `attributes.Concurrency.Lock` + under the hood; the row key disambiguates). + """ + + __slots__ = ["cg", "chunk_ids", "operation_id", "acquired_keys"] + + # Retry budget for partial-acquire failures. Each attempt releases + # anything it got in the previous pass, then re-acquires from scratch. + _MAX_ACQUIRE_ATTEMPTS = 7 + _ACQUIRE_BACKOFF_BASE_SEC = 0.5 + + def __init__( + self, + cg, + chunk_ids: Sequence[np.uint64], + operation_id: np.uint64, + ) -> None: + self.cg = cg + # Sort so every `__enter__` uses a consistent acquisition order + # across workers — reduces contention when overlapping lock sets + # would otherwise race AB/BA. + self.chunk_ids = sorted(int(c) for c in chunk_ids) + self.operation_id = np.uint64(operation_id) + self.acquired_keys: list = [] + + def __enter__(self): + for attempt in range(self._MAX_ACQUIRE_ATTEMPTS): + self.acquired_keys = [] + all_ok = True + for chunk_id in self.chunk_ids: + row_key = _l2_chunk_lock_row_key(chunk_id) + if self.cg.client.lock_by_row_key(row_key, self.operation_id): + self.acquired_keys.append(row_key) + else: + all_ok = False + break + if all_ok: + return self + self._release_acquired() + time.sleep(self._ACQUIRE_BACKOFF_BASE_SEC * (2**attempt)) + raise exceptions.LockingError( + f"Could not acquire L2 chunk locks for chunks {self.chunk_ids} " + f"after {self._MAX_ACQUIRE_ATTEMPTS} attempts" + ) + + def __exit__(self, exception_type, exception_value, traceback): + self._release_acquired() + + def _release_acquired(self): + if not self.acquired_keys: + return + max_workers = min(8, max(1, len(self.acquired_keys))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit( + self.cg.client.unlock_by_row_key, key, self.operation_id + ) + for key in self.acquired_keys + ] + for future in as_completed(futures): + try: + future.result() + except Exception as e: + logger.warning(f"Failed to unlock L2 chunk: {e}") + self.acquired_keys = [] + + def renew(self) -> bool: + """Extend expiry on every held lock. Returns False if any failed.""" + ok = True + for key in self.acquired_keys: + if not self.cg.client.renew_lock_by_row_key(key, self.operation_id): + logger.warning(f"Failed to renew L2 chunk lock {key!r}") + ok = False + return ok diff --git a/pychunkedgraph/tests/graph/test_downsample.py b/pychunkedgraph/tests/graph/test_downsample.py index 0f1e69ee1..2eb799334 100644 --- a/pychunkedgraph/tests/graph/test_downsample.py +++ b/pychunkedgraph/tests/graph/test_downsample.py @@ -5,7 +5,6 @@ import threading import time from types import SimpleNamespace -from unittest.mock import MagicMock import numpy as np import pytest @@ -17,6 +16,10 @@ _downsample_block_lock_row_key, ) from pychunkedgraph.graph import exceptions +from pychunkedgraph.tests.helpers import ( + RowKeyLockRegistry, + make_cg_with_row_key_lock_registry, +) @pytest.fixture @@ -229,56 +232,18 @@ def test_hash_prefix_scatters(self): assert len(prefixes) > 32 -class _LockRegistry: - """In-memory replacement for the kvdbclient row-key lock primitives. - - Tracks which row keys are held by which operation_id so tests can - exercise contention without touching bigtable. - """ - - def __init__(self): - self._lock = threading.Lock() - self._held = {} # row_key -> operation_id - - def lock_by_row_key(self, row_key, operation_id): - with self._lock: - if row_key in self._held: - return False - self._held[row_key] = operation_id - return True - - def unlock_by_row_key(self, row_key, operation_id): - with self._lock: - if self._held.get(row_key) == operation_id: - del self._held[row_key] - return True - return False - - def renew_lock_by_row_key(self, row_key, operation_id): - with self._lock: - return self._held.get(row_key) == operation_id - - -def _make_cg_with_registry(registry): - cg = MagicMock() - cg.client.lock_by_row_key = registry.lock_by_row_key - cg.client.unlock_by_row_key = registry.unlock_by_row_key - cg.client.renew_lock_by_row_key = registry.renew_lock_by_row_key - return cg - - class TestDownsampleBlockLock: def test_acquire_and_release(self): - registry = _LockRegistry() - cg = _make_cg_with_registry(registry) + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) with DownsampleBlockLock(cg, [(0, 0, 0), (1, 0, 0)], np.uint64(42)): assert len(registry._held) == 2 assert registry._held == {} def test_non_overlapping_concurrent(self): """Two locks on disjoint block sets can coexist.""" - registry = _LockRegistry() - cg = _make_cg_with_registry(registry) + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) l1 = DownsampleBlockLock(cg, [(0, 0, 0)], np.uint64(1)) l2 = DownsampleBlockLock(cg, [(5, 5, 5)], np.uint64(2)) l1.__enter__() @@ -293,8 +258,8 @@ def test_overlapping_contends(self, monkeypatch): # Short backoff so the waiting thread retries quickly after release. monkeypatch.setattr(DownsampleBlockLock, "_ACQUIRE_BACKOFF_BASE_SEC", 0.05) - registry = _LockRegistry() - cg = _make_cg_with_registry(registry) + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) l1 = DownsampleBlockLock(cg, [(0, 0, 0)], np.uint64(1)) l1.__enter__() @@ -328,13 +293,13 @@ def test_partial_acquire_released_on_failure(self, monkeypatch): monkeypatch.setattr(DownsampleBlockLock, "_MAX_ACQUIRE_ATTEMPTS", 2) monkeypatch.setattr(DownsampleBlockLock, "_ACQUIRE_BACKOFF_BASE_SEC", 0.01) - registry = _LockRegistry() + registry = RowKeyLockRegistry() # Pre-hold (1,0,0) so the second coord always fails. registry.lock_by_row_key( _downsample_block_lock_row_key((1, 0, 0)), np.uint64(99) ) - cg = _make_cg_with_registry(registry) + cg = make_cg_with_row_key_lock_registry(registry) lock = DownsampleBlockLock(cg, [(0, 0, 0), (1, 0, 0)], np.uint64(1)) with pytest.raises(exceptions.LockingError): lock.__enter__() diff --git a/pychunkedgraph/tests/graph/test_locks.py b/pychunkedgraph/tests/graph/test_locks.py index 97da9334c..ef190748b 100644 --- a/pychunkedgraph/tests/graph/test_locks.py +++ b/pychunkedgraph/tests/graph/test_locks.py @@ -1,10 +1,19 @@ +import threading +import time from time import sleep from datetime import datetime, timedelta, UTC import numpy as np import pytest -from ..helpers import create_chunk, to_label +from ..helpers import ( + RowKeyLockRegistry, + create_chunk, + make_cg_with_row_key_lock_registry, + to_label, +) +from ...graph import exceptions +from ...graph.locks import L2ChunkLock, _l2_chunk_lock_row_key from ...graph.lineage import get_future_root_ids from ...ingest.create.parent_layer import add_parent_chunk @@ -750,3 +759,91 @@ def test_rootlock_as_context_manager(self): assert lock.lock_acquired is True cg.client.unlock_root.assert_called_once() + + +class TestL2ChunkLockRowKey: + def test_length(self): + assert len(_l2_chunk_lock_row_key(0)) == 10 + + def test_deterministic(self): + assert _l2_chunk_lock_row_key(0xDEADBEEF) == _l2_chunk_lock_row_key(0xDEADBEEF) + + def test_distinct_chunks_distinct_keys(self): + assert _l2_chunk_lock_row_key(42) != _l2_chunk_lock_row_key(43) + + def test_hash_prefix_scatters(self): + """Adjacent chunk IDs should not cluster in one first-byte prefix — + that's the whole point of the hash prefix.""" + prefixes = {_l2_chunk_lock_row_key(i)[0] for i in range(256)} + # blake2b over 8 bytes of changing input distributes uniformly. + assert len(prefixes) > 128 + + +class TestL2ChunkLock: + def test_acquire_and_release(self): + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) + with L2ChunkLock(cg, [np.uint64(1), np.uint64(2)], np.uint64(42)): + assert len(registry._held) == 2 + assert registry._held == {} + + def test_non_overlapping_concurrent(self): + """Disjoint chunk sets can coexist — no shared row keys.""" + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) + l1 = L2ChunkLock(cg, [np.uint64(1)], np.uint64(1)) + l2 = L2ChunkLock(cg, [np.uint64(5)], np.uint64(2)) + l1.__enter__() + l2.__enter__() + assert len(registry._held) == 2 + l1.__exit__(None, None, None) + l2.__exit__(None, None, None) + assert registry._held == {} + + def test_overlapping_contends(self, monkeypatch): + """Two overlapping acquisitions serialize: second blocks until first releases.""" + monkeypatch.setattr(L2ChunkLock, "_ACQUIRE_BACKOFF_BASE_SEC", 0.05) + + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) + + l1 = L2ChunkLock(cg, [np.uint64(7)], np.uint64(1)) + l1.__enter__() + + second_entered = threading.Event() + second_failed = threading.Event() + + def second(): + lock = L2ChunkLock(cg, [np.uint64(7)], np.uint64(2)) + try: + lock.__enter__() + second_entered.set() + lock.__exit__(None, None, None) + except exceptions.LockingError: + second_failed.set() + + t = threading.Thread(target=second) + t.start() + time.sleep(0.2) + assert not second_entered.is_set() + l1.__exit__(None, None, None) + t.join(timeout=2.0) + assert second_entered.is_set() + assert not second_failed.is_set() + assert registry._held == {} + + def test_partial_acquire_released_on_failure(self, monkeypatch): + """If any chunk in the set fails to lock, prior ones are released.""" + monkeypatch.setattr(L2ChunkLock, "_MAX_ACQUIRE_ATTEMPTS", 2) + monkeypatch.setattr(L2ChunkLock, "_ACQUIRE_BACKOFF_BASE_SEC", 0.01) + + registry = RowKeyLockRegistry() + registry.lock_by_row_key(_l2_chunk_lock_row_key(np.uint64(2)), np.uint64(99)) + + cg = make_cg_with_row_key_lock_registry(registry) + lock = L2ChunkLock(cg, [np.uint64(1), np.uint64(2)], np.uint64(1)) + with pytest.raises(exceptions.LockingError): + lock.__enter__() + # Only chunk 2 remains held, by the pre-existing holder. + assert len(registry._held) == 1 + assert next(iter(registry._held)) == _l2_chunk_lock_row_key(np.uint64(2)) diff --git a/pychunkedgraph/tests/helpers.py b/pychunkedgraph/tests/helpers.py index c41d629f6..3f87bb3d6 100644 --- a/pychunkedgraph/tests/helpers.py +++ b/pychunkedgraph/tests/helpers.py @@ -1,4 +1,6 @@ +import threading from functools import reduce +from unittest.mock import MagicMock import numpy as np @@ -109,3 +111,44 @@ def get_layer_chunk_bounds( layer_bounds = atomic_chunk_bounds / (2 ** (layer - 2)) layer_bounds_d[layer] = np.ceil(layer_bounds).astype(int) return layer_bounds_d + + +class RowKeyLockRegistry: + """Thread-safe in-memory stand-in for kvdbclient's row-key lock API. + + Matches the `cg.client.lock_by_row_key` / `unlock_by_row_key` / + `renew_lock_by_row_key` surface so row-key-based lock primitives + (DownsampleBlockLock, L2ChunkLock, …) can be exercised in unit + tests without running a bigtable emulator. + """ + + def __init__(self): + self._lock = threading.Lock() + self._held = {} + + def lock_by_row_key(self, row_key, operation_id): + with self._lock: + if row_key in self._held: + return False + self._held[row_key] = operation_id + return True + + def unlock_by_row_key(self, row_key, operation_id): + with self._lock: + if self._held.get(row_key) == operation_id: + del self._held[row_key] + return True + return False + + def renew_lock_by_row_key(self, row_key, operation_id): + with self._lock: + return self._held.get(row_key) == operation_id + + +def make_cg_with_row_key_lock_registry(registry: RowKeyLockRegistry): + """Attach a `RowKeyLockRegistry` to a `MagicMock` cg.client.""" + cg = MagicMock() + cg.client.lock_by_row_key = registry.lock_by_row_key + cg.client.unlock_by_row_key = registry.unlock_by_row_key + cg.client.renew_lock_by_row_key = registry.renew_lock_by_row_key + return cg From 09ecbb0ad387ffc8559f472df9944e23346634ee Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 22 Apr 2026 14:20:35 +0000 Subject: [PATCH 205/367] cutting: return tagged result from run_multicut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers now get Cut(atomic_edges) | PreviewCut(ccs, illegal_split) | SvSplitRequired(sv_remapping) instead of unwrapping-by-convention or catching SupervoxelSplitRequiredError. The exception still unwinds inside LocalMincutGraph — cheapest way to bail out of deep path code — but it's caught once at the run_multicut boundary and never escapes, so callers don't use raise/catch for control flow. --- pychunkedgraph/graph/cutting.py | 96 +++++++++++++++++---- pychunkedgraph/tests/graph/test_cutting.py | 87 ++++++++++--------- pychunkedgraph/tests/graph/test_multicut.py | 32 ++++--- 3 files changed, 141 insertions(+), 74 deletions(-) diff --git a/pychunkedgraph/graph/cutting.py b/pychunkedgraph/graph/cutting.py index e49cc9ded..8dd8fe7c7 100644 --- a/pychunkedgraph/graph/cutting.py +++ b/pychunkedgraph/graph/cutting.py @@ -5,7 +5,8 @@ import graph_tool import graph_tool.flow -from typing import Tuple +from dataclasses import dataclass +from typing import Tuple, Union from typing import Sequence from typing import Iterable @@ -19,6 +20,40 @@ DEBUG_MODE = False +@dataclass +class Cut: + """Multicut produced a clean partition — these SV-pair edges are to be cut.""" + + atomic_edges: np.ndarray # shape (N, 2) + + +@dataclass +class PreviewCut: + """Multicut in preview mode — connected components after the proposed cut. + + `illegal_split` flags cases where the cut isolates source or sink. + """ + + supervoxel_ccs: list + illegal_split: bool + + +@dataclass +class SvSplitRequired: + """Multicut could not partition without first splitting a supervoxel. + + Carries the cross-chunk-representative remapping the caller needs to + run the actual SV split. Returned (not raised) from run_multicut; the + SupervoxelSplitRequiredError that surfaces this condition is caught + inside run_multicut and never escapes as control flow. + """ + + sv_remapping: dict # old_sv_id -> rep_sv_id + + +MulticutResult = Union[Cut, PreviewCut, SvSplitRequired] + + class IsolatingCutException(Exception): """Raised when mincut would split off one of the labeled supervoxel exactly. This is used to trigger a PostconditionError with a custom message. @@ -668,21 +703,38 @@ def run_multicut( path_augment: bool = True, disallow_isolating_cut: bool = True, sv_split_supported: bool = False, -): - local_mincut_graph = LocalMincutGraph( - edges.get_pairs(), - edges.affinities, - source_ids, - sink_ids, - split_preview, - path_augment, - disallow_isolating_cut=disallow_isolating_cut, - sv_split_supported=sv_split_supported, - ) - atomic_edges = local_mincut_graph.compute_mincut() - if len(atomic_edges) == 0: +) -> MulticutResult: + """Run the multicut and return either the cut edges or an SV-split request. + + When `sv_split_supported=True`, the "source and sink share a cross-chunk + rep" condition is returned as `SvSplitRequired` rather than raised — + `SupervoxelSplitRequiredError` is an implementation detail of + `LocalMincutGraph` unwinding, caught at this boundary so it never + drives control flow in callers. + """ + try: + local_mincut_graph = LocalMincutGraph( + edges.get_pairs(), + edges.affinities, + source_ids, + sink_ids, + split_preview, + path_augment, + disallow_isolating_cut=disallow_isolating_cut, + sv_split_supported=sv_split_supported, + ) + mincut_output = local_mincut_graph.compute_mincut() + except SupervoxelSplitRequiredError as err: + return SvSplitRequired(err.sv_remapping) + + if split_preview: + # compute_mincut returns (ccs, illegal_split) in preview mode. + supervoxel_ccs, illegal_split = mincut_output + return PreviewCut(supervoxel_ccs, illegal_split) + + if len(mincut_output) == 0: raise PostconditionError(f"Mincut failed. Try with a different set of points.") - return atomic_edges + return Cut(mincut_output) def run_split_preview( @@ -713,7 +765,7 @@ def run_split_preview( mask0 = np.isin(edges.node_ids1, supervoxels) mask1 = np.isin(edges.node_ids2, supervoxels) edges = edges[mask0 & mask1] - edges_to_remove, illegal_split = run_multicut( + result = run_multicut( edges, source_ids, sink_ids, @@ -722,8 +774,14 @@ def run_split_preview( disallow_isolating_cut=disallow_isolating_cut, sv_split_supported=cg.meta.ocdbt_seg, ) + if isinstance(result, SvSplitRequired): + # Preview callers can't perform an SV split; surface as a precondition. + raise PreconditionError( + "Supervoxel split required to cut these source/sink points; " + "preview is not available until an edit is applied." + ) - if len(edges_to_remove) == 0: + assert isinstance(result, PreviewCut), f"unexpected preview result type: {result!r}" + if len(result.supervoxel_ccs) == 0: raise PostconditionError("Mincut could not find any edges to remove.") - - return edges_to_remove, illegal_split + return result.supervoxel_ccs, result.illegal_split diff --git a/pychunkedgraph/tests/graph/test_cutting.py b/pychunkedgraph/tests/graph/test_cutting.py index 89cf4969d..4411d876c 100644 --- a/pychunkedgraph/tests/graph/test_cutting.py +++ b/pychunkedgraph/tests/graph/test_cutting.py @@ -4,8 +4,10 @@ import pytest from pychunkedgraph.graph.cutting import ( + Cut, IsolatingCutException, LocalMincutGraph, + PreviewCut, merge_cross_chunk_edges_graph_tool, run_multicut, ) @@ -336,8 +338,9 @@ def test_basic_split(self): path_augment=True, disallow_isolating_cut=False, ) - assert len(result) > 0 - result_set = set(map(tuple, result)) + assert isinstance(result, Cut) + assert len(result.atomic_edges) > 0 + result_set = set(map(tuple, result.atomic_edges)) assert (2, 3) in result_set or (3, 2) in result_set def test_basic_split_direct(self): @@ -354,8 +357,9 @@ def test_basic_split_direct(self): path_augment=False, disallow_isolating_cut=False, ) - assert len(result) > 0 - result_set = set(map(tuple, result)) + assert isinstance(result, Cut) + assert len(result.atomic_edges) > 0 + result_set = set(map(tuple, result.atomic_edges)) assert (2, 3) in result_set or (3, 2) in result_set def test_no_edges_raises(self): @@ -377,7 +381,7 @@ def test_no_edges_raises(self): ) def test_split_preview_mode(self): - """run_multicut with split_preview=True returns (ccs, illegal_split).""" + """run_multicut with split_preview=True returns a PreviewCut.""" node_ids1 = np.array([1, 2, 3], dtype=np.uint64) node_ids2 = np.array([2, 3, 4], dtype=np.uint64) affinities = np.array([0.9, 0.05, 0.9], dtype=np.float32) @@ -391,10 +395,10 @@ def test_split_preview_mode(self): path_augment=False, disallow_isolating_cut=False, ) - supervoxel_ccs, illegal_split = result - assert isinstance(supervoxel_ccs, list) - assert len(supervoxel_ccs) >= 2 - assert isinstance(illegal_split, bool) + assert isinstance(result, PreviewCut) + assert isinstance(result.supervoxel_ccs, list) + assert len(result.supervoxel_ccs) >= 2 + assert isinstance(result.illegal_split, bool) class TestMergeCrossChunkEdgesOverlap: @@ -641,7 +645,7 @@ class TestRunMulticutSplitPreview: """Test run_multicut in split_preview mode returns correct structure.""" def test_split_preview_returns_ccs_and_flag(self): - """run_multicut with split_preview=True should return (ccs, illegal_split).""" + """run_multicut with split_preview=True should return a PreviewCut.""" node_ids1 = np.array([1, 2, 3], dtype=np.uint64) node_ids2 = np.array([2, 3, 4], dtype=np.uint64) affinities = np.array([0.9, 0.01, 0.9], dtype=np.float32) @@ -656,15 +660,15 @@ def test_split_preview_returns_ccs_and_flag(self): disallow_isolating_cut=False, ) - supervoxel_ccs, illegal_split = result - assert isinstance(supervoxel_ccs, list) - assert len(supervoxel_ccs) >= 2 - assert isinstance(illegal_split, bool) + assert isinstance(result, PreviewCut) + assert isinstance(result.supervoxel_ccs, list) + assert len(result.supervoxel_ccs) >= 2 + assert isinstance(result.illegal_split, bool) # Source side CC - assert 1 in supervoxel_ccs[0] + assert 1 in result.supervoxel_ccs[0] # Sink side CC - assert 4 in supervoxel_ccs[1] + assert 4 in result.supervoxel_ccs[1] def test_split_preview_with_path_augment(self): """run_multicut with split_preview=True and path_augment=True.""" @@ -682,12 +686,12 @@ def test_split_preview_with_path_augment(self): disallow_isolating_cut=False, ) - supervoxel_ccs, illegal_split = result - assert len(supervoxel_ccs) >= 2 + assert isinstance(result, PreviewCut) + assert len(result.supervoxel_ccs) >= 2 # Source side - assert 1 in supervoxel_ccs[0] + assert 1 in result.supervoxel_ccs[0] # Sink side - assert 5 in supervoxel_ccs[1] + assert 5 in result.supervoxel_ccs[1] def test_split_preview_larger_graph(self): """split_preview on a larger graph with a clear cut point.""" @@ -709,14 +713,14 @@ def test_split_preview_larger_graph(self): disallow_isolating_cut=False, ) - supervoxel_ccs, illegal_split = result - source_cc = set(supervoxel_ccs[0]) - sink_cc = set(supervoxel_ccs[1]) + assert isinstance(result, PreviewCut) + source_cc = set(result.supervoxel_ccs[0]) + sink_cc = set(result.supervoxel_ccs[1]) # Source cluster assert {1, 2, 3}.issubset(source_cc) # Sink cluster assert {4, 5, 6}.issubset(sink_cc) - assert not illegal_split + assert not result.illegal_split class TestLocalMincutGraphWithLogger: @@ -1040,7 +1044,7 @@ class TestRunSplitPreview: """ def test_basic_split_preview(self): - """run_multicut with split_preview should return CCs and a flag.""" + """run_multicut with split_preview should return a PreviewCut.""" edges_sv = Edges( np.array([1, 2, 3, 4], dtype=np.uint64), np.array([2, 3, 4, 5], dtype=np.uint64), @@ -1049,16 +1053,17 @@ def test_basic_split_preview(self): ) sources = np.array([1], dtype=np.uint64) sinks = np.array([5], dtype=np.uint64) - ccs, illegal_split = run_multicut( + result = run_multicut( edges_sv, sources, sinks, split_preview=True, disallow_isolating_cut=False, ) - assert isinstance(ccs, list) - assert isinstance(illegal_split, bool) - assert len(ccs) >= 2 + assert isinstance(result, PreviewCut) + assert isinstance(result.supervoxel_ccs, list) + assert isinstance(result.illegal_split, bool) + assert len(result.supervoxel_ccs) >= 2 def test_split_preview_with_areas(self): """Split preview with areas provided.""" @@ -1070,7 +1075,7 @@ def test_split_preview_with_areas(self): ) sources = np.array([10], dtype=np.uint64) sinks = np.array([40], dtype=np.uint64) - ccs, illegal_split = run_multicut( + result = run_multicut( edges_sv, sources, sinks, @@ -1078,12 +1083,10 @@ def test_split_preview_with_areas(self): path_augment=False, disallow_isolating_cut=False, ) - assert isinstance(ccs, list) - assert len(ccs) >= 2 - # Source side should contain 10 - assert 10 in ccs[0] - # Sink side should contain 40 - assert 40 in ccs[1] + assert isinstance(result, PreviewCut) + assert len(result.supervoxel_ccs) >= 2 + assert 10 in result.supervoxel_ccs[0] + assert 40 in result.supervoxel_ccs[1] def test_split_preview_path_augment(self): """Split preview with path_augment=True.""" @@ -1094,7 +1097,7 @@ def test_split_preview_path_augment(self): ) sources = np.array([1], dtype=np.uint64) sinks = np.array([6], dtype=np.uint64) - ccs, illegal_split = run_multicut( + result = run_multicut( edges_sv, sources, sinks, @@ -1102,11 +1105,11 @@ def test_split_preview_path_augment(self): path_augment=True, disallow_isolating_cut=False, ) - assert isinstance(ccs, list) - assert len(ccs) >= 2 - assert 1 in ccs[0] - assert 6 in ccs[1] - assert not illegal_split + assert isinstance(result, PreviewCut) + assert len(result.supervoxel_ccs) >= 2 + assert 1 in result.supervoxel_ccs[0] + assert 6 in result.supervoxel_ccs[1] + assert not result.illegal_split class TestFilterGraphCCsWithLogger: diff --git a/pychunkedgraph/tests/graph/test_multicut.py b/pychunkedgraph/tests/graph/test_multicut.py index 590476ffd..4edd5962f 100644 --- a/pychunkedgraph/tests/graph/test_multicut.py +++ b/pychunkedgraph/tests/graph/test_multicut.py @@ -2,8 +2,7 @@ import pytest from ...graph.edges import Edges -from ...graph import exceptions -from ...graph.cutting import run_multicut +from ...graph.cutting import Cut, SvSplitRequired, run_multicut class TestGraphMultiCut: @@ -25,13 +24,15 @@ def test_cut_multi_tree(self, gen_graph): source_ids = np.array([1, 2], dtype=np.uint64) sink_ids = np.array([5, 6], dtype=np.uint64) - cut_edges = run_multicut( + result = run_multicut( edges, source_ids, sink_ids, path_augment=False, disallow_isolating_cut=False, ) + assert isinstance(result, Cut) + cut_edges = result.atomic_edges assert cut_edges.shape[0] > 0 # Verify the cut actually separates sources from sinks @@ -64,14 +65,19 @@ def test_path_augmented_multicut(self, sv_data): edges = Edges( sv_edges[:, 0], sv_edges[:, 1], affinities=sv_affinity, areas=sv_area ) - cut_edges_aug = run_multicut(edges, sv_sources, sv_sinks, path_augment=True) - assert cut_edges_aug.shape[0] == 350 + result = run_multicut(edges, sv_sources, sv_sinks, path_augment=True) + assert isinstance(result, Cut) + assert result.atomic_edges.shape[0] == 350 - with pytest.raises(exceptions.SupervoxelSplitRequiredError): - run_multicut( - edges, - sv_sources, - sv_sinks, - path_augment=False, - sv_split_supported=True, - ) + # Without path augmentation on this fixture, source/sink share a + # cross-chunk representative — returned as SvSplitRequired when + # sv_split_supported=True (no exception escapes run_multicut). + sv_result = run_multicut( + edges, + sv_sources, + sv_sinks, + path_augment=False, + sv_split_supported=True, + ) + assert isinstance(sv_result, SvSplitRequired) + assert sv_result.sv_remapping # non-empty mapping From a76cdbb84a13ea195b236466febffc706dcf5d03 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 22 Apr 2026 14:39:43 +0000 Subject: [PATCH 206/367] sv split: extend root lock across multicut + SV split + commit MulticutOperation._apply dispatches on the tagged multicut result and, when an SV split is needed, calls the new edits_sv.split_supervoxels under its surrounding RootLock, refreshes source/sink SV IDs from seg, and retries multicut against the post-split graph. Root lock spans the whole critical section; L2ChunkLock held only around the split loop. This closes two races that existed when split_with_sv_splits handled the flow outside any lock: same-root (root lock now never released between multicut and commit) and cross-root (L2ChunkLock serializes overlapping split regions). split_with_sv_splits is deleted; handle_split calls cg.remove_edges directly. --- pychunkedgraph/app/segmentation/common.py | 100 ++---------------- pychunkedgraph/graph/edits_sv.py | 117 +++++++++++++++++++--- pychunkedgraph/graph/operation.py | 89 ++++++++++++---- 3 files changed, 180 insertions(+), 126 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 5a33c9e71..33a232547 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -10,7 +10,6 @@ import numpy as np import pandas as pd -import fastremap from flask import current_app, g, jsonify, make_response, request from messagingclient import MessagingClient from pytz import UTC @@ -27,7 +26,6 @@ exceptions as cg_exceptions, ) from pychunkedgraph.graph.analysis import pathing -from pychunkedgraph.graph.edits_sv import split_supervoxel from pychunkedgraph.graph.misc import get_contact_sites from pychunkedgraph.debug.sv_split import check_unsplit_sv_bridges from pychunkedgraph.graph.operation import GraphEditOperation @@ -441,93 +439,6 @@ def _get_sources_and_sinks(cg: ChunkedGraph, data): return (source_ids, sink_ids, source_coords, sink_coords) -def split_with_sv_splits(cg, data, user_id="test", mincut=True): - """Remove edges with automatic supervoxel splitting when needed. - - Attempts remove_edges. If source/sink SVs share a cross-chunk representative, - splits the overlapping SVs in the segmentation and retries. - """ - sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) - logger.note(f"pre-split: sources={sources}, sinks={sinks}") - t0 = time.time() - try: - ret = cg.remove_edges( - user_id=user_id, - source_ids=sources, - sink_ids=sinks, - source_coords=source_coords, - sink_coords=sink_coords, - mincut=mincut, - ) - logger.note(f"remove_edges ({time.time() - t0:.2f}s)") - except cg_exceptions.SupervoxelSplitRequiredError as e: - logger.note(f"sv split required ({time.time() - t0:.2f}s): {e}") - sources_remapped = fastremap.remap( - sources, - e.sv_remapping, - preserve_missing_labels=True, - in_place=False, - ) - sinks_remapped = fastremap.remap( - sinks, - e.sv_remapping, - preserve_missing_labels=True, - in_place=False, - ) - logger.note(f"remapped sources={sources_remapped}, sinks={sinks_remapped}") - overlap_mask = np.isin(sources_remapped, sinks_remapped) - logger.note(f"overlapping reps: {np.unique(sources_remapped[overlap_mask])}") - t1 = time.time() - # Collect the base-resolution bbox for each SV split so the downsample - # worker only re-derives coarser mips for regions that actually changed. - # The list is kept as-is (not merged into a single envelope) because - # merging overlapping-but-disjoint bboxes would include corner regions - # that no split touched — the worker would then re-write unchanged - # tiles, inflating the OCDBT delta store. - seg_bboxes = [] - for rep in np.unique(sources_remapped[overlap_mask]): - _mask0 = sources_remapped == rep - _mask1 = sinks_remapped == rep - _, _, seg_bbox = split_supervoxel( - cg, - sources[_mask0][0], - source_coords[_mask0], - sink_coords[_mask1], - e.operation_id, - sv_remapping=e.sv_remapping, - ) - seg_bboxes.append(seg_bbox) - logger.note(f"sv splits done ({time.time() - t1:.2f}s)") - - sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) - logger.note(f"post-split: sources={sources}, sinks={sinks}") - t1 = time.time() - try: - ret = cg.remove_edges( - user_id=user_id, - source_ids=sources, - sink_ids=sinks, - source_coords=source_coords, - sink_coords=sink_coords, - mincut=mincut, - ) - except cg_exceptions.SupervoxelSplitRequiredError as e2: - # The cross-chunk representative group extends beyond the split - # bbox. Unsplit SVs inside the bbox still have inf edges to SVs - # outside, bridging source and sink through the broader component. - - logger.note(f"retry still requires sv split") - # check_unsplit_sv_bridges(cg, e2.sv_remapping, sources, sinks) - raise cg_exceptions.PreconditionError( - "Supervoxel split succeeded but the split region is too small " - "to fully separate source and sink. " - "Try placing source and sink points farther apart." - ) from e2 - logger.note(f"remove_edges after sv split ({time.time() - t1:.2f}s)") - ret = ret._replace(seg_bbox=seg_bboxes) - return ret - - def handle_split(table_id): current_app.table_id = table_id user_id = str(g.auth_user.get("id", current_app.user_id)) @@ -539,8 +450,17 @@ def handle_split(table_id): cg = app_utils.get_cg(table_id, skip_cache=True) current_app.logger.debug(data) + sources, sinks, source_coords, sink_coords = _get_sources_and_sinks(cg, data) + logger.note(f"split inputs: sources={sources}, sinks={sinks}") try: - ret = split_with_sv_splits(cg, data, user_id, mincut) + ret = cg.remove_edges( + user_id=user_id, + source_ids=sources, + sink_ids=sinks, + source_coords=source_coords, + sink_coords=sink_coords, + mincut=mincut, + ) except cg_exceptions.LockingError as e: raise cg_exceptions.InternalServerError(e) except cg_exceptions.PreconditionError as e: diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 5f3ffc827..61f243600 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -5,6 +5,7 @@ import time from datetime import datetime from collections import defaultdict, deque +from typing import TYPE_CHECKING import fastremap import numpy as np @@ -12,7 +13,6 @@ from pychunkedgraph import get_logger from pychunkedgraph.graph import ( attributes, - ChunkedGraph, cache as cache_utils, basetypes, serializers, @@ -20,15 +20,112 @@ from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox from pychunkedgraph.graph.cutting_sv import split_supervoxel_helper from pychunkedgraph.graph.edges_sv import update_edges, add_new_edges +from pychunkedgraph.graph.locks import L2ChunkLock from pychunkedgraph.graph.ocdbt import write_seg from pychunkedgraph.graph.utils import get_local_segmentation from pychunkedgraph.io.edges import get_chunk_edges +if TYPE_CHECKING: + from pychunkedgraph.graph.chunkedgraph import ChunkedGraph + logger = get_logger(__name__) +def _split_bbox(meta, source_coords, sink_coords): + """Chunk-aligned base-resolution bbox covering source + sink coords. + + Same formula `split_supervoxel` uses internally — factored out so + `_l2_chunks_for_split` and any other caller agree on the envelope. + Returns `(bbs, bbe, chunk_min, chunk_max)` in base voxels, clipped to + volume bounds and expanded to the L2 chunk grid. + """ + vol_start = meta.voxel_bounds[:, 0] + vol_end = meta.voxel_bounds[:, 1] + chunk_size = meta.graph_config.CHUNK_SIZE + _coords = np.concatenate([source_coords, sink_coords]) + _padding = np.array([meta.resolution[-1] * 2] * 3) / meta.resolution + bbs = np.clip((np.min(_coords, 0) - _padding).astype(int), vol_start, vol_end) + bbe = np.clip((np.max(_coords, 0) + _padding).astype(int), vol_start, vol_end) + chunk_min = bbs // chunk_size + chunk_max = np.ceil(bbe / chunk_size).astype(int) + bbs, bbe = chunk_min * chunk_size, chunk_max * chunk_size + return bbs, bbe, chunk_min, chunk_max + + +def _l2_chunks_for_split(cg: "ChunkedGraph", source_coords, sink_coords): + """L2 chunk IDs covering the split bbox envelope. + + The L2ChunkLock holds these for the duration of the SV split so + concurrent SV splits on overlapping L2 chunks — including across + different roots — serialize correctly. + """ + bbs, bbe, _, _ = _split_bbox(cg.meta, source_coords, sink_coords) + coords = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) + return sorted( + int(cg.get_chunk_id(layer=2, x=x, y=y, z=z)) for (x, y, z) in coords.keys() + ) + + +def _overlapping_reps(sv_remapping, source_ids, sink_ids, source_coords, sink_coords): + """Yield (rep_sv_id, source_coords_for_rep, sink_coords_for_rep) tuples. + + A rep is a cross-chunk-representative SV shared by at least one source + and one sink in `sv_remapping`. These are the SVs that must be split + before the multicut can partition source from sink. + """ + sources_remapped = fastremap.remap( + source_ids, sv_remapping, preserve_missing_labels=True, in_place=False + ) + sinks_remapped = fastremap.remap( + sink_ids, sv_remapping, preserve_missing_labels=True, in_place=False + ) + overlap_mask = np.isin(sources_remapped, sinks_remapped) + for rep in np.unique(sources_remapped[overlap_mask]): + src_mask = sources_remapped == rep + sink_mask = sinks_remapped == rep + yield source_ids[src_mask][0], source_coords[src_mask], sink_coords[sink_mask] + + +def split_supervoxels( + cg: "ChunkedGraph", + sv_remapping: dict, + source_ids: np.ndarray, + sink_ids: np.ndarray, + source_coords: np.ndarray, + sink_coords: np.ndarray, + operation_id: int, +) -> list: + """Run the SV-split loop under the L2 chunk lock. + + Caller must hold the root lock for the roots containing the SVs in + `sv_remapping` — concurrent same-root edits are kept out by the root + lock, concurrent cross-root edits touching the same L2 chunks are + serialized by the L2 chunk lock acquired here. + + Returns the list of per-rep base-resolution `(bbs, bbe)` bboxes + written to seg. The downsample worker consumes these to re-mip only + the regions that actually changed. + """ + chunk_ids = _l2_chunks_for_split(cg, source_coords, sink_coords) + seg_bboxes = [] + with L2ChunkLock(cg, chunk_ids, operation_id): + for sv_id, src_coords_rep, sink_coords_rep in _overlapping_reps( + sv_remapping, source_ids, sink_ids, source_coords, sink_coords + ): + _, _, seg_bbox = split_supervoxel( + cg, + sv_id, + src_coords_rep, + sink_coords_rep, + operation_id, + sv_remapping=sv_remapping, + ) + seg_bboxes.append(seg_bbox) + return seg_bboxes + + def _get_whole_sv( - cg: ChunkedGraph, node: basetypes.NODE_ID, min_coord, max_coord + cg: "ChunkedGraph", node: basetypes.NODE_ID, min_coord, max_coord ) -> set: all_chunks = [ (x, y, z) @@ -61,7 +158,7 @@ def _get_whole_sv( return explored_nodes -def _update_chunks(cg, chunks_bbox_map, seg, result_seg, bb_start): +def _update_chunks(cg: "ChunkedGraph", chunks_bbox_map, seg, result_seg, bb_start): """Process all chunks in a single pass: assign new SV IDs to split fragments. For each chunk overlapping the split bbox, finds split labels and @@ -136,7 +233,7 @@ def _parse_results(results, seg, bbs, bbe): def split_supervoxel( - cg: ChunkedGraph, + cg: "ChunkedGraph", sv_id: basetypes.NODE_ID, source_coords: np.ndarray, sink_coords: np.ndarray, @@ -152,16 +249,8 @@ def split_supervoxel( """ vol_start = cg.meta.voxel_bounds[:, 0] vol_end = cg.meta.voxel_bounds[:, 1] - chunk_size = cg.meta.graph_config.CHUNK_SIZE - _coords = np.concatenate([source_coords, sink_coords]) - _padding = np.array([cg.meta.resolution[-1] * 2] * 3) / cg.meta.resolution - - bbs = np.clip((np.min(_coords, 0) - _padding).astype(int), vol_start, vol_end) - bbe = np.clip((np.max(_coords, 0) + _padding).astype(int), vol_start, vol_end) - chunk_min, chunk_max = bbs // chunk_size, np.ceil(bbe / chunk_size).astype(int) - bbs, bbe = chunk_min * chunk_size, chunk_max * chunk_size + bbs, bbe, chunk_min, chunk_max = _split_bbox(cg.meta, source_coords, sink_coords) logger.note(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}") - logger.note(f"chunk and padding {chunk_size}; {_padding}") logger.note(f"bbox and chunk min max {(bbs, bbe)}; {(chunk_min, chunk_max)}") t0 = time.time() @@ -250,7 +339,7 @@ def split_supervoxel( def copy_parents_and_add_lineage( - cg: ChunkedGraph, + cg: "ChunkedGraph", operation_id: int, old_new_map: dict, ) -> list: diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 37ecbd675..6c2de3fd2 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -21,6 +21,7 @@ from . import locks from . import edits +from . import edits_sv from . import types from pychunkedgraph.graph import attributes from .edges import Edges @@ -28,10 +29,10 @@ from pychunkedgraph.graph import basetypes from pychunkedgraph.graph import serializers from .cache import CacheService -from .cutting import run_multicut -from .exceptions import PreconditionError, SupervoxelSplitRequiredError +from .cutting import Cut, SvSplitRequired, run_multicut +from .exceptions import PreconditionError from .exceptions import PostconditionError -from .utils.generic import get_bounding_box as get_bbox +from .utils.generic import get_bounding_box as get_bbox, lookup_svs_from_seg from pychunkedgraph.graph import get_valid_timestamp from ..logging.log_db import TimeIt @@ -462,11 +463,6 @@ def execute( new_lvl2_ids=new_lvl2_ids, old_root_ids=root_ids, ) - except SupervoxelSplitRequiredError as err: - # no need for self.cg.cache = None, the cache must be retained after sv split - raise SupervoxelSplitRequiredError( - str(err), err.sv_remapping, operation_id=lock.operation_id - ) from err except PreconditionError as err: self.cg.cache = None raise PreconditionError(err) from err @@ -552,6 +548,10 @@ def _write( new_root_ids=new_root_ids, new_lvl2_ids=new_lvl2_ids, old_root_ids=old_root_ids, + # Only set when the operation actually ran SV splits (MulticutOperation + # populates this; other operations leave the attr absent and it defaults + # to None via the Result namedtuple's default). + seg_bbox=getattr(self, "seg_bboxes", None) or None, ) @@ -868,6 +868,11 @@ class MulticutOperation(GraphEditOperation): "path_augment", "disallow_isolating_cut", "do_sanity_check", + # Base-resolution bboxes of SV splits done as part of this op, one + # per rep. Populated only when the multicut hit SvSplitRequired and + # split_supervoxels actually ran. Surfaced on the Result so the + # downsample worker knows which regions to re-mip. + "seg_bboxes", ] def __init__( @@ -895,6 +900,7 @@ def __init__( self.path_augment = path_augment self.disallow_isolating_cut = disallow_isolating_cut self.do_sanity_check = do_sanity_check + self.seg_bboxes = [] ids = np.concatenate([self.source_ids, self.sink_ids]).astype(basetypes.NODE_ID) layers = self.cg.get_chunk_layers(ids) @@ -916,7 +922,52 @@ def _update_root_ids(self) -> np.ndarray: def _apply( self, *, operation_id, timestamp ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: - # Verify that sink and source are from the same root object + result = self._run_multicut(operation_id) + if isinstance(result, SvSplitRequired): + # Running under GraphEditOperation.execute's RootLock — no same-root + # edit can interleave between the SV split and the retry multicut. + # L2 chunk lock (inside split_supervoxels) serializes cross-root + # overlap. The SVs in source_ids/sink_ids are about to be + # superseded; re-read them from seg after the split lands. + self.seg_bboxes = edits_sv.split_supervoxels( + self.cg, + result.sv_remapping, + self.source_ids, + self.sink_ids, + self.source_coords, + self.sink_coords, + operation_id, + ) + self._refresh_sv_ids() + result = self._run_multicut(operation_id) + if isinstance(result, SvSplitRequired): + raise PreconditionError( + "Supervoxel split succeeded but source and sink remain " + "connected; place source and sink farther apart." + ) + + assert isinstance(result, Cut), f"unexpected multicut result: {result!r}" + self.removed_edges = result.atomic_edges + if not self.removed_edges.size: + raise PostconditionError("Mincut could not find any edges to remove.") + + with TimeIt("remove_edges", self.cg.graph_id, operation_id): + return edits.remove_edges( + self.cg, + operation_id=operation_id, + atomic_edges=self.removed_edges, + time_stamp=timestamp, + parent_ts=self.parent_ts, + do_sanity_check=self.do_sanity_check, + ) + + def _run_multicut(self, operation_id): + """Build the local subgraph and run multicut; returns the tagged result. + + Factored so `_apply` can call it twice — once for initial detection + and again after an SV split to get fresh atomic_edges against the + post-split graph topology. + """ root_ids = set( self.cg.get_roots( np.concatenate([self.source_ids, self.sink_ids]).astype( @@ -938,7 +989,6 @@ def _apply( l2id_agglomeration_d, edges_tuple = self.cg.get_subgraph( root_ids.pop(), bbox=bbox, bbox_is_coordinate=True ) - edges = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) supervoxels = np.concatenate( [agg.supervoxels for agg in l2id_agglomeration_d.values()] @@ -950,7 +1000,7 @@ def _apply( raise PreconditionError("No local edges found.") with TimeIt("multicut", self.cg.graph_id, operation_id): - self.removed_edges = run_multicut( + return run_multicut( edges, self.source_ids, self.sink_ids, @@ -958,18 +1008,13 @@ def _apply( disallow_isolating_cut=self.disallow_isolating_cut, sv_split_supported=self.cg.meta.ocdbt_seg, ) - if not self.removed_edges.size: - raise PostconditionError("Mincut could not find any edges to remove.") - with TimeIt("remove_edges", self.cg.graph_id, operation_id): - return edits.remove_edges( - self.cg, - operation_id=operation_id, - atomic_edges=self.removed_edges, - time_stamp=timestamp, - parent_ts=self.parent_ts, - do_sanity_check=self.do_sanity_check, - ) + def _refresh_sv_ids(self): + """Re-read source_ids / sink_ids from seg after an SV split superseded them.""" + source_coords = np.asarray(self.source_coords, dtype=int) + sink_coords = np.asarray(self.sink_coords, dtype=int) + self.source_ids = lookup_svs_from_seg(self.cg.meta, source_coords) + self.sink_ids = lookup_svs_from_seg(self.cg.meta, sink_coords) def _create_log_record( self, From 7619c9c822eb51e896bdc63bd1e67cc9aa8b90f5 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 22 Apr 2026 23:15:41 +0000 Subject: [PATCH 207/367] sv split: drive lock + write scope from the SVs being split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-compute each rep's bbox from the chunk coords of its CC members in sv_remapping (no coord-padding, no resolution-axis assumption). split_supervoxels builds the union lock set across reps — sparse chunks plus one L2-chunk margin for update_edges's 1-voxel overlap read — acquires once, then loops per-rep splits. _update_chunks surfaces the change_chunks that actually got new SV IDs; write_seg_chunks fires one tensorstore future per change-chunk and awaits together, so only chunks with real label changes hit OCDBT. Gap chunks between CC pieces and neighbor chunks read for the overlap never get rewritten, keeping the delta proportional to the edit. split_supervoxels also threads back the fresh source/sink SV IDs from the in-memory new_seg block (same bytes that just landed on storage), so the retry multicut sees current IDs without an extra seg read. Drops _get_whole_sv (dead since the sv_remapping switch). Adds a high-level architecture doc covering the end-to-end flow, concurrency design, and durable invariants. --- docs/sv_splitting.md | 136 +++++++++++ pychunkedgraph/graph/downsample.py | 9 +- pychunkedgraph/graph/edges_sv.py | 4 +- pychunkedgraph/graph/edits_sv.py | 281 ++++++++++++++--------- pychunkedgraph/graph/locks.py | 2 +- pychunkedgraph/graph/ocdbt.py | 53 +++-- pychunkedgraph/graph/operation.py | 28 +-- pychunkedgraph/tests/graph/test_ocdbt.py | 72 ++++-- 8 files changed, 423 insertions(+), 162 deletions(-) create mode 100644 docs/sv_splitting.md diff --git a/docs/sv_splitting.md b/docs/sv_splitting.md new file mode 100644 index 000000000..fdd2403fb --- /dev/null +++ b/docs/sv_splitting.md @@ -0,0 +1,136 @@ +# Supervoxel splitting + +## What it is + +A *supervoxel split* bisects one physical supervoxel — a connected region in the raw segmentation — along a user-seeded cut. The user supplies a source coordinate and a sink coordinate inside one supervoxel; the system finds a cut surface separating them and assigns new supervoxel IDs to each half, writing the updated segmentation and the corresponding graph hierarchy. + +This only runs on segmentations stored in OCDBT (a writable, append-only segmentation backend). With a read-only segmentation backend the split path is never entered; the multicut instead surfaces a precondition error asking the user to pick different source/sink points. + +## Why it's needed + +The graph is stored in **chunks**: the segmentation volume is partitioned into a regular 3D grid, and each chunk owns its own set of supervoxel IDs. When a physical supervoxel spans a chunk boundary, it is artificially cut into multiple graph-level supervoxel IDs — one per chunk — with infinite-affinity *cross-chunk edges* connecting the pieces so the graph still represents one physical object. + +The multicut algorithm runs on a local graph around source and sink. If it finds that source and sink sit inside the same cross-chunk-connected component — i.e., in the same physical supervoxel — a clean graph cut cannot separate them without first **splitting that physical supervoxel at the voxel level** and giving the resulting halves fresh IDs. That voxel-level cut is what the split flow does. The multicut runs again against the refreshed graph and produces the graph-level edges to remove. + +## End-to-end flow + +``` +Split request (source coord, sink coord) + │ + ▼ +Resolve coords → current supervoxel IDs at those pixels + │ + ▼ +┌───────────────────────────────────────────────────────────────────────┐ +│ ROOT LOCK (held across the whole operation) │ +│ │ +│ multicut: │ +│ build local subgraph around source/sink │ +│ stitch cross-chunk-connected SVs via inf-affinity edges │ +│ run mincut between source and sink │ +│ result ─► one of: │ +│ ● clean cut → edges to remove │ +│ ● SV split needed → cross-chunk-representative mapping │ +│ ● precondition → surface to user, abort │ +│ │ +│ if SV split needed: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ L2 CHUNK LOCK (spatial; sparse set + 1-chunk margin) │ │ +│ │ │ │ +│ │ for each cross-chunk rep linking source to sink: │ │ +│ │ bbs/bbe ◄ envelope of rep's pieces' chunk coords │ │ +│ │ read seg in [bbs-1, bbe+1] │ │ +│ │ (1-voxel shell → anchor voxels for edge routing) │ │ +│ │ compute voxel-level cut between seeds │ │ +│ │ allocate fresh SV IDs per chunk to each half │ │ +│ │ route existing cross-chunk edges onto the new fragments │ │ +│ │ write seg — only chunks that actually received new IDs │ │ +│ │ write hierarchy rows (lineage + new cross-chunk edges) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ refresh source/sink IDs: │ +│ look up the new IDs in the in-memory split output │ +│ (bit-identical to what just landed on storage; no extra read) │ +│ │ +│ multicut (retry against post-split graph): │ +│ result ─► clean cut → edges to remove │ +│ │ still-split-needed → surface precondition error │ +│ │ +│ commit the cut: │ +│ remove graph-level edges │ +│ produce new roots │ +│ write hierarchy rows + operation log │ +└───────────────────────────────────────────────────────────────────────┘ + │ + ▼ +Release root lock — edit is durable + │ + ▼ +Publish pubsub message; when an SV split ran it carries the list of +base-resolution bounding boxes that were rewritten + │ + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ Async downsample worker │ +│ │ +│ partition each published bbox into pyramid blocks │ +│ (cube regions aligned to the coarsest MIP's chunk grid; │ +│ two distinct blocks never share a storage chunk at │ +│ any MIP level) │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ PYRAMID BLOCK LOCK (separate lock family from L2) │ │ +│ │ │ │ +│ │ for each pyramid block: │ │ +│ │ read base resolution │ │ +│ │ mode-downsample through every coarser MIP │ │ +│ │ write only tiles whose footprint intersects │ │ +│ │ a published bbox │ │ +│ └──────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────┘ +``` + +### Notes on the flow + +- **"SV split required" is a return value, not an exception.** The multicut returns one of several tagged outcomes so the caller dispatches with a straight branch. Nothing uses raise/catch for control flow, which is what allows the root lock to stay held across the detect-then-split-then-commit sequence without the exception unwinding the lock. + +- **The cross-chunk-representative mapping** comes out of the multicut for free: as part of building its local graph it stitches every cross-chunk-connected group of graph-level supervoxels into one node and records the mapping. That map tells the split step which supervoxels are artificially-cut pieces of one physical SV, and which of them sit on a source→sink bridge. + +- **The split is per-representative.** If two unrelated physical supervoxels both need splitting in one edit (rare but possible), each is handled in its own pass under the same L2 chunk lock. + +## Concurrency design + +Two races exist at the segmentation layer even with root locks in place: + +- **Same-root race.** Without care, the root lock could drop between "detect split needed" and "perform split", letting another edit on the same root slip in and race for the same supervoxel pieces. +- **Cross-root spatial race.** Two edits on entirely distinct roots can target supervoxels whose pieces live in overlapping chunks. Root locks don't serialize them; segmentation writes would clobber each other. + +The split flow closes both: + +- **Root lock scope covers the full operation.** Detection, supervoxel-level split, retry detection, commit — all under one root lock. Same-root interleaving is impossible; any other edit on the root waits for this one to finish. + +- **L2 chunk lock covers the supervoxel-level split only.** Inside the root lock, the split step additionally acquires a spatial lock on every L2 chunk it will read or write. Keyed by chunk, so edits on different roots but overlapping chunks serialize here. Released as soon as the split writes land; the graph-level commit afterwards runs under the root lock alone. + +### How the spatial lock set is computed + +For each cross-chunk representative being split, take the envelope of the chunks its cross-chunk-connected pieces live in — the exact chunks whose voxels will be rewritten. Expand by one voxel (= at most one L2 chunk of margin in each direction), because the edge-routing step reads a 1-voxel shell outside the rewritten region to see neighboring supervoxels' labels. Union the per-representative chunk sets, sort deterministically so workers with overlapping sets never acquire in opposite orders, lock once. + +The envelope comes from the supervoxels' own chunk coordinates — no coordinate padding, no resolution-axis assumption. The chunks locked are exactly the chunks the split will touch, plus the 1-chunk margin the shell read requires. + +### How the write scope is kept minimal + +Only chunks that actually receive new supervoxel IDs get written to storage. Gap chunks that happen to sit inside an envelope but contain no cross-chunk-connected pieces, and neighbor chunks read only for the edge-routing shell, are never written. The segmentation backend is append-only, so writing unchanged bytes would inflate the on-disk delta for no real change. Writing exactly the changed chunks keeps the delta proportional to the user's edit. + +### Why the post-split ID refresh is safe without an extra read + +After the split lands, the caller-supplied source and sink supervoxel IDs reference now-superseded supervoxels. The retry multicut needs the *current* IDs at the source and sink pixels — the subgraph fetch returns only live supervoxels, so a mincut asking about superseded ones would fail to find its endpoints. + +The in-memory segmentation block produced by the split is bitwise identical to what was just written to storage, and the storage write is synchronous (we wait for it) and happens under the L2 chunk lock (so nothing else can have mutated those voxels). Looking up source/sink coords in that block returns the same IDs a storage re-read would — no extra round-trip needed. + +## Invariants + +- A supervoxel split and its graph-level commit are one atomic operation. Either both land or neither does, under a single root lock. +- Within the supervoxel-split step, concurrent splits on overlapping L2 chunks serialize. No two operations write segmentation to the same chunk at the same time. +- Supervoxel-level writes touch only chunks whose voxels actually changed. Gap chunks between cross-chunk-connected pieces and neighbor chunks read for edge routing are untouched. +- After the commit, readers at the operation's timestamp see new supervoxel IDs in the cut region and new roots reflecting the cut. +- Coarser MIP levels are eventually consistent with the base scale, lagging at most until the async downsample worker processes the operation's pubsub message. diff --git a/pychunkedgraph/graph/downsample.py b/pychunkedgraph/graph/downsample.py index 791043e0f..7a28305c4 100644 --- a/pychunkedgraph/graph/downsample.py +++ b/pychunkedgraph/graph/downsample.py @@ -308,9 +308,12 @@ def process_block( Atomic within the block: caller must hold the block lock. Picks the in-memory path when the base read fits the memory budget, falls - back to the per-mip path otherwise. Both paths only touch tiles - whose footprint intersects `seg_bboxes` — unchanged tiles are - skipped to keep OCDBT delta growth proportional to the actual edit. + back to the per-mip path otherwise. + + Reads and writes only the aligned region covering `seg_bboxes` + inside the block; the rest of the block is untouched. Region + alignment rounds outward to the coarsest mip's grid so the aligned + region is always tinybrain-valid and chunk-aligned at every mip. Args: meta: ChunkedGraphMeta with `ws_ocdbt_scales` / `ws_ocdbt_resolutions`. diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index ea9354990..19a2c2eb3 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -23,8 +23,8 @@ Distance computation: For partners within the segmentation bbox, distances are precomputed via kdtree pairwise distances. For active partners outside the bbox (e.g. - cross-chunk fragments excluded by _get_whole_sv's bbox clipping), distances - are computed from each new fragment's kdtree to the partner's chunk boundary. + cross-chunk fragments not in the rep's CC member set), distances are + computed from each new fragment's kdtree to the partner's chunk boundary. """ from __future__ import annotations diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 61f243600..6dcc6da5b 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -4,7 +4,7 @@ import time from datetime import datetime -from collections import defaultdict, deque +from collections import defaultdict from typing import TYPE_CHECKING import fastremap @@ -21,9 +21,8 @@ from pychunkedgraph.graph.cutting_sv import split_supervoxel_helper from pychunkedgraph.graph.edges_sv import update_edges, add_new_edges from pychunkedgraph.graph.locks import L2ChunkLock -from pychunkedgraph.graph.ocdbt import write_seg +from pychunkedgraph.graph.ocdbt import write_seg_chunks from pychunkedgraph.graph.utils import get_local_segmentation -from pychunkedgraph.io.edges import get_chunk_edges if TYPE_CHECKING: from pychunkedgraph.graph.chunkedgraph import ChunkedGraph @@ -31,47 +30,81 @@ logger = get_logger(__name__) -def _split_bbox(meta, source_coords, sink_coords): - """Chunk-aligned base-resolution bbox covering source + sink coords. +def _rep_bbox(cg: "ChunkedGraph", sv_remapping: dict, sv_id) -> tuple: + """Base-voxel bbox covering every CC member of `sv_id`'s cross-chunk rep. - Same formula `split_supervoxel` uses internally — factored out so - `_l2_chunks_for_split` and any other caller agree on the envelope. - Returns `(bbs, bbe, chunk_min, chunk_max)` in base voxels, clipped to - volume bounds and expanded to the L2 chunk grid. + The multicut produced `sv_remapping` — every SV in the local subgraph + mapped to its cross-chunk representative. Any SV mapped to the same + rep as `sv_id` is part of one physical supervoxel that was + artificially cut at chunk boundaries, and every piece will be + rewritten when this rep is split. The chunk-coord envelope of those + pieces is exactly the region the split needs to read and modify — + no padding, no resolution-axis assumption, no bbox clip that could + drop pieces. """ - vol_start = meta.voxel_bounds[:, 0] - vol_end = meta.voxel_bounds[:, 1] - chunk_size = meta.graph_config.CHUNK_SIZE - _coords = np.concatenate([source_coords, sink_coords]) - _padding = np.array([meta.resolution[-1] * 2] * 3) / meta.resolution - bbs = np.clip((np.min(_coords, 0) - _padding).astype(int), vol_start, vol_end) - bbe = np.clip((np.max(_coords, 0) + _padding).astype(int), vol_start, vol_end) - chunk_min = bbs // chunk_size - chunk_max = np.ceil(bbe / chunk_size).astype(int) - bbs, bbe = chunk_min * chunk_size, chunk_max * chunk_size - return bbs, bbe, chunk_min, chunk_max - - -def _l2_chunks_for_split(cg: "ChunkedGraph", source_coords, sink_coords): - """L2 chunk IDs covering the split bbox envelope. - - The L2ChunkLock holds these for the duration of the SV split so - concurrent SV splits on overlapping L2 chunks — including across - different roots — serialize correctly. + rep = sv_remapping.get(sv_id, sv_id) + all_svs = np.array( + [sv for sv, r in sv_remapping.items() if r == rep], + dtype=basetypes.NODE_ID, + ) + coords = cg.get_chunk_coordinates_multiple(all_svs) + chunk_min = coords.min(axis=0) + chunk_max = coords.max(axis=0) + 1 # exclusive + chunk_size = cg.meta.graph_config.CHUNK_SIZE + return chunk_min * chunk_size, chunk_max * chunk_size + + +def _l2_chunks_for_splits(cg: "ChunkedGraph", per_rep_bboxes: list) -> list[int]: + """Layer-2 chunk IDs every rep's split will read or write. + + Reads extend 1 voxel past `[bbs, bbe]` so `update_edges` has anchor + voxels for cross-chunk neighbors; the lock must cover those neighbor + chunks too, hence the `bbs - 1` / `bbe + 1` expansion. Clipped to + volume bounds so a bbox on the volume edge doesn't enumerate phantom + negative-index chunks. Sorted for deterministic lock-acquire order + (L2ChunkLock relies on sorted input for deadlock avoidance). """ - bbs, bbe, _, _ = _split_bbox(cg.meta, source_coords, sink_coords) - coords = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) + vol_start = cg.meta.voxel_bounds[:, 0] + vol_end = cg.meta.voxel_bounds[:, 1] + chunk_size = cg.meta.graph_config.CHUNK_SIZE + chunk_coords = set() + for bbs, bbe in per_rep_bboxes: + read_lo = np.clip(bbs - 1, vol_start, vol_end) + read_hi = np.clip(bbe + 1, vol_start, vol_end) + chunk_coords.update( + chunks_overlapping_bbox(read_lo, read_hi, chunk_size).keys() + ) return sorted( - int(cg.get_chunk_id(layer=2, x=x, y=y, z=z)) for (x, y, z) in coords.keys() + int(cg.get_chunk_id(layer=2, x=x, y=y, z=z)) for (x, y, z) in chunk_coords ) -def _overlapping_reps(sv_remapping, source_ids, sink_ids, source_coords, sink_coords): - """Yield (rep_sv_id, source_coords_for_rep, sink_coords_for_rep) tuples. - - A rep is a cross-chunk-representative SV shared by at least one source - and one sink in `sv_remapping`. These are the SVs that must be split - before the multicut can partition source from sink. +def _overlapping_reps( + *, + sv_remapping: dict, + source_ids: np.ndarray, + sink_ids: np.ndarray, + source_coords: np.ndarray, + sink_coords: np.ndarray, +): + """Yield per-rep data for every rep that links source and sink. + + A rep is a cross-chunk-representative SV shared by at least one + source and one sink in `sv_remapping`. These are the SVs that must + be split before the multicut can partition source from sink. + + Yields `(sv_id, src_coords_rep, sink_coords_rep, src_mask, sink_mask)`: + sv_id — one of the rep's source SV IDs, used as the + seed for `split_supervoxel`. + src_coords_rep — slice of source_coords whose SV maps to this rep. + sink_coords_rep — slice of sink_coords whose SV maps to this rep. + src_mask — positional boolean mask over source_ids; the + caller uses it to splice per-rep results back + into the full source arrays. + sink_mask — same, for sink_ids. + + Keyword-only signature — positional source/sink args of the same + shape are easy to swap without noticing. """ sources_remapped = fastremap.remap( source_ids, sv_remapping, preserve_missing_labels=True, in_place=False @@ -83,18 +116,25 @@ def _overlapping_reps(sv_remapping, source_ids, sink_ids, source_coords, sink_co for rep in np.unique(sources_remapped[overlap_mask]): src_mask = sources_remapped == rep sink_mask = sinks_remapped == rep - yield source_ids[src_mask][0], source_coords[src_mask], sink_coords[sink_mask] + yield ( + source_ids[src_mask][0], + source_coords[src_mask], + sink_coords[sink_mask], + src_mask, + sink_mask, + ) def split_supervoxels( cg: "ChunkedGraph", + *, sv_remapping: dict, source_ids: np.ndarray, sink_ids: np.ndarray, source_coords: np.ndarray, sink_coords: np.ndarray, operation_id: int, -) -> list: +) -> tuple: """Run the SV-split loop under the L2 chunk lock. Caller must hold the root lock for the roots containing the SVs in @@ -102,69 +142,86 @@ def split_supervoxels( lock, concurrent cross-root edits touching the same L2 chunks are serialized by the L2 chunk lock acquired here. - Returns the list of per-rep base-resolution `(bbs, bbe)` bboxes - written to seg. The downsample worker consumes these to re-mip only - the regions that actually changed. + Each rep's bbox is computed up-front from its CC members' chunk + coords (see `_rep_bbox`), so the lock set — union of read-expanded + chunks across all reps — is known before we block. The lock scope is + then exactly the chunks the per-rep splits will read or write. + + Returns `(seg_bboxes, source_ids_fresh, sink_ids_fresh)`: + seg_bboxes: per-rep base-resolution `(bbs, bbe)` bboxes that were + written to seg; the downsample worker re-mips only these. + source_ids_fresh / sink_ids_fresh: input `source_ids`/`sink_ids` + with positions touched by an overlap rep replaced by the new + SV ID that now lives at that coord. Untouched positions are + unchanged. Callers who need to re-run the multicut after + the split feed these to avoid referencing superseded SVs. """ - chunk_ids = _l2_chunks_for_split(cg, source_coords, sink_coords) + source_ids_fresh = np.asarray(source_ids, dtype=basetypes.NODE_ID).copy() + sink_ids_fresh = np.asarray(sink_ids, dtype=basetypes.NODE_ID).copy() + + # Pre-compute every per-rep bbox (cheap chunk-coord lookups, no lock + # needed yet). Reuse later inside the lock to avoid recomputation. + reps = [] + for ( + sv_id, + src_coords_rep, + sink_coords_rep, + src_mask, + sink_mask, + ) in _overlapping_reps( + sv_remapping=sv_remapping, + source_ids=source_ids, + sink_ids=sink_ids, + source_coords=source_coords, + sink_coords=sink_coords, + ): + bbs, bbe = _rep_bbox(cg, sv_remapping, sv_id) + reps.append( + (sv_id, src_coords_rep, sink_coords_rep, src_mask, sink_mask, bbs, bbe) + ) + + chunk_ids = _l2_chunks_for_splits(cg, [(bbs, bbe) for *_, bbs, bbe in reps]) seg_bboxes = [] with L2ChunkLock(cg, chunk_ids, operation_id): - for sv_id, src_coords_rep, sink_coords_rep in _overlapping_reps( - sv_remapping, source_ids, sink_ids, source_coords, sink_coords - ): - _, _, seg_bbox = split_supervoxel( + for ( + sv_id, + src_coords_rep, + sink_coords_rep, + src_mask, + sink_mask, + bbs, + bbe, + ) in reps: + _, _, seg_bbox, src_new_ids, sink_new_ids = split_supervoxel( cg, sv_id, src_coords_rep, sink_coords_rep, operation_id, sv_remapping=sv_remapping, + bbs=bbs, + bbe=bbe, ) seg_bboxes.append(seg_bbox) - return seg_bboxes - - -def _get_whole_sv( - cg: "ChunkedGraph", node: basetypes.NODE_ID, min_coord, max_coord -) -> set: - all_chunks = [ - (x, y, z) - for x in range(min_coord[0], max_coord[0]) - for y in range(min_coord[1], max_coord[1]) - for z in range(min_coord[2], max_coord[2]) - ] - edges = get_chunk_edges(cg.meta.data_source.EDGES, all_chunks) - cx_edges = edges["cross"].get_pairs() - if len(cx_edges) == 0: - return {node} - - explored_nodes = set([node]) - queue = deque([node]) - while queue: - vertex = queue.popleft() - mask = cx_edges[:, 0] == vertex - neighbors = cx_edges[mask][:, 1] - - if len(neighbors) > 0: - neighbor_coords = cg.get_chunk_coordinates_multiple(neighbors) - min_mask = (neighbor_coords >= min_coord).all(axis=1) - max_mask = (neighbor_coords < max_coord).all(axis=1) - neighbors = neighbors[min_mask & max_mask] - - for neighbor in neighbors: - if neighbor not in explored_nodes: - explored_nodes.add(neighbor) - queue.append(neighbor) - return explored_nodes + source_ids_fresh[src_mask] = src_new_ids + sink_ids_fresh[sink_mask] = sink_new_ids + return seg_bboxes, source_ids_fresh, sink_ids_fresh def _update_chunks(cg: "ChunkedGraph", chunks_bbox_map, seg, result_seg, bb_start): """Process all chunks in a single pass: assign new SV IDs to split fragments. - For each chunk overlapping the split bbox, finds split labels and - batch-allocates new IDs. No multiprocessing needed. + Returns `(results, change_chunks)`: + results: per-chunk (indices, old_values, new_values, label_id_map) + tuples; consumed by `_parse_results`. + change_chunks: `(chunk_coord, chunk_bbox)` for the chunks whose + voxels received new SV IDs. `write_seg_chunks` uses this to + rewrite only those chunks (skipping gap chunks that had no + split activity keeps the OCDBT delta proportional to actual + label changes). """ results = [] + change_chunks = [] for chunk_coord, chunk_bbox in chunks_bbox_map.items(): x, y, z = chunk_coord chunk_id = cg.get_chunk_id(layer=1, x=x, y=y, z=z) @@ -199,7 +256,8 @@ def _update_chunks(cg: "ChunkedGraph", chunks_bbox_map, seg, result_seg, bb_star _old_values = np.concatenate(_old_values) _new_values = np.concatenate(_new_values) results.append((_indices, _old_values, _new_values, _label_id_map)) - return results + change_chunks.append((chunk_coord, chunk_bbox)) + return results, change_chunks def _voxel_crop(bbs, bbe, bbs_, bbe_): @@ -239,35 +297,34 @@ def split_supervoxel( sink_coords: np.ndarray, operation_id: int, sv_remapping: dict, + bbs: np.ndarray, + bbe: np.ndarray, verbose: bool = False, time_stamp: datetime = None, -) -> dict[int, set]: - """ - Lookups coordinates of given supervoxel in segmentation. - Finds its counterparts split by chunk boundaries and splits them as a whole. - Updates the segmentation with new IDs. +) -> tuple: + """Split one cross-chunk-connected SV into connected components. + + `bbs` / `bbe` are the base-voxel bbox envelope covering every CC + member of `sv_id`'s rep — caller (`split_supervoxels`) pre-computes + this via `_rep_bbox`. The envelope is guaranteed to contain every + piece of the SV being split, so no bbox clip is needed inside. """ vol_start = cg.meta.voxel_bounds[:, 0] vol_end = cg.meta.voxel_bounds[:, 1] - bbs, bbe, chunk_min, chunk_max = _split_bbox(cg.meta, source_coords, sink_coords) logger.note(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}") - logger.note(f"bbox and chunk min max {(bbs, bbe)}; {(chunk_min, chunk_max)}") + logger.note(f"bbox: {(bbs, bbe)}") t0 = time.time() rep = sv_remapping.get(sv_id, sv_id) - all_svs = np.array( - [sv for sv, r in sv_remapping.items() if r == rep], - dtype=basetypes.NODE_ID, - ) - coords = cg.get_chunk_coordinates_multiple(all_svs) - in_bbox = (coords >= chunk_min).all(axis=1) & (coords < chunk_max).all(axis=1) - cut_supervoxels = set(all_svs[in_bbox].tolist()) + cut_supervoxels = {int(sv) for sv, r in sv_remapping.items() if r == rep} supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) logger.note( f"whole sv {sv_id} -> {supervoxel_ids.tolist()} ({time.time() - t0:.2f}s)" ) - # one voxel overlap for neighbors + # one voxel overlap for neighbors — update_edges needs anchor voxels + # from neighboring SVs to route existing cross-chunk edges onto the + # new fragments. bbs_ = np.clip(bbs - 1, vol_start, vol_end) bbe_ = np.clip(bbe + 1, vol_start, vol_end) t0 = time.time() @@ -288,11 +345,12 @@ def split_supervoxel( chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) t0 = time.time() - results = _update_chunks( + results, change_chunks = _update_chunks( cg, chunks_bbox_map, seg[voxel_overlap_crop], split_result, bbs ) logger.note( - f"chunk updates {len(chunks_bbox_map)} chunks, {len(results)} with splits ({time.time() - t0:.2f}s)" + f"chunk updates {len(chunks_bbox_map)} chunks, " + f"{len(change_chunks)} with splits ({time.time() - t0:.2f}s)" ) seg_cropped = seg[voxel_overlap_crop].copy() @@ -332,10 +390,23 @@ def split_supervoxel( rows = rows0 + rows1 t0 = time.time() - write_seg(cg.meta, bbs, bbe, new_seg) + write_seg_chunks(cg.meta, change_chunks, new_seg, bbs) cg.client.write(rows) - logger.note(f"write seg + {len(rows)} rows ({time.time() - t0:.2f}s)") - return old_new_map, edges_tuple, (bbs, bbe) + logger.note( + f"write seg ({len(change_chunks)} chunks) + {len(rows)} rows " + f"({time.time() - t0:.2f}s)" + ) + + # Per-coord fresh IDs: identical to what a post-write seg read would + # return, but computed in-memory from new_seg. new_seg is what just + # landed on storage (the write is synchronous and we hold the L2 + # lock), so reading new_seg[coord - bbs] is equivalent to re-reading + # seg — without the storage round-trip. + local_src = (np.asarray(source_coords, dtype=int) - bbs).astype(int) + local_sink = (np.asarray(sink_coords, dtype=int) - bbs).astype(int) + src_new_ids = new_seg[tuple(local_src.T)] + sink_new_ids = new_seg[tuple(local_sink.T)] + return old_new_map, edges_tuple, (bbs, bbe), src_new_ids, sink_new_ids def copy_parents_and_add_lineage( diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index caade7ad0..95e7fb7b1 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -342,7 +342,7 @@ class L2ChunkLock: def __init__( self, cg, - chunk_ids: Sequence[np.uint64], + chunk_ids: Sequence[int], operation_id: np.uint64, ) -> None: self.cg = cg diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index 666160595..5c6617c4c 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -398,20 +398,47 @@ def propagate_to_coarser_scales(dst_scales, resolutions, base_slices): prev_slices = target_slices -def write_seg(meta, bbs, bbe, data): - """Write segmentation at base scale only. +def write_seg_chunks(meta, change_chunks, new_seg, new_seg_origin): + """Write only the L2 chunks that actually received new SV IDs. - Coarser MIP levels are produced asynchronously by the downsample worker, - which consumes a pubsub message published by `publish_edit` after this - call returns. PCG itself only reads the base scale; viewers - (Neuroglancer) consume the coarser scales, and don't need them - synchronously with the edit. + An SV split produces `new_seg` (shape `bbe - bbs`) covering the full + rep envelope — but only some of its chunks contain split fragments. + The rest (gap chunks between cross-chunk-connected pieces, and the + neighbor chunks the overlap read touches) are identical to what's on + disk. Writing those would bloat OCDBT's append-only delta with + unchanged bytes. + + Each per-chunk write is fired as an async tensorstore future and + awaited in aggregate — wall-time is comparable to a single slab + write (tensorstore parallelizes internal OCDBT-chunk writes anyway), + but gap and neighbor chunks are never touched. + + Coarser MIP levels stay the downsample worker's job — it picks up + the pubsub message `publish_edit` sends after this returns. Args: - meta: ChunkedGraphMeta with ws_ocdbt (base-scale handle). - bbs: (3,) array — start of the region in base-resolution voxels. - bbe: (3,) array — end of the region in base-resolution voxels. - data: 3D numpy array of new segmentation IDs. + meta: ChunkedGraphMeta with `ws_ocdbt` (base-scale handle). + change_chunks: iterable of `(chunk_coord, chunk_bbox)` pairs; + `chunk_bbox` is a (2, 3) array of voxel-coord lo/hi for that + L2 chunk (what `_update_chunks` returns). + new_seg: 3D array shaped `(bbe - bbs)` with the split's labels + applied. + new_seg_origin: the rep's `bbs` — used to slice `new_seg` at the + right offset for each chunk's write. """ - slices = tuple(slice(int(s), int(e)) for s, e in zip(bbs, bbe)) - meta.ws_ocdbt[slices + (slice(None),)] = data[..., np.newaxis] + futures = [] + for _, chunk_bbox in change_chunks: + lo, hi = chunk_bbox[0], chunk_bbox[1] + local_lo = lo - new_seg_origin + local_hi = hi - new_seg_origin + data = new_seg[ + local_lo[0] : local_hi[0], + local_lo[1] : local_hi[1], + local_lo[2] : local_hi[2], + ] + slices = tuple(slice(int(s), int(e)) for s, e in zip(lo, hi)) + futures.append( + meta.ws_ocdbt[slices + (slice(None),)].write(data[..., np.newaxis]) + ) + for f in futures: + f.result() diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 6c2de3fd2..03f2eb5a4 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -32,7 +32,7 @@ from .cutting import Cut, SvSplitRequired, run_multicut from .exceptions import PreconditionError from .exceptions import PostconditionError -from .utils.generic import get_bounding_box as get_bbox, lookup_svs_from_seg +from .utils.generic import get_bounding_box as get_bbox from pychunkedgraph.graph import get_valid_timestamp from ..logging.log_db import TimeIt @@ -929,16 +929,17 @@ def _apply( # L2 chunk lock (inside split_supervoxels) serializes cross-root # overlap. The SVs in source_ids/sink_ids are about to be # superseded; re-read them from seg after the split lands. - self.seg_bboxes = edits_sv.split_supervoxels( - self.cg, - result.sv_remapping, - self.source_ids, - self.sink_ids, - self.source_coords, - self.sink_coords, - operation_id, + self.seg_bboxes, self.source_ids, self.sink_ids = ( + edits_sv.split_supervoxels( + self.cg, + sv_remapping=result.sv_remapping, + source_ids=self.source_ids, + sink_ids=self.sink_ids, + source_coords=self.source_coords, + sink_coords=self.sink_coords, + operation_id=operation_id, + ) ) - self._refresh_sv_ids() result = self._run_multicut(operation_id) if isinstance(result, SvSplitRequired): raise PreconditionError( @@ -1009,13 +1010,6 @@ def _run_multicut(self, operation_id): sv_split_supported=self.cg.meta.ocdbt_seg, ) - def _refresh_sv_ids(self): - """Re-read source_ids / sink_ids from seg after an SV split superseded them.""" - source_coords = np.asarray(self.source_coords, dtype=int) - sink_coords = np.asarray(self.sink_coords, dtype=int) - self.source_ids = lookup_svs_from_seg(self.cg.meta, source_coords) - self.sink_ids = lookup_svs_from_seg(self.cg.meta, sink_coords) - def _create_log_record( self, *, diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index 5bd3937a1..b3786af48 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -398,10 +398,11 @@ def test_repeated_update_reflects_latest_base(self, local_ocdbt): assert (scales[2][0:4, 0:4, 0:16, :].read().result() == 2).all() -class TestWriteSeg: - def test_writes_base_only(self, local_ocdbt): - """`write_seg` writes to base scale; coarser scales are untouched - (propagation is now the downsample worker's job).""" +class TestWriteSegChunks: + def test_writes_only_change_chunks(self, local_ocdbt): + """Only chunks listed in `change_chunks` receive writes; sibling + chunks in the same envelope stay untouched (OCDBT delta stays + proportional to the actual SV change, not the envelope).""" scales = local_ocdbt["scales"] res = local_ocdbt["resolutions"] meta = MagicMock() @@ -409,25 +410,54 @@ def test_writes_base_only(self, local_ocdbt): meta.ws_ocdbt_scales = scales meta.ws_ocdbt_resolutions = res - data = np.full((16, 16, 16), 55, dtype=np.uint64) - ocdbt_mod.write_seg(meta, [0, 0, 0], [16, 16, 16], data) - - # Base scale: written region has label 55. - assert (scales[0][0:16, 0:16, 0:16, :].read().result() == 55).all() - # Coarser scales: unchanged (still empty/zero — write_seg does not touch them). - assert (scales[1][0:8, 0:8, 0:16, :].read().result() == 0).all() - assert (scales[2][0:4, 0:4, 0:16, :].read().result() == 0).all() - - def test_single_scale(self, local_ocdbt): - """Single-scale setup still works (write_seg only touches base).""" + # new_seg covers a two-chunk envelope [0..32] along x at base scale + # (chunk_size = 32). Only the first chunk [0..32, 0..32, 0..32] is + # listed as a change chunk; the second chunk should stay zero. + new_seg = np.zeros((64, 32, 32), dtype=np.uint64) + new_seg[0:32, 0:32, 0:32] = 55 + # second chunk gets some filler data in-memory, but it's NOT in + # change_chunks so it should never hit storage. + new_seg[32:64, 0:32, 0:32] = 77 + + change_chunks = [ + ( + (0, 0, 0), + np.array([[0, 0, 0], [32, 32, 32]], dtype=int), + ) + ] + ocdbt_mod.write_seg_chunks(meta, change_chunks, new_seg, np.array([0, 0, 0])) + + # Written chunk has label 55. + assert (scales[0][0:32, 0:32, 0:32, :].read().result() == 55).all() + # Non-change chunk stays zero (skipped). + assert (scales[0][32:64, 0:32, 0:32, :].read().result() == 0).all() + # Coarser scales untouched — downsample worker's job. + assert (scales[1][0:16, 0:16, 0:32, :].read().result() == 0).all() + assert (scales[2][0:8, 0:8, 0:32, :].read().result() == 0).all() + + def test_new_seg_origin_offset(self, local_ocdbt): + """`new_seg_origin` correctly slices the right region of new_seg + when the envelope doesn't start at (0, 0, 0).""" + scales = local_ocdbt["scales"] + res = local_ocdbt["resolutions"] meta = MagicMock() - meta.ws_ocdbt = local_ocdbt["scales"][0] - meta.ws_ocdbt_scales = [local_ocdbt["scales"][0]] - meta.ws_ocdbt_resolutions = [local_ocdbt["resolutions"][0]] + meta.ws_ocdbt = scales[0] + meta.ws_ocdbt_scales = scales + meta.ws_ocdbt_resolutions = res + + # Envelope [32..64] along x, new_seg shape (32, 32, 32). + new_seg = np.full((32, 32, 32), 99, dtype=np.uint64) + change_chunks = [ + ( + (1, 0, 0), + np.array([[32, 0, 0], [64, 32, 32]], dtype=int), + ) + ] + ocdbt_mod.write_seg_chunks(meta, change_chunks, new_seg, np.array([32, 0, 0])) - data = np.full((8, 8, 8), 99, dtype=np.uint64) - ocdbt_mod.write_seg(meta, [0, 0, 0], [8, 8, 8], data) - assert (meta.ws_ocdbt[0:8, 0:8, 0:8, :].read().result() == 99).all() + assert (scales[0][32:64, 0:32, 0:32, :].read().result() == 99).all() + # First chunk (outside the envelope) stays zero. + assert (scales[0][0:32, 0:32, 0:32, :].read().result() == 0).all() class TestMetaToForkEndToEnd: From affa6721d98aa6613df39f732307678eba8985ed Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 02:22:39 +0000 Subject: [PATCH 208/367] feat(ocdbt): pinned-at reads Enable opening a CG's OCDBT at a prior commit via the driver's `version` spec field (int generation or ISO-8601 commit_time upper bound). Groundwork for operator-driven replay of failed SV splits, which needs clean pre-op reads against append-only storage. OCDBT stamps commits from absl::Now() with no caller-override hook, so pins will use OperationTimeStamp captured under the L2 chunk lock rather than aligning OCDBT commit times to operation time. --- pychunkedgraph/graph/ocdbt.py | 38 +- pychunkedgraph/tests/graph/test_ocdbt.py | 435 ++++++++++++----------- 2 files changed, 257 insertions(+), 216 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index 5c6617c4c..83db3c075 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -7,6 +7,27 @@ Multi-scale (MIP pyramid) is supported: the source watershed's info JSON drives the scale layout. All scales share one OCDBT kvstore; the precomputed driver prefixes keys by scale key automatically. + +Versioned reads +--------------- +Every OCDBT commit gets a monotonically-increasing ``generation_number`` and +an ``absl::Now()``-stamped ``commit_time`` (nanoseconds since epoch). The +tensorstore OCDBT driver lets callers pin a read-only open to a prior version +via the ``version`` spec field; accepts either an integer generation number +or an ISO-8601 UTC timestamp string. The timestamp form requires a ``Z`` +suffix (not ``+00:00``) and is interpreted as ``commit_time <= T`` — the open +returns the latest version at or before the pinned time. + +The commit_time itself cannot be overridden by the caller: OCDBT stamps each +commit from the writer's local clock (``absl::Now()`` in +``btree_writer_commit_operation.cc``). This means we can't make OCDBT commits +align exactly with a caller-provided operation timestamp. What the L2 chunk +lock guarantees instead: no other writer can commit to our chunks while we +hold the lock, so any timestamp captured under the lock before our first +commit is a valid pin for "pre-op state of our chunks." + +Retention: the OCDBT spec exposes no pruning fields. All versions are +retained by default. """ import json @@ -153,7 +174,12 @@ def open_base_ocdbt(ws_path: str): return src_list, dst_list, resolutions -def build_cg_ocdbt_spec(ws_path: str, graph_id: str) -> dict: +def build_cg_ocdbt_spec( + ws_path: str, + graph_id: str, + *, + pinned_at: "int | str | None" = None, +) -> dict: """Open-time kvstore spec for a CG's OCDBT, backed by a shared immutable base. The fork directory and its manifest are created automatically by @@ -162,6 +188,11 @@ def build_cg_ocdbt_spec(ws_path: str, graph_id: str) -> dict: All three kvstack layers below AND all three `*_data_prefix` options are load-bearing; removing any of them causes fork writes to leak into the immutable base (verified empirically). + + When `pinned_at` is set, the opened kvstore is read-only and returns + state as of the specified version. Accepts an integer generation + number (exact) or an ISO-8601 UTC timestamp string with `Z` suffix + (interpreted as `commit_time <= T`). """ base = _base_ocdbt_path(ws_path) fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") @@ -189,7 +220,7 @@ def build_cg_ocdbt_spec(ws_path: str, graph_id: str) -> dict: "base": _ensure_trailing_slash(fork_dir + data_prefix), } - return { + spec = { "driver": "ocdbt", "base": { "driver": "kvstack", @@ -202,6 +233,9 @@ def build_cg_ocdbt_spec(ws_path: str, graph_id: str) -> dict: "btree_node_data_prefix": data_prefix, "version_tree_node_data_prefix": data_prefix, } + if pinned_at is not None: + spec["version"] = pinned_at + return spec def fork_base_manifest(ws_path: str, graph_id: str, wipe_existing: bool = False): diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index b3786af48..ee17519f0 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -4,6 +4,8 @@ import os import shutil import tempfile +import time +from datetime import datetime, timezone import numpy as np import pytest @@ -13,6 +15,13 @@ from pychunkedgraph.graph import ocdbt as ocdbt_mod from pychunkedgraph.graph.meta import ChunkedGraphMeta, GraphConfig, DataSource +SCALE_META_BASE = { + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + "chunk_size": [32, 32, 32], +} +MULTISCALE_META = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + def _make_mock_src(num_scales=2): """Build a mock TensorStore source handle with a copyable schema.""" @@ -80,41 +89,33 @@ def test_spec_structure(self): class TestForkBaseManifest: - def test_copies_manifest(self): + """Byte-level behavior of `fork_base_manifest` — manifest copy + wipe.""" + + def test_copies_manifest(self, local_ocdbt): """fork_base_manifest copies the base manifest via tensorstore kvstore.""" - tmpdir = tempfile.mkdtemp() - ws = f"file://{tmpdir}" - try: - # Create a real base OCDBT with a manifest. - base_kvs = ts.KvStore.open(f"{ws}/ocdbt/base/").result() - base_kvs.write("manifest.ocdbt", b"fake_manifest_bytes").result() + ws = local_ocdbt["ws"] + base_kvs = ts.KvStore.open(f"{ws}/ocdbt/base/").result() + base_kvs.write("manifest.ocdbt", b"fake_manifest_bytes").result() - ocdbt_mod.fork_base_manifest(ws, "my_graph") + ocdbt_mod.fork_base_manifest(ws, "my_graph") - fork_kvs = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() - result = fork_kvs.read("manifest.ocdbt").result() - assert result.value == b"fake_manifest_bytes" - finally: - shutil.rmtree(tmpdir) + fork_kvs = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() + assert fork_kvs.read("manifest.ocdbt").result().value == b"fake_manifest_bytes" - def test_wipe_existing_cleans_fork_dir(self): + def test_wipe_existing_cleans_fork_dir(self, local_ocdbt): """wipe_existing=True removes the fork directory before copying.""" - tmpdir = tempfile.mkdtemp() - ws = f"file://{tmpdir}" - try: - base_kvs = ts.KvStore.open(f"{ws}/ocdbt/base/").result() - base_kvs.write("manifest.ocdbt", b"manifest_v1").result() + ws = local_ocdbt["ws"] + base_kvs = ts.KvStore.open(f"{ws}/ocdbt/base/").result() + base_kvs.write("manifest.ocdbt", b"manifest_v1").result() - fork_kvs = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() - fork_kvs.write("stale_file", b"stale").result() + fork_kvs = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() + fork_kvs.write("stale_file", b"stale").result() - ocdbt_mod.fork_base_manifest(ws, "my_graph", wipe_existing=True) + ocdbt_mod.fork_base_manifest(ws, "my_graph", wipe_existing=True) - fork_kvs2 = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() - assert fork_kvs2.read("manifest.ocdbt").result().value == b"manifest_v1" - assert len(fork_kvs2.read("stale_file").result().value) == 0 - finally: - shutil.rmtree(tmpdir) + fork_kvs2 = ts.KvStore.open(f"{ws}/ocdbt/my_graph/").result() + assert fork_kvs2.read("manifest.ocdbt").result().value == b"manifest_v1" + assert len(fork_kvs2.read("stale_file").result().value) == 0 class TestModeDownsample: @@ -216,45 +217,78 @@ def test_boundary_clipping(self): @pytest.fixture def local_ocdbt(): - """Create a local precomputed multi-scale OCDBT store. - - Builds 3 scales (factors 2,2,1 between each) with known segmentation IDs - so downsampling behaviour and propagation can be asserted against exact - values. Returns paths + handles for tests to work against directly. + """Shared OCDBT test environment. + + Creates a local 3-scale precomputed base OCDBT (factors 2,2,1 per + level) and exposes helpers for fork-based tests. Every OCDBT test + that needs real storage uses this fixture — no duplicated tmpdir + scaffolding. + + Yields: + tmpdir: on-disk workspace (cleaned up on teardown). + ws: `file://{tmpdir}` URL — what `build_cg_ocdbt_spec` expects. + base: base OCDBT kvstore URL. + scales: 3 precomputed handles on the base (multi-scale tests). + resolutions: per-scale [x,y,z] resolution arrays. + make_fork(graph_id, *, scale_index=0, pinned_at=None): opens a + precomputed handle through a fork of the base. Creates the + fork on first call per `graph_id` and reuses it thereafter; + repeated calls with the same id never re-copy the manifest + (which would clobber fork writes). """ tmpdir = tempfile.mkdtemp() - base = f"file://{tmpdir}/ocdbt/base" - - mm = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + ws = f"file://{tmpdir}" + base = f"{ws}/ocdbt/base" - def mk(scale_idx, size, resolution, extra_mm=None): + def _mk_scale(size, resolution, *, include_mm): + # Match OCDBT_CONFIG so forks (which always use it) don't trip the + # "Configuration mismatch on max_inline_value_bytes" check. spec = { "driver": "neuroglancer_precomputed", - "kvstore": {"driver": "ocdbt", "base": base}, + "kvstore": { + "driver": "ocdbt", + "base": base, + "config": dict(ocdbt_mod.OCDBT_CONFIG), + }, "scale_metadata": { "size": size, "resolution": resolution, - "encoding": "compressed_segmentation", - "compressed_segmentation_block_size": [8, 8, 8], - "chunk_size": [32, 32, 32], + **SCALE_META_BASE, }, } - if extra_mm: - spec["multiscale_metadata"] = extra_mm + if include_mm: + spec["multiscale_metadata"] = MULTISCALE_META return ts.open(spec, create=True).result() scales = [ - mk(0, [64, 64, 32], [4, 4, 40], extra_mm=mm), - mk(1, [32, 32, 32], [8, 8, 40]), - mk(2, [16, 16, 32], [16, 16, 40]), + _mk_scale([64, 64, 32], [4, 4, 40], include_mm=True), + _mk_scale([32, 32, 32], [8, 8, 40], include_mm=False), + _mk_scale([16, 16, 32], [16, 16, 40], include_mm=False), ] resolutions = [[4, 4, 40], [8, 8, 40], [16, 16, 40]] + _created_forks = set() + + def make_fork(graph_id, *, scale_index=0, pinned_at=None): + if graph_id not in _created_forks: + ocdbt_mod.fork_base_manifest(ws, graph_id) + _created_forks.add(graph_id) + spec = ocdbt_mod.build_cg_ocdbt_spec(ws, graph_id, pinned_at=pinned_at) + return ts.open( + { + "driver": "neuroglancer_precomputed", + "kvstore": spec, + "scale_index": scale_index, + } + ).result() + yield { "tmpdir": tmpdir, + "ws": ws, "base": base, "scales": scales, "resolutions": resolutions, + "make_fork": make_fork, } shutil.rmtree(tmpdir) @@ -460,189 +494,162 @@ def test_new_seg_origin_offset(self, local_ocdbt): assert (scales[0][0:32, 0:32, 0:32, :].read().result() == 0).all() -class TestMetaToForkEndToEnd: - """Full path: ChunkedGraphMeta.ws_ocdbt_scales → real kvstack fork → read/write.""" +class TestWsOcdbtScalesProperty: + """`ChunkedGraphMeta.ws_ocdbt_scales` opens a fork over the shared base. - def test_meta_opens_fork_and_merges_base(self): - """meta.ws_ocdbt_scales opens a real kvstack-backed OCDBT and reads - merge base + fork correctly. + Full path exercised: property → build_cg_ocdbt_spec → kvstack → OCDBT + read/write. Only `_read_source_scales` is mocked (it reads `/info` + which lives on the source watershed, not the OCDBT fork). + """ - Only `_read_source_scales` is mocked (it reads `/info` which is a - GCS-only key). The full meta → build_cg_ocdbt_spec → kvstack → - OCDBT → read/write path is exercised for real. - """ - tmpdir = tempfile.mkdtemp() - ws = f"file://{tmpdir}" - try: - MM = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} - SCALE = { - "size": [64, 64, 32], - "resolution": [4, 4, 40], - "encoding": "compressed_segmentation", - "compressed_segmentation_block_size": [8, 8, 8], - "chunk_size": [32, 32, 32], - } - FAKE_SCALES = [ - { - "resolution": [4, 4, 40], + def test_opens_fork_and_merges_base(self, local_ocdbt): + ws = local_ocdbt["ws"] + + # Source precomputed at ws root — needed by + # get_seg_source_and_destination_ocdbt to copy the schema. + ts.open( + { + "driver": "neuroglancer_precomputed", + "kvstore": f"{ws}/", + "multiscale_metadata": MULTISCALE_META, + "scale_metadata": { "size": [64, 64, 32], - "chunk_sizes": [[32, 32, 32]], - "encoding": "compressed_segmentation", - "compressed_segmentation_block_size": [8, 8, 8], - } - ] - - # Source precomputed — needed by get_seg_source_and_destination_ocdbt - # to open the source handle and copy its schema. - ts.open( - { - "driver": "neuroglancer_precomputed", - "kvstore": f"{ws}/", - "multiscale_metadata": MM, - "scale_metadata": SCALE, + "resolution": [4, 4, 40], + **SCALE_META_BASE, }, - create=True, - ).result() + }, + create=True, + ).result() - # Create base OCDBT with known data. - base_kvstore = { - "driver": "ocdbt", - "base": f"{ws}/ocdbt/base/", - "config": dict(ocdbt_mod.OCDBT_CONFIG), - } - base_store = ts.open( - { - "driver": "neuroglancer_precomputed", - "kvstore": base_kvstore, - "multiscale_metadata": MM, - "scale_metadata": SCALE, - }, - create=True, - ).result() - base_store[...] = np.full((64, 64, 32, 1), 50, dtype=np.uint64) - - # Fork for graph "test_cg". - ocdbt_mod.fork_base_manifest(f"{ws}/", "test_cg") - - gc = GraphConfig(ID="test_cg", CHUNK_SIZE=[32, 32, 32]) - ds = DataSource(WATERSHED=f"{ws}/", DATA_VERSION=4) - meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) - - # Mock only _read_source_scales ('/info' is GCS-only). - with patch.object( - ocdbt_mod, "_read_source_scales", return_value=FAKE_SCALES - ): - scales = meta.ws_ocdbt_scales - assert len(scales) == 1 - - # Read: should see base data. - r = scales[0][0:16, 0:16, 0:16, :].read().result() - assert (r == 50).all(), f"fork should see base, got {np.unique(r)}" - - # Write via the fork handle. - scales[0][0:16, 0:16, 0:16, :] = np.full( - (16, 16, 16, 1), 7, dtype=np.uint64 - ) + # Seed base scale 0 with a known value via the fixture's handle. + local_ocdbt["scales"][0][...] = np.full((64, 64, 32, 1), 50, dtype=np.uint64) - # Read back: edited = 7, untouched = 50. - assert (scales[0][0:16, 0:16, 0:16, :].read().result() == 7).all() - assert (scales[0][32:48, 0:16, 0:16, :].read().result() == 50).all() - - # Base unchanged. - base_ro = ts.open( - { - "driver": "neuroglancer_precomputed", - "kvstore": base_kvstore, - } - ).result() - assert (base_ro[0:16, 0:16, 0:16, :].read().result() == 50).all() - finally: - shutil.rmtree(tmpdir) + gc = GraphConfig(ID="ws_scales_cg", CHUNK_SIZE=[32, 32, 32]) + ds = DataSource(WATERSHED=f"{ws}/", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) + # Trigger fork creation through the same helper the property will use. + local_ocdbt["make_fork"]("ws_scales_cg") -class TestForkIsolation: - """End-to-end: two forks on the same base, writes isolated, base immutable.""" - - def test_two_forks_isolated(self): - tmpdir = tempfile.mkdtemp() - ws = f"file://{tmpdir}" - try: - # Build a base OCDBT with known data. - MM = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} - SCALE = { - "size": [64, 64, 32], + fake_scales = [ + { "resolution": [4, 4, 40], + "size": [64, 64, 32], + "chunk_sizes": [[32, 32, 32]], "encoding": "compressed_segmentation", "compressed_segmentation_block_size": [8, 8, 8], - "chunk_size": [32, 32, 32], - } - base_kvstore = { - "driver": "ocdbt", - "base": f"{ws}/ocdbt/base/", - "config": dict(ocdbt_mod.OCDBT_CONFIG), } - base_store = ts.open( - { - "driver": "neuroglancer_precomputed", - "kvstore": base_kvstore, - "multiscale_metadata": MM, - "scale_metadata": SCALE, - }, - create=True, - ).result() - base_store[...] = np.full((64, 64, 32, 1), 50, dtype=np.uint64) - - base_path = f"{tmpdir}/ocdbt/base" - base_files_before = set( - os.path.relpath(os.path.join(r, f), base_path) - for r, _, fs in os.walk(base_path) - for f in fs - ) + ] + with patch.object(ocdbt_mod, "_read_source_scales", return_value=fake_scales): + scales = meta.ws_ocdbt_scales + assert len(scales) == 1 - # Fork A and B via fork_base_manifest. - ocdbt_mod.fork_base_manifest(ws, "fork_a") - ocdbt_mod.fork_base_manifest(ws, "fork_b") - - def open_fork(gid): - spec = ocdbt_mod.build_cg_ocdbt_spec(ws, gid) - return ts.open( - {"driver": "neuroglancer_precomputed", "kvstore": spec}, - ).result() - - fork_a = open_fork("fork_a") - fork_b = open_fork("fork_b") - - # Both see base data. - assert (fork_a[0:16, 0:16, 0:16, :].read().result() == 50).all() - assert (fork_b[0:16, 0:16, 0:16, :].read().result() == 50).all() - - # Write different values to each fork. - fork_a[0:16, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 1, dtype=np.uint64) - fork_b[32:48, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 2, dtype=np.uint64) - - # Each fork sees ONLY its own edit + base for the rest. - assert (fork_a[0:16, 0:16, 0:16, :].read().result() == 1).all() - assert (fork_a[32:48, 0:16, 0:16, :].read().result() == 50).all() - assert (fork_b[32:48, 0:16, 0:16, :].read().result() == 2).all() - assert (fork_b[0:16, 0:16, 0:16, :].read().result() == 50).all() - - # Base is unchanged. - base_files_after = set( - os.path.relpath(os.path.join(r, f), base_path) - for r, _, fs in os.walk(base_path) - for f in fs + # Fork sees base data. + assert (scales[0][0:16, 0:16, 0:16, :].read().result() == 50).all() + + # Write to the fork and confirm isolation. + scales[0][0:16, 0:16, 0:16, :] = np.full( + (16, 16, 16, 1), 7, dtype=np.uint64 ) - assert ( - base_files_before == base_files_after - ), f"base was mutated: new={base_files_after - base_files_before}" - - # Fork writes went to their own directories. - fork_a_files = os.listdir(f"{tmpdir}/ocdbt/fork_a") - fork_b_files = os.listdir(f"{tmpdir}/ocdbt/fork_b") - assert any("fork_a_d" in f for f in fork_a_files) - assert any("fork_b_d" in f for f in fork_b_files) - finally: - shutil.rmtree(tmpdir) + assert (scales[0][0:16, 0:16, 0:16, :].read().result() == 7).all() + assert (scales[0][32:48, 0:16, 0:16, :].read().result() == 50).all() + + # Base still reports the original value (fork write didn't leak). + assert ( + local_ocdbt["scales"][0][0:16, 0:16, 0:16, :].read().result() == 50 + ).all() + + +class TestForkIsolation: + """Two forks on the same base: writes isolated, base immutable.""" + + def test_two_forks_isolated(self, local_ocdbt): + tmpdir = local_ocdbt["tmpdir"] + # Seed base scale 0 with a known value. + local_ocdbt["scales"][0][...] = np.full((64, 64, 32, 1), 50, dtype=np.uint64) + + base_path = f"{tmpdir}/ocdbt/base" + base_files_before = { + os.path.relpath(os.path.join(r, f), base_path) + for r, _, fs in os.walk(base_path) + for f in fs + } + + fork_a = local_ocdbt["make_fork"]("fork_a") + fork_b = local_ocdbt["make_fork"]("fork_b") + + # Both see base data. + assert (fork_a[0:16, 0:16, 0:16, :].read().result() == 50).all() + assert (fork_b[0:16, 0:16, 0:16, :].read().result() == 50).all() + + # Write different values to each fork. + fork_a[0:16, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 1, dtype=np.uint64) + fork_b[32:48, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 2, dtype=np.uint64) + + # Each fork sees ONLY its own edit + base for the rest. + assert (fork_a[0:16, 0:16, 0:16, :].read().result() == 1).all() + assert (fork_a[32:48, 0:16, 0:16, :].read().result() == 50).all() + assert (fork_b[32:48, 0:16, 0:16, :].read().result() == 2).all() + assert (fork_b[0:16, 0:16, 0:16, :].read().result() == 50).all() + + # Base files unchanged (no new bytes written under ocdbt/base/). + base_files_after = { + os.path.relpath(os.path.join(r, f), base_path) + for r, _, fs in os.walk(base_path) + for f in fs + } + assert ( + base_files_before == base_files_after + ), f"base was mutated: new={base_files_after - base_files_before}" + + # Fork writes went to their own directories. + assert any("fork_a_d" in f for f in os.listdir(f"{tmpdir}/ocdbt/fork_a")) + assert any("fork_b_d" in f for f in os.listdir(f"{tmpdir}/ocdbt/fork_b")) + + +class TestPinnedAt: + """Versioned reads: pinning a fork to a prior generation/timestamp + returns pre-write state; default (unpinned) returns latest. + + Documents both pin forms OCDBT accepts — integer generation (exact) + and ISO-8601 UTC timestamp with `Z` suffix (commit_time upper bound). + """ + + def test_pin_by_generation_and_by_timestamp(self, local_ocdbt): + # Seed base so fork reads see data even before the first fork write. + local_ocdbt["scales"][0][...] = np.full((64, 64, 32, 1), 50, dtype=np.uint64) + + fork = local_ocdbt["make_fork"]("pin_cg") + + # Write v1 then v2 at the same voxels. Capture pin markers between + # the two writes so pre-v2 state is what each pin should return. + fork[0:16, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 1, dtype=np.uint64) + + fork_manifest_kvs = ts.KvStore.open( + f"{local_ocdbt['ws']}/ocdbt/pin_cg/" + ).result() + pin_gen = ts.ocdbt.dump(fork_manifest_kvs).result()["versions"][-1][ + "generation_number" + ] + + time.sleep(0.01) + pin_ts = datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") + time.sleep(0.01) + + fork[0:16, 0:16, 0:16, :] = np.full((16, 16, 16, 1), 2, dtype=np.uint64) + + fork_latest = local_ocdbt["make_fork"]("pin_cg") + assert (fork_latest[0:16, 0:16, 0:16, :].read().result() == 2).all() + + fork_gen = local_ocdbt["make_fork"]("pin_cg", pinned_at=pin_gen) + assert (fork_gen[0:16, 0:16, 0:16, :].read().result() == 1).all() + + fork_ts = local_ocdbt["make_fork"]("pin_cg", pinned_at=pin_ts) + assert (fork_ts[0:16, 0:16, 0:16, :].read().result() == 1).all() + + # Untouched region still shows base data under every pin. + for handle in (fork_latest, fork_gen, fork_ts): + assert (handle[32:48, 0:16, 0:16, :].read().result() == 50).all() class TestCopyWsChunkMultiscale: From f216de780ff6251b722f65ab60d59244e69f0535 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 16:30:14 +0000 Subject: [PATCH 209/367] refactor(sv-split): pure planner + consolidated persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split_supervoxels is now a pure planner returning a SplitResult (seg_bboxes, source_ids_fresh, sink_ids_fresh, seg_writes, bigtable_rows). No lock acquisition, no writes. The caller (MulticutOperation._apply) holds the L2 chunk locks and fires the consolidated persist — OCDBT chunks + bigtable rows — inside an inner lock scope. seg_writes is a flat list of (voxel_slices, data) pairs across all reps so write_seg_chunks fires every chunk write as one parallel tensorstore batch. Removes the per-rep serialization in the old write_seg_chunks loop. get_seg_source_and_destination_ocdbt gains a pinned_at kwarg, forwarded to build_cg_ocdbt_spec — used later by the recovery path. --- pychunkedgraph/graph/edits_sv.py | 199 +++++++++++++++-------- pychunkedgraph/graph/ocdbt.py | 64 ++++---- pychunkedgraph/graph/operation.py | 46 +++++- pychunkedgraph/tests/graph/test_ocdbt.py | 81 +++++---- 4 files changed, 247 insertions(+), 143 deletions(-) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 6dcc6da5b..c4f269470 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -3,9 +3,10 @@ """ import time +from dataclasses import dataclass from datetime import datetime from collections import defaultdict -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List, Tuple import fastremap import numpy as np @@ -20,8 +21,6 @@ from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox from pychunkedgraph.graph.cutting_sv import split_supervoxel_helper from pychunkedgraph.graph.edges_sv import update_edges, add_new_edges -from pychunkedgraph.graph.locks import L2ChunkLock -from pychunkedgraph.graph.ocdbt import write_seg_chunks from pychunkedgraph.graph.utils import get_local_segmentation if TYPE_CHECKING: @@ -30,6 +29,26 @@ logger = get_logger(__name__) +@dataclass +class SplitResult: + """Pure planner output of `split_supervoxels`. + + The caller (`MulticutOperation._apply`) performs the actual writes + under the L2 chunk locks: + - `seg_writes` is fed to `write_seg_chunks` as one flat parallel batch. + - `bigtable_rows` is written via `cg.client.write` in one batch. + """ + + seg_bboxes: List[Tuple[np.ndarray, np.ndarray]] + source_ids_fresh: np.ndarray + sink_ids_fresh: np.ndarray + # Flat list across all reps: (voxel_slices, data_block) per OCDBT + # chunk write. `voxel_slices` is a 3-tuple of `slice` objects; the + # caller appends the channel slice and writes to `meta.ws_ocdbt`. + seg_writes: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] + bigtable_rows: list + + def _rep_bbox(cg: "ChunkedGraph", sv_remapping: dict, sv_id) -> tuple: """Base-voxel bbox covering every CC member of `sv_id`'s cross-chunk rep. @@ -125,7 +144,7 @@ def _overlapping_reps( ) -def split_supervoxels( +def plan_sv_splits( cg: "ChunkedGraph", *, sv_remapping: dict, @@ -133,34 +152,17 @@ def split_supervoxels( sink_ids: np.ndarray, source_coords: np.ndarray, sink_coords: np.ndarray, - operation_id: int, ) -> tuple: - """Run the SV-split loop under the L2 chunk lock. + """Compute the per-rep bboxes and the L2 chunk set a SV-split would touch. - Caller must hold the root lock for the roots containing the SVs in - `sv_remapping` — concurrent same-root edits are kept out by the root - lock, concurrent cross-root edits touching the same L2 chunks are - serialized by the L2 chunk lock acquired here. + Pure function — no bigtable/OCDBT IO, no locks. Lets the caller + acquire the L2 chunk locks (both temporal and indefinite) around + `split_supervoxels` without recomputing the plan inside. - Each rep's bbox is computed up-front from its CC members' chunk - coords (see `_rep_bbox`), so the lock set — union of read-expanded - chunks across all reps — is known before we block. The lock scope is - then exactly the chunks the per-rep splits will read or write. - - Returns `(seg_bboxes, source_ids_fresh, sink_ids_fresh)`: - seg_bboxes: per-rep base-resolution `(bbs, bbe)` bboxes that were - written to seg; the downsample worker re-mips only these. - source_ids_fresh / sink_ids_fresh: input `source_ids`/`sink_ids` - with positions touched by an overlap rep replaced by the new - SV ID that now lives at that coord. Untouched positions are - unchanged. Callers who need to re-run the multicut after - the split feed these to avoid referencing superseded SVs. + Returns `(reps, chunk_ids)` where `reps` is the per-rep tuple fed to + `split_supervoxels`, and `chunk_ids` is the sorted union of + read-expanded L2 chunks for the full operation. """ - source_ids_fresh = np.asarray(source_ids, dtype=basetypes.NODE_ID).copy() - sink_ids_fresh = np.asarray(sink_ids, dtype=basetypes.NODE_ID).copy() - - # Pre-compute every per-rep bbox (cheap chunk-coord lookups, no lock - # needed yet). Reuse later inside the lock to avoid recomputation. reps = [] for ( sv_id, @@ -179,33 +181,87 @@ def split_supervoxels( reps.append( (sv_id, src_coords_rep, sink_coords_rep, src_mask, sink_mask, bbs, bbe) ) - chunk_ids = _l2_chunks_for_splits(cg, [(bbs, bbe) for *_, bbs, bbe in reps]) + return reps, chunk_ids + + +def split_supervoxels( + cg: "ChunkedGraph", + *, + reps: list, + sv_remapping: dict, + source_ids: np.ndarray, + sink_ids: np.ndarray, + operation_id: int, +) -> SplitResult: + """Pure planner for the SV-split step. Returns a `SplitResult` with + all the data the caller needs to persist under locks. + + Does **not** write — the caller (`MulticutOperation._apply`) owns + the L2 chunk lock lifecycle and fires the OCDBT + bigtable writes + inside `IndefiniteL2ChunkLock`. + + Must be called inside the caller's `L2ChunkLock` for the + `plan.chunk_ids` set — the seg reads inside `split_supervoxel` need + to be consistent with concurrent writers. + + Fields on the returned `SplitResult`: + seg_bboxes: per-rep base-resolution `(bbs, bbe)` — downsample + worker input. + source_ids_fresh / sink_ids_fresh: input `source_ids`/`sink_ids` + with positions touched by an overlap rep replaced by the new + SV ID that now lives at that coord. Untouched positions stay + unchanged. Feeds the retry multicut. + seg_writes: flat list of `(voxel_slices, data)` pairs across all + reps — one tensorstore write per pair, fired in parallel. + bigtable_rows: flattened rows from `copy_parents_and_add_lineage` + + `add_new_edges` across all reps. + """ + source_ids_fresh = np.asarray(source_ids, dtype=basetypes.NODE_ID).copy() + sink_ids_fresh = np.asarray(sink_ids, dtype=basetypes.NODE_ID).copy() + seg_bboxes = [] - with L2ChunkLock(cg, chunk_ids, operation_id): - for ( + seg_writes: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = [] + bigtable_rows: list = [] + for ( + sv_id, + src_coords_rep, + sink_coords_rep, + src_mask, + sink_mask, + bbs, + bbe, + ) in reps: + ( + _, + _, + seg_bbox, + src_new_ids, + sink_new_ids, + seg_write_pairs, + rows, + ) = split_supervoxel( + cg, sv_id, src_coords_rep, sink_coords_rep, - src_mask, - sink_mask, - bbs, - bbe, - ) in reps: - _, _, seg_bbox, src_new_ids, sink_new_ids = split_supervoxel( - cg, - sv_id, - src_coords_rep, - sink_coords_rep, - operation_id, - sv_remapping=sv_remapping, - bbs=bbs, - bbe=bbe, - ) - seg_bboxes.append(seg_bbox) - source_ids_fresh[src_mask] = src_new_ids - sink_ids_fresh[sink_mask] = sink_new_ids - return seg_bboxes, source_ids_fresh, sink_ids_fresh + operation_id, + sv_remapping=sv_remapping, + bbs=bbs, + bbe=bbe, + ) + seg_bboxes.append(seg_bbox) + source_ids_fresh[src_mask] = src_new_ids + sink_ids_fresh[sink_mask] = sink_new_ids + seg_writes.extend(seg_write_pairs) + bigtable_rows.extend(rows) + return SplitResult( + seg_bboxes=seg_bboxes, + source_ids_fresh=source_ids_fresh, + sink_ids_fresh=sink_ids_fresh, + seg_writes=seg_writes, + bigtable_rows=bigtable_rows, + ) def _update_chunks(cg: "ChunkedGraph", chunks_bbox_map, seg, result_seg, bb_start): @@ -389,24 +445,39 @@ def split_supervoxel( rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) rows = rows0 + rows1 - t0 = time.time() - write_seg_chunks(cg.meta, change_chunks, new_seg, bbs) - cg.client.write(rows) - logger.note( - f"write seg ({len(change_chunks)} chunks) + {len(rows)} rows " - f"({time.time() - t0:.2f}s)" - ) - - # Per-coord fresh IDs: identical to what a post-write seg read would - # return, but computed in-memory from new_seg. new_seg is what just - # landed on storage (the write is synchronous and we hold the L2 - # lock), so reading new_seg[coord - bbs] is equivalent to re-reading - # seg — without the storage round-trip. + # Prepare per-chunk OCDBT write payloads. The caller batches these + # across all reps into one parallel tensorstore write — no serial + # per-rep loop. + seg_write_pairs: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = [] + for _, chunk_bbox in change_chunks: + lo, hi = chunk_bbox[0], chunk_bbox[1] + local_lo = lo - bbs + local_hi = hi - bbs + data = new_seg[ + local_lo[0] : local_hi[0], + local_lo[1] : local_hi[1], + local_lo[2] : local_hi[2], + ] + voxel_slices = tuple(slice(int(s), int(e)) for s, e in zip(lo, hi)) + seg_write_pairs.append((voxel_slices, data)) + + # Per-coord fresh IDs: bit-identical to what a post-write seg read + # would return — new_seg is what the caller is about to write, and + # the caller holds the L2 chunk lock when it does, so the storage + # round-trip would see the same bytes. local_src = (np.asarray(source_coords, dtype=int) - bbs).astype(int) local_sink = (np.asarray(sink_coords, dtype=int) - bbs).astype(int) src_new_ids = new_seg[tuple(local_src.T)] sink_new_ids = new_seg[tuple(local_sink.T)] - return old_new_map, edges_tuple, (bbs, bbe), src_new_ids, sink_new_ids + return ( + old_new_map, + edges_tuple, + (bbs, bbe), + src_new_ids, + sink_new_ids, + seg_write_pairs, + rows, + ) def copy_parents_and_add_lineage( diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index 83db3c075..4bed043ad 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -261,19 +261,28 @@ def fork_base_manifest(ws_path: str, graph_id: str, wipe_existing: bool = False) fork_kvs.write("manifest.ocdbt", manifest).result() -def get_seg_source_and_destination_ocdbt(ws_path: str, graph_id: str) -> tuple: +def get_seg_source_and_destination_ocdbt( + ws_path: str, + graph_id: str, + *, + pinned_at: "int | str | None" = None, +) -> tuple: """Open source watershed + CG's delta OCDBT destination (all scales). Always uses the fork-based kvstack spec. Requires the base to exist and the fork's manifest to be present (set up at ingest time). + When `pinned_at` is set, the destination OCDBT handles are opened + read-only at that version — used by the recovery path to read + pre-op seg values via `ChunkedGraphMeta.pinned_seg_reads`. + Returns: (src_list, dst_list, resolutions): per-scale TensorStore handles and [x,y,z] resolutions. """ scales = _read_source_scales(ws_path) resolutions = [s["resolution"] for s in scales] - cg_kvstore = build_cg_ocdbt_spec(ws_path, graph_id) + cg_kvstore = build_cg_ocdbt_spec(ws_path, graph_id, pinned_at=pinned_at) src_list, dst_list = [], [] for i in range(len(scales)): @@ -432,47 +441,32 @@ def propagate_to_coarser_scales(dst_scales, resolutions, base_slices): prev_slices = target_slices -def write_seg_chunks(meta, change_chunks, new_seg, new_seg_origin): - """Write only the L2 chunks that actually received new SV IDs. +def write_seg_chunks(meta, seg_writes): + """Write a flat batch of pre-sliced L2 chunks to OCDBT in parallel. - An SV split produces `new_seg` (shape `bbe - bbs`) covering the full - rep envelope — but only some of its chunks contain split fragments. - The rest (gap chunks between cross-chunk-connected pieces, and the - neighbor chunks the overlap read touches) are identical to what's on - disk. Writing those would bloat OCDBT's append-only delta with - unchanged bytes. + `seg_writes` is the aggregated output of `edits_sv.split_supervoxels` + across every rep in an operation — each pair is one L2 chunk's worth + of `(voxel_slices, data)`. Flattening across reps matters: one + `write_seg_chunks` call fires every chunk write in one parallel + tensorstore batch instead of serializing rep-by-rep. - Each per-chunk write is fired as an async tensorstore future and - awaited in aggregate — wall-time is comparable to a single slab - write (tensorstore parallelizes internal OCDBT-chunk writes anyway), - but gap and neighbor chunks are never touched. + Only chunks that actually received new SV IDs appear here; gap + chunks between cross-chunk-connected pieces and neighbor chunks the + overlap read touched are skipped by the split planner. Coarser MIP levels stay the downsample worker's job — it picks up the pubsub message `publish_edit` sends after this returns. Args: meta: ChunkedGraphMeta with `ws_ocdbt` (base-scale handle). - change_chunks: iterable of `(chunk_coord, chunk_bbox)` pairs; - `chunk_bbox` is a (2, 3) array of voxel-coord lo/hi for that - L2 chunk (what `_update_chunks` returns). - new_seg: 3D array shaped `(bbe - bbs)` with the split's labels - applied. - new_seg_origin: the rep's `bbs` — used to slice `new_seg` at the - right offset for each chunk's write. + seg_writes: iterable of `(voxel_slices, data)` pairs, where + `voxel_slices` is a 3-tuple of `slice` objects covering one + L2 chunk's x/y/z extent and `data` is the 3D label block + (shape matches the slice extents). """ - futures = [] - for _, chunk_bbox in change_chunks: - lo, hi = chunk_bbox[0], chunk_bbox[1] - local_lo = lo - new_seg_origin - local_hi = hi - new_seg_origin - data = new_seg[ - local_lo[0] : local_hi[0], - local_lo[1] : local_hi[1], - local_lo[2] : local_hi[2], - ] - slices = tuple(slice(int(s), int(e)) for s, e in zip(lo, hi)) - futures.append( - meta.ws_ocdbt[slices + (slice(None),)].write(data[..., np.newaxis]) - ) + futures = [ + meta.ws_ocdbt[voxel_slices + (slice(None),)].write(data[..., np.newaxis]) + for voxel_slices, data in seg_writes + ] for f in futures: f.result() diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 03f2eb5a4..cdfb0a3c2 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -23,6 +23,7 @@ from . import edits from . import edits_sv from . import types +from .ocdbt import write_seg_chunks from pychunkedgraph.graph import attributes from .edges import Edges from .edges.utils import get_edges_status @@ -926,20 +927,49 @@ def _apply( if isinstance(result, SvSplitRequired): # Running under GraphEditOperation.execute's RootLock — no same-root # edit can interleave between the SV split and the retry multicut. - # L2 chunk lock (inside split_supervoxels) serializes cross-root - # overlap. The SVs in source_ids/sink_ids are about to be - # superseded; re-read them from seg after the split lands. - self.seg_bboxes, self.source_ids, self.sink_ids = ( - edits_sv.split_supervoxels( + # `plan_sv_splits` returns the chunk scope for both locks below, + # `split_supervoxels` is a pure planner that computes the full + # payload. Writes happen here inside nested L2 chunk locks: + # - `L2ChunkLock` (temporal) spans the seg reads (inside + # `split_supervoxels`) and the writes, so no concurrent + # op can mutate our chunks mid-compute. + # - `IndefiniteL2ChunkLock` is scoped tightly to the writes + # only. A worker death inside it leaves the indefinite + # cell set on every chunk row in scope, blocking future + # ops until operator replay clears them. + reps, chunk_ids = edits_sv.plan_sv_splits( + self.cg, + sv_remapping=result.sv_remapping, + source_ids=self.source_ids, + sink_ids=self.sink_ids, + source_coords=self.source_coords, + sink_coords=self.sink_coords, + ) + with locks.L2ChunkLock( + self.cg, + chunk_ids, + operation_id, + privileged_mode=self.privileged_mode, + ): + sv_result = edits_sv.split_supervoxels( self.cg, + reps=reps, sv_remapping=result.sv_remapping, source_ids=self.source_ids, sink_ids=self.sink_ids, - source_coords=self.source_coords, - sink_coords=self.sink_coords, operation_id=operation_id, ) - ) + with locks.IndefiniteL2ChunkLock( + self.cg, + chunk_ids, + operation_id, + privileged_mode=self.privileged_mode, + ): + write_seg_chunks(self.cg.meta, sv_result.seg_writes) + self.cg.client.write(sv_result.bigtable_rows) + self.seg_bboxes = sv_result.seg_bboxes + self.source_ids = sv_result.source_ids_fresh + self.sink_ids = sv_result.sink_ids_fresh result = self._run_multicut(operation_id) if isinstance(result, SvSplitRequired): raise PreconditionError( diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index ee17519f0..4f2af7b7b 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -433,64 +433,73 @@ def test_repeated_update_reflects_latest_base(self, local_ocdbt): class TestWriteSegChunks: - def test_writes_only_change_chunks(self, local_ocdbt): - """Only chunks listed in `change_chunks` receive writes; sibling - chunks in the same envelope stay untouched (OCDBT delta stays - proportional to the actual SV change, not the envelope).""" + """`write_seg_chunks` now takes a flat list of (slices, data) pairs. + + `edits_sv.split_supervoxels` is responsible for producing this list + across all reps so the outer rep loop is a pure data gather — + tensorstore writes fire in one parallel batch. + """ + + def test_writes_only_supplied_chunks(self, local_ocdbt): + """Chunks absent from `seg_writes` stay untouched (OCDBT delta + stays proportional to the actual SV change).""" scales = local_ocdbt["scales"] - res = local_ocdbt["resolutions"] meta = MagicMock() meta.ws_ocdbt = scales[0] - meta.ws_ocdbt_scales = scales - meta.ws_ocdbt_resolutions = res - - # new_seg covers a two-chunk envelope [0..32] along x at base scale - # (chunk_size = 32). Only the first chunk [0..32, 0..32, 0..32] is - # listed as a change chunk; the second chunk should stay zero. - new_seg = np.zeros((64, 32, 32), dtype=np.uint64) - new_seg[0:32, 0:32, 0:32] = 55 - # second chunk gets some filler data in-memory, but it's NOT in - # change_chunks so it should never hit storage. - new_seg[32:64, 0:32, 0:32] = 77 - - change_chunks = [ + + # One chunk at [0..32] with label 55. The adjacent chunk at + # [32..64] is NOT in the write list, so it should stay zero. + chunk_data = np.full((32, 32, 32), 55, dtype=np.uint64) + seg_writes = [ ( - (0, 0, 0), - np.array([[0, 0, 0], [32, 32, 32]], dtype=int), + (slice(0, 32), slice(0, 32), slice(0, 32)), + chunk_data, ) ] - ocdbt_mod.write_seg_chunks(meta, change_chunks, new_seg, np.array([0, 0, 0])) + ocdbt_mod.write_seg_chunks(meta, seg_writes) - # Written chunk has label 55. assert (scales[0][0:32, 0:32, 0:32, :].read().result() == 55).all() - # Non-change chunk stays zero (skipped). assert (scales[0][32:64, 0:32, 0:32, :].read().result() == 0).all() # Coarser scales untouched — downsample worker's job. assert (scales[1][0:16, 0:16, 0:32, :].read().result() == 0).all() assert (scales[2][0:8, 0:8, 0:32, :].read().result() == 0).all() - def test_new_seg_origin_offset(self, local_ocdbt): - """`new_seg_origin` correctly slices the right region of new_seg - when the envelope doesn't start at (0, 0, 0).""" + def test_multiple_chunks_in_one_batch(self, local_ocdbt): + """Multiple chunks (e.g. from different reps) fire in one call.""" + scales = local_ocdbt["scales"] + meta = MagicMock() + meta.ws_ocdbt = scales[0] + + seg_writes = [ + ( + (slice(0, 32), slice(0, 32), slice(0, 32)), + np.full((32, 32, 32), 11, dtype=np.uint64), + ), + ( + (slice(32, 64), slice(0, 32), slice(0, 32)), + np.full((32, 32, 32), 22, dtype=np.uint64), + ), + ] + ocdbt_mod.write_seg_chunks(meta, seg_writes) + + assert (scales[0][0:32, 0:32, 0:32, :].read().result() == 11).all() + assert (scales[0][32:64, 0:32, 0:32, :].read().result() == 22).all() + + def test_offset_region(self, local_ocdbt): + """Writes at a non-origin offset land in the right chunk.""" scales = local_ocdbt["scales"] - res = local_ocdbt["resolutions"] meta = MagicMock() meta.ws_ocdbt = scales[0] - meta.ws_ocdbt_scales = scales - meta.ws_ocdbt_resolutions = res - # Envelope [32..64] along x, new_seg shape (32, 32, 32). - new_seg = np.full((32, 32, 32), 99, dtype=np.uint64) - change_chunks = [ + seg_writes = [ ( - (1, 0, 0), - np.array([[32, 0, 0], [64, 32, 32]], dtype=int), + (slice(32, 64), slice(0, 32), slice(0, 32)), + np.full((32, 32, 32), 99, dtype=np.uint64), ) ] - ocdbt_mod.write_seg_chunks(meta, change_chunks, new_seg, np.array([32, 0, 0])) + ocdbt_mod.write_seg_chunks(meta, seg_writes) assert (scales[0][32:64, 0:32, 0:32, :].read().result() == 99).all() - # First chunk (outside the envelope) stays zero. assert (scales[0][0:32, 0:32, 0:32, :].read().result() == 0).all() From 984634d399a0a0d596db3104a336d50e45684f71 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 16:31:02 +0000 Subject: [PATCH 210/367] feat(locks): IndefiniteL2ChunkLock + exception-path hold New L2 chunk counterpart to IndefiniteRootLock, keyed by chunk row. L2ChunkLock now acquires via lock_by_row_key_with_indefinite so a temporal acquire sees a crashed op's indefinite cell and refuses. IndefiniteL2ChunkLock records its chunk scope on the op-log row's L2ChunkLockScope column at __enter__ and clears it on clean exit, giving recovery a durable scope without a bigtable-wide scan. Both indefinite locks (root and L2 chunk) now short-circuit __exit__ when an exception is propagating: cells stay held, scope stays set. Partial writes may exist after an exception; leaving the cells forces subsequent ops to refuse at lock-acquire and the operator to run recovery explicitly. privileged_mode=True on either lock is the operator recovery escape hatch: skips acquire, pre-populates acquired_keys so __exit__'s value-matched release deletes the crashed op's cells. RowKeyLockRegistry (test helper) gains the three new kvdbclient primitives. --- pychunkedgraph/graph/locks.py | 173 ++++++++++++++++++- pychunkedgraph/tests/graph/test_locks.py | 207 ++++++++++++++++++++++- pychunkedgraph/tests/helpers.py | 40 ++++- 3 files changed, 411 insertions(+), 9 deletions(-) diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index 95e7fb7b1..bdff4f789 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -9,7 +9,7 @@ from pychunkedgraph import get_logger -from . import exceptions +from . import attributes, exceptions, serializers from .types import empty_1d from .lineage import lineage_graph @@ -166,6 +166,14 @@ def __enter__(self): return self def __exit__(self, exception_type, exception_value, traceback): + if exception_type is not None: + # Partial bigtable hierarchy writes may have landed before + # the exception propagated. Keep the indefinite cells held + # so subsequent ops on these roots refuse to acquire — + # forces operator recovery (`repair_operation(..., unlock= + # True)`) rather than letting a silent corruption slip into + # further edits. + return if self.acquired: max_workers = min(8, max(1, len(self.root_ids))) with ThreadPoolExecutor(max_workers=max_workers) as executor: @@ -332,7 +340,13 @@ class L2ChunkLock: under the hood; the row key disambiguates). """ - __slots__ = ["cg", "chunk_ids", "operation_id", "acquired_keys"] + __slots__ = [ + "cg", + "chunk_ids", + "operation_id", + "privileged_mode", + "acquired_keys", + ] # Retry budget for partial-acquire failures. Each attempt releases # anything it got in the previous pass, then re-acquires from scratch. @@ -344,6 +358,8 @@ def __init__( cg, chunk_ids: Sequence[int], operation_id: np.uint64, + *, + privileged_mode: bool = False, ) -> None: self.cg = cg # Sort so every `__enter__` uses a consistent acquisition order @@ -351,15 +367,33 @@ def __init__( # would otherwise race AB/BA. self.chunk_ids = sorted(int(c) for c in chunk_ids) self.operation_id = np.uint64(operation_id) + self.privileged_mode = privileged_mode self.acquired_keys: list = [] def __enter__(self): + if self.privileged_mode: + # Replay path: the crashed op's `IndefiniteL2ChunkLock` cells + # are still set on these chunks (that's what's blocking new + # ops), and `lock_by_row_key_with_indefinite` would refuse. + # Mirror `RootLock`/`IndefiniteRootLock`'s privileged escape + # hatch — skip temporal acquire, the indefinite cells are + # our de-facto lock and they'll be released by the inner + # `IndefiniteL2ChunkLock(privileged_mode=True)` on exit. + return self for attempt in range(self._MAX_ACQUIRE_ATTEMPTS): self.acquired_keys = [] all_ok = True for chunk_id in self.chunk_ids: row_key = _l2_chunk_lock_row_key(chunk_id) - if self.cg.client.lock_by_row_key(row_key, self.operation_id): + # `_with_indefinite`: the temporal acquire must also + # refuse if the indefinite column is set. Closes the + # crash-recovery race — a worker that died holding + # `IndefiniteL2ChunkLock` leaves the indefinite cell + # set, and the next op must see it rather than silently + # racing into partial state. + if self.cg.client.lock_by_row_key_with_indefinite( + row_key, self.operation_id + ): self.acquired_keys.append(row_key) else: all_ok = False @@ -402,3 +436,136 @@ def renew(self) -> bool: logger.warning(f"Failed to renew L2 chunk lock {key!r}") ok = False return ok + + +class IndefiniteL2ChunkLock: + """Upgrade held-temporal L2 chunk locks to indefinite. + + Structurally mirrors `IndefiniteRootLock`: acquired inside the + temporal lock (`L2ChunkLock`) context after preconditions are + established, and held across the write phase. Doesn't expire — the + cell persists on bigtable until explicitly released (or operator + recovery clears it), so a worker that dies with writes in flight + leaves the chunks marked indefinitely-held. + + The temporal `L2ChunkLock` must already be held by the same + `operation_id`; the acquire filter for temporal now rejects on + indefinite cells, so future temporal acquires on these chunks + refuse until this lock is released. + + Durable scope: `__enter__` writes `chunk_ids` to the op-log row's + `OperationLogs.L2ChunkLockScope` column. This persists through a + worker crash, giving `stuck_ops replay` the exact chunk set to + clean up without a bigtable-wide lock-row scan. + + `privileged_mode=True` is the operator recovery escape hatch: + skips the acquire step (the cells already exist, held by this same + op_id from the crashed attempt), pre-populates `acquired_keys` from + `chunk_ids` so `__exit__` still value-matches-releases those cells, + and does not re-write the op-log scope column. + """ + + __slots__ = ["cg", "chunk_ids", "operation_id", "privileged_mode", "acquired_keys"] + + def __init__( + self, + cg, + chunk_ids: Sequence[int], + operation_id: np.uint64, + *, + privileged_mode: bool = False, + ) -> None: + self.cg = cg + self.chunk_ids = sorted(int(c) for c in chunk_ids) + self.operation_id = np.uint64(operation_id) + self.privileged_mode = privileged_mode + self.acquired_keys: list = [] + + def __enter__(self): + if self.privileged_mode: + # Recovery path: crashed op's indefinite cells already exist + # under this op_id. Populate acquired_keys so __exit__'s + # value-matched release deletes them after the replay writes + # succeed. + self.acquired_keys = [_l2_chunk_lock_row_key(c) for c in self.chunk_ids] + return self + for chunk_id in self.chunk_ids: + row_key = _l2_chunk_lock_row_key(chunk_id) + if not self.cg.client.lock_by_row_key_indefinitely( + row_key, self.operation_id + ): + # Partial acquire: release what we got and fail. No + # retry — an indefinite cell belongs to a currently- + # running or crashed op and won't clear on its own. + self._release_acquired() + raise exceptions.LockingError( + f"Could not upgrade L2 chunk {chunk_id} to indefinite lock " + f"(another op holds it)" + ) + self.acquired_keys.append(row_key) + self._write_scope_to_op_log() + return self + + def __exit__(self, exception_type, exception_value, traceback): + if exception_type is not None: + # Partial OCDBT seg / bigtable SV-hierarchy writes may have + # landed before the exception propagated. Leave the + # indefinite cells held and the op-log scope intact so + # subsequent ops refuse at `L2ChunkLock` acquire — forces + # operator recovery (`stuck_ops replay`) rather than + # leaking orphan SV IDs into downstream reads. + return + self._release_acquired() + self._clear_scope_on_op_log() + + def _write_scope_to_op_log(self): + """Record the chunk scope on the op-log row before seg/bigtable + writes begin. A worker crash after this point leaves both the + per-chunk indefinite cells AND this field set, so recovery can + locate the partial-write region without a bigtable scan. + """ + row_key = serializers.serialize_uint64(self.operation_id) + scope = np.asarray(self.chunk_ids, dtype=np.uint64) + entry = self.cg.client.mutate_row( + row_key, + {attributes.OperationLogs.L2ChunkLockScope: scope}, + ) + self.cg.client.write([entry]) + + def _clear_scope_on_op_log(self): + """Clear the scope record on normal exit — op completed or was + cleanly rolled back, so no partial state needs recovery. Overwrites + with an empty array; a subsequent `read_log_entries` returns an + empty scope (recovery skips). Best-effort; failures here are + logged but don't propagate. + """ + try: + row_key = serializers.serialize_uint64(self.operation_id) + empty = np.array([], dtype=np.uint64) + entry = self.cg.client.mutate_row( + row_key, + {attributes.OperationLogs.L2ChunkLockScope: empty}, + ) + self.cg.client.write([entry]) + except Exception as e: + logger.warning(f"Failed to clear L2ChunkLockScope on op-log row: {e}") + + def _release_acquired(self): + if not self.acquired_keys: + return + max_workers = min(8, max(1, len(self.acquired_keys))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit( + self.cg.client.unlock_indefinitely_locked_by_row_key, + key, + self.operation_id, + ) + for key in self.acquired_keys + ] + for future in as_completed(futures): + try: + future.result() + except Exception as e: + logger.warning(f"Failed to unlock indefinite L2 chunk: {e}") + self.acquired_keys = [] diff --git a/pychunkedgraph/tests/graph/test_locks.py b/pychunkedgraph/tests/graph/test_locks.py index ef190748b..0d82262a9 100644 --- a/pychunkedgraph/tests/graph/test_locks.py +++ b/pychunkedgraph/tests/graph/test_locks.py @@ -12,8 +12,12 @@ make_cg_with_row_key_lock_registry, to_label, ) -from ...graph import exceptions -from ...graph.locks import L2ChunkLock, _l2_chunk_lock_row_key +from ...graph import attributes, exceptions +from ...graph.locks import ( + IndefiniteL2ChunkLock, + L2ChunkLock, + _l2_chunk_lock_row_key, +) from ...graph.lineage import get_future_root_ids from ...ingest.create.parent_layer import add_parent_chunk @@ -711,6 +715,30 @@ def test_indefiniterootlock_exit_handles_exception(self): # Should not raise lock.__exit__(None, None, None) + def test_indefiniterootlock_exit_holds_on_exception_path(self): + """When `__exit__` is called with a propagating exception, cells + stay held — partial bigtable hierarchy writes may have landed + and further ops must refuse until operator recovery runs. + """ + cg = _make_mock_cg() + root_ids = np.array([np.uint64(100), np.uint64(101)]) + cg.client.lock_roots_indefinitely.return_value = ( + True, + list(root_ids), + [], + ) + + lock = IndefiniteRootLock( + cg, + np.uint64(10), + root_ids, + future_root_ids_d=defaultdict(list), + ) + lock.__enter__() + lock.__exit__(ValueError, ValueError("boom"), None) + + cg.client.unlock_indefinitely_locked_root.assert_not_called() + class TestIndefiniteRootLockComputesFutureRootIds: def test_indefiniterootlock_computes_future_root_ids(self): @@ -847,3 +875,178 @@ def test_partial_acquire_released_on_failure(self, monkeypatch): # Only chunk 2 remains held, by the pre-existing holder. assert len(registry._held) == 1 assert next(iter(registry._held)) == _l2_chunk_lock_row_key(np.uint64(2)) + + def test_privileged_mode_skips_acquire(self): + """Replay path: indefinite cells from the crashed op are still + set, so a normal temporal acquire would refuse. Privileged mode + bypasses the acquire entirely — the indefinite cells are the + de-facto lock and the inner `IndefiniteL2ChunkLock(privileged=True)` + releases them on exit. + """ + registry = RowKeyLockRegistry() + # Crashed op's indefinite cells block a normal temporal acquire. + crashed_op = np.uint64(42) + for c in (np.uint64(1), np.uint64(2)): + registry.lock_by_row_key_indefinitely(_l2_chunk_lock_row_key(c), crashed_op) + + cg = make_cg_with_row_key_lock_registry(registry) + + # Normal acquire refuses because indefinite is held. + normal = L2ChunkLock(cg, [np.uint64(1), np.uint64(2)], np.uint64(99)) + with pytest.raises(exceptions.LockingError): + normal.__enter__() + + # Privileged acquire — called from replay with the same op_id as + # the crashed op — skips the acquire and returns cleanly. + priv = L2ChunkLock( + cg, [np.uint64(1), np.uint64(2)], crashed_op, privileged_mode=True + ) + priv.__enter__() + priv.__exit__(None, None, None) + # Indefinite cells still held (privileged-L2ChunkLock doesn't + # touch them — that's IndefiniteL2ChunkLock(privileged=True)'s job). + assert len(registry._held_indefinite) == 2 + + +class TestIndefiniteL2ChunkLock: + """`IndefiniteL2ChunkLock` lifecycle: acquire + scope write on enter, + release + scope clear on exit; privileged mode releases pre-existing + cells left by a crashed op. + """ + + def _scope_mutate_calls(self, cg): + """Extract (row_key, scope_value) from cg.client.mutate_row calls + that set `L2ChunkLockScope`. Lets tests assert on what was written.""" + calls = [] + for call in cg.client.mutate_row.call_args_list: + row_key, val_dict = call[0][:2] + if attributes.OperationLogs.L2ChunkLockScope in val_dict: + calls.append( + (row_key, val_dict[attributes.OperationLogs.L2ChunkLockScope]) + ) + return calls + + def test_enter_writes_scope_and_acquires_cells(self): + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) + chunks = [np.uint64(3), np.uint64(1), np.uint64(2)] + op_id = np.uint64(42) + + lock = IndefiniteL2ChunkLock(cg, chunks, op_id) + lock.__enter__() + try: + # Every chunk now has an indefinite cell. + assert len(registry._held_indefinite) == 3 + # Scope written to op-log row; value is the sorted chunk list. + scope_calls = self._scope_mutate_calls(cg) + non_empty = [c for c in scope_calls if len(c[1]) > 0] + assert len(non_empty) == 1 + assert list(non_empty[0][1]) == [1, 2, 3] + finally: + lock.__exit__(None, None, None) + + def test_exit_releases_cells_and_clears_scope(self): + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) + chunks = [np.uint64(1), np.uint64(2)] + with IndefiniteL2ChunkLock(cg, chunks, np.uint64(42)): + pass + # Cells released. + assert registry._held_indefinite == {} + # Scope cleared: one write of an empty array to L2ChunkLockScope. + empty_calls = [c for c in self._scope_mutate_calls(cg) if len(c[1]) == 0] + assert len(empty_calls) == 1 + + def test_privileged_mode_releases_preexisting(self): + """Crashed op left indefinite cells under its op_id; the replay + re-enters with privileged_mode=True and the `__exit__` is expected + to delete those pre-existing cells (value-matched by op_id). + """ + registry = RowKeyLockRegistry() + op_id = np.uint64(42) + chunks = [np.uint64(10), np.uint64(20)] + for c in chunks: + assert registry.lock_by_row_key_indefinitely( + _l2_chunk_lock_row_key(c), op_id + ) + assert len(registry._held_indefinite) == 2 + + cg = make_cg_with_row_key_lock_registry(registry) + with IndefiniteL2ChunkLock(cg, chunks, op_id, privileged_mode=True): + # Privileged enter skips acquire, so pre-existing cells persist. + assert len(registry._held_indefinite) == 2 + # Privileged mode does not re-write the scope either; only the + # clear-on-exit writes `L2ChunkLockScope`. + assert self._scope_mutate_calls(cg) == [] + # Exit released the pre-existing cells. + assert registry._held_indefinite == {} + + def test_double_acquire_fails(self): + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) + op_a = np.uint64(1) + op_b = np.uint64(2) + with IndefiniteL2ChunkLock(cg, [np.uint64(5)], op_a): + lock_b = IndefiniteL2ChunkLock(cg, [np.uint64(5)], op_b) + with pytest.raises(exceptions.LockingError): + lock_b.__enter__() + # Op A's cell still held. + assert len(registry._held_indefinite) == 1 + + def test_replay_nested_privileged_clears_crashed_cells(self): + """Replay lock-dance against a crashed op's pre-existing cells. + + Simulates what `MulticutOperation._apply` does during replay: + `with L2ChunkLock(privileged=True): with IndefiniteL2ChunkLock( + privileged=True): ...`. Both locks must succeed despite indefinite + cells being pre-held, and the inner `__exit__` must release them. + + This regresses the bug where `L2ChunkLock` lacked a privileged + escape hatch — the temporal acquire would refuse because + `lock_by_row_key_with_indefinite` sees the crashed op's + indefinite cell. + """ + registry = RowKeyLockRegistry() + crashed_op = np.uint64(42) + chunks = [np.uint64(1), np.uint64(2), np.uint64(3)] + # Seed crashed op's indefinite cells. + for c in chunks: + registry.lock_by_row_key_indefinitely(_l2_chunk_lock_row_key(c), crashed_op) + assert len(registry._held_indefinite) == 3 + + cg = make_cg_with_row_key_lock_registry(registry) + + # Replay's exact lock-dance from operation.py _apply. + with L2ChunkLock(cg, chunks, crashed_op, privileged_mode=True): + with IndefiniteL2ChunkLock(cg, chunks, crashed_op, privileged_mode=True): + # Simulated replay writes would happen here; we just + # assert the locks entered without raising. + pass + # Crashed op's cells released. + assert registry._held_indefinite == {} + + def test_exit_holds_on_exception_path(self): + """When `__exit__` is called with a propagating exception, cells + stay held and the op-log scope is NOT cleared — partial OCDBT / + bigtable writes may exist and subsequent ops must refuse until + operator recovery runs. + """ + registry = RowKeyLockRegistry() + cg = make_cg_with_row_key_lock_registry(registry) + chunks = [np.uint64(1), np.uint64(2)] + op_id = np.uint64(42) + + lock = IndefiniteL2ChunkLock(cg, chunks, op_id) + lock.__enter__() + # Enter wrote scope + held cells. + assert len(registry._held_indefinite) == 2 + scope_writes = self._scope_mutate_calls(cg) + assert any(len(v) > 0 for _, v in scope_writes) + + # Simulate an exception propagating through the `with` block. + lock.__exit__(ValueError, ValueError("boom"), None) + + # Cells still held, scope not cleared (no empty-array mutate). + assert len(registry._held_indefinite) == 2 + empty_writes = [(k, v) for k, v in self._scope_mutate_calls(cg) if len(v) == 0] + assert empty_writes == [] diff --git a/pychunkedgraph/tests/helpers.py b/pychunkedgraph/tests/helpers.py index 3f87bb3d6..009fec730 100644 --- a/pychunkedgraph/tests/helpers.py +++ b/pychunkedgraph/tests/helpers.py @@ -116,15 +116,21 @@ def get_layer_chunk_bounds( class RowKeyLockRegistry: """Thread-safe in-memory stand-in for kvdbclient's row-key lock API. - Matches the `cg.client.lock_by_row_key` / `unlock_by_row_key` / - `renew_lock_by_row_key` surface so row-key-based lock primitives - (DownsampleBlockLock, L2ChunkLock, …) can be exercised in unit - tests without running a bigtable emulator. + Matches the full `cg.client.lock_by_row_key*` / `unlock_by_row_key*` + / `renew_lock_by_row_key` surface — including the indefinite-column + variants — so row-key-based lock primitives (DownsampleBlockLock, + L2ChunkLock, IndefiniteL2ChunkLock, …) can be exercised without a + bigtable emulator. + + Two separate maps, one per column. The "with_indefinite" temporal + acquire refuses if either map holds the row, mirroring the filter + union that `lock_by_row_key_with_indefinite` uses on bigtable. """ def __init__(self): self._lock = threading.Lock() self._held = {} + self._held_indefinite = {} def lock_by_row_key(self, row_key, operation_id): with self._lock: @@ -133,6 +139,20 @@ def lock_by_row_key(self, row_key, operation_id): self._held[row_key] = operation_id return True + def lock_by_row_key_with_indefinite(self, row_key, operation_id): + with self._lock: + if row_key in self._held or row_key in self._held_indefinite: + return False + self._held[row_key] = operation_id + return True + + def lock_by_row_key_indefinitely(self, row_key, operation_id): + with self._lock: + if row_key in self._held_indefinite: + return False + self._held_indefinite[row_key] = operation_id + return True + def unlock_by_row_key(self, row_key, operation_id): with self._lock: if self._held.get(row_key) == operation_id: @@ -140,6 +160,13 @@ def unlock_by_row_key(self, row_key, operation_id): return True return False + def unlock_indefinitely_locked_by_row_key(self, row_key, operation_id): + with self._lock: + if self._held_indefinite.get(row_key) == operation_id: + del self._held_indefinite[row_key] + return True + return False + def renew_lock_by_row_key(self, row_key, operation_id): with self._lock: return self._held.get(row_key) == operation_id @@ -149,6 +176,11 @@ def make_cg_with_row_key_lock_registry(registry: RowKeyLockRegistry): """Attach a `RowKeyLockRegistry` to a `MagicMock` cg.client.""" cg = MagicMock() cg.client.lock_by_row_key = registry.lock_by_row_key + cg.client.lock_by_row_key_with_indefinite = registry.lock_by_row_key_with_indefinite + cg.client.lock_by_row_key_indefinitely = registry.lock_by_row_key_indefinitely cg.client.unlock_by_row_key = registry.unlock_by_row_key + cg.client.unlock_indefinitely_locked_by_row_key = ( + registry.unlock_indefinitely_locked_by_row_key + ) cg.client.renew_lock_by_row_key = registry.renew_lock_by_row_key return cg From ff6f2154e163a849c7b9e215343a2e193efe9c79 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 16:31:51 +0000 Subject: [PATCH 211/367] =?UTF-8?q?feat(repair):=20stuck=5Fops=20=E2=80=94?= =?UTF-8?q?=20cleanup-then-replay=20for=20SV-split=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator recovery for SV-split ops that crashed mid-write. A worker death inside IndefiniteL2ChunkLock leaves per-chunk indefinite cells set and records the chunk scope on the op-log row. Recovery reverts partial OCDBT writes using a version-pinned read of pre-op voxels, then replays the op normally. list_stuck scans OperationLogs for ops still at CREATED past a min-age threshold. replay(cg, op_id) runs cleanup_partial_writes followed by repair.edits.repair_operation(..., unlock=True); IndefiniteL2ChunkLock's privileged-mode __exit__ deletes the crashed op's pre-existing cells after the replay's writes land. Architecture-level operator guide at docs/sv_splitting_recovery.md, linked from docs/sv_splitting.md's Concurrency section. --- docs/sv_splitting.md | 4 + docs/sv_splitting_recovery.md | 64 +++++ pychunkedgraph/repair/stuck_ops.py | 213 +++++++++++++++++ pychunkedgraph/tests/graph/test_stuck_ops.py | 233 +++++++++++++++++++ 4 files changed, 514 insertions(+) create mode 100644 docs/sv_splitting_recovery.md create mode 100644 pychunkedgraph/repair/stuck_ops.py create mode 100644 pychunkedgraph/tests/graph/test_stuck_ops.py diff --git a/docs/sv_splitting.md b/docs/sv_splitting.md index fdd2403fb..b46c7ef2a 100644 --- a/docs/sv_splitting.md +++ b/docs/sv_splitting.md @@ -127,6 +127,10 @@ After the split lands, the caller-supplied source and sink supervoxel IDs refere The in-memory segmentation block produced by the split is bitwise identical to what was just written to storage, and the storage write is synchronous (we wait for it) and happens under the L2 chunk lock (so nothing else can have mutated those voxels). Looking up source/sink coords in that block returns the same IDs a storage re-read would — no extra round-trip needed. +### Worker crash mid-write + +A worker that dies inside the indefinite L2 chunk lock's scope leaves the lock cells set and the op-log row in `CREATED` status with a durable record of which chunks were being written. Future ops on any of those chunks refuse to start — the crashed state is isolated, not amplified. An operator runs the recovery flow described in [sv_splitting_recovery.md](sv_splitting_recovery.md) to revert the partial writes and replay the op. + ## Invariants - A supervoxel split and its graph-level commit are one atomic operation. Either both land or neither does, under a single root lock. diff --git a/docs/sv_splitting_recovery.md b/docs/sv_splitting_recovery.md new file mode 100644 index 000000000..af39c02dd --- /dev/null +++ b/docs/sv_splitting_recovery.md @@ -0,0 +1,64 @@ +# Supervoxel split recovery + +## What it is + +A recovery path for supervoxel-split operations that crash mid-write, leaving partial state in segmentation and per-chunk locks held indefinitely. An operator runs a one-shot command that reverts the crashed op's partial segmentation writes and re-runs the op from scratch, producing a clean successful edit and freeing the affected chunks for future work. + +## When it applies + +Every supervoxel split writes two things atomically from the point of view of the operation: +- Segmentation chunks — the voxel-level split, where each L2 chunk touched by the split gets fresh supervoxel IDs at the voxels that moved to a new fragment. +- Graph hierarchy rows — lineage from the old supervoxels to the new ones plus the cross-chunk edges linking the new fragments. + +Both writes happen under an indefinite L2 chunk lock covering the exact chunks being rewritten. If the worker running the op dies before the lock's context exits — process kill, pod eviction, hardware failure, OOM — the lock stays held. From that moment on, any new op whose chunk set overlaps the stuck op's chunks refuses to start, blocking further corruption. + +The operator runs recovery when the lock has been held long enough that the worker is definitively gone, not merely slow. A minimum-age threshold (10 minutes is a reasonable default) distinguishes stuck ops from ops still in flight. + +## Concurrent edits on other regions keep working + +The indefinite lock is per L2 chunk. While op X is stuck on chunks `{C1, C2, C3}`, another op Y on chunks `{C4, C5}` sees no indefinite cell on its chunks and proceeds normally. Its writes advance the latest OCDBT manifest. By the time the operator gets to recovery, the manifest has moved past the stuck op's `OperationTimeStamp` and other regions of segmentation reflect Y's (and any subsequent ops') work. + +This is important: the recovery must not undo Y's changes. It also cannot rely on a single "read pre-op segmentation" pin because that would return pre-Y state outside the stuck op's own chunks, and the replay would overwrite neighbor state with stale values. + +## Why pre-op pinning is not enough on its own + +A supervoxel split reads more than just its own chunks. To route existing cross-chunk edges onto the new fragments, the split reads a one-voxel shell around its chunk envelope — supervoxel IDs from neighboring chunks serve as anchors for the re-routed edges. + +If the replay opened segmentation with a pin at the stuck op's `OperationTimeStamp` and then read that shell, the neighbor voxels would show their pre-op state, not their current state. If Y had split a supervoxel in one of those neighbor chunks in the interim, the pinned read would see the old neighbor IDs, and the replay would route its cross-chunk edges to supervoxel IDs that no longer exist. Graph corruption. + +So the replay cannot read the world through a single pinned view. It must see latest state for the neighbor shell and clean pre-op state for its own chunks. + +## Cleanup-then-replay + +Recovery proceeds in two steps. + +**Cleanup.** For each chunk in the stuck op's durably-recorded scope, the operator reads the chunk's pre-op voxel values from a segmentation handle pinned at the op's `OperationTimeStamp`, and writes those values back to the latest (unpinned) handle. The result: those chunks, at the latest manifest, now show pre-op segmentation — as if the crashed op had never started. Neighbor chunks and every chunk outside the stuck op's scope are untouched, so any concurrent op's work is preserved. + +**Replay.** The operator then re-runs the op under the privileged-repair path. The run reads latest state, which is now consistent — clean pre-op values on the stuck op's own chunks, current state everywhere else — and goes through the normal edit flow. It allocates fresh supervoxel IDs, re-computes the split, writes new segmentation and hierarchy, and transitions the op-log row from `CREATED` through `WRITE_STARTED` to `SUCCESS`. + +When the replay's indefinite lock context exits, it issues value-matched releases on every chunk in the scope. Because the replay re-uses the crashed op's operation ID, the value match succeeds and the pre-existing indefinite cells are deleted. The chunks are free for new ops again. + +## Orphans in segmentation history + +OCDBT is append-only. The crashed op's partial segmentation writes still exist in the store's commit history — they are not deleted, only overshadowed. At the latest manifest, the cleanup step has overwritten them with pre-op values, so a normal (unpinned) read returns the pre-op state and the replay's fresh writes take effect on top of that. Readers that explicitly pin a historical version between the crash and the replay will still see the partial writes as a snapshot, but readers at latest never observe them. + +The orphan supervoxel IDs allocated by the crashed op are never referenced by any hierarchy row — the crashed op never wrote its hierarchy rows to completion, and the replay allocated a new set of IDs. From the graph's perspective those orphan IDs do not exist. + +## Operator workflow + +1. **List stuck ops.** The operator runs the list command with a minimum-age threshold. It returns op-log rows still at `CREATED` status past that age, along with each op's user ID, timestamp, age, and the number of chunks in its recorded scope. Ops too young to classify are skipped. + +2. **Inspect.** For each candidate, confirm from logs or monitoring that the worker that submitted the op is definitively dead — not, for example, paused on a long-running multicut. The minimum-age threshold exists to reduce false positives but the operator retains final judgment. + +3. **Replay.** The operator runs the replay command with the op ID. It performs the cleanup pass, then hands off to the privileged-repair path to rerun the op. On success, the op-log row shows `SUCCESS` and the previously-held indefinite lock cells are released. + +4. **Verify.** A second list invocation should no longer include the op. Any new ops that were waiting on the affected chunks proceed. + +If the replay itself fails — for example, the operator's judgment about the worker's status was wrong and the original worker comes back — the replay surfaces the error and leaves the op-log row and lock state as it found them. The operator investigates, potentially clears the lock manually via direct bigtable tools, and tries again. + +## Invariants + +- A stuck op's durable scope record (written before any segmentation or hierarchy write begins) lets recovery locate every chunk that might have received a partial write, without a bigtable-wide scan. +- Cleanup only touches chunks in the stuck op's scope. Neighbor state and any concurrent ops' changes are preserved byte-for-byte. +- The replay sees a consistent world: pre-op values on the stuck op's own chunks (from the cleanup), current state on every other chunk (from the latest manifest). +- After successful replay, the op-log row is at `SUCCESS`, all indefinite cells previously held by that op are released, and the affected chunks are available to new ops. The op's original intent — the edit the user asked for — is realized with a fresh set of supervoxel IDs. diff --git a/pychunkedgraph/repair/stuck_ops.py b/pychunkedgraph/repair/stuck_ops.py new file mode 100644 index 000000000..76e1dd21a --- /dev/null +++ b/pychunkedgraph/repair/stuck_ops.py @@ -0,0 +1,213 @@ +"""Operator recovery for SV-split ops that crashed mid-write. + +A crash inside `IndefiniteL2ChunkLock`'s critical section leaves the +per-chunk `Concurrency.IndefiniteLock` cells set *and* records the +chunk scope on the op-log row's `OperationLogs.L2ChunkLockScope` field. +Ops on other (non-overlapping) chunks continue to succeed and advance +the OCDBT manifest while the stuck op sits there blocking its own +chunks. + +Recovery = cleanup + replay. The cleanup step reverts the stuck op's +partial OCDBT writes by copying pre-op voxel values (read from a +version-pinned OCDBT handle at the op's `OperationTimeStamp`) back to +the latest manifest. The replay then runs the op normally via the +existing `repair.edits.repair_operation` path — reads latest (clean on +the stuck op's chunks, current on everyone else's), writes fresh SV +IDs, and `IndefiniteL2ChunkLock`'s privileged-mode exit deletes the +crashed op's pre-existing cells. + +See `docs/sv_splitting_recovery.md` for the full architecture and +correctness argument. +""" + +import argparse +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta, timezone + +import numpy as np + +from pychunkedgraph import get_logger +from pychunkedgraph.graph import ChunkedGraph, attributes +from pychunkedgraph.graph.chunks.utils import get_chunk_coordinates +from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt +from pychunkedgraph.repair.edits import repair_operation + +logger = get_logger(__name__) + + +def _operation_ts_to_pin(operation_ts: datetime) -> str: + """Convert an op-log `OperationTimeStamp` to the OCDBT `version` + string format — ISO-8601 UTC with `Z` suffix, microsecond + precision. OCDBT's binder rejects `+00:00`. + """ + if operation_ts.tzinfo is None: + operation_ts = operation_ts.replace(tzinfo=timezone.utc) + else: + operation_ts = operation_ts.astimezone(timezone.utc) + return operation_ts.isoformat().replace("+00:00", "Z") + + +def _chunk_voxel_slices(cg: ChunkedGraph, chunk_id: int) -> tuple: + """Voxel-space slice tuple covering one L2 chunk, clipped to volume bounds.""" + coords = get_chunk_coordinates(cg.meta, np.uint64(chunk_id)) + chunk_size = np.array(cg.meta.graph_config.CHUNK_SIZE, dtype=int) + voxel_bounds = cg.meta.voxel_bounds + lo = coords * chunk_size + voxel_bounds[:, 0] + hi = np.minimum(lo + chunk_size, voxel_bounds[:, 1]) + return tuple(slice(int(s), int(e)) for s, e in zip(lo, hi)) + + +def list_stuck(cg: ChunkedGraph, min_age: timedelta = timedelta(minutes=10)) -> list: + """Return op-log entries still at `CREATED` status past `min_age`. + + `CREATED` is the initial log state; normal ops transition to + `WRITE_STARTED` inside `_write` and then to `SUCCESS`. An op lingering + at `CREATED` past `min_age` is a candidate for operator inspection — + either the worker crashed mid-write (indefinite cell set, needs + `replay`) or the op is still in flight (leave it alone). + """ + now = datetime.now(timezone.utc) + cutoff = now - min_age + entries = cg.client.read_log_entries() + stuck = [] + created_code = attributes.OperationLogs.StatusCodes.CREATED.value + for op_id, entry in entries.items(): + if entry.get(attributes.OperationLogs.Status) != created_code: + continue + op_ts = entry.get(attributes.OperationLogs.OperationTimeStamp) + if op_ts is None: + continue + if op_ts.tzinfo is None: + op_ts = op_ts.replace(tzinfo=timezone.utc) + if op_ts > cutoff: + continue + stuck.append( + { + "op_id": int(op_id), + "operation_ts": op_ts, + "age": now - op_ts, + "user_id": entry.get(attributes.OperationLogs.UserID), + "l2_chunk_scope": entry.get(attributes.OperationLogs.L2ChunkLockScope), + } + ) + stuck.sort(key=lambda r: r["op_id"]) + return stuck + + +def cleanup_partial_writes(cg: ChunkedGraph, op_id: int) -> int: + """Revert a stuck op's partial OCDBT writes to pre-op voxel values. + + Reads each chunk in the op's `L2ChunkLockScope` through an OCDBT + handle pinned at the op's `OperationTimeStamp` (which pre-dates any + of its commits), then writes those pre-op values back to the latest + manifest. Overwrites the crashed op's partial seg writes at the + same chunk keys; neighbor chunks are untouched, preserving any + concurrent ops' updates. + + Returns the number of chunks rewritten. + """ + log_entries = cg.client.read_log_entries(operation_ids=[np.uint64(op_id)]) + if not log_entries: + raise ValueError(f"No op-log row for op_id={op_id}") + entry = log_entries[np.uint64(op_id)] + + scope = entry.get(attributes.OperationLogs.L2ChunkLockScope) + if scope is None or len(scope) == 0: + logger.info(f"op {op_id} has no L2ChunkLockScope — nothing to clean up") + return 0 + + operation_ts = entry.get(attributes.OperationLogs.OperationTimeStamp) + if operation_ts is None: + raise ValueError(f"op {op_id} has no OperationTimeStamp") + pin_str = _operation_ts_to_pin(operation_ts) + + # Pinned read handle (read-only at pre-op version) vs. unpinned + # write handle (latest). Tensorstore refuses writes on version-pinned + # kvstores, so the two paths use separate handles. + _, pinned_scales, _ = get_seg_source_and_destination_ocdbt( + cg.meta.data_source.WATERSHED, + cg.meta.graph_id, + pinned_at=pin_str, + ) + pinned_ws = pinned_scales[0] + latest_ws = cg.meta.ws_ocdbt + + def _revert_chunk(chunk_id: int) -> None: + voxel_slices = _chunk_voxel_slices(cg, int(chunk_id)) + pre_op = pinned_ws[voxel_slices + (slice(None),)].read().result() + latest_ws[voxel_slices + (slice(None),)].write(pre_op).result() + + # Parallel read-then-write per chunk. Bounded pool so large scopes + # don't saturate tensorstore's internal concurrency. + max_workers = min(16, max(1, len(scope))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(_revert_chunk, int(c)) for c in scope] + for future in as_completed(futures): + future.result() + + logger.info(f"op {op_id}: reverted {len(scope)} partial chunk writes") + return len(scope) + + +def replay(cg: ChunkedGraph, op_id: int): + """Recovery: clean up partial OCDBT writes, then run the op normally. + + Delegates the actual rerun to `repair.edits.repair_operation`, which + loads the op, runs `operation.execute(..., privileged_mode=True, + parent_ts=)`, and unlocks the root row on success. + Our `IndefiniteL2ChunkLock.__enter__` in privileged mode populates + `acquired_keys` from the scope so `__exit__` releases the crashed + op's pre-existing indefinite cells after the replay writes land. + """ + cleanup_partial_writes(cg, op_id) + return repair_operation(cg, op_id, unlock=True) + + +def _main(): + parser = argparse.ArgumentParser( + description="Recover stuck SV-split operations via cleanup + replay." + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_list = sub.add_parser( + "list", help="List stuck ops (CREATED status past min-age)." + ) + p_list.add_argument("--graph", required=True, help="Graph ID.") + p_list.add_argument( + "--min-age", + type=int, + default=10, + help="Minimum age in minutes before an op is considered stuck (default: 10).", + ) + + p_replay = sub.add_parser( + "replay", help="Clean up partial writes and replay a stuck op." + ) + p_replay.add_argument("--graph", required=True, help="Graph ID.") + p_replay.add_argument("--op-id", type=int, required=True, help="Op ID to replay.") + + args = parser.parse_args() + cg = ChunkedGraph(graph_id=args.graph) + + if args.cmd == "list": + stuck = list_stuck(cg, min_age=timedelta(minutes=args.min_age)) + if not stuck: + print("No stuck ops.") + return + for row in stuck: + scope_size = ( + len(row["l2_chunk_scope"]) if row["l2_chunk_scope"] is not None else 0 + ) + print( + f"op {row['op_id']}: user={row['user_id']} " + f"ts={row['operation_ts'].isoformat()} " + f"age={row['age']} " + f"l2_chunks={scope_size}" + ) + elif args.cmd == "replay": + result = replay(cg, args.op_id) + print(f"replay complete: {result}") + + +if __name__ == "__main__": + _main() diff --git a/pychunkedgraph/tests/graph/test_stuck_ops.py b/pychunkedgraph/tests/graph/test_stuck_ops.py new file mode 100644 index 000000000..e1a17fd01 --- /dev/null +++ b/pychunkedgraph/tests/graph/test_stuck_ops.py @@ -0,0 +1,233 @@ +"""Tests for pychunkedgraph.repair.stuck_ops — cleanup + replay path +for SV-split ops that crashed mid-write. + +The heavy test (`test_cleanup_reverts_partial_writes_to_pre_op`) +exercises the full cleanup flow against a real local OCDBT store — it +writes a known pre-op state, snapshots the manifest, writes simulated +"partial" data, constructs an op-log row with `L2ChunkLockScope` and +`OperationTimeStamp`, and asserts that cleanup reverts the scoped +chunks to pre-op values while leaving neighbor chunks alone. +""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +import tensorstore as ts + +from pychunkedgraph.graph import attributes, ocdbt as ocdbt_mod +from pychunkedgraph.graph.chunks.utils import get_chunk_coordinates +from pychunkedgraph.graph.locks import _l2_chunk_lock_row_key +from pychunkedgraph.graph.meta import ChunkedGraphMeta, DataSource, GraphConfig +from pychunkedgraph.repair import stuck_ops + +# Pick up the shared `local_ocdbt` fixture from test_ocdbt. +from .test_ocdbt import local_ocdbt # noqa: F401 + + +class TestListStuck: + """`list_stuck` filters the op-log by Status==CREATED past min_age.""" + + def _entry(self, status, age_seconds, user="u", scope=None): + now = datetime.now(timezone.utc) + entry = { + attributes.OperationLogs.Status: status, + attributes.OperationLogs.OperationTimeStamp: now + - timedelta(seconds=age_seconds), + attributes.OperationLogs.UserID: user, + } + if scope is not None: + entry[attributes.OperationLogs.L2ChunkLockScope] = np.asarray( + scope, dtype=np.uint64 + ) + return entry + + def _cg(self, entries): + cg = MagicMock() + cg.client.read_log_entries.return_value = entries + return cg + + def test_filters_out_success(self): + success = attributes.OperationLogs.StatusCodes.SUCCESS.value + created = attributes.OperationLogs.StatusCodes.CREATED.value + cg = self._cg( + { + np.uint64(1): self._entry(success, 900), + np.uint64(2): self._entry(created, 900, scope=[10, 20]), + } + ) + stuck = stuck_ops.list_stuck(cg, min_age=timedelta(minutes=1)) + assert [r["op_id"] for r in stuck] == [2] + + def test_filters_out_young_ops(self): + created = attributes.OperationLogs.StatusCodes.CREATED.value + cg = self._cg( + { + np.uint64(1): self._entry(created, 10), # too young + np.uint64(2): self._entry(created, 3600), # an hour old + } + ) + stuck = stuck_ops.list_stuck(cg, min_age=timedelta(minutes=10)) + assert [r["op_id"] for r in stuck] == [2] + + def test_returns_scope_and_user(self): + created = attributes.OperationLogs.StatusCodes.CREATED.value + cg = self._cg( + { + np.uint64(7): self._entry(created, 1800, user="op", scope=[100, 200]), + } + ) + stuck = stuck_ops.list_stuck(cg, min_age=timedelta(minutes=10)) + assert len(stuck) == 1 + row = stuck[0] + assert row["op_id"] == 7 + assert row["user_id"] == "op" + assert list(row["l2_chunk_scope"]) == [100, 200] + assert row["age"] > timedelta(minutes=10) + + +class TestCleanupPartialWrites: + """Cleanup reverts partial OCDBT writes using pinned reads of pre-op state.""" + + def _meta_with_fork(self, local_ocdbt_fixture, graph_id): + """Build a real ChunkedGraphMeta pointing at the fixture's fork so + `ws_ocdbt` reads/writes go through the same kvstack as production. + + Creates a matching source precomputed at the watershed root so + `get_seg_source_and_destination_ocdbt` and `ws_cv` both work. + Sets `layer_count` explicitly to bypass `ws_cv.bounds` inference. + """ + ws = local_ocdbt_fixture["ws"] + mm = {"type": "segmentation", "data_type": "uint64", "num_channels": 1} + scale_metadata = { + "size": [64, 64, 32], + "resolution": [4, 4, 40], + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + "chunk_size": [32, 32, 32], + } + ts.open( + { + "driver": "neuroglancer_precomputed", + "kvstore": f"{ws}/", + "multiscale_metadata": mm, + "scale_metadata": scale_metadata, + }, + create=True, + ).result() + + local_ocdbt_fixture["make_fork"](graph_id) + + gc = GraphConfig( + ID=graph_id, + CHUNK_SIZE=np.array([32, 32, 32], dtype=int), + ) + ds = DataSource(WATERSHED=f"{ws}/", DATA_VERSION=4) + meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) + meta.layer_count = 3 # avoids lazy cloudvolume layer inference + return meta + + def _capture_fork_pin(self, local_ocdbt_fixture, graph_id): + """Return an ISO-8601 `Z`-suffix pin string for the fork's current + manifest commit — the pre-op timestamp for cleanup to pin on. + """ + fork_manifest_kvs = ts.KvStore.open( + f"{local_ocdbt_fixture['ws']}/ocdbt/{graph_id}/" + ).result() + manifest = ts.ocdbt.dump(fork_manifest_kvs).result() + # commit_time is recorded as int ns since epoch; use a timestamp + # just past the last commit as the pin so the upper-bound filter + # picks up everything written so far. + last_ns = manifest["versions"][-1]["commit_time"] + return datetime.fromtimestamp(last_ns / 1e9 + 0.001, tz=timezone.utc) + + def test_cleanup_reverts_partial_writes_to_pre_op(self, local_ocdbt): + """Write known pre-op state, snapshot time, write partial state to + one chunk, simulate a stuck op with that chunk in scope, and + confirm cleanup reverts the chunk while leaving a non-scoped + neighbor chunk untouched. + """ + fixture = local_ocdbt + + meta = self._meta_with_fork(fixture, "stuck_cg") + fork_scale0 = fixture["make_fork"]("stuck_cg") + + # Pre-op state: chunk 0 region filled with 111, chunk 1 with 222. + # Chunk grid is at base resolution with 32^3 voxels per chunk. + fork_scale0[0:32, 0:32, 0:32, :] = np.full( + (32, 32, 32, 1), 111, dtype=np.uint64 + ) + fork_scale0[32:64, 0:32, 0:32, :] = np.full( + (32, 32, 32, 1), 222, dtype=np.uint64 + ) + + # Snapshot pin timestamp just after the pre-op writes. + pre_op_pin_dt = self._capture_fork_pin(fixture, "stuck_cg") + + # Partial "crash" writes: overwrite chunk 0 with garbage, touch + # chunk 1 too to prove scope-boundedness (scope will only list + # chunk 0, so chunk 1's garbage must persist after cleanup). + fork_scale0[0:32, 0:32, 0:32, :] = np.full( + (32, 32, 32, 1), 999, dtype=np.uint64 + ) + fork_scale0[32:64, 0:32, 0:32, :] = np.full( + (32, 32, 32, 1), 888, dtype=np.uint64 + ) + + # Chunk IDs for chunk-coord (0,0,0) and (1,0,0) at layer 2. + chunk_id_0 = _chunk_id_from_coord(meta, layer=2, coord=(0, 0, 0)) + chunk_id_1 = _chunk_id_from_coord(meta, layer=2, coord=(1, 0, 0)) + + # Sanity: scope chunk decodes back to the right coord. + assert tuple(get_chunk_coordinates(meta, chunk_id_0)) == (0, 0, 0) + + # Synthetic op-log row with scope=[chunk_id_0] and OperationTimeStamp=pre_op_pin. + op_id = 777 + op_log_row = { + attributes.OperationLogs.L2ChunkLockScope: np.asarray( + [chunk_id_0], dtype=np.uint64 + ), + attributes.OperationLogs.OperationTimeStamp: pre_op_pin_dt, + } + + cg = MagicMock() + cg.meta = meta + cg.client.read_log_entries.return_value = {np.uint64(op_id): op_log_row} + + # `_read_source_scales` reads `/info` from the watershed via + # tensorstore's kvstore interface — fine on GCS, not on file://. + # Bypass with a fake scale list matching the test's scale 0. + fake_scales = [ + { + "resolution": [4, 4, 40], + "size": [64, 64, 32], + "chunk_sizes": [[32, 32, 32]], + "encoding": "compressed_segmentation", + "compressed_segmentation_block_size": [8, 8, 8], + } + ] + with patch.object(ocdbt_mod, "_read_source_scales", return_value=fake_scales): + reverted = stuck_ops.cleanup_partial_writes(cg, op_id) + assert reverted == 1 + + # Scoped chunk reverted to pre-op. + scoped = fork_scale0[0:32, 0:32, 0:32, :].read().result() + assert ( + scoped == 111 + ).all(), f"scoped chunk not reverted: unique={np.unique(scoped)}" + # Non-scoped neighbor still has its post-crash "garbage" (888) — + # cleanup does not touch it. + neighbor = fork_scale0[32:64, 0:32, 0:32, :].read().result() + assert ( + neighbor == 888 + ).all(), f"neighbor chunk erroneously reverted: unique={np.unique(neighbor)}" + + +def _chunk_id_from_coord(meta, layer, coord): + """Encode (layer, x, y, z) into a chunk ID using the graph's bitmasks.""" + from pychunkedgraph.graph.chunks.utils import get_chunk_id + + return get_chunk_id( + meta, layer=layer, x=int(coord[0]), y=int(coord[1]), z=int(coord[2]) + ) From a7fae0017ee2db3bba4d31b43893ed08e2084c45 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 16:39:04 +0000 Subject: [PATCH 212/367] deps: kvdbclient>=0.7.0 --- requirements.in | 2 +- requirements.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.in b/requirements.in index 57e07e5c2..0b1a03f94 100644 --- a/requirements.in +++ b/requirements.in @@ -28,7 +28,7 @@ task-queue>=2.14.0 messagingclient>0.3.0 dracopy>=1.5.0 datastoreflex>=0.5.0 -kvdbclient>0.5.0 +kvdbclient>=0.7.0 zstandard>=0.23.0 tinybrain>=1.7.0 diff --git a/requirements.txt b/requirements.txt index 7edbee1a2..6710c7fca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --output-file=requirements.txt requirements.in +# pip-compile requirements.in # attrs==25.4.0 # via @@ -195,7 +195,7 @@ jsonschema==4.26.0 # python-jsonschema-objects jsonschema-specifications==2025.9.1 # via jsonschema -kvdbclient==0.6.0 +kvdbclient==0.7.0 # via -r requirements.in lazy-loader==0.4 # via scikit-image From 4b7aa1d32f2683b13a438fed89a4e97249e302cf Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 17:33:48 +0000 Subject: [PATCH 213/367] refactor(sv-split): thread operation_ts + named structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass the op's timestamp from execute → _apply → split_supervoxels → split_supervoxel → copy_parents_and_add_lineage / add_new_edges so every new-SV bigtable mutation lands at the op's logical write time. Gets atomic visibility under a parent_ts filter and makes replay's override_ts actually control what time-filtered readers see after a repair_operation. Parent-copy and Child-list writes deliberately keep the old cell's timestamp so pre-op readers still see the old hierarchy. Replace the seven-tuple in/out soup in split_supervoxels with named dataclasses: SvSplitTask (plan_sv_splits → split_supervoxel input) and SvSplitOutcome (split_supervoxel's per-task output). Drop the two unused return fields on split_supervoxel. --- pychunkedgraph/graph/edits_sv.py | 196 +++++++++++++------- pychunkedgraph/graph/operation.py | 5 +- pychunkedgraph/tests/graph/test_edits_sv.py | 53 ++++++ 3 files changed, 180 insertions(+), 74 deletions(-) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index c4f269470..813cf882e 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -29,6 +29,39 @@ logger = get_logger(__name__) +@dataclass +class SvSplitTask: + """One SV-split task per cross-chunk rep. + + Produced by `plan_sv_splits` (pure, no IO), consumed by + `split_supervoxel`. `src_mask`/`sink_mask` are positional masks + back into the caller's `source_ids`/`sink_ids` arrays so the + aggregator can splice the per-task fresh IDs in at the right + positions. + """ + + sv_id: int + src_coords: np.ndarray + sink_coords: np.ndarray + src_mask: np.ndarray + sink_mask: np.ndarray + bbs: np.ndarray + bbe: np.ndarray + + +@dataclass +class SvSplitOutcome: + """Output of `split_supervoxel` for one task. Aggregated into + `SplitResult` by `split_supervoxels`.""" + + seg_bbox: Tuple[np.ndarray, np.ndarray] + src_new_ids: np.ndarray + sink_new_ids: np.ndarray + # Per-chunk OCDBT write payloads for this task. + seg_write_pairs: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] + bigtable_rows: list + + @dataclass class SplitResult: """Pure planner output of `split_supervoxels`. @@ -42,7 +75,7 @@ class SplitResult: seg_bboxes: List[Tuple[np.ndarray, np.ndarray]] source_ids_fresh: np.ndarray sink_ids_fresh: np.ndarray - # Flat list across all reps: (voxel_slices, data_block) per OCDBT + # Flat list across all tasks: (voxel_slices, data_block) per OCDBT # chunk write. `voxel_slices` is a 3-tuple of `slice` objects; the # caller appends the channel slice and writes to `meta.ws_ocdbt`. seg_writes: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] @@ -152,18 +185,19 @@ def plan_sv_splits( sink_ids: np.ndarray, source_coords: np.ndarray, sink_coords: np.ndarray, -) -> tuple: - """Compute the per-rep bboxes and the L2 chunk set a SV-split would touch. +) -> Tuple[List[SvSplitTask], list]: + """Compute one `SvSplitTask` per rep and the L2 chunk set the splits + will touch. Pure function — no bigtable/OCDBT IO, no locks. Lets the caller acquire the L2 chunk locks (both temporal and indefinite) around `split_supervoxels` without recomputing the plan inside. - Returns `(reps, chunk_ids)` where `reps` is the per-rep tuple fed to - `split_supervoxels`, and `chunk_ids` is the sorted union of - read-expanded L2 chunks for the full operation. + Returns `(tasks, chunk_ids)` — `tasks` feeds `split_supervoxels`, + `chunk_ids` is the sorted union of read-expanded L2 chunks the full + operation touches. """ - reps = [] + tasks: List[SvSplitTask] = [] for ( sv_id, src_coords_rep, @@ -178,21 +212,30 @@ def plan_sv_splits( sink_coords=sink_coords, ): bbs, bbe = _rep_bbox(cg, sv_remapping, sv_id) - reps.append( - (sv_id, src_coords_rep, sink_coords_rep, src_mask, sink_mask, bbs, bbe) + tasks.append( + SvSplitTask( + sv_id=sv_id, + src_coords=src_coords_rep, + sink_coords=sink_coords_rep, + src_mask=src_mask, + sink_mask=sink_mask, + bbs=bbs, + bbe=bbe, + ) ) - chunk_ids = _l2_chunks_for_splits(cg, [(bbs, bbe) for *_, bbs, bbe in reps]) - return reps, chunk_ids + chunk_ids = _l2_chunks_for_splits(cg, [(t.bbs, t.bbe) for t in tasks]) + return tasks, chunk_ids def split_supervoxels( cg: "ChunkedGraph", *, - reps: list, + tasks: List[SvSplitTask], sv_remapping: dict, source_ids: np.ndarray, sink_ids: np.ndarray, operation_id: int, + timestamp: datetime = None, ) -> SplitResult: """Pure planner for the SV-split step. Returns a `SplitResult` with all the data the caller needs to persist under locks. @@ -205,17 +248,22 @@ def split_supervoxels( `plan.chunk_ids` set — the seg reads inside `split_supervoxel` need to be consistent with concurrent writers. + `timestamp` is the op's logical write time; threaded down to every + `mutate_row` in the persist block so all new-SV cells land at the + same logical time (atomic visibility for `parent_ts`-filtered + readers, and deterministic replay via `override_ts`). + Fields on the returned `SplitResult`: - seg_bboxes: per-rep base-resolution `(bbs, bbe)` — downsample + seg_bboxes: per-task base-resolution `(bbs, bbe)` — downsample worker input. source_ids_fresh / sink_ids_fresh: input `source_ids`/`sink_ids` - with positions touched by an overlap rep replaced by the new - SV ID that now lives at that coord. Untouched positions stay - unchanged. Feeds the retry multicut. + with positions touched by an overlap task replaced by the + new SV ID that now lives at that coord. Untouched positions + stay unchanged. Feeds the retry multicut. seg_writes: flat list of `(voxel_slices, data)` pairs across all - reps — one tensorstore write per pair, fired in parallel. + tasks — one tensorstore write per pair, fired in parallel. bigtable_rows: flattened rows from `copy_parents_and_add_lineage` - + `add_new_edges` across all reps. + + `add_new_edges` across all tasks. """ source_ids_fresh = np.asarray(source_ids, dtype=basetypes.NODE_ID).copy() sink_ids_fresh = np.asarray(sink_ids, dtype=basetypes.NODE_ID).copy() @@ -223,38 +271,19 @@ def split_supervoxels( seg_bboxes = [] seg_writes: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = [] bigtable_rows: list = [] - for ( - sv_id, - src_coords_rep, - sink_coords_rep, - src_mask, - sink_mask, - bbs, - bbe, - ) in reps: - ( - _, - _, - seg_bbox, - src_new_ids, - sink_new_ids, - seg_write_pairs, - rows, - ) = split_supervoxel( + for task in tasks: + out = split_supervoxel( cg, - sv_id, - src_coords_rep, - sink_coords_rep, + task, operation_id, sv_remapping=sv_remapping, - bbs=bbs, - bbe=bbe, + time_stamp=timestamp, ) - seg_bboxes.append(seg_bbox) - source_ids_fresh[src_mask] = src_new_ids - sink_ids_fresh[sink_mask] = sink_new_ids - seg_writes.extend(seg_write_pairs) - bigtable_rows.extend(rows) + seg_bboxes.append(out.seg_bbox) + source_ids_fresh[task.src_mask] = out.src_new_ids + sink_ids_fresh[task.sink_mask] = out.sink_new_ids + seg_writes.extend(out.seg_write_pairs) + bigtable_rows.extend(out.bigtable_rows) return SplitResult( seg_bboxes=seg_bboxes, source_ids_fresh=source_ids_fresh, @@ -348,23 +377,30 @@ def _parse_results(results, seg, bbs, bbe): def split_supervoxel( cg: "ChunkedGraph", - sv_id: basetypes.NODE_ID, - source_coords: np.ndarray, - sink_coords: np.ndarray, + task: SvSplitTask, operation_id: int, + *, sv_remapping: dict, - bbs: np.ndarray, - bbe: np.ndarray, - verbose: bool = False, time_stamp: datetime = None, -) -> tuple: + verbose: bool = False, +) -> SvSplitOutcome: """Split one cross-chunk-connected SV into connected components. - `bbs` / `bbe` are the base-voxel bbox envelope covering every CC - member of `sv_id`'s rep — caller (`split_supervoxels`) pre-computes + `task.bbs` / `task.bbe` are the base-voxel bbox envelope covering + every CC member of `task.sv_id`'s rep — `plan_sv_splits` pre-computed this via `_rep_bbox`. The envelope is guaranteed to contain every piece of the SV being split, so no bbox clip is needed inside. + + `time_stamp` is the op's logical write time; threaded through to + `copy_parents_and_add_lineage` + `add_new_edges` so every new-SV + mutation lands at the same timestamp. """ + sv_id = task.sv_id + source_coords = task.src_coords + sink_coords = task.sink_coords + bbs = task.bbs + bbe = task.bbe + vol_start = cg.meta.voxel_bounds[:, 0] vol_end = cg.meta.voxel_bounds[:, 1] logger.note(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}") @@ -441,13 +477,15 @@ def split_supervoxel( ) logger.note(f"edge update ({time.time() - t0:.2f}s)") - rows0 = copy_parents_and_add_lineage(cg, operation_id, old_new_map) + rows0 = copy_parents_and_add_lineage( + cg, operation_id, old_new_map, time_stamp=time_stamp + ) rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) rows = rows0 + rows1 # Prepare per-chunk OCDBT write payloads. The caller batches these - # across all reps into one parallel tensorstore write — no serial - # per-rep loop. + # across all tasks into one parallel tensorstore write — no serial + # per-task loop. seg_write_pairs: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = [] for _, chunk_bbox in change_chunks: lo, hi = chunk_bbox[0], chunk_bbox[1] @@ -469,14 +507,12 @@ def split_supervoxel( local_sink = (np.asarray(sink_coords, dtype=int) - bbs).astype(int) src_new_ids = new_seg[tuple(local_src.T)] sink_new_ids = new_seg[tuple(local_sink.T)] - return ( - old_new_map, - edges_tuple, - (bbs, bbe), - src_new_ids, - sink_new_ids, - seg_write_pairs, - rows, + return SvSplitOutcome( + seg_bbox=(bbs, bbe), + src_new_ids=src_new_ids, + sink_new_ids=sink_new_ids, + seg_write_pairs=seg_write_pairs, + bigtable_rows=rows, ) @@ -484,11 +520,19 @@ def copy_parents_and_add_lineage( cg: "ChunkedGraph", operation_id: int, old_new_map: dict, + *, + time_stamp: datetime = None, ) -> list: - """ - Copy parents column from `old_id` to each of `new_ids`. - This makes it easy to get old hierarchy with `new_ids` using an older timestamp. - Link `old_id` and `new_ids` to create a lineage at supervoxel layer. + """Copy parent pointers from old SVs onto their new-ID fragments + and write the lineage (FormerIdentity / NewIdentity) + L2 Child + list updates. + + `time_stamp` is the op's logical write time — used for every new-SV + cell this function writes so a `parent_ts`-filtered reader sees the + op atomically. The Parent-copy and Child-list writes deliberately + preserve the old cell's timestamp (so pre-op readers still see the + old hierarchy via the old timestamp). + Returns a list of mutations to be persisted. """ result = [] @@ -506,7 +550,11 @@ def copy_parents_and_add_lineage( attributes.OperationLogs.OperationID: operation_id, } result.append( - cg.client.mutate_row(serializers.serialize_uint64(new_id), val_dict) + cg.client.mutate_row( + serializers.serialize_uint64(new_id), + val_dict, + time_stamp=time_stamp, + ) ) for cell in parent_cells_map[old_id]: cache_utils.update(cg.cache.parents_cache, [new_id], cell.value) @@ -522,7 +570,11 @@ def copy_parents_and_add_lineage( attributes.Hierarchy.NewIdentity: np.array(new_ids, dtype=basetypes.NODE_ID) } result.append( - cg.client.mutate_row(serializers.serialize_uint64(old_id), val_dict) + cg.client.mutate_row( + serializers.serialize_uint64(old_id), + val_dict, + time_stamp=time_stamp, + ) ) children_cells_map = cg.client.read_nodes( diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index cdfb0a3c2..5cd403cfd 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -937,7 +937,7 @@ def _apply( # only. A worker death inside it leaves the indefinite # cell set on every chunk row in scope, blocking future # ops until operator replay clears them. - reps, chunk_ids = edits_sv.plan_sv_splits( + tasks, chunk_ids = edits_sv.plan_sv_splits( self.cg, sv_remapping=result.sv_remapping, source_ids=self.source_ids, @@ -953,11 +953,12 @@ def _apply( ): sv_result = edits_sv.split_supervoxels( self.cg, - reps=reps, + tasks=tasks, sv_remapping=result.sv_remapping, source_ids=self.source_ids, sink_ids=self.sink_ids, operation_id=operation_id, + timestamp=timestamp, ) with locks.IndefiniteL2ChunkLock( self.cg, diff --git a/pychunkedgraph/tests/graph/test_edits_sv.py b/pychunkedgraph/tests/graph/test_edits_sv.py index bced0a070..1093139a6 100644 --- a/pychunkedgraph/tests/graph/test_edits_sv.py +++ b/pychunkedgraph/tests/graph/test_edits_sv.py @@ -237,3 +237,56 @@ def test_operation_id_stored(self): assert val_dict[attributes.OperationLogs.OperationID] == 99 op_id_found = True assert op_id_found + + def test_time_stamp_threaded_to_new_sv_writes(self): + """New-SV writes (FormerIdentity/OperationID on new, NewIdentity + on old) land at `time_stamp`. Parent-copy and Child-list writes + preserve the old cell's timestamp so pre-op readers still see + the old hierarchy. + """ + from datetime import datetime, timezone + + old = np.uint64(10) + new1 = np.uint64(101) + parent = np.uint64(1000) + + old_cell_ts = 42 # old cell's timestamp, preserved on Parent/Child copies + op_ts = datetime(2026, 4, 23, tzinfo=timezone.utc) # op's logical write time + + parent_cells_map = {old: [_FakeCell(parent, timestamp=old_cell_ts)]} + children_cells_map = { + parent: [ + _FakeCell( + np.array([old], dtype=basetypes.NODE_ID), timestamp=old_cell_ts + ) + ] + } + cg = self._make_cg(parent_cells_map, children_cells_map) + + copy_parents_and_add_lineage( + cg, operation_id=7, old_new_map={old: {new1}}, time_stamp=op_ts + ) + + # Classify each mutate_row call by which column it writes. + for call in cg.client.mutate_row.call_args_list: + val_dict = call[0][1] + kw = call[1] + ts = kw.get("time_stamp") + cols = set(val_dict.keys()) + + if attributes.Hierarchy.FormerIdentity in cols: + # New-SV lineage write — should use op's time_stamp. + assert ts == op_ts, f"FormerIdentity write ts={ts}, expected {op_ts}" + elif attributes.Hierarchy.NewIdentity in cols: + # Old-SV NewIdentity write — should use op's time_stamp. + assert ts == op_ts, f"NewIdentity write ts={ts}, expected {op_ts}" + elif attributes.Hierarchy.Parent in cols: + # Copied-parent write — preserves old cell's timestamp. + assert ( + ts == old_cell_ts + ), f"Parent-copy write ts={ts}, expected {old_cell_ts}" + elif attributes.Hierarchy.Child in cols: + # Updated-children write on L2 parent — preserves old timestamp. + assert ( + ts == old_cell_ts + ), f"Child-list write ts={ts}, expected {old_cell_ts}" From f19eb4d1192548052a0419fc64ea6a4118017181 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 18:31:00 +0000 Subject: [PATCH 214/367] feat(stuck_ops): surface exception-path + verify cells before replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_stuck filter switches from Status==CREATED to "L2ChunkLockScope set and Status != SUCCESS past min_age". The authoritative signal for stuck-ness is "scope recorded, not cleared" — worker crash (Status stays CREATED) and Python exception during persist (Status=EXCEPTION after Fix 1) both fall under it. Ops without scope aren't blocking other ops and are outside stuck_ops' concern. replay now verifies each chunk in the recorded scope actually has Concurrency.IndefiniteLock held by this op_id before running cleanup or repair. If cells are missing or held by a different op, raises with a clear error. Protects against double-replay (first run already released cells) and out-of-band clearing (manual bigtable edit, buggy release path) — both would have cleanup_partial_writes revert chunks that aren't ours. --- pychunkedgraph/repair/stuck_ops.py | 111 ++++++++++--- pychunkedgraph/tests/graph/test_stuck_ops.py | 157 ++++++++++++++++++- 2 files changed, 245 insertions(+), 23 deletions(-) diff --git a/pychunkedgraph/repair/stuck_ops.py b/pychunkedgraph/repair/stuck_ops.py index 76e1dd21a..ae695bdc4 100644 --- a/pychunkedgraph/repair/stuck_ops.py +++ b/pychunkedgraph/repair/stuck_ops.py @@ -29,6 +29,7 @@ from pychunkedgraph import get_logger from pychunkedgraph.graph import ChunkedGraph, attributes from pychunkedgraph.graph.chunks.utils import get_chunk_coordinates +from pychunkedgraph.graph.locks import _l2_chunk_lock_row_key from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt from pychunkedgraph.repair.edits import repair_operation @@ -58,21 +59,34 @@ def _chunk_voxel_slices(cg: ChunkedGraph, chunk_id: int) -> tuple: def list_stuck(cg: ChunkedGraph, min_age: timedelta = timedelta(minutes=10)) -> list: - """Return op-log entries still at `CREATED` status past `min_age`. - - `CREATED` is the initial log state; normal ops transition to - `WRITE_STARTED` inside `_write` and then to `SUCCESS`. An op lingering - at `CREATED` past `min_age` is a candidate for operator inspection — - either the worker crashed mid-write (indefinite cell set, needs - `replay`) or the op is still in flight (leave it alone). + """Return op-log entries whose `L2ChunkLockScope` is set past `min_age`, + excluding successfully-completed ops. + + The authoritative signal for a stuck op is "scope recorded" — + `IndefiniteL2ChunkLock.__enter__` writes it before any seg/bigtable + write and its clean `__exit__` clears it. An op whose scope is + still populated is either a worker crash (Status=CREATED, Fix 1's + `__exit__` short-circuit never ran) or an exception during the + persist block (Status=EXCEPTION, Fix 1 held the cells on the way + out). Either way it's still holding `Concurrency.IndefiniteLock` + cells on the listed chunks and blocking any new op that overlaps. + + Ops that reach `SUCCESS` normally have scope cleared — we defensively + filter them out in case `_clear_scope_on_op_log`'s best-effort write + failed and logged. Failed ops that never touched the persist block + (e.g. a PreconditionError from multicut) have no scope and don't + show up here; they're not blocking anything. """ now = datetime.now(timezone.utc) cutoff = now - min_age entries = cg.client.read_log_entries() stuck = [] - created_code = attributes.OperationLogs.StatusCodes.CREATED.value + success_code = attributes.OperationLogs.StatusCodes.SUCCESS.value for op_id, entry in entries.items(): - if entry.get(attributes.OperationLogs.Status) != created_code: + scope = entry.get(attributes.OperationLogs.L2ChunkLockScope) + if scope is None or len(scope) == 0: + continue + if entry.get(attributes.OperationLogs.Status) == success_code: continue op_ts = entry.get(attributes.OperationLogs.OperationTimeStamp) if op_ts is None: @@ -87,7 +101,8 @@ def list_stuck(cg: ChunkedGraph, min_age: timedelta = timedelta(minutes=10)) -> "operation_ts": op_ts, "age": now - op_ts, "user_id": entry.get(attributes.OperationLogs.UserID), - "l2_chunk_scope": entry.get(attributes.OperationLogs.L2ChunkLockScope), + "l2_chunk_scope": scope, + "status": entry.get(attributes.OperationLogs.Status), } ) stuck.sort(key=lambda r: r["op_id"]) @@ -149,16 +164,76 @@ def _revert_chunk(chunk_id: int) -> None: return len(scope) +def _verify_indefinite_cells(cg: ChunkedGraph, op_id: int, scope) -> list: + """Check each chunk in `scope` actually has `Concurrency.IndefiniteLock` + held by `op_id`. Returns the list of chunk IDs whose cell is missing + or held by a different op_id — an empty list means everything is + consistent. + + Guards `replay` against acting on a stale scope: if cells aren't + actually held (operator already ran replay, manual intervention, + any bug that released cells without clearing scope), `cleanup_ + partial_writes` would revert chunks that another op may have + legitimately written to in the meantime. Refusing loudly is safer + than assuming. + """ + lock_column = attributes.Concurrency.IndefiniteLock + expected = np.uint64(op_id) + discrepancies = [] + for chunk_id in scope: + row_key = _l2_chunk_lock_row_key(int(chunk_id)) + cells = cg.client._read_byte_row(row_key, columns=lock_column) + if not cells: + discrepancies.append(int(chunk_id)) + continue + held_by = cells[0].value if hasattr(cells[0], "value") else None + if held_by != expected: + discrepancies.append(int(chunk_id)) + return discrepancies + + def replay(cg: ChunkedGraph, op_id: int): - """Recovery: clean up partial OCDBT writes, then run the op normally. - - Delegates the actual rerun to `repair.edits.repair_operation`, which - loads the op, runs `operation.execute(..., privileged_mode=True, - parent_ts=)`, and unlocks the root row on success. - Our `IndefiniteL2ChunkLock.__enter__` in privileged mode populates - `acquired_keys` from the scope so `__exit__` releases the crashed - op's pre-existing indefinite cells after the replay writes land. + """Recovery: verify locks, clean up partial OCDBT writes, then run + the op normally. + + Before any destructive step, read back the per-chunk + `Concurrency.IndefiniteLock` cells listed in the op's + `L2ChunkLockScope` and confirm they're still held by `op_id`. If + any are missing or held by another op, raise and do nothing — + proceeding would have `cleanup_partial_writes` revert chunks we + don't actually own. + + On clean verification, `cleanup_partial_writes` reverts the op's + partial OCDBT writes, then `repair.edits.repair_operation` reruns + `operation.execute(..., privileged_mode=True, parent_ts=)`. `IndefiniteL2ChunkLock.__enter__` in privileged mode + populates `acquired_keys` from the scope so `__exit__` releases the + crashed op's pre-existing indefinite cells after the replay writes + land. """ + log_entries = cg.client.read_log_entries(operation_ids=[np.uint64(op_id)]) + if not log_entries: + raise ValueError(f"No op-log row for op_id={op_id}") + entry = log_entries[np.uint64(op_id)] + scope = entry.get(attributes.OperationLogs.L2ChunkLockScope) + if scope is None or len(scope) == 0: + raise RuntimeError( + f"op {op_id} has no L2ChunkLockScope — not a stuck SV-split op. " + "If the op failed cleanly, the client should re-submit under a " + "fresh op_id rather than replay." + ) + + mismatched = _verify_indefinite_cells(cg, op_id, scope) + if mismatched: + raise RuntimeError( + f"op {op_id}: L2ChunkLockScope lists chunks {[int(c) for c in scope]}, " + f"but the following chunks do not have Concurrency.IndefiniteLock " + f"held by op_id={op_id}: {mismatched}. Refusing to replay — the " + "recorded scope disagrees with live lock state. Possible causes: " + "replay already ran, cells were manually cleared, or a different " + "op acquired these chunks. Investigate before retrying." + ) + cleanup_partial_writes(cg, op_id) return repair_operation(cg, op_id, unlock=True) diff --git a/pychunkedgraph/tests/graph/test_stuck_ops.py b/pychunkedgraph/tests/graph/test_stuck_ops.py index e1a17fd01..823c7595e 100644 --- a/pychunkedgraph/tests/graph/test_stuck_ops.py +++ b/pychunkedgraph/tests/graph/test_stuck_ops.py @@ -27,7 +27,9 @@ class TestListStuck: - """`list_stuck` filters the op-log by Status==CREATED past min_age.""" + """`list_stuck` surfaces ops with non-empty `L2ChunkLockScope` past + `min_age` whose Status isn't SUCCESS — i.e. still holding + `Concurrency.IndefiniteLock` cells somewhere.""" def _entry(self, status, age_seconds, user="u", scope=None): now = datetime.now(timezone.utc) @@ -48,24 +50,61 @@ def _cg(self, entries): cg.client.read_log_entries.return_value = entries return cg - def test_filters_out_success(self): + def test_filters_out_success_with_scope(self): + """Defensive: a SUCCESS op with stale scope (if + `_clear_scope_on_op_log` ever failed silently) must not be + listed as stuck.""" success = attributes.OperationLogs.StatusCodes.SUCCESS.value created = attributes.OperationLogs.StatusCodes.CREATED.value cg = self._cg( { - np.uint64(1): self._entry(success, 900), + np.uint64(1): self._entry(success, 900, scope=[10, 20]), np.uint64(2): self._entry(created, 900, scope=[10, 20]), } ) stuck = stuck_ops.list_stuck(cg, min_age=timedelta(minutes=1)) assert [r["op_id"] for r in stuck] == [2] + def test_filters_out_empty_scope(self): + """Ops that never touched the persist block (no scope) are not + stuck via L2 locks — they're outside `stuck_ops`' concern.""" + created = attributes.OperationLogs.StatusCodes.CREATED.value + exception = attributes.OperationLogs.StatusCodes.EXCEPTION.value + cg = self._cg( + { + np.uint64(1): self._entry(created, 900), # no scope + np.uint64(2): self._entry(exception, 900), # no scope + np.uint64(3): self._entry(created, 900, scope=[42]), + } + ) + stuck = stuck_ops.list_stuck(cg, min_age=timedelta(minutes=1)) + assert [r["op_id"] for r in stuck] == [3] + + def test_surfaces_exception_path_with_scope(self): + """After Fix 1, a Python exception during the persist block + leaves cells held + scope set but Status=EXCEPTION. The op must + be listed so the operator can recover it.""" + exception = attributes.OperationLogs.StatusCodes.EXCEPTION.value + cg = self._cg( + { + np.uint64(42): self._entry( + exception, 900, user="alice", scope=[100, 200] + ), + } + ) + stuck = stuck_ops.list_stuck(cg, min_age=timedelta(minutes=1)) + assert len(stuck) == 1 + row = stuck[0] + assert row["op_id"] == 42 + assert row["status"] == exception + assert list(row["l2_chunk_scope"]) == [100, 200] + def test_filters_out_young_ops(self): created = attributes.OperationLogs.StatusCodes.CREATED.value cg = self._cg( { - np.uint64(1): self._entry(created, 10), # too young - np.uint64(2): self._entry(created, 3600), # an hour old + np.uint64(1): self._entry(created, 10, scope=[1]), # too young + np.uint64(2): self._entry(created, 3600, scope=[2]), # an hour old } ) stuck = stuck_ops.list_stuck(cg, min_age=timedelta(minutes=10)) @@ -87,6 +126,114 @@ def test_returns_scope_and_user(self): assert row["age"] > timedelta(minutes=10) +class TestVerifyIndefiniteCells: + """`_verify_indefinite_cells` reads each chunk's indefinite-lock cell + and reports any that don't match the expected op_id.""" + + class _Cell: + def __init__(self, value): + self.value = value + + def _cg(self, cells_by_row_key): + cg = MagicMock() + + def read(row_key, columns=None): + return cells_by_row_key.get(row_key, []) + + cg.client._read_byte_row.side_effect = read + return cg + + def test_all_held_by_same_op(self): + op_id = 42 + scope = [np.uint64(1), np.uint64(2)] + cells = { + stuck_ops._l2_chunk_lock_row_key(1): [self._Cell(np.uint64(op_id))], + stuck_ops._l2_chunk_lock_row_key(2): [self._Cell(np.uint64(op_id))], + } + cg = self._cg(cells) + assert stuck_ops._verify_indefinite_cells(cg, op_id, scope) == [] + + def test_cell_missing_flagged(self): + op_id = 42 + scope = [np.uint64(1), np.uint64(2)] + cells = { + stuck_ops._l2_chunk_lock_row_key(1): [self._Cell(np.uint64(op_id))], + # chunk 2 has no cell + } + cg = self._cg(cells) + discrepancies = stuck_ops._verify_indefinite_cells(cg, op_id, scope) + assert discrepancies == [2] + + def test_cell_held_by_different_op_flagged(self): + op_id = 42 + other_op = np.uint64(99) + scope = [np.uint64(1), np.uint64(2)] + cells = { + stuck_ops._l2_chunk_lock_row_key(1): [self._Cell(other_op)], + stuck_ops._l2_chunk_lock_row_key(2): [self._Cell(np.uint64(op_id))], + } + cg = self._cg(cells) + discrepancies = stuck_ops._verify_indefinite_cells(cg, op_id, scope) + assert discrepancies == [1] + + +class TestReplayVerifies: + """`replay` refuses to call cleanup_partial_writes or repair_operation + when the recorded scope disagrees with live indefinite-lock state.""" + + def test_replay_refuses_when_cells_missing(self, monkeypatch): + op_id = 77 + scope = np.asarray([1, 2], dtype=np.uint64) + + cg = MagicMock() + cg.client.read_log_entries.return_value = { + np.uint64(op_id): { + attributes.OperationLogs.L2ChunkLockScope: scope, + attributes.OperationLogs.OperationTimeStamp: datetime.now(timezone.utc), + } + } + # No cells held on either chunk. + cg.client._read_byte_row.return_value = [] + + # Spy on the destructive steps — neither should be called. + cleanup_called = {"v": False} + repair_called = {"v": False} + monkeypatch.setattr( + stuck_ops, + "cleanup_partial_writes", + lambda *a, **k: cleanup_called.__setitem__("v", True), + ) + monkeypatch.setattr( + stuck_ops, + "repair_operation", + lambda *a, **k: repair_called.__setitem__("v", True), + ) + + with pytest.raises(RuntimeError, match="Refusing to replay"): + stuck_ops.replay(cg, op_id) + assert not cleanup_called["v"] + assert not repair_called["v"] + + def test_replay_refuses_when_empty_scope(self, monkeypatch): + op_id = 77 + cg = MagicMock() + cg.client.read_log_entries.return_value = { + np.uint64(op_id): { + attributes.OperationLogs.OperationTimeStamp: datetime.now(timezone.utc), + } + } + cleanup_called = {"v": False} + monkeypatch.setattr( + stuck_ops, + "cleanup_partial_writes", + lambda *a, **k: cleanup_called.__setitem__("v", True), + ) + + with pytest.raises(RuntimeError, match="not a stuck SV-split op"): + stuck_ops.replay(cg, op_id) + assert not cleanup_called["v"] + + class TestCleanupPartialWrites: """Cleanup reverts partial OCDBT writes using pinned reads of pre-op state.""" From 56923afef131d06459c998f71715bb9dba90d5e0 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 23 Apr 2026 22:23:40 +0000 Subject: [PATCH 215/367] include kvstack spec in info --- pychunkedgraph/graph/meta.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index 2d2d1d289..b6db3471c 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -8,7 +8,10 @@ import numpy as np from cloudvolume import CloudVolume -from pychunkedgraph.graph.ocdbt import get_seg_source_and_destination_ocdbt +from pychunkedgraph.graph.ocdbt import ( + build_cg_ocdbt_spec, + get_seg_source_and_destination_ocdbt, +) from .utils.generic import compute_bitmasks from .chunks.utils import get_chunks_boundary @@ -296,12 +299,18 @@ def dataset_info(self) -> Dict: "n_layers": self.layer_count, "spatial_bit_masks": self.bitmasks, "ocdbt_seg": self.ocdbt_seg, - # Per-CG delta OCDBT path. Neuroglancer must open this - # via the kvstack spec from build_cg_ocdbt_spec() to see - # both base + delta data. Opening it as plain OCDBT only - # sees the delta. - "ocdbt_path": ( - f"ocdbt/{self.graph_id}" if self._graph_config.ID else None + # Full kvstore spec a reader hands to tensorstore's + # `neuroglancer_precomputed` driver. Server owns the + # contract — paths, data prefixes, and OCDBT config + # (e.g. `max_inline_value_bytes`) are all resolved + # here, so readers don't duplicate configuration and + # future schema changes are picked up on re-fetch. + # Readers pass this verbatim as `kvstore`; add a + # `version` field for time-travel reads. + "ocdbt_kvstore_spec": ( + build_cg_ocdbt_spec(self._data_source.WATERSHED, self.graph_id) + if self.ocdbt_seg and self._graph_config.ID + else None ), }, } From 7672ceb9c533995e10463bc21736cbea5cec0528 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 24 Apr 2026 01:29:03 +0000 Subject: [PATCH 216/367] feat(meta): require fork to be pre-created; assert fork_exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fork_base_manifest is now an explicit step — invoked from the ingest CLI's --ocdbt path or the seg_ocdbt notebook — rather than being auto-triggered on first ws_ocdbt_scales access. ws_ocdbt_scales asserts fork_exists() so a missing fork fails with a clear pointer instead of a tensorstore mismatch/not-found error. --- pychunkedgraph/graph/meta.py | 10 +++++++--- pychunkedgraph/graph/ocdbt.py | 17 +++++++++++++++-- pychunkedgraph/tests/graph/test_meta.py | 6 ++++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index b6db3471c..e03eb8cba 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -10,6 +10,7 @@ from pychunkedgraph.graph.ocdbt import ( build_cg_ocdbt_spec, + fork_exists, get_seg_source_and_destination_ocdbt, ) @@ -134,10 +135,13 @@ def ws_ocdbt_scales(self): """ assert self.ocdbt_seg, "make sure this pcg has segmentation in ocdbt format" if self._ws_ocdbt_scales is None: + ws = self.data_source.WATERSHED + assert fork_exists(ws, self.graph_id), ( + f"ocdbt fork missing at {ws}/ocdbt/{self.graph_id}/ — " + "create it via fork_base_manifest or the seg_ocdbt notebook" + ) _, self._ws_ocdbt_scales, self._ws_ocdbt_resolutions = ( - get_seg_source_and_destination_ocdbt( - self.data_source.WATERSHED, self.graph_id - ) + get_seg_source_and_destination_ocdbt(ws, self.graph_id) ) return self._ws_ocdbt_scales diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index 4bed043ad..bfc7baadb 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -103,6 +103,14 @@ def base_exists(ws_path: str) -> bool: return result.value is not None and len(result.value) > 0 +def fork_exists(ws_path: str, graph_id: str) -> bool: + """Check if this ChunkedGraph's fork has been initialized.""" + fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") + kvs = ts.KvStore.open(fork_dir).result() + result = kvs.read("manifest.ocdbt").result() + return result.value is not None and len(result.value) > 0 + + def create_base_ocdbt(ws_path: str): """One-time bootstrap: create the shared base OCDBT at /ocdbt/base/. @@ -182,8 +190,13 @@ def build_cg_ocdbt_spec( ) -> dict: """Open-time kvstore spec for a CG's OCDBT, backed by a shared immutable base. - The fork directory and its manifest are created automatically by - `fork_base_manifest` as part of CG creation — no manual setup. + This function is a pure spec-constructor — it doesn't materialize + the fork. The fork's `manifest.ocdbt` must exist before `ts.open` + on this spec will succeed; it's created by `fork_base_manifest` + (invoked from the ingest CLI's `--ocdbt` path or the `seg_ocdbt` + notebook). `ChunkedGraphMeta.ws_ocdbt_scales` asserts presence via + `fork_exists` so callers get a clear error instead of a tensorstore + internal failure. All three kvstack layers below AND all three `*_data_prefix` options are load-bearing; removing any of them causes fork writes to leak diff --git a/pychunkedgraph/tests/graph/test_meta.py b/pychunkedgraph/tests/graph/test_meta.py index fc24f9917..d8f8134d9 100644 --- a/pychunkedgraph/tests/graph/test_meta.py +++ b/pychunkedgraph/tests/graph/test_meta.py @@ -582,8 +582,9 @@ def test_ws_ocdbt_asserts_when_not_ocdbt(self): with pytest.raises(AssertionError, match="ocdbt"): _ = meta.ws_ocdbt + @patch("pychunkedgraph.graph.meta.fork_exists", return_value=True) @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") - def test_ws_ocdbt_returns_base_scale(self, mock_get_ocdbt): + def test_ws_ocdbt_returns_base_scale(self, mock_get_ocdbt, _mock_fork_exists): gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) @@ -602,8 +603,9 @@ def test_ws_ocdbt_returns_base_scale(self, mock_get_ocdbt): assert meta.ws_ocdbt_resolutions == [[4, 4, 40], [8, 8, 40]] mock_get_ocdbt.assert_called_once_with("gs://bucket/ws", "test_graph") + @patch("pychunkedgraph.graph.meta.fork_exists", return_value=True) @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") - def test_ws_ocdbt_cached(self, mock_get_ocdbt): + def test_ws_ocdbt_cached(self, mock_get_ocdbt, _mock_fork_exists): gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) From ed7f9f0fc19544fd6f26b04d77e27a9e796a0f21 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 24 Apr 2026 01:42:30 +0000 Subject: [PATCH 217/367] docs: scope-set stuck signal; pre-replay cell verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stuck-op detection keys off L2ChunkLockScope being populated, not Status=CREATED — that filter also covers caught-exception paths (Status=FAILED) where cells are held but the row isn't CREATED. Recovery now verifies each chunk's IndefiniteLock is actually held by the op before cleaning up, so a stale scope can't have us revert chunks another op owns. Reflect both in docs and the list CLI help. Drop "mode-downsample" from the SV-split diagram — tinybrain owns the algorithm; the doc shouldn't pin it. --- docs/sv_splitting.md | 4 ++-- docs/sv_splitting_recovery.md | 10 ++++++---- pychunkedgraph/repair/stuck_ops.py | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/sv_splitting.md b/docs/sv_splitting.md index b46c7ef2a..d6b56a94b 100644 --- a/docs/sv_splitting.md +++ b/docs/sv_splitting.md @@ -83,7 +83,7 @@ base-resolution bounding boxes that were rewritten │ │ │ │ │ │ for each pyramid block: │ │ │ │ read base resolution │ │ -│ │ mode-downsample through every coarser MIP │ │ +│ │ downsample through every coarser MIP │ │ │ │ write only tiles whose footprint intersects │ │ │ │ a published bbox │ │ │ └──────────────────────────────────────────────────────┘ │ @@ -129,7 +129,7 @@ The in-memory segmentation block produced by the split is bitwise identical to w ### Worker crash mid-write -A worker that dies inside the indefinite L2 chunk lock's scope leaves the lock cells set and the op-log row in `CREATED` status with a durable record of which chunks were being written. Future ops on any of those chunks refuse to start — the crashed state is isolated, not amplified. An operator runs the recovery flow described in [sv_splitting_recovery.md](sv_splitting_recovery.md) to revert the partial writes and replay the op. +A worker that dies — or raises from the persist block — inside the indefinite L2 chunk lock's scope leaves the lock cells set and the op-log row's `L2ChunkLockScope` populated with the exact chunks being written. Future ops on any of those chunks refuse to start — the crashed state is isolated, not amplified. An operator runs the recovery flow described in [sv_splitting_recovery.md](sv_splitting_recovery.md) to revert the partial writes and replay the op. ## Invariants diff --git a/docs/sv_splitting_recovery.md b/docs/sv_splitting_recovery.md index af39c02dd..cc9efaa57 100644 --- a/docs/sv_splitting_recovery.md +++ b/docs/sv_splitting_recovery.md @@ -10,7 +10,9 @@ Every supervoxel split writes two things atomically from the point of view of th - Segmentation chunks — the voxel-level split, where each L2 chunk touched by the split gets fresh supervoxel IDs at the voxels that moved to a new fragment. - Graph hierarchy rows — lineage from the old supervoxels to the new ones plus the cross-chunk edges linking the new fragments. -Both writes happen under an indefinite L2 chunk lock covering the exact chunks being rewritten. If the worker running the op dies before the lock's context exits — process kill, pod eviction, hardware failure, OOM — the lock stays held. From that moment on, any new op whose chunk set overlaps the stuck op's chunks refuses to start, blocking further corruption. +Both writes happen under an indefinite L2 chunk lock covering the exact chunks being rewritten. If the worker running the op dies before the lock's context exits — process kill, pod eviction, hardware failure, OOM — or raises a caught exception inside the persist block, the lock stays held and the op-log row's `L2ChunkLockScope` stays populated with the affected chunk IDs. From that moment on, any new op whose chunk set overlaps the stuck op's chunks refuses to start, blocking further corruption. + +The authoritative signal that an op is stuck is `L2ChunkLockScope` being non-empty — the clean `__exit__` path clears it on success. Either a crash (`Status=CREATED`, exit never ran) or a caught exception (`Status=FAILED`, held-cells-on-exception path) keeps the scope set. The operator runs recovery when the lock has been held long enough that the worker is definitively gone, not merely slow. A minimum-age threshold (10 minutes is a reasonable default) distinguishes stuck ops from ops still in flight. @@ -34,7 +36,7 @@ Recovery proceeds in two steps. **Cleanup.** For each chunk in the stuck op's durably-recorded scope, the operator reads the chunk's pre-op voxel values from a segmentation handle pinned at the op's `OperationTimeStamp`, and writes those values back to the latest (unpinned) handle. The result: those chunks, at the latest manifest, now show pre-op segmentation — as if the crashed op had never started. Neighbor chunks and every chunk outside the stuck op's scope are untouched, so any concurrent op's work is preserved. -**Replay.** The operator then re-runs the op under the privileged-repair path. The run reads latest state, which is now consistent — clean pre-op values on the stuck op's own chunks, current state everywhere else — and goes through the normal edit flow. It allocates fresh supervoxel IDs, re-computes the split, writes new segmentation and hierarchy, and transitions the op-log row from `CREATED` through `WRITE_STARTED` to `SUCCESS`. +**Replay.** The operator then re-runs the op under the privileged-repair path. The run reads latest state, which is now consistent — clean pre-op values on the stuck op's own chunks, current state everywhere else — and goes through the normal edit flow. It allocates fresh supervoxel IDs, re-computes the split, writes new segmentation and hierarchy, and lands the op-log row at `SUCCESS`. When the replay's indefinite lock context exits, it issues value-matched releases on every chunk in the scope. Because the replay re-uses the crashed op's operation ID, the value match succeeds and the pre-existing indefinite cells are deleted. The chunks are free for new ops again. @@ -46,11 +48,11 @@ The orphan supervoxel IDs allocated by the crashed op are never referenced by an ## Operator workflow -1. **List stuck ops.** The operator runs the list command with a minimum-age threshold. It returns op-log rows still at `CREATED` status past that age, along with each op's user ID, timestamp, age, and the number of chunks in its recorded scope. Ops too young to classify are skipped. +1. **List stuck ops.** The operator runs the list command with a minimum-age threshold. It returns op-log rows whose `L2ChunkLockScope` is still populated past that age (excluding any that have reached `SUCCESS`), along with each op's user ID, timestamp, age, status, and the number of chunks in its recorded scope. Ops too young to classify are skipped. 2. **Inspect.** For each candidate, confirm from logs or monitoring that the worker that submitted the op is definitively dead — not, for example, paused on a long-running multicut. The minimum-age threshold exists to reduce false positives but the operator retains final judgment. -3. **Replay.** The operator runs the replay command with the op ID. It performs the cleanup pass, then hands off to the privileged-repair path to rerun the op. On success, the op-log row shows `SUCCESS` and the previously-held indefinite lock cells are released. +3. **Replay.** The operator runs the replay command with the op ID. Before any destructive step the replay cross-checks the recorded scope against live lock state: for every chunk in `L2ChunkLockScope` it reads back the `Concurrency.IndefiniteLock` cell and verifies it's held by the op being replayed. Any discrepancy (cell missing, or held by a different op) aborts the replay loudly — a stale scope could otherwise have cleanup revert chunks another op legitimately owns. On clean verification, cleanup reverts the op's partial writes, then the privileged-repair path reruns the op. On success, the op-log row shows `SUCCESS` and the previously-held indefinite lock cells are released. 4. **Verify.** A second list invocation should no longer include the op. Any new ops that were waiting on the affected chunks proceed. diff --git a/pychunkedgraph/repair/stuck_ops.py b/pychunkedgraph/repair/stuck_ops.py index ae695bdc4..32d3107fc 100644 --- a/pychunkedgraph/repair/stuck_ops.py +++ b/pychunkedgraph/repair/stuck_ops.py @@ -245,7 +245,8 @@ def _main(): sub = parser.add_subparsers(dest="cmd", required=True) p_list = sub.add_parser( - "list", help="List stuck ops (CREATED status past min-age)." + "list", + help="List stuck ops (L2ChunkLockScope still populated past min-age).", ) p_list.add_argument("--graph", required=True, help="Graph ID.") p_list.add_argument( From 0eace5e58b2bea4a4c06f55676dee92b9f62bc4a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 28 Apr 2026 01:07:02 +0000 Subject: [PATCH 218/367] sv-split: drive bbox from user seeds, narrow cut set to bbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_rep_bbox` enveloped every piece of the cross-chunk-connected rep — for physical SVs split into many pieces across chunks, the bbox grew far wider than the cut surface needs. Replace with `_coords_bbox`: envelope of the user-placed source/sink coords plus a one-chunk margin (matches the existing L2 lock margin and 1-voxel shell). After the seg read, `cut_supervoxels` is intersected with the IDs present in seg, so the "whole sv" set names only the rep pieces the bbox actually touches. Pieces of the rep outside the bbox keep their existing IDs — their cross-chunk edges to in-bbox split fragments are routed via the 1-voxel shell, edges between two unsplit pieces don't change. Adds `TestCoordsBbox` covering envelope+margin, volume-bound clipping, and that `plan_sv_splits` returns a tight bbox regardless of how distant the rep's other pieces sit. --- pychunkedgraph/graph/edits_sv.py | 82 +++++++++++++-------- pychunkedgraph/tests/graph/test_edits_sv.py | 76 +++++++++++++++++++ 2 files changed, 126 insertions(+), 32 deletions(-) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 813cf882e..be605c6e4 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -82,28 +82,37 @@ class SplitResult: bigtable_rows: list -def _rep_bbox(cg: "ChunkedGraph", sv_remapping: dict, sv_id) -> tuple: - """Base-voxel bbox covering every CC member of `sv_id`'s cross-chunk rep. - - The multicut produced `sv_remapping` — every SV in the local subgraph - mapped to its cross-chunk representative. Any SV mapped to the same - rep as `sv_id` is part of one physical supervoxel that was - artificially cut at chunk boundaries, and every piece will be - rewritten when this rep is split. The chunk-coord envelope of those - pieces is exactly the region the split needs to read and modify — - no padding, no resolution-axis assumption, no bbox clip that could - drop pieces. +def _coords_bbox( + cg: "ChunkedGraph", + src_coords_rep: np.ndarray, + sink_coords_rep: np.ndarray, +) -> tuple: + """Base-voxel bbox covering the user's source/sink seeds plus a margin. + + The cut surface lives between the user-placed source and sink + voxels; voxels of the rep that are far from those seeds never + contribute to the cut. So the read region is the seeds' envelope, + not the rep's full chunk envelope — for a physical SV cut into many + pieces across chunks, this can be orders of magnitude smaller. + + The margin is one CG chunk on each side. It matches the existing + L2 chunk lock margin and the 1-voxel shell read in + `split_supervoxel`, and gives `split_supervoxel_helper` headroom + around the seeds for the cut surface to travel along the SV. + + Pieces of the rep that fall outside the bbox keep their existing + IDs — they aren't read here and aren't rewritten. Cross-chunk-edge + routing for boundary-adjacent pieces is handled by the 1-voxel + shell at read time; cross-chunk edges entirely between unsplit + pieces don't change because their IDs don't change. """ - rep = sv_remapping.get(sv_id, sv_id) - all_svs = np.array( - [sv for sv, r in sv_remapping.items() if r == rep], - dtype=basetypes.NODE_ID, - ) - coords = cg.get_chunk_coordinates_multiple(all_svs) - chunk_min = coords.min(axis=0) - chunk_max = coords.max(axis=0) + 1 # exclusive - chunk_size = cg.meta.graph_config.CHUNK_SIZE - return chunk_min * chunk_size, chunk_max * chunk_size + coords = np.concatenate([src_coords_rep, sink_coords_rep], axis=0) + margin = np.array(cg.meta.graph_config.CHUNK_SIZE, dtype=int) + vol_start = cg.meta.voxel_bounds[:, 0] + vol_end = cg.meta.voxel_bounds[:, 1] + bbs = np.clip(coords.min(axis=0) - margin, vol_start, vol_end) + bbe = np.clip(coords.max(axis=0) + margin, vol_start, vol_end) + return bbs, bbe def _l2_chunks_for_splits(cg: "ChunkedGraph", per_rep_bboxes: list) -> list[int]: @@ -211,7 +220,7 @@ def plan_sv_splits( source_coords=source_coords, sink_coords=sink_coords, ): - bbs, bbe = _rep_bbox(cg, sv_remapping, sv_id) + bbs, bbe = _coords_bbox(cg, src_coords_rep, sink_coords_rep) tasks.append( SvSplitTask( sv_id=sv_id, @@ -386,10 +395,11 @@ def split_supervoxel( ) -> SvSplitOutcome: """Split one cross-chunk-connected SV into connected components. - `task.bbs` / `task.bbe` are the base-voxel bbox envelope covering - every CC member of `task.sv_id`'s rep — `plan_sv_splits` pre-computed - this via `_rep_bbox`. The envelope is guaranteed to contain every - piece of the SV being split, so no bbox clip is needed inside. + `task.bbs` / `task.bbe` are the base-voxel bbox covering the user's + source and sink seeds plus a one-chunk margin — `plan_sv_splits` + pre-computed this via `_coords_bbox`. The bbox is driven by where + the user wants the cut, not by the rep's full chunk envelope; rep + pieces outside the bbox aren't read and keep their existing IDs. `time_stamp` is the op's logical write time; threaded through to `copy_parents_and_add_lineage` + `add_new_edges` so every new-SV @@ -406,13 +416,8 @@ def split_supervoxel( logger.note(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}") logger.note(f"bbox: {(bbs, bbe)}") - t0 = time.time() rep = sv_remapping.get(sv_id, sv_id) - cut_supervoxels = {int(sv) for sv, r in sv_remapping.items() if r == rep} - supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) - logger.note( - f"whole sv {sv_id} -> {supervoxel_ids.tolist()} ({time.time() - t0:.2f}s)" - ) + rep_pieces = {int(sv) for sv, r in sv_remapping.items() if r == rep} # one voxel overlap for neighbors — update_edges needs anchor voxels # from neighboring SVs to route existing cross-chunk edges onto the @@ -423,6 +428,19 @@ def split_supervoxel( seg = get_local_segmentation(cg.meta, bbs_, bbe_).squeeze() logger.note(f"segmentation read {seg.shape} ({time.time() - t0:.2f}s)") + # Narrow the rep to pieces actually present in the bbox seg. Pieces + # of the rep whose voxels lie outside the seed-driven bbox don't + # appear in `seg` and so don't contribute to `binary_seg` anyway — + # carrying them in `cut_supervoxels` is just log noise plus inflated + # `unsplit` diff churn. + seg_ids = {int(x) for x in fastremap.unique(seg) if x != 0} + cut_supervoxels = rep_pieces & seg_ids + supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) + logger.note( + f"whole sv {sv_id} -> {supervoxel_ids.tolist()} " + f"({len(rep_pieces) - len(cut_supervoxels)} rep pieces outside bbox)" + ) + binary_seg = np.isin(seg, supervoxel_ids) voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) t0 = time.time() diff --git a/pychunkedgraph/tests/graph/test_edits_sv.py b/pychunkedgraph/tests/graph/test_edits_sv.py index 1093139a6..34a4de109 100644 --- a/pychunkedgraph/tests/graph/test_edits_sv.py +++ b/pychunkedgraph/tests/graph/test_edits_sv.py @@ -6,9 +6,11 @@ from unittest.mock import MagicMock, patch from pychunkedgraph.graph.edits_sv import ( + _coords_bbox, _voxel_crop, _parse_results, copy_parents_and_add_lineage, + plan_sv_splits, ) from pychunkedgraph.graph import attributes, basetypes @@ -290,3 +292,77 @@ def test_time_stamp_threaded_to_new_sv_writes(self): assert ( ts == old_cell_ts ), f"Child-list write ts={ts}, expected {old_cell_ts}" + + +# ============================================================ +# Tests: _coords_bbox / plan_sv_splits bbox is seed-driven, not rep-driven +# ============================================================ +class TestCoordsBbox: + def _make_cg(self, chunk_size=(64, 64, 64), volume=(1024, 1024, 1024)): + cg = MagicMock() + cg.meta.graph_config.CHUNK_SIZE = list(chunk_size) + cg.meta.voxel_bounds = np.array( + [[0, volume[0]], [0, volume[1]], [0, volume[2]]] + ) + cg.get_chunk_id.side_effect = lambda layer, x, y, z: ( + (layer << 60) | (x << 40) | (y << 20) | z + ) + return cg + + def test_envelope_around_seeds_with_one_chunk_margin(self): + cg = self._make_cg(chunk_size=(64, 64, 64)) + src = np.array([[100, 200, 300]]) + sink = np.array([[150, 250, 350]]) + bbs, bbe = _coords_bbox(cg, src, sink) + # min - chunk_size, max + chunk_size, clipped to volume bounds. + np.testing.assert_array_equal(bbs, np.array([100 - 64, 200 - 64, 300 - 64])) + np.testing.assert_array_equal(bbe, np.array([150 + 64, 250 + 64, 350 + 64])) + + def test_clipped_to_volume_bounds(self): + cg = self._make_cg(chunk_size=(64, 64, 64), volume=(256, 256, 256)) + src = np.array([[10, 10, 10]]) + sink = np.array([[250, 250, 250]]) + bbs, bbe = _coords_bbox(cg, src, sink) + # Lower seed - 64 = -54 → clipped to 0; upper seed + 64 = 314 → clipped to 256. + np.testing.assert_array_equal(bbs, np.array([0, 0, 0])) + np.testing.assert_array_equal(bbe, np.array([256, 256, 256])) + + def test_plan_sv_splits_bbox_independent_of_rep_extent(self): + """The returned per-task bbox follows the seeds, not the rep's + cross-chunk pieces. A rep whose pieces span the whole volume + produces the same tight bbox as a rep with one piece, given the + same src/sink coords. + """ + cg = self._make_cg(chunk_size=(64, 64, 64), volume=(1024, 1024, 1024)) + + # Two source/sink IDs that map to the same rep — the SV-split + # trigger condition. The rep's other pieces (b..z) sit far from + # the seeds. They would have ballooned the old `_rep_bbox`; the + # new `_coords_bbox` ignores them. + rep = np.uint64(1) + sv_remapping = { + np.uint64(10): rep, # src + np.uint64(20): rep, # sink + **{np.uint64(100 + i): rep for i in range(28)}, # 28 distant pieces + } + + source_ids = np.array([10], dtype=basetypes.NODE_ID) + sink_ids = np.array([20], dtype=basetypes.NODE_ID) + source_coords = np.array([[100, 200, 300]]) + sink_coords = np.array([[150, 250, 350]]) + + tasks, _ = plan_sv_splits( + cg, + sv_remapping=sv_remapping, + source_ids=source_ids, + sink_ids=sink_ids, + source_coords=source_coords, + sink_coords=sink_coords, + ) + assert len(tasks) == 1 + np.testing.assert_array_equal( + tasks[0].bbs, np.array([100 - 64, 200 - 64, 300 - 64]) + ) + np.testing.assert_array_equal( + tasks[0].bbe, np.array([150 + 64, 250 + 64, 350 + 64]) + ) From 8eec8ca1f1806fee89c5c485190cd96c4f5cadfe Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 28 Apr 2026 01:07:10 +0000 Subject: [PATCH 219/367] docs: edge update after sv split --- docs/sv_splitting_edges.md | 132 +++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/sv_splitting_edges.md diff --git a/docs/sv_splitting_edges.md b/docs/sv_splitting_edges.md new file mode 100644 index 000000000..d19bc08e8 --- /dev/null +++ b/docs/sv_splitting_edges.md @@ -0,0 +1,132 @@ +# Edge updates after a supervoxel split + +## Context + +A supervoxel split rewrites voxels inside a bbox: a single old SV is replaced by N new fragments (one per chunk × per side of the cut). Every atomic edge that referenced the old SV — to neighbors inside the same root, to neighbors in a different root, and to other pieces of the same physical supervoxel — must now reference an appropriate fragment instead, or the graph hierarchy diverges from the new segmentation. + +Edge update is the second half of `split_supervoxel`. The first half produced a labeled bbox, an `old_new_map` (`old_sv_id → set[new_sv_ids]`), and a `new_id_label_map` (`new_sv_id → cut-side label`). This document covers what happens from there. + +## Algorithm overview + +``` +inputs from voxel-level split + ├─ new_seg bbox volume with new SV IDs in place of old + ├─ old_new_map which old SVs got split, and into which new IDs + └─ new_id_label_map for each new ID, which side of the cut it's on + +update_edges (edges_sv.py): + 1. fetch atomic subgraph inside bbox, rooted at the rep's root + 2. dedupe edges, drop self-loops + 3. group by partner-root vs split-root → active / inactive + 4. for each old SV: + inactive partners → broadcast edge to every fragment + active partners → expand split partners, match by label/proximity + intra-fragment → low-affinity edges between every fragment pair + 5. validate (no cross-label inf bridges, no self-loops, completeness) + 6. return new (edges, affinities, areas) + +add_new_edges (edges_sv.py): + 1. duplicate bidirectional, group by L2 parent chunk + 2. per chunk: append to SplitEdges (history) and rewrite + CompactedSplitEdges (snapshot, with stale rows filtered) +``` + +## Inputs to `update_edges` + +- `cg, root_id, bbox` — the rep's root and the bbox the voxel-level cut acted on. +- `new_seg` — segmentation in the read window (bbox + 1-voxel shell). The shell is what makes anchor lookups work for unsplit pieces of the rep on the other side of a chunk boundary; without it, cross-chunk edges from those pieces would route to whatever happens to lie at the boundary face, not to the actual fragment the cut produced. +- `old_new_map` — drives which edges need re-routing. +- `new_id_label_map` — used to pair fragments with the same cut-side label across cross-chunk edges. + +`update_edges` calls `cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True)`. This returns every atomic edge whose endpoint sits in the bbox under the rep's root. That set already includes both intra-cut edges (between split SVs) and the cross-chunk-shell edges to neighbors outside the rewritten region. + +After fetch, edges are sorted within each pair, deduped, and self-loops filtered. The remaining set is the input to classification. + +## Classification + +For each edge, the partner's root determines the routing path. `sv_root_map` is built from one batched `cg.get_roots(...)` over all unique partners. + +### Inactive partner (`partner_root != root_id`) + +The partner sits in a different agglomerated object. The split's cut-side has no semantic relationship to that neighbor — *any* fragment of the old SV that touched the neighbor's voxels still touches them after the split. **Broadcast**: for each old SV split into N fragments, copy the edge to every fragment, preserving affinity and area. + +This intentionally over-creates edges. They cost nothing if both endpoints stay in different roots forever; they collapse harmlessly into a single root-level edge if the two roots later merge. + +### Active partner (`partner_root == root_id`) + +The partner is inside the same agglomerated object as the rep — the partner is either: + +- another piece of the same physical SV (cross-chunk-connected), +- a different SV in the same root reachable via L2 hierarchy. + +For active partners, edges are routed based on affinity type: + +#### Inf-affinity, partner also split + +The partner SV is itself in `old_new_map` (e.g. it's another piece of the rep that was rewritten). We need each new fragment of the old SV to connect to the *matching-label* new fragment of the partner — the one on the same cut-side. `_match_by_label` does this lookup via `new_id_label_map`. If no fragment of the partner shares the source SV's label (rare, indicates a partial split), fallback to the closest fragment by distance. + +#### Inf-affinity, partner unsplit + +This is the cross-chunk edge to a piece of the rep that the bbox didn't include — by construction with the seed-driven bbox, these are the rep's far-away pieces that keep their old IDs. The unsplit partner has no `new_id_label_map` entry. + +**Critical**: do *not* broadcast this edge to all fragments. An unsplit partner connected via inf-affinity to fragments on both sides of the cut would form an uncuttable bridge — a future mincut on this object would route through `frag_a → unsplit_partner → frag_b` with infinite affinity and never separate them. So `_match_inf_unsplit` assigns the edge to exactly one fragment: the one closest to the partner. + +`validate_split_edges` enforces this with check (A): no inf-affinity edge from an unsplit partner to fragments with different cut-side labels. + +#### Finite-affinity (regular) + +Real adjacency edges between SVs based on per-pair affinity. `_match_by_proximity` assigns the edge to *every* fragment within `cg.meta.sv_split_threshold` voxels of the partner, fallback to closest if none qualify. Multiple fragments may legitimately neighbor the partner; the threshold preserves the original adjacency where it actually exists. + +### Intra-fragment edges + +For each old SV split into multiple new fragments, add a low-affinity (0.001) edge between every pair of fragments. These are cuttable by future mincut operations — they record that the fragments share a graph-level neighborhood (they came from the same SV) without forcing them to stay agglomerated. Without these edges, an entirely-disconnected fragment of an old SV would have no link to the rest of the object; with them, the standard mincut machinery handles the relationship correctly. + +## Distance computation + +Distances drive both proximity matching and the closest-fragment fallback. Each fragment gets a `cKDTree` over its voxels (built from `build_coords_by_label(new_seg)`). + +- **Partner inside bbox**: build a kdtree on the partner's voxels too. For each fragment, the smaller-tree-queries-larger heuristic minimizes work; result is the minimum voxel distance. +- **Active partner outside bbox**: the partner's voxels aren't in `new_seg`. `_compute_boundary_distances` uses the partner's chunk coordinate to determine which face of the source chunk the edge crosses, then measures each fragment's distance to that boundary plane. This is an over-estimate for non-boundary-aligned partners but it's the only signal available without extra reads. + +## Validation + +`validate_split_edges` checks four invariants and raises `PostconditionError` on any violation. Failures abort the operation cleanly under the indefinite L2 chunk lock; the recovery flow then handles cleanup. + +| Check | Why | +|-------|-----| +| (A) No inf-affinity bridges between cut-sides via an unsplit partner | Would be uncuttable by future mincuts | +| (B) No self-loops | Indicates a routing bug; would skew degree counts and break some traversal assumptions | +| (C) Every old SV has at least one replacement edge from its fragments | Catches old SVs that vanished from the edge set entirely (would orphan them in the hierarchy) | +| (D) All fragment pairs of each old SV are connected | Confirms the intra-fragment low-affinity edges were emitted | + +These run before any bigtable write, so the validation is the last line of defense before the writes commit under the lock. + +## Persisting: `add_new_edges` + +The new edges are batched into bigtable per L2 chunk. Two columns get written per chunk per op: + +### `SplitEdges` (history) + +An append-only column. Each split op writes its new edges as a fresh cell with the op's logical timestamp. Time-travel reads at any timestamp T walk all cells with `ts ≤ T`, then apply the stale-edge resolution path to filter out edges whose endpoints have been superseded by later ops. This is the authoritative store for historical reads. + +### `CompactedSplitEdges` (snapshot) + +A latest-only column for fast current-time reads. On each op: + +1. Read the previous compacted cell (if any) plus its matching `CompactedAffinity` and `CompactedArea`. +2. Filter out rows whose endpoints reference any old SV in `old_new_map.keys()` (these are the SVs that just got split — their edges are stale). +3. Concatenate the new rows. +4. Write the whole thing as one fresh cell. + +Current-time readers can take this single cell directly without history walks or stale-edge resolution. + +The chunk grouping uses each edge's first endpoint's L2 parent chunk: `cg.get_chunk_ids_from_node_ids(cg.get_parents(nodes))`. Parent chunks (not the SV's own L1 chunk) is the correct routing — the edge belongs to the chunk where its endpoint lives in the L2 hierarchy. Bidirectional duplication ensures every edge is owned by both endpoints' chunks; readers picking up either side find it. + +Both writes use `time_stamp=task.operation_ts`, so all rows from one op land at the same logical time. Concurrent SV-splits on disjoint chunks don't interfere because they write disjoint chunk rows. + +## Invariants + +- For every old SV in `old_new_map`, every atomic edge that referenced it in the pre-split graph has at least one corresponding edge among its fragments after the split. +- No inf-affinity edge crosses cut-sides through an unsplit partner. +- Every cross-chunk piece of the rep that the bbox didn't include keeps its old ID and its existing edges resolve unchanged (because no edge in those rows references the now-split SVs at endpoints — the routing only touches edges whose endpoints are in the bbox or its 1-voxel shell). +- `SplitEdges` and `CompactedSplitEdges` agree at the latest timestamp: the compacted snapshot is the result of replaying the history through the stale-edge filter. From 4216ba692feb8f89cf7237d7b383b040a87902b8 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 7 May 2026 21:04:00 +0000 Subject: [PATCH 220/367] sv discrepancy error message details --- pychunkedgraph/graph/cutting.py | 12 +++-- pychunkedgraph/graph/operation.py | 81 ++++++++++++++++--------------- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/pychunkedgraph/graph/cutting.py b/pychunkedgraph/graph/cutting.py index 8dd8fe7c7..95031c6d9 100644 --- a/pychunkedgraph/graph/cutting.py +++ b/pychunkedgraph/graph/cutting.py @@ -747,11 +747,15 @@ def run_split_preview( path_augment: bool = True, disallow_isolating_cut: bool = True, ): - root_ids = set( - cg.get_roots(np.concatenate([source_ids, sink_ids]), assert_roots=True) - ) + sink_and_source_ids = np.concatenate([source_ids, sink_ids]) + roots = cg.get_roots(sink_and_source_ids, assert_roots=True) + root_ids = set(roots) if len(root_ids) > 1: - raise PreconditionError("Supervoxels must belong to the same object.") + raise PreconditionError( + f"Supervoxels must belong to the same object. " + f"sources={list(source_ids)} sinks={list(sink_ids)} " + f"sv_id->root: {dict(zip(sink_and_source_ids.tolist(), roots.tolist()))}" + ) bbox = get_bounding_box(source_coords, sink_coords, bb_offset) l2id_agglomeration_d, edges = cg.get_subgraph( diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 5cd403cfd..60a2305c5 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -633,13 +633,14 @@ def _update_root_ids(self) -> np.ndarray: def _apply( self, *, operation_id, timestamp ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: - root_ids = set( - self.cg.get_roots( - self.added_edges.ravel(), assert_roots=True, time_stamp=self.parent_ts - ) - ) + sv_ids = self.added_edges.ravel() + roots = self.cg.get_roots(sv_ids, assert_roots=True, time_stamp=self.parent_ts) + root_ids = set(roots) if len(root_ids) < 2 and not self.allow_same_segment_merge: - raise PreconditionError("Supervoxels must belong to different objects.") + raise PreconditionError( + f"Supervoxels must belong to different objects. " + f"sv_id->root: {dict(zip(sv_ids.tolist(), roots.tolist()))}" + ) atomic_edges = self.added_edges fake_edge_rows = [] @@ -764,33 +765,26 @@ def __init__( assert np.sum(layers) == layers.size, "IDs must be supervoxels." def _update_root_ids(self) -> np.ndarray: - root_ids = np.unique( - self.cg.get_roots( - self.removed_edges.ravel(), - assert_roots=True, - time_stamp=self.parent_ts, - ) - ) + sv_ids = self.removed_edges.ravel() + roots = self.cg.get_roots(sv_ids, assert_roots=True, time_stamp=self.parent_ts) + root_ids = np.unique(roots) if len(root_ids) > 1: - raise PreconditionError("Supervoxels must belong to the same object.") + raise PreconditionError( + f"Supervoxels must belong to the same object. " + f"sv_id->root: {dict(zip(sv_ids.tolist(), roots.tolist()))}" + ) return root_ids def _apply( self, *, operation_id, timestamp ) -> Tuple[np.ndarray, np.ndarray, List[Any]]: - if ( - len( - set( - self.cg.get_roots( - self.removed_edges.ravel(), - assert_roots=True, - time_stamp=self.parent_ts, - ) - ) + sv_ids = self.removed_edges.ravel() + roots = self.cg.get_roots(sv_ids, assert_roots=True, time_stamp=self.parent_ts) + if len(set(roots)) > 1: + raise PreconditionError( + f"Supervoxels must belong to the same object. " + f"sv_id->root: {dict(zip(sv_ids.tolist(), roots.tolist()))}" ) - > 1 - ): - raise PreconditionError("Supervoxels must belong to the same object.") with TimeIt("remove_edges", self.cg.graph_id, operation_id): return edits.remove_edges( @@ -911,13 +905,16 @@ def _update_root_ids(self) -> np.ndarray: sink_and_source_ids = np.concatenate((self.source_ids, self.sink_ids)).astype( basetypes.NODE_ID ) - root_ids = np.unique( - self.cg.get_roots( - sink_and_source_ids, assert_roots=True, time_stamp=self.parent_ts - ) + roots = self.cg.get_roots( + sink_and_source_ids, assert_roots=True, time_stamp=self.parent_ts ) + root_ids = np.unique(roots) if len(root_ids) > 1: - raise PreconditionError("Supervoxels must belong to the same segment.") + raise PreconditionError( + f"Supervoxels must belong to the same segment. " + f"sources={self.source_ids.tolist()} sinks={self.sink_ids.tolist()} " + f"sv_id->root: {dict(zip(sink_and_source_ids.tolist(), roots.tolist()))}" + ) return root_ids def _apply( @@ -1000,17 +997,21 @@ def _run_multicut(self, operation_id): and again after an SV split to get fresh atomic_edges against the post-split graph topology. """ - root_ids = set( - self.cg.get_roots( - np.concatenate([self.source_ids, self.sink_ids]).astype( - basetypes.NODE_ID - ), - assert_roots=True, - time_stamp=self.parent_ts, - ) + sink_and_source_ids = np.concatenate([self.source_ids, self.sink_ids]).astype( + basetypes.NODE_ID ) + roots = self.cg.get_roots( + sink_and_source_ids, + assert_roots=True, + time_stamp=self.parent_ts, + ) + root_ids = set(roots) if len(root_ids) > 1: - raise PreconditionError("Supervoxels must belong to the same object.") + raise PreconditionError( + f"Supervoxels must belong to the same object. " + f"sources={self.source_ids.tolist()} sinks={self.sink_ids.tolist()} " + f"sv_id->root: {dict(zip(sink_and_source_ids.tolist(), roots.tolist()))}" + ) bbox = get_bbox( self.source_coords, From d7fcd2e765f1443bcd21e5518e032f8749e1b347 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 7 May 2026 23:24:15 +0000 Subject: [PATCH 221/367] update gitignore, add hybrid ws seg plan --- .dockerignore | 1 + .gitignore | 1 + docs/precomputed_ocdbt_hybrid.md | 101 +++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 docs/precomputed_ocdbt_hybrid.md diff --git a/.dockerignore b/.dockerignore index 66349c8a4..09e319073 100644 --- a/.dockerignore +++ b/.dockerignore @@ -106,6 +106,7 @@ venv.bak/ # Visual Code .vscode/ +*.code-workspace # terraform .terraform/ diff --git a/.gitignore b/.gitignore index 044d3b64c..e39b39890 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,7 @@ venv.bak/ # local dev stuff +*.code-workspace .claude/ .devcontainer/ *.ipynb diff --git a/docs/precomputed_ocdbt_hybrid.md b/docs/precomputed_ocdbt_hybrid.md new file mode 100644 index 000000000..d1caafd5f --- /dev/null +++ b/docs/precomputed_ocdbt_hybrid.md @@ -0,0 +1,101 @@ +# Hybrid base: precomputed + OCDBT fork (proposal) + +Status: proposal, not implemented. Open question is whether storage and ingest-compute savings justify the read-path complexity. + +## Problem + +PCG ingest copies the entire watershed segmentation into `/ocdbt/base/` in OCDBT format before any CG edit can happen. Per-CG forks at `/ocdbt//` store only the deltas from SV splits. Two costs follow: + +- **Storage**: roughly 2× the segmentation footprint per dataset — original precomputed plus full OCDBT copy. +- **Ingest compute**: a per-chunk pass that reads the precomputed and writes it through the OCDBT driver. Hours of cluster time on TB-scale datasets. + +Both costs are paid up-front, before any user has done a single edit. The proposal here: skip the base copy and serve unedited chunks directly from the raw precomputed directory. Per-CG OCDBT forks remain as the delta store. + +## Why the current architecture has the base copy + +Today's per-CG read spec is: + +``` +neuroglancer_precomputed + └─ kvstore: ocdbt + ├─ base: kvstack [base_layer, fork_manifest, fork_data] + └─ config: { compression, max_inline_value_bytes, ... } +``` + +When a reader asks for chunk key `8_8_40/1024-..._0-128`: + +1. The `neuroglancer_precomputed` driver passes the chunk key to its kvstore (the OCDBT driver). +2. OCDBT looks up the key **in its B+tree**. The B+tree's leaves map chunk keys to values. +3. If the key isn't in the B+tree, OCDBT returns not-found. It does not consult the kvstack any further. + +The three kvstack layers serve OCDBT's *internal* storage (B+tree manifest + node blobs + leaf blobs) — they have no visibility into chunk-key lookups. So the OCDBT B+tree must contain every chunk key the reader will ever ask for, and that's why ingest copies the whole watershed: to populate the B+tree. + +## What tensorstore primitives provide + +Confirmed against tensorstore docs: + +- **`kvstack` routes by exact / prefix match, with no fallthrough on miss.** A layer that claims a key range absorbs misses — they return `state='missing'` and do not cascade to the next layer. So we can't put raw precomputed below an OCDBT layer in a kvstack and expect kvstack to fall through when OCDBT doesn't have a key. +- **No native overlay/fallback kvstore driver.** `kvstack` is the only composition primitive at the kvstore level; it's precedence-based, not fallthrough. +- **OCDBT has no external-blob references.** B+tree leaves either inline the value or point to a data file under the OCDBT directory. There's no way to make a leaf reference a raw GCS precomputed file. +- **Array-level `stack` / `ts.overlay`** layers arrays by spatial domain. In overlapping regions, the later layer takes absolute precedence — missing-in-later does not fall back to earlier. + +No single tensorstore primitive provides "try OCDBT delta first, fall through to raw precomputed on miss." + +## Architectural options + +### A — Two-stage read at the pcg layer + +PCG reads open two handles: the OCDBT fork for the delta, and a raw `neuroglancer_precomputed` reader for the watershed base. For any voxel region, issue both reads and merge with "delta wins where present, base fills the rest." + +- **Pros**: works inside pcg (`lookup_svs_from_seg`, sanity checks, debug tools) without any tensorstore changes. +- **Cons**: every pcg caller that uses `meta.ws_ocdbt` needs to route through a new merging reader. Neuroglancer doesn't benefit — it still gets a single kvstore spec from `dataset_info`. Either NG runs two layers itself (Option B) or we stand up a server-side proxy that does the merge before serving. + +### B — NG-side layer stack + +`dataset_info` publishes two precomputed layers: the raw watershed (read-only base) and the per-CG OCDBT fork (delta). NG composites them — visible segmentation is whichever has data at a given chunk. + +- **Pros**: no change to pcg's read path. Pushes the architecture complexity into the viewer. +- **Cons**: requires NG to treat "missing chunk in delta" as "fall through to base," not "render as background." Default NG behavior is the latter, so a viewer-side or proxy-side shim is likely needed. + +### C — Custom tensorstore kvstore driver + +A new "fallthrough" kvstore driver: read tries layer N, falls through on miss to layer N−1. Implement upstream in tensorstore or fork-and-maintain. + +- **Pros**: cleanest consumer-facing story — pcg and NG both keep using a single kvstore spec. +- **Cons**: tensorstore kvstore drivers are C++. Non-trivial maintenance surface; review/merge timeline if upstreaming. + +### D — Lazy base population (not a win on its own) + +Skip the ingest copy; copy a chunk from precomputed to OCDBT on first edit. Saves ingest compute. Does **not** save storage for reads — unedited chunks still 404 in OCDBT for a reader that doesn't have a fallback. Only useful in combination with A/B/C. + +## Recommendation + +Measure first. Confirm the actual storage and ingest-compute savings on a real dataset and weigh against the engineering cost of A/B/C. + +If the savings justify the work, **A + B together** is the most pragmatic path: +- A gives pcg a single merged-read API. Edits, sanity checks, debug tooling keep working. +- B avoids standing up a proxy service for the viewer by letting NG handle the overlay. + +Both require upstream verification: +- **For A**: confirm that `(x0:x1, y0:y1, z0:z1)` reads on an OCDBT with sparse keys surface missing-ness *per chunk* at the `neuroglancer_precomputed` array layer (not per-region, not silently fill-valued). +- **For B**: confirm NG's segmentation loader can be configured to fall through gaps in one layer to another. If it can't, build a small server-side merging shim — at which point Option A's reader becomes that shim and B reduces to "publish two specs." + +C is the cleanest design but carries the highest cost. Pursue only if A/B turn out to have unworkable semantics. + +## Open questions before any implementation + +1. Does OCDBT's `read_result.state == 'missing'` surface per-chunk at the `neuroglancer_precomputed` array layer, or does the array silently fill missing chunks with fill-value? Verifiable by opening an OCDBT with sparse keys and reading a region that spans present + missing chunks. +2. Does NG distinguish "chunk returned as missing" from "chunk is all fill-value"? If not, a viewer-side overlay needs a shim regardless. +3. What's the actual delta volume per CG over its lifetime? If SV splits eventually touch a significant fraction of chunks, the storage win shrinks toward zero — at which point the simpler architecture (today's full base copy) wins on engineering cost. + +## Files to start from when implementing + +- `pychunkedgraph/graph/ocdbt.py` — spec construction (`build_cg_ocdbt_spec`), base population (`create_base_ocdbt`), fork setup (`fork_base_manifest`). +- `pychunkedgraph/ingest/cli.py`, `pychunkedgraph/ingest/cluster.py` — current base-copy flow. +- `pychunkedgraph/graph/utils/generic.py::get_local_segmentation` — single pcg read entry point that would need the two-stage merge in Option A. + +## Verification (per chosen option) + +- **A**: unit test that simulates a partial-delta OCDBT + raw precomputed and confirms the pcg reader returns the correct labels for spans crossing both. +- **B**: configure an NG link with both layers against a test dataset; compare the rendered segmentation to a known-good reference at edited and unedited regions. +- **C**: a tensorstore build with the new driver passes a fallthrough test (missing key in upper layer resolves from lower layer). From df4ac6a1fd3374969b76a3bbb10d9099525ce23e Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 13 May 2026 20:52:31 +0000 Subject: [PATCH 222/367] unify ocdbt sv lookup for 2d and 3d clicks handle_supervoxel_id_lookup and id_helpers.get_atomic_ids_from_coords both short-circuit to lookup_svs_from_seg whenever ocdbt_seg is true, regardless of node-id layer. 2D slice clicks send L1 IDs from a view that may be stale after an SV split; 3D mesh clicks send a root and no L1 at all. Either way the current SV is what matters, so we read seg at the click coords and let downstream same/different-root checks surface any staleness with the sv_id->root diagnostic. Also vectorize lookup_svs_from_seg's per-coord indexing into a single advanced-index op. --- pychunkedgraph/app/app_utils.py | 20 ++++++++++++++------ pychunkedgraph/graph/utils/generic.py | 9 +++++---- pychunkedgraph/graph/utils/id_helpers.py | 8 ++++++-- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/pychunkedgraph/app/app_utils.py b/pychunkedgraph/app/app_utils.py index 9d69c3650..888b39ae4 100644 --- a/pychunkedgraph/app/app_utils.py +++ b/pychunkedgraph/app/app_utils.py @@ -229,20 +229,28 @@ def ccs(coordinates_nm_): return ccs coordinates = np.array(coordinates, dtype=int) - coordinates_nm = coordinates * cg.meta.resolution - max_dist_steps = np.array([4, 8, 14, 28], dtype=float) * np.mean(cg.meta.resolution) - - node_ids = np.array(node_ids, dtype=np.uint64) if len(coordinates.shape) != 2: raise cg_exceptions.BadRequest( f"Could not determine supervoxel ID for coordinates " f"{coordinates} - Validation stage." ) - # Fast path: all node_ids are L1 and OCDBT — single seg read for all coords - if cg.meta.ocdbt_seg and np.all(cg.get_chunk_layers(np.unique(node_ids)) == 1): + # OCDBT: always read the current segmentation at the click coords, + # regardless of node_ids layer. + # - 2D slice click: NG sends `node_id` = L1 SV from the slice view. + # That slice can be stale after an SV split; the seg read returns + # the current SV at that voxel (which may have a different root). + # - 3D mesh click: NG sends `node_id` = root; no L1 SV is attached, + # so we have to look it up against current seg anyway. + # `node_ids` are not used as a constraint here. Stale UI surfaces + # downstream as "different roots" with the sv_id->root diagnostic + # mapping added in operation.py / cutting.py. + if cg.meta.ocdbt_seg: return lookup_svs_from_seg(cg.meta, coordinates) + coordinates_nm = coordinates * cg.meta.resolution + max_dist_steps = np.array([4, 8, 14, 28], dtype=float) * np.mean(cg.meta.resolution) + node_ids = np.array(node_ids, dtype=np.uint64) atomic_ids = np.zeros(len(coordinates), dtype=np.uint64) for node_id in np.unique(node_ids): node_id_m = node_ids == node_id diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 4ebe0533e..64d37a5bc 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -173,8 +173,9 @@ def get_local_segmentation(meta, bbox_start, bbox_end, mip: int = 0) -> np.ndarr def lookup_svs_from_seg(meta, coordinates): """Read SV IDs directly from OCDBT segmentation at given coordinates.""" - bbox_start = np.min(coordinates, axis=0) - bbox_end = np.max(coordinates, axis=0) + 1 + coordinates = np.asarray(coordinates, dtype=int) + bbox_start = coordinates.min(axis=0) + bbox_end = coordinates.max(axis=0) + 1 seg = get_local_segmentation(meta, bbox_start, bbox_end)[..., 0] - local_coords = coordinates - bbox_start - return np.array([seg[tuple(c)] for c in local_coords], dtype=np.uint64) + local = coordinates - bbox_start + return seg[local[:, 0], local[:, 1], local[:, 2]].astype(np.uint64) diff --git a/pychunkedgraph/graph/utils/id_helpers.py b/pychunkedgraph/graph/utils/id_helpers.py index 7f7d8f927..60b1d0799 100644 --- a/pychunkedgraph/graph/utils/id_helpers.py +++ b/pychunkedgraph/graph/utils/id_helpers.py @@ -128,10 +128,14 @@ def get_atomic_ids_from_coords( """ import fastremap - if parent_id_layer == 1 and meta.ocdbt_seg: + if meta.ocdbt_seg: + # Unified path: any OCDBT lookup reads the current seg at the + # coords, ignoring the user-supplied parent (the parent may be + # stale after an SV split, or — for 3D mesh clicks — not an L1 + # SV at all). See handle_supervoxel_id_lookup for the rationale. return lookup_svs_from_seg(meta, coordinates) - if parent_id_layer == 1 and not meta.ocdbt_seg: + if parent_id_layer == 1: return np.array([parent_id] * len(coordinates), dtype=np.uint64) coordinates_nm = coordinates * np.array(meta.resolution) From 69b71df64fa3f6add782eb7b4cce1b4af1f96ac3 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 19 May 2026 21:33:43 +0000 Subject: [PATCH 223/367] fix(ocdbt): drop redundant `shape` from schema clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_schema_from_src` was passing both `domain` and `shape` to ts.open when cloning the source schema to the destination. For sources with a non-zero `voxel_offset`, `domain` carries absolute bounds (e.g. [17756, 62244)) while `shape` implies an origin of 0, and tensorstore refuses to merge them — base creation fails on any precomputed source that doesn't start at the origin. `domain` already encodes both extent and offset, so passing only it is sufficient and avoids the conflict. --- pychunkedgraph/graph/ocdbt.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index bfc7baadb..10c0b52f2 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -73,14 +73,19 @@ def _open_precomputed_scale(kvstore, scale_index, create=False, **schema_kw): def _schema_from_src(src_handle): - """Extract the schema kwargs needed to open a matching destination.""" + """Extract the schema kwargs needed to open a matching destination. + + `domain` already carries both extent and origin (voxel_offset). Passing + `shape` alongside conflicts with non-zero-origin sources because shape + implies origin=0 — tensorstore refuses to merge `[0, N)` with + `[offset, offset+N)`. + """ s = src_handle.schema return dict( rank=s.rank, dtype=s.dtype, codec=s.codec, domain=s.domain, - shape=s.shape, chunk_layout=s.chunk_layout, dimension_units=s.dimension_units, ) From e684d50a8898d34fd3c7b77d7be8d4ce9b754295 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 19 May 2026 22:14:14 +0000 Subject: [PATCH 224/367] ci/ingest: cache cloudbuild layers, explicit --populate-base flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cloudbuild now uses BuildKit registry cache (`:buildcache`) so unchanged stages reuse the prior build's layer artifacts and already-warm nodes skip re-downloading them on pull. The fresh-ingest CLI no longer infers `ocdbt_populate_base` from `base_exists` — manifest presence didn't reflect whether chunks were actually copied. Replaced with an explicit `--populate-base` flag; operator sets it on first ingest and omits on subsequent runs. --- cloudbuild.yaml | 23 +++++++++++++++-------- pychunkedgraph/ingest/cli.py | 13 +++++++++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 5f28ab80b..9d4934df6 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -5,19 +5,26 @@ steps: args: ["-c", "docker login --username=$$USERNAME --password=$$PASSWORD"] secretEnv: ["USERNAME", "PASSWORD"] + # Set up a buildx builder — needed for registry-type cache export - name: "gcr.io/cloud-builders/docker" entrypoint: "bash" - args: - - "-c" - - | - DOCKER_BUILDKIT=1 docker build -t $$USERNAME/pychunkedgraph:$TAG_NAME . - timeout: 600s - secretEnv: ["USERNAME"] + args: ["-c", "docker buildx create --use --name pcg-builder"] - # Push the final image to Dockerhub + # Build + push in one BuildKit invocation. Registry cache at the fixed + # :buildcache tag lets unchanged stages (conda env, bigtable emulator, + # pip install) reuse the previous build's exact layer artifacts, so + # already-warm nodes only download what actually changed on pull. - name: "gcr.io/cloud-builders/docker" entrypoint: "bash" - args: ["-c", "docker push $$USERNAME/pychunkedgraph:$TAG_NAME"] + args: + - "-c" + - | + docker buildx build \ + --cache-from type=registry,ref=$$USERNAME/pychunkedgraph:buildcache \ + --cache-to type=registry,ref=$$USERNAME/pychunkedgraph:buildcache,mode=max \ + --push \ + -t $$USERNAME/pychunkedgraph:$TAG_NAME . + timeout: 1800s secretEnv: ["USERNAME"] availableSecrets: diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index bd2f07626..0f2d128c2 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -67,6 +67,12 @@ def flush_redis(): is_flag=True, help="Wipe base AND this CG's delta OCDBT, then recreate from scratch.", ) +@click.option( + "--populate-base", + is_flag=True, + help="Have workers copy precomputed chunks into the OCDBT base. " + "Required on first ingest; skip on subsequent runs against the same base.", +) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @job_type_guard(group_name) def ingest_graph( @@ -77,6 +83,7 @@ def ingest_graph( raw: bool, retry: bool, reset_ocdbt: bool, + populate_base: bool, test: bool, ): """ @@ -96,7 +103,6 @@ def ingest_graph( if not retry: cg.create() - needs_base = False if ocdbt: ws = cg.meta.data_source.WATERSHED cg.meta.custom_data["seg"] = { @@ -108,8 +114,7 @@ def ingest_graph( if reset_ocdbt: wipe_base_ocdbt(ws) - needs_base = not base_exists(ws) - if needs_base: + if not base_exists(ws): create_base_ocdbt(ws) fork_base_manifest(ws, graph_id, wipe_existing=retry or reset_ocdbt) @@ -118,7 +123,7 @@ def ingest_graph( ingest_config, meta, ocdbt_seg=ocdbt, - ocdbt_populate_base=needs_base, + ocdbt_populate_base=populate_base, ) enqueue_l2_tasks(imanager, create_atomic_chunk) os._exit(0) From 9594b2cbba83daeb5e70871ccab0ad885ea55ca7 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 19 May 2026 22:44:17 +0000 Subject: [PATCH 225/367] ci: combine buildx create + build into one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker buildx create --use` stores the "current builder" client-side in ~/.docker/buildx/, which doesn't persist across cloudbuild steps — each step runs in a fresh container. The build step then fell back to the default `docker` driver, which doesn't support `--cache-to type=registry`. Cache export silently no-op'd, so the :buildcache image never got pushed and every subsequent build's --cache-from missed. Move the create + use into the same step as the build and explicitly set --driver docker-container. --- cloudbuild.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 9d4934df6..434248a9b 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -5,20 +5,20 @@ steps: args: ["-c", "docker login --username=$$USERNAME --password=$$PASSWORD"] secretEnv: ["USERNAME", "PASSWORD"] - # Set up a buildx builder — needed for registry-type cache export - - name: "gcr.io/cloud-builders/docker" - entrypoint: "bash" - args: ["-c", "docker buildx create --use --name pcg-builder"] - - # Build + push in one BuildKit invocation. Registry cache at the fixed - # :buildcache tag lets unchanged stages (conda env, bigtable emulator, - # pip install) reuse the previous build's exact layer artifacts, so - # already-warm nodes only download what actually changed on pull. + # Build + push in one BuildKit invocation using a docker-container + # builder (required for registry-type cache export). The builder + # `--use` setting is client-side and doesn't persist across cloudbuild + # steps, so create + use + build must happen in a single step. + # Registry cache at the fixed :buildcache tag lets unchanged stages + # (conda env, bigtable emulator, pip install) reuse the previous + # build's exact layer artifacts, so already-warm nodes only download + # what actually changed on pull. - name: "gcr.io/cloud-builders/docker" entrypoint: "bash" args: - "-c" - | + docker buildx create --use --name pcg-builder --driver docker-container docker buildx build \ --cache-from type=registry,ref=$$USERNAME/pychunkedgraph:buildcache \ --cache-to type=registry,ref=$$USERNAME/pychunkedgraph:buildcache,mode=max \ From 70deb56a6a9d30118107c928cb72ea2ee005818a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 19 May 2026 22:57:44 +0000 Subject: [PATCH 226/367] fix(ocdbt): wipe via underlying driver; create base before CG table create_base_ocdbt and wipe_base_ocdbt both wiped through the OCDBT driver, which on an empty dir creates a default-config manifest stub (max_inline_value_bytes=100) and on a populated dir only clears the B+tree leaving the manifest in place. Either way the subsequent open with OCDBT_CONFIG mismatches. Wipe through the underlying GCS/file driver so the manifest itself gets deleted. Move OCDBT base setup before cg.create() so a failure in OCDBT setup doesn't leave behind an orphan bigtable table. --- pychunkedgraph/graph/ocdbt.py | 13 ++++++++++--- pychunkedgraph/ingest/cli.py | 17 ++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index 10c0b52f2..6729d2431 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -128,9 +128,14 @@ def create_base_ocdbt(ws_path: str): copy_ws_chunk_multiscale. """ base = _base_ocdbt_path(ws_path) - # Wipe existing base for a clean slate. + # Wipe via the underlying GCS/file driver, NOT through the ocdbt + # driver. Opening as ocdbt on an empty dir creates a default-config + # `manifest.ocdbt` stub (max_inline_value_bytes=100); on a dir with + # an existing manifest it only clears the B+tree, leaving the + # manifest's config in place. Either way the subsequent open with + # OCDBT_CONFIG mismatches. try: - kvs = ts.KvStore.open({"driver": "ocdbt", "base": base}).result() + kvs = ts.KvStore.open(base).result() kvs.delete_range(ts.KvStore.KeyRange()).result() except Exception: pass @@ -155,8 +160,10 @@ def create_base_ocdbt(ws_path: str): def wipe_base_ocdbt(ws_path: str): """Wipe the base OCDBT entirely (for --reset-ocdbt).""" base = _base_ocdbt_path(ws_path) + # Wipe via the underlying GCS/file driver so the manifest file is + # deleted too. Opening as ocdbt only clears the B+tree. try: - kvs = ts.KvStore.open({"driver": "ocdbt", "base": base}).result() + kvs = ts.KvStore.open(base).result() kvs.delete_range(ts.KvStore.KeyRange()).result() except Exception: pass diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 0f2d128c2..42a7f3063 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -99,26 +99,25 @@ def ingest_graph( configure_logging(level=DEBUG) meta, ingest_config, client_info = bootstrap(graph_id, config, raw, test) + if ocdbt: + ws = meta.data_source.WATERSHED + if reset_ocdbt: + wipe_base_ocdbt(ws) + if not base_exists(ws): + create_base_ocdbt(ws) + fork_base_manifest(ws, graph_id, wipe_existing=retry or reset_ocdbt) + cg = ChunkedGraph(meta=meta, client_info=client_info) if not retry: cg.create() if ocdbt: - ws = cg.meta.data_source.WATERSHED cg.meta.custom_data["seg"] = { "ocdbt": True, "sv_split_threshold": sv_split_threshold, } cg.update_meta(cg.meta, overwrite=True) - if reset_ocdbt: - wipe_base_ocdbt(ws) - - if not base_exists(ws): - create_base_ocdbt(ws) - - fork_base_manifest(ws, graph_id, wipe_existing=retry or reset_ocdbt) - imanager = IngestionManager( ingest_config, meta, From 2e62e066b95f4d3f3681919ba6f6b0ac961f6644 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 01:36:49 +0000 Subject: [PATCH 227/367] feat(ocdbt): move populate to configurable parent-layer task with atomic txn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the precomputed→OCDBT base copy out of L2 atomic tasks into a single parent-layer task per chunk, layer configurable via --populate-layer (default 4). Each task batches every underlying L2 chunk across every MIP scale into one OCDBT commit via ts.Transaction(atomic=True) — without atomic=True the precomputed driver splits a multi-chunk write into two OCDBT sub-commits regardless of the surrounding python-level transaction. The chosen populate-layer is persisted in /ocdbt/.populated/meta.json alongside the per-chunk markers; mixing layers is unsupported, so a mismatch errors out and tells the operator to delete /ocdbt/ if they want to repopulate at a different layer. --- pychunkedgraph/graph/ocdbt.py | 87 ++++++++++++++++++++++++++++++++ pychunkedgraph/ingest/cli.py | 24 +++++++++ pychunkedgraph/ingest/cluster.py | 40 +++++++++------ pychunkedgraph/ingest/manager.py | 3 ++ 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index 6729d2431..c3860300b 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -31,6 +31,7 @@ """ import json +from typing import Optional import numpy as np import tensorstore as ts @@ -100,6 +101,48 @@ def _base_ocdbt_path(ws_path): return _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/base") +def _populate_markers_path(ws_path): + return _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/.populated") + + +def _marker_key(layer: int, coords) -> str: + return f"l{int(layer)}_{int(coords[0])}_{int(coords[1])}_{int(coords[2])}" + + +def is_chunk_populated(ws_path: str, layer: int, coords) -> bool: + """Check whether this chunk's precomputed→OCDBT copy has already completed. + + Markers live outside the OCDBT keyspace at + `/ocdbt/.populated/l___` so retried ingest tasks + don't re-copy chunks and bloat the database with redundant versioned + writes. + """ + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + result = kvs.read(_marker_key(layer, coords)).result() + return result.value is not None and len(result.value) > 0 + + +def mark_chunk_populated(ws_path: str, layer: int, coords) -> None: + """Record that this chunk's precomputed→OCDBT copy completed.""" + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + kvs.write(_marker_key(layer, coords), b"1").result() + + +def read_populate_meta(ws_path: str) -> Optional[dict]: + """Return the per-base populate config dict, or None if not yet written.""" + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + r = kvs.read("meta.json").result() + if r.value is None or len(r.value) == 0: + return None + return json.loads(r.value) + + +def write_populate_meta(ws_path: str, meta: dict) -> None: + """Persist the per-base populate config (layer, etc.) alongside markers.""" + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + kvs.write("meta.json", json.dumps(meta).encode()).result() + + def base_exists(ws_path: str) -> bool: """Check if the base OCDBT has already been created for this watershed.""" base = _base_ocdbt_path(ws_path) @@ -385,6 +428,50 @@ def copy_ws_chunk_multiscale( dst[x0:x1, y0:y1, z0:z1].write(data).result() +def _layer_bbox(meta, layer: int, coords) -> tuple: + """Base-resolution voxel bbox of a chunk at this layer.""" + chunk_size = np.array(meta.graph_config.CHUNK_SIZE, dtype=int) + layer_chunk_size = chunk_size * (1 << (layer - 2)) + coords = np.array(coords, dtype=int) + vol_start = meta.voxel_bounds[:, 0] + vol_end = meta.voxel_bounds[:, 1] + lo = coords * layer_chunk_size + vol_start + hi = np.minimum(lo + layer_chunk_size, vol_end) + return lo, hi + + +def copy_ws_bbox_multiscale( + src_list, + dst_list, + resolutions, + bbox_lo: np.ndarray, + bbox_hi: np.ndarray, +): + """Copy a base-resolution voxel bbox across all MIP scales in ONE OCDBT commit. + + `ts.Transaction(atomic=True)` is load-bearing: without it the precomputed + driver splits a multi-chunk `.write()` into two OCDBT sub-commits (one for + the first chunk, another for the rest), and each scale's write becomes its + own pair of sub-commits — even when wrapped in the same Python transaction. + With `atomic=True`, every per-chunk underlying-kvstore write across every + scale is batched into a single OCDBT commit, producing exactly one new + data file in `d/` regardless of how many chunks or scales are written. + """ + assert len(src_list) == len(dst_list) == len(resolutions) + base_res = np.array(resolutions[0]) + txn = ts.Transaction(atomic=True) + for i, (src, dst) in enumerate(zip(src_list, dst_list)): + factor = (np.array(resolutions[i]) / base_res).astype(int) + x0, y0, z0 = bbox_lo // factor + x1, y1, z1 = bbox_hi // factor + if x1 <= x0 or y1 <= y0 or z1 <= z0: + logger.debug(f"skipping empty region at scale {i}") + continue + data = src[x0:x1, y0:y1, z0:z1].read().result() + dst.with_transaction(txn)[x0:x1, y0:y1, z0:z1].write(data).result() + txn.commit_async().result() + + def _mode_downsample(data: np.ndarray, factors: tuple) -> np.ndarray: """Mode downsample 4D segmentation array [X,Y,Z,C] by per-axis factors. diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 42a7f3063..0f43f26b0 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -29,7 +29,9 @@ base_exists, create_base_ocdbt, fork_base_manifest, + read_populate_meta, wipe_base_ocdbt, + write_populate_meta, ) from ..utils.redis import get_redis_connection, keys as r_keys @@ -73,6 +75,15 @@ def flush_redis(): help="Have workers copy precomputed chunks into the OCDBT base. " "Required on first ingest; skip on subsequent runs against the same base.", ) +@click.option( + "--populate-layer", + type=int, + default=4, + help="Layer at which populate tasks copy precomputed chunks into the OCDBT " + "base. Each task batches all underlying L2 chunks across all scales into " + "one OCDBT commit. Stored alongside the base on first --populate-base; " + "subsequent ingests must agree or pass --reset-ocdbt.", +) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @job_type_guard(group_name) def ingest_graph( @@ -84,6 +95,7 @@ def ingest_graph( retry: bool, reset_ocdbt: bool, populate_base: bool, + populate_layer: int, test: bool, ): """ @@ -105,6 +117,17 @@ def ingest_graph( wipe_base_ocdbt(ws) if not base_exists(ws): create_base_ocdbt(ws) + if populate_base: + existing = read_populate_meta(ws) + if existing is None: + write_populate_meta(ws, {"layer": populate_layer}) + elif existing.get("layer") != populate_layer: + raise click.ClickException( + f"OCDBT base is already populated at layer {existing.get('layer')}, " + f"but --populate-layer={populate_layer} was passed. " + f"Mixing populate layers is unsupported. To repopulate at a " + f"different layer, manually delete {ws.rstrip('/')}/ocdbt/ and rerun." + ) fork_base_manifest(ws, graph_id, wipe_existing=retry or reset_ocdbt) cg = ChunkedGraph(meta=meta, client_info=client_info) @@ -123,6 +146,7 @@ def ingest_graph( meta, ocdbt_seg=ocdbt, ocdbt_populate_base=populate_base, + ocdbt_populate_layer=populate_layer, ) enqueue_l2_tasks(imanager, create_atomic_chunk) os._exit(0) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 4e8149ead..b4b917d6f 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -29,7 +29,13 @@ from .upgrade.parent_layer import update_chunk as update_parent_chunk from ..graph.edges import EDGE_TYPES, Edges, put_edges from ..graph import ChunkedGraph, ChunkedGraphMeta -from ..graph.ocdbt import copy_ws_chunk_multiscale, open_base_ocdbt +from ..graph.ocdbt import ( + _layer_bbox, + copy_ws_bbox_multiscale, + is_chunk_populated, + mark_chunk_populated, + open_base_ocdbt, +) from ..graph.chunks.hierarchy import get_children_chunk_coords from ..graph.basetypes import NODE_ID from ..io.edges import get_chunk_edges @@ -76,6 +82,23 @@ def create_parent_chunk( parent_coords, ), ) + + if ( + imanager.ocdbt_seg + and imanager.ocdbt_populate_base + and parent_layer == imanager.ocdbt_populate_layer + ): + # Populate the shared base OCDBT with precomputed chunks at the + # configured layer. One task batches all underlying L2 chunks + # across all scales into a single OCDBT commit via the atomic + # transaction inside copy_ws_bbox_multiscale. + ws = imanager.cg.meta.data_source.WATERSHED + if not is_chunk_populated(ws, parent_layer, parent_coords): + src_list, dst_list, resolutions = open_base_ocdbt(ws) + lo, hi = _layer_bbox(imanager.cg.meta, parent_layer, parent_coords) + copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) + mark_chunk_populated(ws, parent_layer, parent_coords) + _post_task_completion(imanager, parent_layer, parent_coords) @@ -146,21 +169,6 @@ def create_atomic_chunk(coords: Sequence[int]): for k, v in chunk_edges_active.items(): logger.debug(f"active_{k}: {len(v)}") - if imanager.ocdbt_seg and imanager.ocdbt_populate_base: - # Populate the shared base OCDBT with precomputed chunks (one-time - # per watershed). Uses the raw base handles, NOT the per-CG fork - # spec — the fork only stores SV-split deltas. - src_list, dst_list, resolutions = open_base_ocdbt( - imanager.cg.meta.data_source.WATERSHED - ) - copy_ws_chunk_multiscale( - src_list, - dst_list, - resolutions, - imanager.cg.meta.graph_config.CHUNK_SIZE, - coords, - imanager.cg.meta.voxel_bounds, - ) _post_task_completion(imanager, 2, coords) diff --git a/pychunkedgraph/ingest/manager.py b/pychunkedgraph/ingest/manager.py index 915538320..11419d125 100644 --- a/pychunkedgraph/ingest/manager.py +++ b/pychunkedgraph/ingest/manager.py @@ -17,6 +17,7 @@ def __init__( chunkedgraph_meta: ChunkedGraphMeta, ocdbt_seg: bool = False, ocdbt_populate_base: bool = False, + ocdbt_populate_layer: int = 4, _from_pickle: bool = False, ): self._config = config @@ -27,6 +28,7 @@ def __init__( self._from_pickle = _from_pickle self.ocdbt_seg = ocdbt_seg self.ocdbt_populate_base = ocdbt_populate_base + self.ocdbt_populate_layer = ocdbt_populate_layer if not _from_pickle: # initiate redis and store serialized state @@ -61,6 +63,7 @@ def serialized(self, pickled=False): "chunkedgraph_meta": self._chunkedgraph_meta, "ocdbt_seg": self.ocdbt_seg, "ocdbt_populate_base": self.ocdbt_populate_base, + "ocdbt_populate_layer": self.ocdbt_populate_layer, } if pickled: return pickle.dumps(params) From 9cef7062ef730fc48e20ca678aa65008f35d2239 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 01:55:19 +0000 Subject: [PATCH 228/367] docs(ocdbt): clarify copy_ws_bbox_multiscale file-count invariant --- pychunkedgraph/graph/ocdbt.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index c3860300b..ecb32be81 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -447,15 +447,15 @@ def copy_ws_bbox_multiscale( bbox_lo: np.ndarray, bbox_hi: np.ndarray, ): - """Copy a base-resolution voxel bbox across all MIP scales in ONE OCDBT commit. + """Copy a base-resolution voxel bbox across all MIP scales under one atomic txn. `ts.Transaction(atomic=True)` is load-bearing: without it the precomputed - driver splits a multi-chunk `.write()` into two OCDBT sub-commits (one for - the first chunk, another for the rest), and each scale's write becomes its - own pair of sub-commits — even when wrapped in the same Python transaction. - With `atomic=True`, every per-chunk underlying-kvstore write across every - scale is batched into a single OCDBT commit, producing exactly one new - data file in `d/` regardless of how many chunks or scales are written. + driver splits a multi-chunk `.write()` into multiple OCDBT sub-commits, + so file count would scale with the number of precomputed chunks inside + the bbox. With `atomic=True`, every per-chunk underlying-kvstore write + inside one precomputed handle collapses into a single OCDBT commit — so + the d/ file count for one call to this function is constant in the + number of chunks inside the bbox; it only grows with scale count. """ assert len(src_list) == len(dst_list) == len(resolutions) base_res = np.array(resolutions[0]) From 8de3ccb2d08cd592224d58423fd93ed939fac751 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 02:34:20 +0000 Subject: [PATCH 229/367] feat(ingest): retry reuses stored config; populate runs async; richer status + per-chunk node/edge logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ingest graph --retry` now loads the existing IngestionManager from redis instead of requiring the OCDBT flags to be re-passed; `dataset` becomes optional under --retry. The base/populate-meta/cg.create one-time setup is skipped — only the per-CG fork manifest is re-wiped and L2 tasks are re-enqueued. The job_type_guard already handles cross-mode misuse. create_parent_chunk fires the OCDBT populate on a worker thread before add_parent_chunk so the GCS copy overlaps with the BigTable hierarchy build; the populate future is awaited (and exceptions propagated) before marking the task complete. print_status now surfaces ocdbt_seg / ocdbt_populate_base / ocdbt_populate_layer so operators can see at-a-glance what mode the running job is in. add_atomic_chunk and add_parent_chunk each log node and edge counts so each task's BigTable write volume is visible in the worker logs. --- pychunkedgraph/ingest/cli.py | 27 +++++++++-- pychunkedgraph/ingest/cluster.py | 50 ++++++++++++-------- pychunkedgraph/ingest/create/atomic_layer.py | 8 ++++ pychunkedgraph/ingest/create/parent_layer.py | 10 ++++ pychunkedgraph/ingest/utils.py | 8 +++- 5 files changed, 79 insertions(+), 24 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 0f43f26b0..f6ff424f9 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -54,7 +54,7 @@ def flush_redis(): @ingest_cli.command("graph") @click.argument("graph_id", type=str) -@click.argument("dataset", type=click.Path(exists=True)) +@click.argument("dataset", type=click.Path(exists=True), required=False) @click.option("--ocdbt", is_flag=True, help="Precomputed supervoxel seg into ocdbt.") @click.option( "--sv-split-threshold", @@ -103,6 +103,26 @@ def ingest_graph( Takes ingest config from a yaml file and queues atomic tasks. """ redis = get_redis_connection() + + if retry: + imanager_pickle = redis.get(r_keys.INGESTION_MANAGER) + if imanager_pickle is None: + raise click.ClickException( + f"--retry requires an existing `{group_name}` job in redis. " + f"Run without --retry to start a new job." + ) + if test: + configure_logging(level=DEBUG) + imanager = IngestionManager.from_pickle(imanager_pickle) + if imanager.ocdbt_seg: + ws = imanager.cg_meta.data_source.WATERSHED + fork_base_manifest(ws, graph_id, wipe_existing=True) + enqueue_l2_tasks(imanager, create_atomic_chunk) + os._exit(0) + + if dataset is None: + raise click.ClickException("dataset is required unless --retry is passed.") + redis.set(r_keys.JOB_TYPE, group_name) with open(dataset, "r") as stream: config = yaml.safe_load(stream) @@ -128,11 +148,10 @@ def ingest_graph( f"Mixing populate layers is unsupported. To repopulate at a " f"different layer, manually delete {ws.rstrip('/')}/ocdbt/ and rerun." ) - fork_base_manifest(ws, graph_id, wipe_existing=retry or reset_ocdbt) + fork_base_manifest(ws, graph_id, wipe_existing=reset_ocdbt) cg = ChunkedGraph(meta=meta, client_info=client_info) - if not retry: - cg.create() + cg.create() if ocdbt: cg.meta.custom_data["seg"] = { diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index b4b917d6f..34cbba818 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -4,17 +4,18 @@ Ingest / create chunkedgraph with workers on a cluster. """ +from concurrent.futures import ThreadPoolExecutor from os import environ - -from pychunkedgraph import get_logger - -logger = get_logger(__name__) from time import sleep from typing import Callable, Dict, Iterable, Tuple, Sequence import numpy as np from rq import Queue as RQueue, Retry +from pychunkedgraph import get_logger + +logger = get_logger(__name__) + from .utils import chunk_id_str, get_chunks_not_done, randomize_grid_points from .manager import IngestionManager @@ -67,11 +68,35 @@ def _post_task_completion( logger.note(f"{chunk_str} marked as complete") +def _populate_ocdbt_chunk(imanager, ws, layer, coords): + src_list, dst_list, resolutions = open_base_ocdbt(ws) + lo, hi = _layer_bbox(imanager.cg.meta, layer, coords) + copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) + mark_chunk_populated(ws, layer, coords) + + def create_parent_chunk( parent_layer: int, parent_coords: Sequence[int], ) -> None: imanager = _get_imanager() + + # If this task is responsible for populating the OCDBT base at the + # configured layer, kick the copy off on a worker thread so it overlaps + # with add_parent_chunk (BigTable-bound) — they share no state. + populate_future = None + if ( + imanager.ocdbt_seg + and imanager.ocdbt_populate_base + and parent_layer == imanager.ocdbt_populate_layer + ): + ws = imanager.cg.meta.data_source.WATERSHED + if not is_chunk_populated(ws, parent_layer, parent_coords): + executor = ThreadPoolExecutor(max_workers=1) + populate_future = executor.submit( + _populate_ocdbt_chunk, imanager, ws, parent_layer, parent_coords + ) + add_parent_chunk( imanager.cg, parent_layer, @@ -83,21 +108,8 @@ def create_parent_chunk( ), ) - if ( - imanager.ocdbt_seg - and imanager.ocdbt_populate_base - and parent_layer == imanager.ocdbt_populate_layer - ): - # Populate the shared base OCDBT with precomputed chunks at the - # configured layer. One task batches all underlying L2 chunks - # across all scales into a single OCDBT commit via the atomic - # transaction inside copy_ws_bbox_multiscale. - ws = imanager.cg.meta.data_source.WATERSHED - if not is_chunk_populated(ws, parent_layer, parent_coords): - src_list, dst_list, resolutions = open_base_ocdbt(ws) - lo, hi = _layer_bbox(imanager.cg.meta, parent_layer, parent_coords) - copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) - mark_chunk_populated(ws, parent_layer, parent_coords) + if populate_future is not None: + populate_future.result() # surface populate exceptions before marking complete _post_task_completion(imanager, parent_layer, parent_coords) diff --git a/pychunkedgraph/ingest/create/atomic_layer.py b/pychunkedgraph/ingest/create/atomic_layer.py index 30043710d..69ea8a709 100644 --- a/pychunkedgraph/ingest/create/atomic_layer.py +++ b/pychunkedgraph/ingest/create/atomic_layer.py @@ -11,6 +11,8 @@ import numpy as np +from pychunkedgraph import get_logger + from ...graph import attributes, basetypes, serializers, get_valid_timestamp from ...graph.chunkedgraph import ChunkedGraph from ...graph.edges import Edges @@ -19,6 +21,8 @@ from ...graph.utils.flatgraph import build_gt_graph from ...graph.utils.flatgraph import connected_components +logger = get_logger(__name__) + def add_atomic_chunk( cg: ChunkedGraph, @@ -28,6 +32,10 @@ def add_atomic_chunk( time_stamp: Optional[datetime.datetime] = None, ): chunk_node_ids, chunk_edge_ids = _get_chunk_nodes_and_edges(chunk_edges_d, isolated) + logger.note( + f"L2 chunk {tuple(coords)}: nodes={len(chunk_node_ids):,} " + f"edges={len(chunk_edge_ids):,}" + ) if not chunk_node_ids.size: return diff --git a/pychunkedgraph/ingest/create/parent_layer.py b/pychunkedgraph/ingest/create/parent_layer.py index a12d2b858..1d36f9edd 100644 --- a/pychunkedgraph/ingest/create/parent_layer.py +++ b/pychunkedgraph/ingest/create/parent_layer.py @@ -12,6 +12,9 @@ import fastremap import numpy as np + +from pychunkedgraph import get_logger + from ...graph import types, attributes, basetypes, serializers, get_valid_timestamp from ...utils.general import chunked from ...graph.utils import flatgraph @@ -22,6 +25,8 @@ from .cross_edges import get_children_chunk_cross_edges from .cross_edges import get_chunk_nodes_cross_edge_layer +logger = get_logger(__name__) + def add_parent_chunk( cg: ChunkedGraph, @@ -50,6 +55,11 @@ def add_parent_chunk( raw_ccs = flatgraph.connected_components(graph) # connected components with indices connected_components = [graph_ids[cc] for cc in raw_ccs] + logger.note( + f"L{layer_id} chunk {tuple(coords)}: nodes={len(connected_components):,} " + f"cx_edges={len(cx_edges):,}" + ) + _write_connected_components( cg, layer_id, diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index d69756104..4159a2707 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -190,8 +190,14 @@ def _refresh_status(): f"\nversion: \t{imanager.cg.version}" f"\ngraph_id: \t{imanager.cg.graph_id}" f"\nchunk_size: \t{imanager.cg.meta.graph_config.CHUNK_SIZE}" - "\n\nlayer status:" + f"\nocdbt_seg: \t{imanager.ocdbt_seg}" ) + if imanager.ocdbt_seg: + header += ( + f"\nocdbt_populate_base: \t{imanager.ocdbt_populate_base}" + f"\nocdbt_populate_layer: \t{imanager.ocdbt_populate_layer}" + ) + header += "\n\nlayer status:" print(header) while True: for layer, done, count in zip(layers, completed, layer_counts): From 9ac370cd8af0d6f750fad357379d37ce227e5c83 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 03:07:14 +0000 Subject: [PATCH 230/367] feat(ingest status): rich live panel + busy worker count + configurable refresh print_status now renders a single rounded Rich Panel via Live. Two header mini-tables (graph fields, ocdbt fields when enabled) sit above the per-layer progress/queue/busy table; both header groups share computed column widths so their boundaries line up. job_type lives in the Panel title so it stays out of the row data. Busy-worker count comes straight from Redis: SMEMBERS rq:workers:l{N} to enumerate worker keys per queue, then a single pipelined HGET state across all of them and a sum of state == b'busy'. Two round-trips per refresh regardless of worker count, displayed as busy/total. Refresh cadence defaults to 5s and is overridable via --refresh on both `ingest status` and `upgrade status`. --- pychunkedgraph/ingest/cli.py | 5 +- pychunkedgraph/ingest/cli_upgrade.py | 5 +- pychunkedgraph/ingest/utils.py | 248 +++++++++++++++++++++------ requirements.in | 1 + requirements.txt | 10 +- 5 files changed, 210 insertions(+), 59 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index f6ff424f9..44e3f2944 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -207,13 +207,14 @@ def queue_layer(parent_layer): @ingest_cli.command("status") +@click.option("--refresh", type=int, default=5, help="Seconds between redis polls.") @job_type_guard(group_name) -def ingest_status(): +def ingest_status(refresh: int): """Print ingest status to console by layer.""" redis = get_redis_connection() try: imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - print_status(imanager, redis) + print_status(imanager, redis, refresh_seconds=refresh) except TypeError as err: print(f"\nNo current `{group_name}` job found in redis: {err}") diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index 3a3ccb2e1..eeafea864 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -153,13 +153,14 @@ def queue_layer(parent_layer: int, splits: int = 0): @upgrade_cli.command("status") +@click.option("--refresh", type=int, default=5, help="Seconds between redis polls.") @job_type_guard(group_name) -def upgrade_status(): +def upgrade_status(refresh: int): """Print upgrade status to console.""" redis = get_redis_connection() try: imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - print_status(imanager, redis, upgrade=True) + print_status(imanager, redis, upgrade=True, refresh_seconds=refresh) except TypeError as err: print(f"\nNo current `{group_name}` job found in redis: {err}") diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 4159a2707..1ff7f3bd1 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -14,6 +14,14 @@ import tensorstore as ts from rq import Queue, Retry, Worker from rq.worker import WorkerStatus +from rq.worker_registration import WORKERS_BY_QUEUE_KEY +from rich import box +from rich.console import Group +from rich.live import Live +from rich.panel import Panel +from rich.rule import Rule +from rich.table import Table +from rich.text import Text from . import IngestConfig from .manager import IngestionManager @@ -148,70 +156,202 @@ def print_completion_rate(imanager: IngestionManager, layer: int, span: int = 30 move_up() -def print_status(imanager: IngestionManager, redis, upgrade: bool = False): +def _busy_over_total_per_queue(redis, worker_keys_per_layer) -> list: + """For each layer's set of worker keys, return "busy/total" or "-" if no workers. + + Two-round-trip approach: caller already fetched the SMEMBERS sets; this + function pipelines HGET state for every worker key and counts busy. + """ + state_pipe = redis.pipeline() + for keys in worker_keys_per_layer: + for wk in keys: + state_pipe.hget(wk, "state") + states = state_pipe.execute() if any(worker_keys_per_layer) else [] + + out = [] + idx = 0 + for keys in worker_keys_per_layer: + total = len(keys) + busy = 0 + for _ in keys: + if states[idx] == b"busy": + busy += 1 + idx += 1 + out.append(f"{busy}/{total}" if total else "-") + return out + + +def _layer_status(redis, layers): + """Pipelined fetch of job_type + per-layer counts + busy-worker ratios.""" + pipeline = redis.pipeline() + pipeline.get(r_keys.JOB_TYPE) + for layer in layers: + pipeline.scard(f"{layer}c") + queue = Queue(f"l{layer}", connection=redis) + pipeline.llen(queue.key) + pipeline.zcard(queue.failed_job_registry.key) + pipeline.smembers(WORKERS_BY_QUEUE_KEY % f"l{layer}") + results = pipeline.execute() + + job_type = results[0].decode() if results[0] else "not_available" + completed, queued, failed, worker_keys_per_layer = [], [], [], [] + for i in range(1, len(results), 4): + completed.append(results[i]) + queued.append(results[i + 1]) + failed.append(results[i + 2]) + worker_keys_per_layer.append(results[i + 3]) + + worker_busy = _busy_over_total_per_queue(redis, worker_keys_per_layer) + return job_type, completed, queued, failed, worker_busy + + +def _sized_table(columns: list, rows: list, **table_kwargs) -> Table: + """Build a Rich Table whose column widths are sized to the actual data. + + `columns` is a list of (name, justify) tuples. + `rows` is a list of tuples of cell strings (one per column). + Each column gets width = max(len(name), max(len(cell)) over rows) so Rich + never wraps or crops because no column is implicitly squeezed. + """ + table = Table( + box=None, + pad_edge=False, + padding=(0, 1), + show_header=True, + header_style="bold", + **table_kwargs, + ) + for col_idx, (name, justify) in enumerate(columns): + width = max(len(name), max((len(row[col_idx]) for row in rows), default=0)) + table.add_column(name, justify=justify, width=width, no_wrap=True) + for row in rows: + table.add_row(*row) + return table + + +def _aligned_kv_table(pairs: list, widths: list) -> Table: + """One-data-row mini-table with externally-provided per-column widths.""" + table = Table( + box=None, pad_edge=False, padding=(0, 1), show_header=True, header_style="bold" + ) + for (name, _), w in zip(pairs, widths): + table.add_column(name, justify="left", width=w, no_wrap=True) + table.add_row(*(v for _, v in pairs)) + return table + + +def _header_renderables(imanager: IngestionManager) -> list: + """Graph and ocdbt rows as mini-tables sharing column widths so columns line up.""" + graph_pairs = [ + ("version", str(imanager.cg.version)), + ("graph_id", imanager.cg.graph_id), + ("chunk_size", str(imanager.cg.meta.graph_config.CHUNK_SIZE)), + ] + ocdbt_pairs = [] + if imanager.ocdbt_seg: + ocdbt_pairs = [ + ("ocdbt", str(imanager.ocdbt_seg)), + ("populate_base", str(imanager.ocdbt_populate_base)), + ("populate_layer", str(imanager.ocdbt_populate_layer)), + ] + + # Per-column width = max length seen in EITHER row's header or value at that index. + n = max(len(graph_pairs), len(ocdbt_pairs)) + widths = [] + for i in range(n): + sizes = [] + if i < len(graph_pairs): + sizes.append(len(graph_pairs[i][0])) + sizes.append(len(graph_pairs[i][1])) + if i < len(ocdbt_pairs): + sizes.append(len(ocdbt_pairs[i][0])) + sizes.append(len(ocdbt_pairs[i][1])) + widths.append(max(sizes)) + + out = [_aligned_kv_table(graph_pairs, widths)] + if ocdbt_pairs: + out.append(Rule(style="dim")) + out.append(_aligned_kv_table(ocdbt_pairs, widths)) + return out + + +def _status_table( + layers, layer_counts, completed, queued, failed, worker_busy +) -> Table: + """One row per layer with progress, queue, and worker stats.""" + columns = [ + ("layer", "right"), + ("done", "right"), + ("total", "right"), + ("%", "right"), + ("queued", "right"), + ("failed", "right"), + ("busy", "left"), + ] + rows = [] + for layer, done, count, q, f, wb in zip( + layers, completed, layer_counts, queued, failed, worker_busy + ): + pct = math.floor((done / count) * 100) if count else 0 + rows.append( + ( + str(layer), + f"{done:,}", + f"{count:,}", + f"{pct}%", + f"{q:,}", + f"{f:,}", + str(wb), + ) + ) + return _sized_table(columns, rows) + + +def _status_renderable( + imanager, layers, layer_counts, job_type, completed, queued, failed, worker_busy +): + """Combine header rows + per-layer table inside one Panel; job_type goes in the title.""" + body = Group( + *_header_renderables(imanager), + Rule(style="dim"), + _status_table(layers, layer_counts, completed, queued, failed, worker_busy), + ) + return Panel( + body, + title=job_type, + title_align="left", + box=box.ROUNDED, + padding=(0, 1), + expand=False, + ) + + +def print_status( + imanager: IngestionManager, + redis, + upgrade: bool = False, + refresh_seconds: int = 5, +): """ - Helper to print status to console. + Print status to console. If `upgrade=True`, status does not include the root layer, since there is no need to update cross edges for root ids. + `refresh_seconds` is how often redis is re-polled between redraws. """ layers = range(2, imanager.cg_meta.layer_count + 1) if upgrade: layers = range(2, imanager.cg_meta.layer_count) - - def _refresh_status(): - pipeline = redis.pipeline() - pipeline.get(r_keys.JOB_TYPE) - worker_busy = ["-"] * len(layers) - for layer in layers: - pipeline.scard(f"{layer}c") - queue = Queue(f"l{layer}", connection=redis) - pipeline.llen(queue.key) - pipeline.zcard(queue.failed_job_registry.key) - - results = pipeline.execute() - job_type = "not_available" - if results[0] is not None: - job_type = results[0].decode() - completed = [] - queued = [] - failed = [] - for i in range(1, len(results), 3): - result = results[i : i + 3] - completed.append(result[0]) - queued.append(result[1]) - failed.append(result[2]) - return job_type, completed, queued, failed, worker_busy - - job_type, completed, queued, failed, worker_busy = _refresh_status() - layer_counts = imanager.cg_meta.layer_chunk_counts - header = ( - f"\njob_type: \t{job_type}" - f"\nversion: \t{imanager.cg.version}" - f"\ngraph_id: \t{imanager.cg.graph_id}" - f"\nchunk_size: \t{imanager.cg.meta.graph_config.CHUNK_SIZE}" - f"\nocdbt_seg: \t{imanager.ocdbt_seg}" - ) - if imanager.ocdbt_seg: - header += ( - f"\nocdbt_populate_base: \t{imanager.ocdbt_populate_base}" - f"\nocdbt_populate_layer: \t{imanager.ocdbt_populate_layer}" - ) - header += "\n\nlayer status:" - print(header) - while True: - for layer, done, count in zip(layers, completed, layer_counts): - print( - f"{layer}\t| {done:9} / {count} \t| {math.floor((done/count)*100):6}%" - ) - print("\n\nqueue status:") - for layer, q, f, wb in zip(layers, queued, failed, worker_busy): - print(f"l{layer}\t| queued: {q:<10} failed: {f:<10} busy: {wb}") + def render(): + return _status_renderable( + imanager, layers, layer_counts, *_layer_status(redis, layers) + ) - sleep(1) - _, completed, queued, failed, worker_busy = _refresh_status() - move_up(lines=2 * len(layers) + 3) + with Live(render(), screen=False) as live: + while True: + sleep(refresh_seconds) + live.update(render()) def queue_layer_helper( diff --git a/requirements.in b/requirements.in index 0b1a03f94..42f6cd592 100644 --- a/requirements.in +++ b/requirements.in @@ -14,6 +14,7 @@ pyyaml cachetools werkzeug tensorstore +rich edt connected-components-3d scikit-image diff --git a/requirements.txt b/requirements.txt index 6710c7fca..c0d5fe147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -201,11 +201,15 @@ lazy-loader==0.4 # via scikit-image markdown==3.10.2 # via python-jsonschema-objects +markdown-it-py==4.2.0 + # via rich markupsafe==3.0.3 # via # flask # jinja2 # werkzeug +mdurl==0.1.2 + # via markdown-it-py messagingclient==0.4.0 # via -r requirements.in microviewer==1.20.0 @@ -321,7 +325,9 @@ pybind11==3.0.2 pycparser==3.0 # via cffi pygments==2.19.2 - # via pytest + # via + # pytest + # rich pysimdjson==7.0.2 # via cloud-volume pytest==9.0.2 @@ -360,6 +366,8 @@ requests==2.32.5 # kvdbclient # middle-auth-client # task-queue +rich==15.0.0 + # via -r requirements.in rpds-py==0.30.0 # via # jsonschema From 6dd4bfbf07c124c72ac365d2d4db472e8cb92e57 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 13:28:46 +0000 Subject: [PATCH 231/367] perf(ingest status): precompute redis keys; skip per-refresh rq allocations Each refresh was constructing a fresh Queue plus a fresh FailedJobRegistry per layer, and the first FailedJobRegistry access triggered a lazy `from rq.registry import FailedJobRegistry` import, which dominated first-call latency. The relevant keys are stable strings (`rq:queue:l{N}`, `rq:failed:l{N}`, `rq:workers:l{N}`), so compute them once before the live loop and pass them straight into the pipeline. --- pychunkedgraph/ingest/utils.py | 42 ++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 1ff7f3bd1..dcda607af 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -12,8 +12,7 @@ import numpy as np import tensorstore as ts -from rq import Queue, Retry, Worker -from rq.worker import WorkerStatus +from rq import Queue, Retry from rq.worker_registration import WORKERS_BY_QUEUE_KEY from rich import box from rich.console import Group @@ -181,16 +180,32 @@ def _busy_over_total_per_queue(redis, worker_keys_per_layer) -> list: return out -def _layer_status(redis, layers): +def _layer_keys(layers) -> list: + """Stable per-layer redis keys (completed-set, queue list, failed zset, workers set). + + Returned once before the refresh loop so each refresh skips Queue / + FailedJobRegistry construction and the lazy rq.registry import. + """ + return [ + ( + f"{layer}c", + f"rq:queue:l{layer}", + f"rq:failed:l{layer}", + WORKERS_BY_QUEUE_KEY % f"l{layer}", + ) + for layer in layers + ] + + +def _layer_status(redis, layer_keys): """Pipelined fetch of job_type + per-layer counts + busy-worker ratios.""" pipeline = redis.pipeline() pipeline.get(r_keys.JOB_TYPE) - for layer in layers: - pipeline.scard(f"{layer}c") - queue = Queue(f"l{layer}", connection=redis) - pipeline.llen(queue.key) - pipeline.zcard(queue.failed_job_registry.key) - pipeline.smembers(WORKERS_BY_QUEUE_KEY % f"l{layer}") + for completed_key, queue_key, failed_key, workers_key in layer_keys: + pipeline.scard(completed_key) + pipeline.llen(queue_key) + pipeline.zcard(failed_key) + pipeline.smembers(workers_key) results = pipeline.execute() job_type = results[0].decode() if results[0] else "not_available" @@ -342,16 +357,19 @@ def print_status( if upgrade: layers = range(2, imanager.cg_meta.layer_count) layer_counts = imanager.cg_meta.layer_chunk_counts + layer_keys = _layer_keys(layers) def render(): return _status_renderable( - imanager, layers, layer_counts, *_layer_status(redis, layers) + imanager, layers, layer_counts, *_layer_status(redis, layer_keys) ) - with Live(render(), screen=False) as live: + # Start Live with a placeholder so the panel paints instantly; the first + # real fetch (which includes redis connection setup) replaces it. + with Live(Text("loading…"), screen=False) as live: while True: - sleep(refresh_seconds) live.update(render()) + sleep(refresh_seconds) def queue_layer_helper( From 603e2827ed12abed80c24c72cff7624037eadff2 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 15:13:57 +0000 Subject: [PATCH 232/367] fix(ingest): run ocdbt populate synchronously after add_parent_chunk Overlapping the GCS copy with add_parent_chunk via a worker thread doubled peak memory inside the worker (the multi-scale read buffer and the BigTable hierarchy state are both live), which was OOM-killing workers. Drop the ThreadPoolExecutor and let the populate run inline after add_parent_chunk completes; _populate_ocdbt_chunk stays as a named helper. --- pychunkedgraph/ingest/cluster.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 34cbba818..4d3db6d2b 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -4,7 +4,6 @@ Ingest / create chunkedgraph with workers on a cluster. """ -from concurrent.futures import ThreadPoolExecutor from os import environ from time import sleep from typing import Callable, Dict, Iterable, Tuple, Sequence @@ -80,23 +79,6 @@ def create_parent_chunk( parent_coords: Sequence[int], ) -> None: imanager = _get_imanager() - - # If this task is responsible for populating the OCDBT base at the - # configured layer, kick the copy off on a worker thread so it overlaps - # with add_parent_chunk (BigTable-bound) — they share no state. - populate_future = None - if ( - imanager.ocdbt_seg - and imanager.ocdbt_populate_base - and parent_layer == imanager.ocdbt_populate_layer - ): - ws = imanager.cg.meta.data_source.WATERSHED - if not is_chunk_populated(ws, parent_layer, parent_coords): - executor = ThreadPoolExecutor(max_workers=1) - populate_future = executor.submit( - _populate_ocdbt_chunk, imanager, ws, parent_layer, parent_coords - ) - add_parent_chunk( imanager.cg, parent_layer, @@ -108,8 +90,14 @@ def create_parent_chunk( ), ) - if populate_future is not None: - populate_future.result() # surface populate exceptions before marking complete + if ( + imanager.ocdbt_seg + and imanager.ocdbt_populate_base + and parent_layer == imanager.ocdbt_populate_layer + ): + ws = imanager.cg.meta.data_source.WATERSHED + if not is_chunk_populated(ws, parent_layer, parent_coords): + _populate_ocdbt_chunk(imanager, ws, parent_layer, parent_coords) _post_task_completion(imanager, parent_layer, parent_coords) From ddea330cf7ff352b5ecee20aab75eb059091139a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 15:23:42 +0000 Subject: [PATCH 233/367] =?UTF-8?q?perf(ocdbt):=20stream=20copy=5Fws=5Fbbo?= =?UTF-8?q?x=5Fmultiscale=20via=20dst.write(src)=20=E2=80=94=20no=20interm?= =?UTF-8?q?ediate=20numpy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing the source TensorStore directly into write() lets tensorstore copy through its own pipeline rather than materializing a uint64 numpy array in Python and then handing it back to write(). On a 512 MiB region the duplicate-buffer cost goes away — peak RSS drops by roughly one scale's worth. Inside the atomic transaction the txn itself still buffers staged writes, so this trims the avoidable half of memory, not all of it. --- pychunkedgraph/graph/ocdbt.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt.py index ecb32be81..5ebb35fa2 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt.py @@ -456,6 +456,11 @@ def copy_ws_bbox_multiscale( inside one precomputed handle collapses into a single OCDBT commit — so the d/ file count for one call to this function is constant in the number of chunks inside the bbox; it only grows with scale count. + + Passing the source TensorStore directly into `write(...)` lets + tensorstore stream the copy without materializing an intermediate + numpy array in Python — peak RSS drops by roughly one scale's + worth versus the read-into-numpy-then-write pattern. """ assert len(src_list) == len(dst_list) == len(resolutions) base_res = np.array(resolutions[0]) @@ -467,8 +472,9 @@ def copy_ws_bbox_multiscale( if x1 <= x0 or y1 <= y0 or z1 <= z0: logger.debug(f"skipping empty region at scale {i}") continue - data = src[x0:x1, y0:y1, z0:z1].read().result() - dst.with_transaction(txn)[x0:x1, y0:y1, z0:z1].write(data).result() + dst.with_transaction(txn)[x0:x1, y0:y1, z0:z1].write( + src[x0:x1, y0:y1, z0:z1] + ).result() txn.commit_async().result() From e4462fd5b92490b65e990a7defb2dc0053fb6590 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 21:10:56 +0000 Subject: [PATCH 234/367] =?UTF-8?q?refactor(ocdbt):=20module=20=E2=86=92?= =?UTF-8?q?=20package;=20OcdbtConfig=20dataclass;=20yaml-driven=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split ocdbt.py into ocdbt/{__init__,meta,utils,main}.py. OcdbtConfig dataclass replaces the OCDBT_CONFIG module constant; spec builders take it explicitly. Per-CG settings persist in custom_data["ocdbt_config"]; legacy custom_data["seg"] still reads. Drop --ocdbt, --populate-base, --populate-layer, --sv-split-threshold from `ingest graph`; those move to yaml under a top-level `ocdbt_config:` section. bootstrap() parses it. IngestionManager carries one ocdbt_config dict with @property accessors so cluster.py callers don't change. max_inline_value_bytes default bumped to 1 MiB (tensorstore's hard ceiling). --- pychunkedgraph/graph/meta.py | 31 ++- pychunkedgraph/graph/ocdbt/__init__.py | 55 +++++ .../graph/{ocdbt.py => ocdbt/main.py} | 221 ++++-------------- pychunkedgraph/graph/ocdbt/meta.py | 65 ++++++ pychunkedgraph/graph/ocdbt/utils.py | 133 +++++++++++ pychunkedgraph/ingest/cli.py | 81 +++---- pychunkedgraph/ingest/cluster.py | 4 +- pychunkedgraph/ingest/manager.py | 24 +- pychunkedgraph/ingest/utils.py | 34 +-- pychunkedgraph/repair/stuck_ops.py | 1 + pychunkedgraph/tests/graph/test_ocdbt.py | 18 +- 11 files changed, 409 insertions(+), 258 deletions(-) create mode 100644 pychunkedgraph/graph/ocdbt/__init__.py rename pychunkedgraph/graph/{ocdbt.py => ocdbt/main.py} (69%) create mode 100644 pychunkedgraph/graph/ocdbt/meta.py create mode 100644 pychunkedgraph/graph/ocdbt/utils.py diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index e03eb8cba..5c64af392 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -9,6 +9,7 @@ from cloudvolume import CloudVolume from pychunkedgraph.graph.ocdbt import ( + OcdbtConfig, build_cg_ocdbt_spec, fork_exists, get_seg_source_and_destination_ocdbt, @@ -115,10 +116,26 @@ def ws_cv(self): ... return self._ws_cv + @property + def ocdbt_config(self) -> OcdbtConfig: + """Per-CG OCDBT settings, built from custom_data["ocdbt_config"]. + + Falls back to the legacy custom_data["seg"] shape used before the + dataclass landed so CGs ingested under the older layout keep working. + """ + d = self._custom_data.get("ocdbt_config") + if d is None: + seg = self._custom_data.get("seg", {}) + d = { + "enabled": bool(seg.get("ocdbt", False)), + "sv_split_threshold": int(seg.get("sv_split_threshold", 10)), + } + return OcdbtConfig.from_dict(d) + @property def ocdbt_seg(self) -> bool: if self._ocdbt_seg is None: - self._ocdbt_seg = self._custom_data.get("seg", {}).get("ocdbt", False) + self._ocdbt_seg = self.ocdbt_config.enabled return self._ocdbt_seg @property @@ -141,7 +158,9 @@ def ws_ocdbt_scales(self): "create it via fork_base_manifest or the seg_ocdbt notebook" ) _, self._ws_ocdbt_scales, self._ws_ocdbt_resolutions = ( - get_seg_source_and_destination_ocdbt(ws, self.graph_id) + get_seg_source_and_destination_ocdbt( + ws, self.graph_id, self.ocdbt_config + ) ) return self._ws_ocdbt_scales @@ -279,7 +298,7 @@ def READ_ONLY(self): @property def sv_split_threshold(self) -> int: - return self._custom_data.get("seg", {}).get("sv_split_threshold", 10) + return self.ocdbt_config.sv_split_threshold @property def split_bounding_offset(self): @@ -312,7 +331,11 @@ def dataset_info(self) -> Dict: # Readers pass this verbatim as `kvstore`; add a # `version` field for time-travel reads. "ocdbt_kvstore_spec": ( - build_cg_ocdbt_spec(self._data_source.WATERSHED, self.graph_id) + build_cg_ocdbt_spec( + self._data_source.WATERSHED, + self.graph_id, + self.ocdbt_config, + ) if self.ocdbt_seg and self._graph_config.ID else None ), diff --git a/pychunkedgraph/graph/ocdbt/__init__.py b/pychunkedgraph/graph/ocdbt/__init__.py new file mode 100644 index 000000000..0bbd5d156 --- /dev/null +++ b/pychunkedgraph/graph/ocdbt/__init__.py @@ -0,0 +1,55 @@ +"""Public API for the OCDBT-backed segmentation store. + +See ``main.py`` for the architectural notes. This module just re-exports +the names that external callers (ingest, edits, runtime, tests) reach for. +""" + +from .meta import OcdbtConfig +from .utils import ( + _layer_bbox, + _read_source_scales, + base_exists, + fork_exists, + is_chunk_populated, + mark_chunk_populated, + read_populate_meta, + write_populate_meta, +) +from .main import ( + _mode_downsample, + build_cg_ocdbt_spec, + copy_ws_bbox_multiscale, + copy_ws_chunk, + copy_ws_chunk_multiscale, + create_base_ocdbt, + fork_base_manifest, + get_seg_source_and_destination_ocdbt, + open_base_ocdbt, + propagate_to_coarser_scales, + wipe_base_ocdbt, + write_seg_chunks, +) + +__all__ = [ + "OcdbtConfig", + "_layer_bbox", + "_mode_downsample", + "_read_source_scales", + "base_exists", + "build_cg_ocdbt_spec", + "copy_ws_bbox_multiscale", + "copy_ws_chunk", + "copy_ws_chunk_multiscale", + "create_base_ocdbt", + "fork_base_manifest", + "fork_exists", + "get_seg_source_and_destination_ocdbt", + "is_chunk_populated", + "mark_chunk_populated", + "open_base_ocdbt", + "propagate_to_coarser_scales", + "read_populate_meta", + "wipe_base_ocdbt", + "write_populate_meta", + "write_seg_chunks", +] diff --git a/pychunkedgraph/graph/ocdbt.py b/pychunkedgraph/graph/ocdbt/main.py similarity index 69% rename from pychunkedgraph/graph/ocdbt.py rename to pychunkedgraph/graph/ocdbt/main.py index 5ebb35fa2..cfaaef690 100644 --- a/pychunkedgraph/graph/ocdbt.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -1,8 +1,8 @@ -"""OCDBT-backed neuroglancer_precomputed segmentation store. +"""OCDBT-backed neuroglancer_precomputed segmentation store — public API. Architecture: one immutable base OCDBT per watershed + one delta OCDBT per ChunkedGraph. Reads merge base + delta via tensorstore's kvstack driver. -Writes land in the delta via OCDBT's *_data_prefix options. +Writes land in the delta via OCDBT's ``*_data_prefix`` options. Multi-scale (MIP pyramid) is supported: the source watershed's info JSON drives the scale layout. All scales share one OCDBT kvstore; the precomputed @@ -30,153 +30,42 @@ retained by default. """ -import json -from typing import Optional - import numpy as np import tensorstore as ts from pychunkedgraph import get_logger -logger = get_logger(__name__) - -OCDBT_SEG_COMPRESSION_LEVEL = 12 - -OCDBT_CONFIG = { - "compression": {"id": "zstd", "level": OCDBT_SEG_COMPRESSION_LEVEL}, - # Inline chunk values into B+tree leaves so they share the leaf's zstd - # compression context. Default (100 bytes) puts every chunk in its own - # out-of-line blob with independent zstd framing → ~7x bloat on GCS. - # 512 KiB captures every compressed_segmentation chunk we've measured. - "max_inline_value_bytes": 524288, -} - - -def _read_source_scales(ws_path): - """Read the source precomputed `info` JSON to get scale count and resolutions. - - The leading '/' in '/info' is required for GCS — without it the read - returns empty. - """ - kvs = ts.KvStore.open(ws_path).result() - info = json.loads(kvs.read("/info").result().value) - return info["scales"] - - -def _open_precomputed_scale(kvstore, scale_index, create=False, **schema_kw): - """Open one neuroglancer_precomputed scale on top of a kvstore spec.""" - spec = { - "driver": "neuroglancer_precomputed", - "kvstore": kvstore, - "scale_index": scale_index, - } - return ts.open(spec, create=create, **schema_kw).result() - - -def _schema_from_src(src_handle): - """Extract the schema kwargs needed to open a matching destination. - - `domain` already carries both extent and origin (voxel_offset). Passing - `shape` alongside conflicts with non-zero-origin sources because shape - implies origin=0 — tensorstore refuses to merge `[0, N)` with - `[offset, offset+N)`. - """ - s = src_handle.schema - return dict( - rank=s.rank, - dtype=s.dtype, - codec=s.codec, - domain=s.domain, - chunk_layout=s.chunk_layout, - dimension_units=s.dimension_units, - ) - - -def _ensure_trailing_slash(path): - """Ensure kvstore paths end with / so they're treated as directories.""" - return path if path.endswith("/") else path + "/" - - -def _base_ocdbt_path(ws_path): - return _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/base") - - -def _populate_markers_path(ws_path): - return _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/.populated") +from .meta import OcdbtConfig +from .utils import ( + _base_ocdbt_path, + _ensure_trailing_slash, + _open_precomputed_scale, + _read_source_scales, + _schema_from_src, + base_exists, +) - -def _marker_key(layer: int, coords) -> str: - return f"l{int(layer)}_{int(coords[0])}_{int(coords[1])}_{int(coords[2])}" - - -def is_chunk_populated(ws_path: str, layer: int, coords) -> bool: - """Check whether this chunk's precomputed→OCDBT copy has already completed. - - Markers live outside the OCDBT keyspace at - `/ocdbt/.populated/l___` so retried ingest tasks - don't re-copy chunks and bloat the database with redundant versioned - writes. - """ - kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() - result = kvs.read(_marker_key(layer, coords)).result() - return result.value is not None and len(result.value) > 0 - - -def mark_chunk_populated(ws_path: str, layer: int, coords) -> None: - """Record that this chunk's precomputed→OCDBT copy completed.""" - kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() - kvs.write(_marker_key(layer, coords), b"1").result() - - -def read_populate_meta(ws_path: str) -> Optional[dict]: - """Return the per-base populate config dict, or None if not yet written.""" - kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() - r = kvs.read("meta.json").result() - if r.value is None or len(r.value) == 0: - return None - return json.loads(r.value) - - -def write_populate_meta(ws_path: str, meta: dict) -> None: - """Persist the per-base populate config (layer, etc.) alongside markers.""" - kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() - kvs.write("meta.json", json.dumps(meta).encode()).result() - - -def base_exists(ws_path: str) -> bool: - """Check if the base OCDBT has already been created for this watershed.""" - base = _base_ocdbt_path(ws_path) - kvs = ts.KvStore.open(base).result() - result = kvs.read("manifest.ocdbt").result() - return result.value is not None and len(result.value) > 0 - - -def fork_exists(ws_path: str, graph_id: str) -> bool: - """Check if this ChunkedGraph's fork has been initialized.""" - fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") - kvs = ts.KvStore.open(fork_dir).result() - result = kvs.read("manifest.ocdbt").result() - return result.value is not None and len(result.value) > 0 +logger = get_logger(__name__) -def create_base_ocdbt(ws_path: str): - """One-time bootstrap: create the shared base OCDBT at /ocdbt/base/. +def create_base_ocdbt(ws_path: str, config: OcdbtConfig): + """One-time bootstrap: create the shared base OCDBT at ``/ocdbt/base/``. Wipes any existing base first, then opens each scale with create=True so the info JSON is built from the source. Populating the base with - actual chunk data happens separately via copy_ws_chunk_multiscale - during the per-chunk ingest tasks. + actual chunk data happens separately via ``copy_ws_chunk_multiscale`` + or ``copy_ws_bbox_multiscale`` during the per-chunk ingest tasks. Returns (src_list, dst_list, resolutions) for the caller to use with - copy_ws_chunk_multiscale. + the copy helpers. """ base = _base_ocdbt_path(ws_path) # Wipe via the underlying GCS/file driver, NOT through the ocdbt # driver. Opening as ocdbt on an empty dir creates a default-config # `manifest.ocdbt` stub (max_inline_value_bytes=100); on a dir with # an existing manifest it only clears the B+tree, leaving the - # manifest's config in place. Either way the subsequent open with - # OCDBT_CONFIG mismatches. + # manifest's config in place. Either way the subsequent open with a + # different config mismatches. try: kvs = ts.KvStore.open(base).result() kvs.delete_range(ts.KvStore.KeyRange()).result() @@ -185,7 +74,7 @@ def create_base_ocdbt(ws_path: str): scales = _read_source_scales(ws_path) resolutions = [s["resolution"] for s in scales] - base_kvstore = {"driver": "ocdbt", "base": base, "config": dict(OCDBT_CONFIG)} + base_kvstore = {"driver": "ocdbt", "base": base, "config": config.ts_config()} src_list, dst_list = [], [] for i in range(len(scales)): @@ -212,19 +101,19 @@ def wipe_base_ocdbt(ws_path: str): pass -def open_base_ocdbt(ws_path: str): +def open_base_ocdbt(ws_path: str, config: OcdbtConfig): """Open the existing base OCDBT (read/write) for populating during ingest. Used by per-chunk ingest tasks that copy precomputed data into the shared base. NOT used at runtime — runtime always goes through the per-CG fork - spec via get_seg_source_and_destination_ocdbt. + spec via ``get_seg_source_and_destination_ocdbt``. Returns (src_list, dst_list, resolutions). """ base = _base_ocdbt_path(ws_path) scales = _read_source_scales(ws_path) resolutions = [s["resolution"] for s in scales] - base_kvstore = {"driver": "ocdbt", "base": base, "config": dict(OCDBT_CONFIG)} + base_kvstore = {"driver": "ocdbt", "base": base, "config": config.ts_config()} src_list, dst_list = [], [] for i in range(len(scales)): @@ -240,27 +129,28 @@ def open_base_ocdbt(ws_path: str): def build_cg_ocdbt_spec( ws_path: str, graph_id: str, + config: OcdbtConfig, *, pinned_at: "int | str | None" = None, ) -> dict: """Open-time kvstore spec for a CG's OCDBT, backed by a shared immutable base. This function is a pure spec-constructor — it doesn't materialize - the fork. The fork's `manifest.ocdbt` must exist before `ts.open` - on this spec will succeed; it's created by `fork_base_manifest` - (invoked from the ingest CLI's `--ocdbt` path or the `seg_ocdbt` - notebook). `ChunkedGraphMeta.ws_ocdbt_scales` asserts presence via - `fork_exists` so callers get a clear error instead of a tensorstore + the fork. The fork's ``manifest.ocdbt`` must exist before ``ts.open`` + on this spec will succeed; it's created by ``fork_base_manifest`` + (invoked from the ingest CLI's OCDBT path or the ``seg_ocdbt`` + notebook). ``ChunkedGraphMeta.ws_ocdbt_scales`` asserts presence via + ``fork_exists`` so callers get a clear error instead of a tensorstore internal failure. - All three kvstack layers below AND all three `*_data_prefix` options + All three kvstack layers below AND all three ``*_data_prefix`` options are load-bearing; removing any of them causes fork writes to leak into the immutable base (verified empirically). - When `pinned_at` is set, the opened kvstore is read-only and returns + When ``pinned_at`` is set, the opened kvstore is read-only and returns state as of the specified version. Accepts an integer generation - number (exact) or an ISO-8601 UTC timestamp string with `Z` suffix - (interpreted as `commit_time <= T`). + number (exact) or an ISO-8601 UTC timestamp string with ``Z`` suffix + (interpreted as ``commit_time <= T``). """ base = _base_ocdbt_path(ws_path) fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") @@ -294,7 +184,7 @@ def build_cg_ocdbt_spec( "driver": "kvstack", "layers": [base_layer, fork_manifest_layer, fork_data_layer], }, - "config": dict(OCDBT_CONFIG), + "config": config.ts_config(), # Steer every kind of OCDBT write under `_d/` so the # fork_data_layer catches them. "value_data_prefix": data_prefix, @@ -332,6 +222,7 @@ def fork_base_manifest(ws_path: str, graph_id: str, wipe_existing: bool = False) def get_seg_source_and_destination_ocdbt( ws_path: str, graph_id: str, + config: OcdbtConfig, *, pinned_at: "int | str | None" = None, ) -> tuple: @@ -340,9 +231,9 @@ def get_seg_source_and_destination_ocdbt( Always uses the fork-based kvstack spec. Requires the base to exist and the fork's manifest to be present (set up at ingest time). - When `pinned_at` is set, the destination OCDBT handles are opened + When ``pinned_at`` is set, the destination OCDBT handles are opened read-only at that version — used by the recovery path to read - pre-op seg values via `ChunkedGraphMeta.pinned_seg_reads`. + pre-op seg values via ``ChunkedGraphMeta.pinned_seg_reads``. Returns: (src_list, dst_list, resolutions): per-scale TensorStore handles @@ -350,7 +241,7 @@ def get_seg_source_and_destination_ocdbt( """ scales = _read_source_scales(ws_path) resolutions = [s["resolution"] for s in scales] - cg_kvstore = build_cg_ocdbt_spec(ws_path, graph_id, pinned_at=pinned_at) + cg_kvstore = build_cg_ocdbt_spec(ws_path, graph_id, config, pinned_at=pinned_at) src_list, dst_list = [], [] for i in range(len(scales)): @@ -428,18 +319,6 @@ def copy_ws_chunk_multiscale( dst[x0:x1, y0:y1, z0:z1].write(data).result() -def _layer_bbox(meta, layer: int, coords) -> tuple: - """Base-resolution voxel bbox of a chunk at this layer.""" - chunk_size = np.array(meta.graph_config.CHUNK_SIZE, dtype=int) - layer_chunk_size = chunk_size * (1 << (layer - 2)) - coords = np.array(coords, dtype=int) - vol_start = meta.voxel_bounds[:, 0] - vol_end = meta.voxel_bounds[:, 1] - lo = coords * layer_chunk_size + vol_start - hi = np.minimum(lo + layer_chunk_size, vol_end) - return lo, hi - - def copy_ws_bbox_multiscale( src_list, dst_list, @@ -449,15 +328,15 @@ def copy_ws_bbox_multiscale( ): """Copy a base-resolution voxel bbox across all MIP scales under one atomic txn. - `ts.Transaction(atomic=True)` is load-bearing: without it the precomputed - driver splits a multi-chunk `.write()` into multiple OCDBT sub-commits, + ``ts.Transaction(atomic=True)`` is load-bearing: without it the precomputed + driver splits a multi-chunk ``.write()`` into multiple OCDBT sub-commits, so file count would scale with the number of precomputed chunks inside - the bbox. With `atomic=True`, every per-chunk underlying-kvstore write + the bbox. With ``atomic=True``, every per-chunk underlying-kvstore write inside one precomputed handle collapses into a single OCDBT commit — so the d/ file count for one call to this function is constant in the number of chunks inside the bbox; it only grows with scale count. - Passing the source TensorStore directly into `write(...)` lets + Passing the source TensorStore directly into ``write(...)`` lets tensorstore stream the copy without materializing an intermediate numpy array in Python — peak RSS drops by roughly one scale's worth versus the read-into-numpy-then-write pattern. @@ -562,10 +441,10 @@ def propagate_to_coarser_scales(dst_scales, resolutions, base_slices): def write_seg_chunks(meta, seg_writes): """Write a flat batch of pre-sliced L2 chunks to OCDBT in parallel. - `seg_writes` is the aggregated output of `edits_sv.split_supervoxels` + ``seg_writes`` is the aggregated output of ``edits_sv.split_supervoxels`` across every rep in an operation — each pair is one L2 chunk's worth - of `(voxel_slices, data)`. Flattening across reps matters: one - `write_seg_chunks` call fires every chunk write in one parallel + of ``(voxel_slices, data)``. Flattening across reps matters: one + ``write_seg_chunks`` call fires every chunk write in one parallel tensorstore batch instead of serializing rep-by-rep. Only chunks that actually received new SV IDs appear here; gap @@ -573,13 +452,13 @@ def write_seg_chunks(meta, seg_writes): overlap read touched are skipped by the split planner. Coarser MIP levels stay the downsample worker's job — it picks up - the pubsub message `publish_edit` sends after this returns. + the pubsub message ``publish_edit`` sends after this returns. Args: - meta: ChunkedGraphMeta with `ws_ocdbt` (base-scale handle). - seg_writes: iterable of `(voxel_slices, data)` pairs, where - `voxel_slices` is a 3-tuple of `slice` objects covering one - L2 chunk's x/y/z extent and `data` is the 3D label block + meta: ChunkedGraphMeta with ``ws_ocdbt`` (base-scale handle). + seg_writes: iterable of ``(voxel_slices, data)`` pairs, where + ``voxel_slices`` is a 3-tuple of ``slice`` objects covering one + L2 chunk's x/y/z extent and ``data`` is the 3D label block (shape matches the slice extents). """ futures = [ diff --git a/pychunkedgraph/graph/ocdbt/meta.py b/pychunkedgraph/graph/ocdbt/meta.py new file mode 100644 index 000000000..d4c79dc89 --- /dev/null +++ b/pychunkedgraph/graph/ocdbt/meta.py @@ -0,0 +1,65 @@ +"""OcdbtConfig dataclass — single source of truth for per-CG OCDBT settings.""" + +from dataclasses import asdict, dataclass, field +from typing import Dict, Optional + + +@dataclass +class OcdbtConfig: + """Per-CG OCDBT settings, persisted in ``ChunkedGraphMeta.custom_data["ocdbt_config"]``. + + Carries both ingest-time choices (populate base? at which layer?) and + tensorstore kvstore options (compression, inline byte cap) that must + stay consistent for the lifetime of the OCDBT base. Built once from + the dataset yaml's ``ocdbt_config:`` section and stored alongside the + CG so every code path that opens an OCDBT store reads back the same + values. + """ + + enabled: bool = False + populate_base: bool = False + populate_layer: int = 3 + sv_split_threshold: int = 10 + compression: Dict = field(default_factory=lambda: {"id": "zstd", "level": 12}) + # Inline chunk values into B+tree leaves so they share the leaf's zstd + # compression context. Default tensorstore value (100 bytes) puts every + # chunk in its own out-of-line blob with independent zstd framing → + # ~7× bloat on GCS. 1 MiB is tensorstore's hard ceiling for this field + # and captures every compressed_segmentation chunk we've measured. + max_inline_value_bytes: int = 1048576 + + @classmethod + def from_dict(cls, d: Optional[Dict]) -> "OcdbtConfig": + """Build from a dict. Unknown keys are ignored so older configs don't + break newer code, and newer fields default in when older configs are + loaded. + """ + if not d: + return cls() + known = {f.name for f in cls.__dataclass_fields__.values()} + return cls(**{k: v for k, v in d.items() if k in known}) + + @classmethod + def resolve(cls, *dicts: Optional[Dict]) -> "OcdbtConfig": + """Layered merge: later dicts override earlier ones, all over defaults. + + Use to express precedence — e.g. ``resolve(yaml_dict, info_file_dict)`` + gives info-file values priority over yaml-supplied ones, with + dataclass defaults filling anything neither side specifies. + ``None`` and empty dicts are no-ops. + """ + merged: Dict = {} + for d in dicts: + if d: + merged.update(d) + return cls.from_dict(merged) + + def to_dict(self) -> Dict: + return asdict(self) + + def ts_config(self) -> Dict: + """The subset that belongs inside a tensorstore OCDBT kvstore ``config``.""" + return { + "compression": dict(self.compression), + "max_inline_value_bytes": self.max_inline_value_bytes, + } diff --git a/pychunkedgraph/graph/ocdbt/utils.py b/pychunkedgraph/graph/ocdbt/utils.py new file mode 100644 index 000000000..6158eda53 --- /dev/null +++ b/pychunkedgraph/graph/ocdbt/utils.py @@ -0,0 +1,133 @@ +"""Internal helpers for the OCDBT package. + +Path builders, schema extraction, populate-marker IO, layer-bbox math. +Not part of the public API except for the marker IO and ``_layer_bbox`` +which the ingest worker uses across the package boundary. +""" + +import json +from typing import Optional + +import numpy as np +import tensorstore as ts + + +def _ensure_trailing_slash(path: str) -> str: + """Ensure kvstore paths end with / so they're treated as directories.""" + return path if path.endswith("/") else path + "/" + + +def _base_ocdbt_path(ws_path: str) -> str: + return _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/base") + + +def _populate_markers_path(ws_path: str) -> str: + return _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/.populated") + + +def _marker_key(layer: int, coords) -> str: + return f"l{int(layer)}_{int(coords[0])}_{int(coords[1])}_{int(coords[2])}" + + +def _read_source_scales(ws_path: str): + """Read the source precomputed ``info`` JSON to get scale count and resolutions. + + The leading '/' in '/info' is required for GCS — without it the read + returns empty. + """ + kvs = ts.KvStore.open(ws_path).result() + info = json.loads(kvs.read("/info").result().value) + return info["scales"] + + +def _open_precomputed_scale( + kvstore, scale_index: int, create: bool = False, **schema_kw +): + """Open one neuroglancer_precomputed scale on top of a kvstore spec.""" + spec = { + "driver": "neuroglancer_precomputed", + "kvstore": kvstore, + "scale_index": scale_index, + } + return ts.open(spec, create=create, **schema_kw).result() + + +def _schema_from_src(src_handle) -> dict: + """Extract the schema kwargs needed to open a matching destination. + + ``domain`` already carries both extent and origin (voxel_offset). Passing + ``shape`` alongside conflicts with non-zero-origin sources because shape + implies origin=0 — tensorstore refuses to merge ``[0, N)`` with + ``[offset, offset+N)``. + """ + s = src_handle.schema + return dict( + rank=s.rank, + dtype=s.dtype, + codec=s.codec, + domain=s.domain, + chunk_layout=s.chunk_layout, + dimension_units=s.dimension_units, + ) + + +def is_chunk_populated(ws_path: str, layer: int, coords) -> bool: + """Check whether this chunk's precomputed→OCDBT copy has already completed. + + Markers live outside the OCDBT keyspace at + ``/ocdbt/.populated/l___`` so retried ingest tasks + don't re-copy chunks and bloat the database with redundant versioned + writes. + """ + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + result = kvs.read(_marker_key(layer, coords)).result() + return result.value is not None and len(result.value) > 0 + + +def mark_chunk_populated(ws_path: str, layer: int, coords) -> None: + """Record that this chunk's precomputed→OCDBT copy completed.""" + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + kvs.write(_marker_key(layer, coords), b"1").result() + + +def read_populate_meta(ws_path: str) -> Optional[dict]: + """Return the per-base populate config dict, or None if not yet written.""" + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + r = kvs.read("meta.json").result() + if r.value is None or len(r.value) == 0: + return None + return json.loads(r.value) + + +def write_populate_meta(ws_path: str, meta: dict) -> None: + """Persist the per-base populate config (layer, etc.) alongside markers.""" + kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() + kvs.write("meta.json", json.dumps(meta).encode()).result() + + +def base_exists(ws_path: str) -> bool: + """Check if the base OCDBT has already been created for this watershed.""" + base = _base_ocdbt_path(ws_path) + kvs = ts.KvStore.open(base).result() + result = kvs.read("manifest.ocdbt").result() + return result.value is not None and len(result.value) > 0 + + +def fork_exists(ws_path: str, graph_id: str) -> bool: + """Check if this ChunkedGraph's fork has been initialized.""" + fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") + kvs = ts.KvStore.open(fork_dir).result() + result = kvs.read("manifest.ocdbt").result() + return result.value is not None and len(result.value) > 0 + + +def _layer_bbox(meta, layer: int, coords) -> tuple: + """Base-resolution voxel bbox of a chunk at this layer.""" + chunk_size = np.array(meta.graph_config.CHUNK_SIZE, dtype=int) + layer_chunk_size = chunk_size * (1 << (layer - 2)) + coords = np.array(coords, dtype=int) + vol_start = meta.voxel_bounds[:, 0] + vol_end = meta.voxel_bounds[:, 1] + lo = coords * layer_chunk_size + vol_start + hi = np.minimum(lo + layer_chunk_size, vol_end) + return lo, hi diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 44e3f2944..a8ec7771e 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -26,6 +26,7 @@ from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph from ..graph.ocdbt import ( + OcdbtConfig, base_exists, create_base_ocdbt, fork_base_manifest, @@ -55,13 +56,6 @@ def flush_redis(): @ingest_cli.command("graph") @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True), required=False) -@click.option("--ocdbt", is_flag=True, help="Precomputed supervoxel seg into ocdbt.") -@click.option( - "--sv-split-threshold", - type=int, - default=10, - help="Distance threshold for SV split edge matching.", -) @click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") @click.option("--retry", is_flag=True, help="Rerun without creating a new table.") @click.option( @@ -69,39 +63,17 @@ def flush_redis(): is_flag=True, help="Wipe base AND this CG's delta OCDBT, then recreate from scratch.", ) -@click.option( - "--populate-base", - is_flag=True, - help="Have workers copy precomputed chunks into the OCDBT base. " - "Required on first ingest; skip on subsequent runs against the same base.", -) -@click.option( - "--populate-layer", - type=int, - default=4, - help="Layer at which populate tasks copy precomputed chunks into the OCDBT " - "base. Each task batches all underlying L2 chunks across all scales into " - "one OCDBT commit. Stored alongside the base on first --populate-base; " - "subsequent ingests must agree or pass --reset-ocdbt.", -) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @job_type_guard(group_name) def ingest_graph( graph_id: str, dataset: click.Path, - ocdbt: bool, - sv_split_threshold: int, raw: bool, retry: bool, reset_ocdbt: bool, - populate_base: bool, - populate_layer: int, test: bool, ): - """ - Main ingest command. - Takes ingest config from a yaml file and queues atomic tasks. - """ + """Main ingest command. Takes config from yaml, queues atomic tasks.""" redis = get_redis_connection() if retry: @@ -130,42 +102,41 @@ def ingest_graph( if test: configure_logging(level=DEBUG) - meta, ingest_config, client_info = bootstrap(graph_id, config, raw, test) - if ocdbt: + meta, ingest_config, client_info, ocdbt_config_dict = bootstrap( + graph_id, config, raw, test + ) + yaml_only_cfg = OcdbtConfig.from_dict(ocdbt_config_dict) + if yaml_only_cfg.enabled: ws = meta.data_source.WATERSHED if reset_ocdbt: wipe_base_ocdbt(ws) if not base_exists(ws): - create_base_ocdbt(ws) - if populate_base: - existing = read_populate_meta(ws) - if existing is None: - write_populate_meta(ws, {"layer": populate_layer}) - elif existing.get("layer") != populate_layer: - raise click.ClickException( - f"OCDBT base is already populated at layer {existing.get('layer')}, " - f"but --populate-layer={populate_layer} was passed. " - f"Mixing populate layers is unsupported. To repopulate at a " - f"different layer, manually delete {ws.rstrip('/')}/ocdbt/ and rerun." - ) + # No on-disk OCDBT yet — create it with yaml-supplied values. + create_base_ocdbt(ws, yaml_only_cfg) + # Precedence: info-file (per-base, already on disk) > yaml > defaults. + # Existing bases own the on-disk-format fields (compression, + # max_inline_value_bytes, populate_layer); yaml fills missing fields + # and supplies values for new bases. + info_dict = read_populate_meta(ws) + ocdbt_cfg = OcdbtConfig.resolve(ocdbt_config_dict, info_dict) + ocdbt_config_dict = ocdbt_cfg.to_dict() + if ocdbt_cfg.populate_base: + write_populate_meta(ws, ocdbt_config_dict) fork_base_manifest(ws, graph_id, wipe_existing=reset_ocdbt) + else: + ocdbt_cfg = yaml_only_cfg cg = ChunkedGraph(meta=meta, client_info=client_info) cg.create() - if ocdbt: - cg.meta.custom_data["seg"] = { - "ocdbt": True, - "sv_split_threshold": sv_split_threshold, - } + if ocdbt_cfg.enabled: + cg.meta.custom_data["ocdbt_config"] = ocdbt_config_dict cg.update_meta(cg.meta, overwrite=True) imanager = IngestionManager( ingest_config, meta, - ocdbt_seg=ocdbt, - ocdbt_populate_base=populate_base, - ocdbt_populate_layer=populate_layer, + ocdbt_config=ocdbt_config_dict, ) enqueue_l2_tasks(imanager, create_atomic_chunk) os._exit(0) @@ -187,8 +158,10 @@ def pickle_imanager(graph_id: str, dataset: click.Path, raw: bool): except yaml.YAMLError as exc: print(exc) - meta, ingest_config, _ = bootstrap(graph_id, config=config, raw=raw) - imanager = IngestionManager(ingest_config, meta) + meta, ingest_config, _, ocdbt_config_dict = bootstrap( + graph_id, config=config, raw=raw + ) + imanager = IngestionManager(ingest_config, meta, ocdbt_config=ocdbt_config_dict) imanager.redis.set(r_keys.JOB_TYPE, group_name) diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 4d3db6d2b..35875e64e 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -30,6 +30,7 @@ from ..graph.edges import EDGE_TYPES, Edges, put_edges from ..graph import ChunkedGraph, ChunkedGraphMeta from ..graph.ocdbt import ( + OcdbtConfig, _layer_bbox, copy_ws_bbox_multiscale, is_chunk_populated, @@ -68,7 +69,8 @@ def _post_task_completion( def _populate_ocdbt_chunk(imanager, ws, layer, coords): - src_list, dst_list, resolutions = open_base_ocdbt(ws) + cfg = OcdbtConfig.from_dict(imanager.ocdbt_config) + src_list, dst_list, resolutions = open_base_ocdbt(ws, cfg) lo, hi = _layer_bbox(imanager.cg.meta, layer, coords) copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) mark_chunk_populated(ws, layer, coords) diff --git a/pychunkedgraph/ingest/manager.py b/pychunkedgraph/ingest/manager.py index 11419d125..407a31ed8 100644 --- a/pychunkedgraph/ingest/manager.py +++ b/pychunkedgraph/ingest/manager.py @@ -15,9 +15,7 @@ def __init__( self, config: IngestConfig, chunkedgraph_meta: ChunkedGraphMeta, - ocdbt_seg: bool = False, - ocdbt_populate_base: bool = False, - ocdbt_populate_layer: int = 4, + ocdbt_config: dict = None, _from_pickle: bool = False, ): self._config = config @@ -26,9 +24,7 @@ def __init__( self._redis = None self._task_queues = {} self._from_pickle = _from_pickle - self.ocdbt_seg = ocdbt_seg - self.ocdbt_populate_base = ocdbt_populate_base - self.ocdbt_populate_layer = ocdbt_populate_layer + self.ocdbt_config = ocdbt_config or {} if not _from_pickle: # initiate redis and store serialized state @@ -57,13 +53,23 @@ def redis(self): self._redis.set(r_keys.INGESTION_MANAGER, self.serialized(pickled=True)) return self._redis + @property + def ocdbt_seg(self) -> bool: + return bool(self.ocdbt_config.get("enabled")) + + @property + def ocdbt_populate_base(self) -> bool: + return bool(self.ocdbt_config.get("populate_base")) + + @property + def ocdbt_populate_layer(self) -> int: + return int(self.ocdbt_config.get("populate_layer", 3)) + def serialized(self, pickled=False): params = { "config": self._config, "chunkedgraph_meta": self._chunkedgraph_meta, - "ocdbt_seg": self.ocdbt_seg, - "ocdbt_populate_base": self.ocdbt_populate_base, - "ocdbt_populate_layer": self.ocdbt_populate_layer, + "ocdbt_config": self.ocdbt_config, } if pickled: return pickle.dumps(params) diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index dcda607af..e9da04e88 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -1,19 +1,15 @@ # pylint: disable=invalid-name, missing-docstring import functools - -from pychunkedgraph import get_logger - -logger = get_logger(__name__) -import math, random, sys +import math +import sys from os import environ from time import sleep -from typing import Any, Generator, Tuple +from typing import Any, Dict, Generator, Tuple import numpy as np import tensorstore as ts -from rq import Queue, Retry -from rq.worker_registration import WORKERS_BY_QUEUE_KEY +from kvdbclient import BigTableConfig, HBaseConfig from rich import box from rich.console import Group from rich.live import Live @@ -21,16 +17,22 @@ from rich.rule import Rule from rich.table import Table from rich.text import Text +from rq import Queue, Retry +from rq.worker_registration import WORKERS_BY_QUEUE_KEY + +from pychunkedgraph import get_logger from . import IngestConfig from .manager import IngestionManager -from ..graph.meta import ChunkedGraphMeta, DataSource, GraphConfig from ..graph import BackendClientInfo -from kvdbclient import BigTableConfig, HBaseConfig +from ..graph.meta import ChunkedGraphMeta, DataSource, GraphConfig +from ..graph.ocdbt import OcdbtConfig from ..utils.general import chunked from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys +logger = get_logger(__name__) + chunk_id_str = lambda layer, coords: f"{layer}_{'_'.join(map(str, coords))}" @@ -39,8 +41,13 @@ def bootstrap( config: dict, raw: bool = False, test_run: bool = False, -) -> Tuple[ChunkedGraphMeta, IngestConfig, BackendClientInfo]: - """Parse config loaded from a yaml file.""" +) -> Tuple[ChunkedGraphMeta, IngestConfig, BackendClientInfo, Dict]: + """Parse config loaded from a yaml file. + + Returns ``(meta, ingest_config, client_info, ocdbt_config_dict)`` where the + ocdbt config dict is sanitized through ``OcdbtConfig.from_dict(...).to_dict()`` + so unknown yaml keys are dropped and missing fields take dataclass defaults. + """ ingest_config = IngestConfig( **config.get("ingest_config", {}), USE_RAW_EDGES=raw, @@ -62,7 +69,8 @@ def bootstrap( data_source = DataSource(**config["data_source"]) meta = ChunkedGraphMeta(graph_config, data_source) - return (meta, ingest_config, client_info) + ocdbt_config_dict = OcdbtConfig.from_dict(config.get("ocdbt_config")).to_dict() + return (meta, ingest_config, client_info, ocdbt_config_dict) def move_up(lines: int = 1): diff --git a/pychunkedgraph/repair/stuck_ops.py b/pychunkedgraph/repair/stuck_ops.py index 32d3107fc..4f22b508f 100644 --- a/pychunkedgraph/repair/stuck_ops.py +++ b/pychunkedgraph/repair/stuck_ops.py @@ -142,6 +142,7 @@ def cleanup_partial_writes(cg: ChunkedGraph, op_id: int) -> int: _, pinned_scales, _ = get_seg_source_and_destination_ocdbt( cg.meta.data_source.WATERSHED, cg.meta.graph_id, + cg.meta.ocdbt_config, pinned_at=pin_str, ) pinned_ws = pinned_scales[0] diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index 4f2af7b7b..501229ada 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -71,7 +71,9 @@ def _setup_ts_mock(mock_ts, num_scales=2): class TestBuildCgOcdbtSpec: def test_spec_structure(self): """build_cg_ocdbt_spec returns the expected kvstack-layered spec.""" - spec = ocdbt_mod.build_cg_ocdbt_spec("gs://bucket/ws", "my_graph") + spec = ocdbt_mod.build_cg_ocdbt_spec( + "gs://bucket/ws", "my_graph", ocdbt_mod.OcdbtConfig() + ) assert spec["driver"] == "ocdbt" layers = spec["base"]["layers"] assert len(layers) == 3 @@ -241,14 +243,14 @@ def local_ocdbt(): base = f"{ws}/ocdbt/base" def _mk_scale(size, resolution, *, include_mm): - # Match OCDBT_CONFIG so forks (which always use it) don't trip the - # "Configuration mismatch on max_inline_value_bytes" check. + # Match OcdbtConfig defaults so forks (which always use them) don't + # trip the "Configuration mismatch on max_inline_value_bytes" check. spec = { "driver": "neuroglancer_precomputed", "kvstore": { "driver": "ocdbt", "base": base, - "config": dict(ocdbt_mod.OCDBT_CONFIG), + "config": ocdbt_mod.OcdbtConfig().ts_config(), }, "scale_metadata": { "size": size, @@ -273,7 +275,9 @@ def make_fork(graph_id, *, scale_index=0, pinned_at=None): if graph_id not in _created_forks: ocdbt_mod.fork_base_manifest(ws, graph_id) _created_forks.add(graph_id) - spec = ocdbt_mod.build_cg_ocdbt_spec(ws, graph_id, pinned_at=pinned_at) + spec = ocdbt_mod.build_cg_ocdbt_spec( + ws, graph_id, ocdbt_mod.OcdbtConfig(), pinned_at=pinned_at + ) return ts.open( { "driver": "neuroglancer_precomputed", @@ -549,7 +553,9 @@ def test_opens_fork_and_merges_base(self, local_ocdbt): "compressed_segmentation_block_size": [8, 8, 8], } ] - with patch.object(ocdbt_mod, "_read_source_scales", return_value=fake_scales): + with patch.object( + ocdbt_mod.main, "_read_source_scales", return_value=fake_scales + ): scales = meta.ws_ocdbt_scales assert len(scales) == 1 From 54173ae07f283262ecc6a0d8b6ff2cbbe9abd11b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 21:11:48 +0000 Subject: [PATCH 235/367] feat(meta): info-file > custom_data precedence; redis-cached helper shared with ws_cv ChunkedGraphMeta.ocdbt_config now consults /ocdbt/.populated/meta.json at runtime and resolves it on top of custom_data per the info-file > custom_data > defaults order. New module-level _redis_cached_json(key, loader) caches the JSON dict in Redis so distributed workers hit Redis once per process instead of GCS per CG instance; ws_cv refactored to use the same helper. --- pychunkedgraph/graph/meta.py | 86 ++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index 5c64af392..e527f875b 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -13,6 +13,7 @@ build_cg_ocdbt_spec, fork_exists, get_seg_source_and_destination_ocdbt, + read_populate_meta, ) from .utils.generic import compute_bitmasks @@ -55,6 +56,29 @@ ) +def _redis_cached_json(key: str, loader): + """Return JSON-decoded value at ``key`` in Redis, or call ``loader()`` and + write the result through. Spares distributed workers from re-fetching the + same GCS object on every CG instantiation. Silently bypasses Redis if it + is unreachable; returns ``loader()`` directly in that case. + """ + redis = None + try: + redis = get_redis_connection() + cached = redis.get(key) + if cached is not None: + return json.loads(cached) + except Exception: + redis = None + value = loader() + if value is not None and redis is not None: + try: + redis.set(key, json.dumps(value)) + except Exception: + ... + return value + + class ChunkedGraphMeta: def __init__( self, graph_config: GraphConfig, data_source: DataSource, custom_data: Dict = {} @@ -76,6 +100,7 @@ def __init__( self._layer_count = None self._bitmasks = None self._ocdbt_seg = None + self._ocdbt_config_cached = None @property def graph_id(self): @@ -98,39 +123,52 @@ def custom_data(self): def ws_cv(self): if self._ws_cv: return self._ws_cv - - cache_key = f"{self.graph_config.ID}:ws_cv_info_cached" - try: - # try reading a cached info file for distributed workers - # useful to avoid md5 errors on high gcs load - redis = get_redis_connection() - cached_info = json.loads(redis.get(cache_key)) - self._ws_cv = CloudVolume( - self._data_source.WATERSHED, info=cached_info, progress=False - ) - except Exception: - self._ws_cv = CloudVolume(self._data_source.WATERSHED, progress=False) - try: - redis.set(cache_key, json.dumps(self._ws_cv.info)) - except Exception: - ... + ws = self._data_source.WATERSHED + info = _redis_cached_json( + f"ws_cv_info_cached:{ws}", + lambda: CloudVolume(ws, progress=False).info, + ) + self._ws_cv = CloudVolume(ws, info=info, progress=False) return self._ws_cv @property def ocdbt_config(self) -> OcdbtConfig: - """Per-CG OCDBT settings, built from custom_data["ocdbt_config"]. - - Falls back to the legacy custom_data["seg"] shape used before the - dataclass landed so CGs ingested under the older layout keep working. + """Per-CG OCDBT settings with precedence info-file > custom_data > defaults. + + The watershed's ``/ocdbt/.populated/meta.json`` is the authoritative + on-disk source for fields that affect the OCDBT format (compression, + max_inline_value_bytes, populate_layer). custom_data fills per-CG + fields (enabled, sv_split_threshold) and anything the info file + doesn't pin. Both layers fall through to dataclass defaults. + + The info-file fetch goes through a Redis cache (same pattern as + ``ws_cv``) so distributed workers don't re-read the same GCS + object on every CG instantiation. Result is also cached in + instance state after first access. Legacy ``custom_data["seg"]`` + shape is read when ``"ocdbt_config"`` is absent so pre-refactor + CGs still open. """ - d = self._custom_data.get("ocdbt_config") - if d is None: + if self._ocdbt_config_cached is not None: + return self._ocdbt_config_cached + + meta_d = self._custom_data.get("ocdbt_config") + if meta_d is None: seg = self._custom_data.get("seg", {}) - d = { + meta_d = { "enabled": bool(seg.get("ocdbt", False)), "sv_split_threshold": int(seg.get("sv_split_threshold", 10)), } - return OcdbtConfig.from_dict(d) + + info_d = None + ws = self._data_source.WATERSHED + if ws: + info_d = _redis_cached_json( + f"ocdbt_info_cached:{ws}", + lambda: read_populate_meta(ws), + ) + + self._ocdbt_config_cached = OcdbtConfig.resolve(meta_d, info_d) + return self._ocdbt_config_cached @property def ocdbt_seg(self) -> bool: From cc20f10aa858efd3358110acb42e83ebde829a6d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 23:39:41 +0000 Subject: [PATCH 236/367] refactor(ingest): consolidate OCDBT helpers into ingest/ocdbt.py; drop edges-OCDBT path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ingest/ocdbt.py owns the per-chunk populate task, the shared base/fork setup, and a coordinator-server context manager that the populate-layer CLI will wire in next. populate_chunk fails loudly when the coordinator address isn't advertised in Redis — uncoordinated parallel commits race the shared manifest and leak orphan d/ files, the exact bug this module exists to prevent. The --ocdbt-edges branch and start_ocdbt_server (edges-only writer with a different base path) go away with their tests; no caller is left. --- pychunkedgraph/ingest/cli_upgrade.py | 36 ++---- pychunkedgraph/ingest/cluster.py | 64 +--------- pychunkedgraph/ingest/ocdbt.py | 116 ++++++++++++++++++ pychunkedgraph/ingest/utils.py | 13 +- .../tests/ingest/test_ingest_utils.py | 52 -------- 5 files changed, 128 insertions(+), 153 deletions(-) create mode 100644 pychunkedgraph/ingest/ocdbt.py diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index eeafea864..2abd6b0b4 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -4,33 +4,21 @@ cli for running upgrade """ -from time import sleep - -from pychunkedgraph import get_logger - -logger = get_logger(__name__) - import click -import tensorstore as ts from flask.cli import AppGroup -from pychunkedgraph import __version__ + +from pychunkedgraph import __version__, get_logger from pychunkedgraph.graph.meta import GraphConfig from . import IngestConfig -from .cluster import ( - convert_edges_to_ocdbt, - enqueue_l2_tasks, - upgrade_atomic_chunk, - upgrade_parent_chunk, -) +from .cluster import enqueue_l2_tasks, upgrade_atomic_chunk, upgrade_parent_chunk from .manager import IngestionManager from .utils import ( chunk_id_str, + job_type_guard, print_completion_rate, print_status, queue_layer_helper, - start_ocdbt_server, - job_type_guard, ) from ..graph.chunkedgraph import ChunkedGraph, ChunkedGraphMeta from ..graph.ocdbt import ( @@ -42,6 +30,8 @@ from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys +logger = get_logger(__name__) + group_name = "upgrade" upgrade_cli = AppGroup(group_name) @@ -63,7 +53,6 @@ def flush_redis(): @click.argument("graph_id", type=str) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @click.option("--ocdbt", is_flag=True, help="Enable ocdbt seg (SV splitting support).") -@click.option("--ocdbt-edges", is_flag=True, help="Convert edges to ocdbt kv store.") @click.option( "--sv-split-threshold", type=int, @@ -80,7 +69,6 @@ def upgrade_graph( graph_id: str, test: bool, ocdbt: bool, - ocdbt_edges: bool, sv_split_threshold: int, reset_ocdbt: bool, ): @@ -124,17 +112,7 @@ def upgrade_graph( ... imanager = IngestionManager(ingest_config, cg.meta) - if ocdbt_edges: - server = ts.ocdbt.DistributedCoordinatorServer() - start_ocdbt_server(imanager, server) - - fn = convert_edges_to_ocdbt if ocdbt_edges else upgrade_atomic_chunk - enqueue_l2_tasks(imanager, fn) - - if ocdbt_edges: - logger.note("All tasks queued. Keep this alive for ocdbt coordinator server.") - while True: - sleep(60) + enqueue_l2_tasks(imanager, upgrade_atomic_chunk) @upgrade_cli.command("layer") diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 35875e64e..47b9c3aea 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -18,6 +18,7 @@ from .utils import chunk_id_str, get_chunks_not_done, randomize_grid_points from .manager import IngestionManager +from .ocdbt import populate_chunk from .ran_agglomeration import ( get_active_edges, read_raw_edge_data, @@ -27,18 +28,10 @@ from .create.parent_layer import add_parent_chunk from .upgrade.atomic_layer import update_chunk as update_atomic_chunk from .upgrade.parent_layer import update_chunk as update_parent_chunk -from ..graph.edges import EDGE_TYPES, Edges, put_edges +from ..graph.edges import EDGE_TYPES from ..graph import ChunkedGraph, ChunkedGraphMeta -from ..graph.ocdbt import ( - OcdbtConfig, - _layer_bbox, - copy_ws_bbox_multiscale, - is_chunk_populated, - mark_chunk_populated, - open_base_ocdbt, -) +from ..graph.ocdbt import is_chunk_populated from ..graph.chunks.hierarchy import get_children_chunk_coords -from ..graph.basetypes import NODE_ID from ..io.edges import get_chunk_edges from ..io.components import get_chunk_components from ..utils.redis import keys as r_keys, get_redis_connection @@ -68,14 +61,6 @@ def _post_task_completion( logger.note(f"{chunk_str} marked as complete") -def _populate_ocdbt_chunk(imanager, ws, layer, coords): - cfg = OcdbtConfig.from_dict(imanager.ocdbt_config) - src_list, dst_list, resolutions = open_base_ocdbt(ws, cfg) - lo, hi = _layer_bbox(imanager.cg.meta, layer, coords) - copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) - mark_chunk_populated(ws, layer, coords) - - def create_parent_chunk( parent_layer: int, parent_coords: Sequence[int], @@ -99,7 +84,7 @@ def create_parent_chunk( ): ws = imanager.cg.meta.data_source.WATERSHED if not is_chunk_populated(ws, parent_layer, parent_coords): - _populate_ocdbt_chunk(imanager, ws, parent_layer, parent_coords) + populate_chunk(imanager, ws, parent_layer, parent_coords) _post_task_completion(imanager, parent_layer, parent_coords) @@ -182,47 +167,6 @@ def upgrade_atomic_chunk(coords: Sequence[int]): _post_task_completion(imanager, 2, coords) -def convert_edges_to_ocdbt(coords: Sequence[int]): - """ - Convert edges stored per chunk to ajacency list in the tensorstore ocdbt kv store. - """ - imanager = _get_imanager() - coords = np.array(list(coords), dtype=int) - chunk_edges_all, mapping = _get_atomic_chunk_data(imanager, coords) - - node_ids1 = [] - node_ids2 = [] - affinities = [] - areas = [] - for edges in chunk_edges_all.values(): - node_ids1.extend(edges.node_ids1) - node_ids2.extend(edges.node_ids2) - affinities.extend(edges.affinities) - areas.extend(edges.areas) - - edges = Edges(node_ids1, node_ids2, affinities=affinities, areas=areas) - nodes = np.concatenate( - [edges.node_ids1, edges.node_ids2, np.fromiter(mapping.keys(), dtype=NODE_ID)] - ) - nodes = np.unique(nodes) - - chunk_id = imanager.cg.get_chunk_id(layer=1, x=coords[0], y=coords[1], z=coords[2]) - chunk_ids = imanager.cg.get_chunk_ids_from_node_ids(nodes) - - host = imanager.redis.get("OCDBT_COORDINATOR_HOST").decode() - port = imanager.redis.get("OCDBT_COORDINATOR_PORT").decode() - environ["OCDBT_COORDINATOR_HOST"] = host - environ["OCDBT_COORDINATOR_PORT"] = port - logger.note(f"OCDBT Coordinator address {host}:{port}") - - put_edges( - f"{imanager.cg.meta.data_source.EDGES}/ocdbt", - nodes[chunk_ids == chunk_id], - edges, - ) - _post_task_completion(imanager, 2, coords) - - def _get_test_chunks(meta: ChunkedGraphMeta): """Chunks at the center most likely not to be empty""" parent_coords = np.array(meta.layer_chunk_bounds[3]) // 2 diff --git a/pychunkedgraph/ingest/ocdbt.py b/pychunkedgraph/ingest/ocdbt.py new file mode 100644 index 000000000..93fb59590 --- /dev/null +++ b/pychunkedgraph/ingest/ocdbt.py @@ -0,0 +1,116 @@ +"""OCDBT-specific ingest helpers. + +Single home for everything OCDBT-related at the ingest layer: + * coordinator-server lifecycle (`coordinator`) + * per-chunk populate task (`populate_chunk`), used from `create_parent_chunk` + * shared base setup (`setup_base`), used by both ingest and upgrade CLIs +""" + +from contextlib import contextmanager +from os import environ + +import tensorstore as ts + +from pychunkedgraph import get_logger + +from ..graph.ocdbt import ( + OcdbtConfig, + _layer_bbox, + base_exists, + copy_ws_bbox_multiscale, + create_base_ocdbt, + fork_base_manifest, + mark_chunk_populated, + open_base_ocdbt, + read_populate_meta, + wipe_base_ocdbt, + write_populate_meta, +) + +logger = get_logger(__name__) + +_COORD_HOST_KEY = "OCDBT_COORDINATOR_HOST" +_COORD_PORT_KEY = "OCDBT_COORDINATOR_PORT" + + +@contextmanager +def coordinator(redis): + """Start a ``DistributedCoordinatorServer`` and advertise its address in + Redis so parallel populate workers route every OCDBT commit through this + one server — no manifest-CAS races, no orphan ``d/`` files. + + The server lives as long as the ``with`` block does; on exit the Redis + advertisement is cleared so a stale address can't outlive the server. + Caller blocks inside the ``with`` body (e.g. ``while True: sleep(60)``) + to keep the server reference alive across the populate phase. + """ + server = ts.ocdbt.DistributedCoordinatorServer() + host = environ.get("MY_POD_IP", "localhost") + redis.set(_COORD_HOST_KEY, host) + redis.set(_COORD_PORT_KEY, str(server.port)) + logger.note(f"OCDBT Coordinator listening at {host}:{server.port}") + try: + yield server + finally: + redis.delete(_COORD_HOST_KEY, _COORD_PORT_KEY) + logger.note("OCDBT Coordinator advertisement cleared.") + + +def _apply_coordinator_env(redis) -> None: + """Worker-side: copy advertised coordinator address from Redis into env + vars so this process's OCDBT commits route through the coordinator. + + Fails loudly if the address isn't advertised. Uncoordinated parallel + commits race the shared manifest and leak orphan ``d/`` files — the + exact bug this code exists to prevent. + """ + host = redis.get(_COORD_HOST_KEY) + port = redis.get(_COORD_PORT_KEY) + if not host or not port: + raise RuntimeError( + "OCDBT coordinator address not advertised in Redis " + f"({_COORD_HOST_KEY}/{_COORD_PORT_KEY} unset). " + "Run `flask ingest layer N` (with N == ocdbt_populate_layer) to " + "start the coordinator before queuing populate workers." + ) + environ[_COORD_HOST_KEY] = host.decode() + environ[_COORD_PORT_KEY] = port.decode() + + +def populate_chunk(imanager, ws: str, layer: int, coords) -> None: + """One LN parent-layer task's OCDBT populate. + + Routes through the advertised coordinator (mandatory — raises if it + isn't running), copies the base-resolution bbox at every scale under + one atomic transaction, and records the per-chunk completion marker. + """ + _apply_coordinator_env(imanager.redis) + cfg = OcdbtConfig.from_dict(imanager.ocdbt_config) + src_list, dst_list, resolutions = open_base_ocdbt(ws, cfg) + lo, hi = _layer_bbox(imanager.cg.meta, layer, coords) + copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) + mark_chunk_populated(ws, layer, coords) + + +def setup_base(cg, ocdbt_cfg: OcdbtConfig, reset: bool = False) -> OcdbtConfig: + """Idempotent OCDBT base + fork setup, shared by ingest and upgrade. + + Wipes if ``reset``; creates the base if missing; reconciles the + yaml/CLI-supplied config with the on-disk populate_meta (info-file + wins per ``OcdbtConfig.resolve``); persists the resolved config to + ``cg.meta.custom_data["ocdbt_config"]``; forks the manifest for this + CG. Returns the resolved OcdbtConfig. + """ + ws = cg.meta.data_source.WATERSHED + if reset: + wipe_base_ocdbt(ws) + if not base_exists(ws): + create_base_ocdbt(ws, ocdbt_cfg) + info = read_populate_meta(ws) + resolved = OcdbtConfig.resolve(ocdbt_cfg.to_dict(), info) + if resolved.populate_base: + write_populate_meta(ws, resolved.to_dict()) + cg.meta.custom_data["ocdbt_config"] = resolved.to_dict() + cg.update_meta(cg.meta, overwrite=True) + fork_base_manifest(ws, cg.meta.graph_id, wipe_existing=reset) + return resolved diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index e9da04e88..95bd12e6f 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -5,10 +5,9 @@ import sys from os import environ from time import sleep -from typing import Any, Dict, Generator, Tuple +from typing import Dict, Generator, Tuple import numpy as np -import tensorstore as ts from kvdbclient import BigTableConfig, HBaseConfig from rich import box from rich.console import Group @@ -110,16 +109,6 @@ def postprocess_edge_data(im, edge_dict): raise ValueError(f"Unknown data_version: {data_version}") -def start_ocdbt_server(imanager: IngestionManager, server: Any): - spec = {"driver": "ocdbt", "base": f"{imanager.cg.meta.data_source.EDGES}/ocdbt"} - spec["coordinator"] = {"address": f"localhost:{server.port}"} - ts.KvStore.open(spec).result() - imanager.redis.set("OCDBT_COORDINATOR_PORT", str(server.port)) - ocdbt_host = environ.get("MY_POD_IP", "localhost") - imanager.redis.set("OCDBT_COORDINATOR_HOST", ocdbt_host) - logger.note(f"OCDBT Coordinator address {ocdbt_host}:{server.port}") - - def randomize_grid_points(X: int, Y: int, Z: int) -> Generator[int, int, int]: indices = np.arange(X * Y * Z) np.random.shuffle(indices) diff --git a/pychunkedgraph/tests/ingest/test_ingest_utils.py b/pychunkedgraph/tests/ingest/test_ingest_utils.py index 4c5bdf0af..a0940d775 100644 --- a/pychunkedgraph/tests/ingest/test_ingest_utils.py +++ b/pychunkedgraph/tests/ingest/test_ingest_utils.py @@ -329,7 +329,6 @@ def my_func(): # ===================================================================== # Additional pure unit tests # ===================================================================== -from pychunkedgraph.ingest.utils import start_ocdbt_server class TestGetChunksNotDoneWithSplits: @@ -390,57 +389,6 @@ def test_get_chunks_not_done_splits_coord_str_format(self): assert call_args[0][1] == ["2_3_4_0"] -class TestStartOcdbtServer: - """Test start_ocdbt_server function.""" - - @patch("pychunkedgraph.ingest.utils.ts") - @patch.dict("os.environ", {"MY_POD_IP": "10.0.0.1"}) - def test_start_ocdbt_server(self, mock_ts): - """start_ocdbt_server should open a KvStore and set redis keys.""" - imanager = MagicMock() - imanager.cg.meta.data_source.EDGES = "gs://bucket/edges" - mock_redis = MagicMock() - imanager.redis = mock_redis - - server = MagicMock() - server.port = 12345 - - mock_kv_future = MagicMock() - mock_ts.KvStore.open.return_value = mock_kv_future - - start_ocdbt_server(imanager, server) - - # Verify tensorstore was called with the right spec - call_args = mock_ts.KvStore.open.call_args[0][0] - assert call_args["driver"] == "ocdbt" - assert "gs://bucket/edges/ocdbt" in call_args["base"] - assert call_args["coordinator"]["address"] == "localhost:12345" - mock_kv_future.result.assert_called_once() - - # Verify redis keys were set - mock_redis.set.assert_any_call("OCDBT_COORDINATOR_PORT", "12345") - mock_redis.set.assert_any_call("OCDBT_COORDINATOR_HOST", "10.0.0.1") - - @patch("pychunkedgraph.ingest.utils.ts") - @patch.dict("os.environ", {}, clear=True) - def test_start_ocdbt_server_default_host(self, mock_ts): - """When MY_POD_IP is not set, should default to localhost.""" - imanager = MagicMock() - imanager.cg.meta.data_source.EDGES = "gs://bucket/edges" - mock_redis = MagicMock() - imanager.redis = mock_redis - - server = MagicMock() - server.port = 9999 - - mock_kv_future = MagicMock() - mock_ts.KvStore.open.return_value = mock_kv_future - - start_ocdbt_server(imanager, server) - - mock_redis.set.assert_any_call("OCDBT_COORDINATOR_HOST", "localhost") - - class TestPostprocessEdgeDataNoneValues: """Test postprocess_edge_data when edge_dict values are None.""" From dc758046eb6ba2ec03a207f255628c8d74ecdb1a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 23:43:55 +0000 Subject: [PATCH 237/367] feat(ingest): coordinator-managed OCDBT populate; drop --reset-ocdbt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flask ingest layer N` now wraps queue_layer_helper in a DistributedCoordinatorServer when N matches the OCDBT populate layer: the server starts in-process, its address is advertised in Redis, and the foreground blocks so workers route every commit through it. Eliminates the manifest-CAS races that were emitting orphan d/ files at ~47× the rate of committed data. Both CLIs lose --reset-ocdbt — wipe out of band with `gcloud storage rm -r gs:///ocdbt/`. The shared base/fork/persist dance moves out of both `graph` subcommands into the single setup_base helper. upgrade's legacy custom_data["seg"] write is also replaced by the canonical ocdbt_config schema setup_base persists. --- pychunkedgraph/ingest/cli.py | 62 +++++++++++----------------- pychunkedgraph/ingest/cli_upgrade.py | 32 +++----------- pychunkedgraph/ingest/ocdbt.py | 16 ++++--- 3 files changed, 36 insertions(+), 74 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index a8ec7771e..9a21b7fa9 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -5,6 +5,7 @@ """ import os +from time import sleep from pychunkedgraph import configure_logging, DEBUG @@ -14,6 +15,7 @@ from .cluster import create_atomic_chunk, create_parent_chunk, enqueue_l2_tasks from .manager import IngestionManager +from .ocdbt import coordinator, setup_base from .utils import ( bootstrap, chunk_id_str, @@ -25,15 +27,7 @@ from .simple_tests import run_all from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph -from ..graph.ocdbt import ( - OcdbtConfig, - base_exists, - create_base_ocdbt, - fork_base_manifest, - read_populate_meta, - wipe_base_ocdbt, - write_populate_meta, -) +from ..graph.ocdbt import OcdbtConfig, fork_base_manifest from ..utils.redis import get_redis_connection, keys as r_keys group_name = "ingest" @@ -58,11 +52,6 @@ def flush_redis(): @click.argument("dataset", type=click.Path(exists=True), required=False) @click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") @click.option("--retry", is_flag=True, help="Rerun without creating a new table.") -@click.option( - "--reset-ocdbt", - is_flag=True, - help="Wipe base AND this CG's delta OCDBT, then recreate from scratch.", -) @click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") @job_type_guard(group_name) def ingest_graph( @@ -70,7 +59,6 @@ def ingest_graph( dataset: click.Path, raw: bool, retry: bool, - reset_ocdbt: bool, test: bool, ): """Main ingest command. Takes config from yaml, queues atomic tasks.""" @@ -105,33 +93,13 @@ def ingest_graph( meta, ingest_config, client_info, ocdbt_config_dict = bootstrap( graph_id, config, raw, test ) - yaml_only_cfg = OcdbtConfig.from_dict(ocdbt_config_dict) - if yaml_only_cfg.enabled: - ws = meta.data_source.WATERSHED - if reset_ocdbt: - wipe_base_ocdbt(ws) - if not base_exists(ws): - # No on-disk OCDBT yet — create it with yaml-supplied values. - create_base_ocdbt(ws, yaml_only_cfg) - # Precedence: info-file (per-base, already on disk) > yaml > defaults. - # Existing bases own the on-disk-format fields (compression, - # max_inline_value_bytes, populate_layer); yaml fills missing fields - # and supplies values for new bases. - info_dict = read_populate_meta(ws) - ocdbt_cfg = OcdbtConfig.resolve(ocdbt_config_dict, info_dict) - ocdbt_config_dict = ocdbt_cfg.to_dict() - if ocdbt_cfg.populate_base: - write_populate_meta(ws, ocdbt_config_dict) - fork_base_manifest(ws, graph_id, wipe_existing=reset_ocdbt) - else: - ocdbt_cfg = yaml_only_cfg - cg = ChunkedGraph(meta=meta, client_info=client_info) cg.create() + ocdbt_cfg = OcdbtConfig.from_dict(ocdbt_config_dict) if ocdbt_cfg.enabled: - cg.meta.custom_data["ocdbt_config"] = ocdbt_config_dict - cg.update_meta(cg.meta, overwrite=True) + resolved = setup_base(cg, ocdbt_cfg) + ocdbt_config_dict = resolved.to_dict() imanager = IngestionManager( ingest_config, @@ -172,11 +140,27 @@ def queue_layer(parent_layer): """ Queue all chunk tasks at a given layer. Must be used when all the chunks at `parent_layer - 1` have completed. + + When this layer is the OCDBT populate layer, also start a + ``DistributedCoordinatorServer`` so every worker's commit routes through + one process — eliminates manifest-CAS races and orphan ``d/`` files. + Stays in the foreground until killed. """ assert parent_layer > 2, "This command is for layers 3 and above." redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - queue_layer_helper(parent_layer, imanager, create_parent_chunk) + + if ( + imanager.ocdbt_seg + and imanager.ocdbt_populate_base + and parent_layer == imanager.ocdbt_populate_layer + ): + with coordinator(imanager.redis): + queue_layer_helper(parent_layer, imanager, create_parent_chunk) + while True: + sleep(60) + else: + queue_layer_helper(parent_layer, imanager, create_parent_chunk) @ingest_cli.command("status") diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index 2abd6b0b4..6185fa497 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -13,6 +13,7 @@ from . import IngestConfig from .cluster import enqueue_l2_tasks, upgrade_atomic_chunk, upgrade_parent_chunk from .manager import IngestionManager +from .ocdbt import setup_base from .utils import ( chunk_id_str, job_type_guard, @@ -21,12 +22,7 @@ queue_layer_helper, ) from ..graph.chunkedgraph import ChunkedGraph, ChunkedGraphMeta -from ..graph.ocdbt import ( - base_exists, - create_base_ocdbt, - fork_base_manifest, - wipe_base_ocdbt, -) +from ..graph.ocdbt import OcdbtConfig from ..utils.redis import get_redis_connection from ..utils.redis import keys as r_keys @@ -59,18 +55,12 @@ def flush_redis(): default=10, help="Distance threshold for SV split edge matching.", ) -@click.option( - "--reset-ocdbt", - is_flag=True, - help="Wipe base AND this CG's delta OCDBT, then recreate from scratch.", -) @job_type_guard(group_name) def upgrade_graph( graph_id: str, test: bool, ocdbt: bool, sv_split_threshold: int, - reset_ocdbt: bool, ): """ Main upgrade command. Queues atomic tasks. @@ -91,21 +81,11 @@ def upgrade_graph( cg = ChunkedGraph(graph_id=graph_id) if ocdbt: - ws = cg.meta.data_source.WATERSHED - cg.meta.custom_data["seg"] = { - "ocdbt": True, - "sv_split_threshold": sv_split_threshold, - } - cg.update_meta(cg.meta, overwrite=True) + ocdbt_cfg = OcdbtConfig.from_dict(cg.meta.custom_data.get("ocdbt_config")) + ocdbt_cfg.enabled = True + ocdbt_cfg.sv_split_threshold = sv_split_threshold + setup_base(cg, ocdbt_cfg) logger.note(f"enabled ocdbt seg with sv_split_threshold={sv_split_threshold}") - - if reset_ocdbt: - wipe_base_ocdbt(ws) - - if not base_exists(ws): - create_base_ocdbt(ws) - - fork_base_manifest(ws, graph_id, wipe_existing=reset_ocdbt) try: cg.client.create_column_family("4") except Exception: diff --git a/pychunkedgraph/ingest/ocdbt.py b/pychunkedgraph/ingest/ocdbt.py index 93fb59590..36f348fb7 100644 --- a/pychunkedgraph/ingest/ocdbt.py +++ b/pychunkedgraph/ingest/ocdbt.py @@ -23,7 +23,6 @@ mark_chunk_populated, open_base_ocdbt, read_populate_meta, - wipe_base_ocdbt, write_populate_meta, ) @@ -92,18 +91,17 @@ def populate_chunk(imanager, ws: str, layer: int, coords) -> None: mark_chunk_populated(ws, layer, coords) -def setup_base(cg, ocdbt_cfg: OcdbtConfig, reset: bool = False) -> OcdbtConfig: +def setup_base(cg, ocdbt_cfg: OcdbtConfig) -> OcdbtConfig: """Idempotent OCDBT base + fork setup, shared by ingest and upgrade. - Wipes if ``reset``; creates the base if missing; reconciles the - yaml/CLI-supplied config with the on-disk populate_meta (info-file - wins per ``OcdbtConfig.resolve``); persists the resolved config to + Creates the base if missing; reconciles the yaml/CLI-supplied config + with the on-disk populate_meta (info-file wins per + ``OcdbtConfig.resolve``); persists the resolved config to ``cg.meta.custom_data["ocdbt_config"]``; forks the manifest for this - CG. Returns the resolved OcdbtConfig. + CG. Returns the resolved OcdbtConfig. To wipe and start over, use + ``gcloud storage rm -r gs:///ocdbt/`` before invoking. """ ws = cg.meta.data_source.WATERSHED - if reset: - wipe_base_ocdbt(ws) if not base_exists(ws): create_base_ocdbt(ws, ocdbt_cfg) info = read_populate_meta(ws) @@ -112,5 +110,5 @@ def setup_base(cg, ocdbt_cfg: OcdbtConfig, reset: bool = False) -> OcdbtConfig: write_populate_meta(ws, resolved.to_dict()) cg.meta.custom_data["ocdbt_config"] = resolved.to_dict() cg.update_meta(cg.meta, overwrite=True) - fork_base_manifest(ws, cg.meta.graph_id, wipe_existing=reset) + fork_base_manifest(ws, cg.meta.graph_id) return resolved From d4ede7118c51de5a50621fc8aeccd0c00d52bd3a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 23:46:28 +0000 Subject: [PATCH 238/367] refactor(ingest): consolidate duplicated chunk-requeue body into utils.requeue_chunk ingest_chunk and upgrade_chunk were identical aside from their L2 / L3+ function pair. The shared body lives in utils.requeue_chunk; each CLI's chunk command is one line that passes its own (atomic_fn, parent_fn). --- pychunkedgraph/ingest/cli.py | 22 +++------------------- pychunkedgraph/ingest/cli_upgrade.py | 20 ++------------------ pychunkedgraph/ingest/utils.py | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 9a21b7fa9..c98826346 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -18,11 +18,11 @@ from .ocdbt import coordinator, setup_base from .utils import ( bootstrap, - chunk_id_str, + job_type_guard, print_completion_rate, print_status, queue_layer_helper, - job_type_guard, + requeue_chunk, ) from .simple_tests import run_all from .create.parent_layer import add_parent_chunk @@ -182,23 +182,7 @@ def ingest_status(refresh: int): @job_type_guard(group_name) def ingest_chunk(queue: str, chunk_info): """Manually queue chunk when a job is stuck for whatever reason.""" - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - layer, coords = chunk_info[0], chunk_info[1:] - - func = create_parent_chunk - args = (layer, coords) - if layer == 2: - func = create_atomic_chunk - args = (coords,) - queue = imanager.get_task_queue(queue) - queue.enqueue( - func, - job_id=chunk_id_str(layer, coords), - job_timeout=f"{int(layer * layer)}m", - result_ttl=0, - args=args, - ) + requeue_chunk(queue, chunk_info, create_atomic_chunk, create_parent_chunk) @ingest_cli.command("chunk_local") diff --git a/pychunkedgraph/ingest/cli_upgrade.py b/pychunkedgraph/ingest/cli_upgrade.py index 6185fa497..83e9e53c8 100644 --- a/pychunkedgraph/ingest/cli_upgrade.py +++ b/pychunkedgraph/ingest/cli_upgrade.py @@ -15,11 +15,11 @@ from .manager import IngestionManager from .ocdbt import setup_base from .utils import ( - chunk_id_str, job_type_guard, print_completion_rate, print_status, queue_layer_helper, + requeue_chunk, ) from ..graph.chunkedgraph import ChunkedGraph, ChunkedGraphMeta from ..graph.ocdbt import OcdbtConfig @@ -129,23 +129,7 @@ def upgrade_status(refresh: int): @job_type_guard(group_name) def upgrade_chunk(queue: str, chunk_info): """Manually queue chunk when a job is stuck for whatever reason.""" - redis = get_redis_connection() - imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - layer, coords = chunk_info[0], chunk_info[1:] - - func = upgrade_parent_chunk - args = (layer, coords) - if layer == 2: - func = upgrade_atomic_chunk - args = (coords,) - queue = imanager.get_task_queue(queue) - queue.enqueue( - func, - job_id=chunk_id_str(layer, coords), - job_timeout=f"{int(layer * layer)}m", - result_ttl=0, - args=args, - ) + requeue_chunk(queue, chunk_info, upgrade_atomic_chunk, upgrade_parent_chunk) @upgrade_cli.command("rate") diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 95bd12e6f..d6a5a18e0 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -428,6 +428,30 @@ def queue_layer_helper( logger.note(f"Queued {len(job_datas)} chunks.") +def requeue_chunk(queue_name: str, chunk_info, atomic_fn, parent_fn): + """Body of the ``chunk`` CLI command (shared by ingest and upgrade). + + Loads the manager from Redis, dispatches ``atomic_fn`` for L2 or + ``parent_fn`` for L3+, and enqueues a single task with the standard + job_id / timeout convention. + """ + redis = get_redis_connection() + imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) + layer, coords = chunk_info[0], chunk_info[1:] + if layer == 2: + fn, args = atomic_fn, (coords,) + else: + fn, args = parent_fn, (layer, coords) + queue = imanager.get_task_queue(queue_name) + queue.enqueue( + fn, + job_id=chunk_id_str(layer, coords), + job_timeout=f"{int(layer * layer)}m", + result_ttl=0, + args=args, + ) + + def job_type_guard(job_type: str): def decorator_job_type_guard(func): @functools.wraps(func) From 86ee9fe9641992d7ff9938298c1bbf255c98cf47 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 20 May 2026 23:49:32 +0000 Subject: [PATCH 239/367] fix(tests): unpack 4-tuple from bootstrap in test_from_config bootstrap returns (meta, ingest_config, client_info, ocdbt_config_dict) since the OCDBT-package refactor, but the test still unpacked three values and raised at call site. Update the unpack and assert the new dict element is present. --- pychunkedgraph/tests/ingest/test_ingest_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pychunkedgraph/tests/ingest/test_ingest_utils.py b/pychunkedgraph/tests/ingest/test_ingest_utils.py index a0940d775..400ce3a0c 100644 --- a/pychunkedgraph/tests/ingest/test_ingest_utils.py +++ b/pychunkedgraph/tests/ingest/test_ingest_utils.py @@ -44,10 +44,13 @@ def test_from_config(self): }, "ingest_config": {}, } - meta, ingest_config, client_info = bootstrap("test_graph", config=config) + meta, ingest_config, client_info, ocdbt_config_dict = bootstrap( + "test_graph", config=config + ) assert meta.graph_config.ID == "test_graph" assert meta.graph_config.FANOUT == 2 assert ingest_config.USE_RAW_EDGES is False + assert isinstance(ocdbt_config_dict, dict) class TestPostprocessEdgeData: From b4985c32a050b498daa00666aae5e497588c69ac Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 00:47:05 +0000 Subject: [PATCH 240/367] chore(ingest status): reorder + rename columns; split workers/busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queued sits at position 2 — the column you actually watch during a run. Layer centers under its header now that it's the lone left column. % becomes progress; done becomes completed. The composite workers [busy] column splits into two right-aligned columns (workers, busy) so the total and the active count line up by digit and don't visually fuse. Global padding bumps to (0, 2) for breathing room. Headers go through rich.text.Text so any future bracketed header would render literally instead of being parsed as Rich markup. --- pychunkedgraph/ingest/utils.py | 62 +++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index d6a5a18e0..26885cbd6 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -152,8 +152,9 @@ def print_completion_rate(imanager: IngestionManager, layer: int, span: int = 30 move_up() -def _busy_over_total_per_queue(redis, worker_keys_per_layer) -> list: - """For each layer's set of worker keys, return "busy/total" or "-" if no workers. +def _workers_busy_per_queue(redis, worker_keys_per_layer): + """For each layer's set of worker keys, return parallel (workers, busy) + string lists — "-" / "-" when no workers are registered for that layer. Two-round-trip approach: caller already fetched the SMEMBERS sets; this function pipelines HGET state for every worker key and counts busy. @@ -164,17 +165,18 @@ def _busy_over_total_per_queue(redis, worker_keys_per_layer) -> list: state_pipe.hget(wk, "state") states = state_pipe.execute() if any(worker_keys_per_layer) else [] - out = [] + workers, busy = [], [] idx = 0 for keys in worker_keys_per_layer: total = len(keys) - busy = 0 + b = 0 for _ in keys: if states[idx] == b"busy": - busy += 1 + b += 1 idx += 1 - out.append(f"{busy}/{total}" if total else "-") - return out + workers.append(f"{total}" if total else "-") + busy.append(f"{b}" if total else "-") + return workers, busy def _layer_keys(layers) -> list: @@ -213,8 +215,8 @@ def _layer_status(redis, layer_keys): failed.append(results[i + 2]) worker_keys_per_layer.append(results[i + 3]) - worker_busy = _busy_over_total_per_queue(redis, worker_keys_per_layer) - return job_type, completed, queued, failed, worker_busy + workers, busy = _workers_busy_per_queue(redis, worker_keys_per_layer) + return job_type, completed, queued, failed, workers, busy def _sized_table(columns: list, rows: list, **table_kwargs) -> Table: @@ -228,14 +230,18 @@ def _sized_table(columns: list, rows: list, **table_kwargs) -> Table: table = Table( box=None, pad_edge=False, - padding=(0, 1), + padding=(0, 2), show_header=True, header_style="bold", **table_kwargs, ) for col_idx, (name, justify) in enumerate(columns): width = max(len(name), max((len(row[col_idx]) for row in rows), default=0)) - table.add_column(name, justify=justify, width=width, no_wrap=True) + # Header wrapped in Text so any brackets in `name` render literally + # rather than being parsed as Rich markup tags. + table.add_column( + Text(name, style="bold"), justify=justify, width=width, no_wrap=True + ) for row in rows: table.add_row(*row) return table @@ -288,45 +294,55 @@ def _header_renderables(imanager: IngestionManager) -> list: def _status_table( - layers, layer_counts, completed, queued, failed, worker_busy + layers, layer_counts, completed, queued, failed, workers, busy ) -> Table: """One row per layer with progress, queue, and worker stats.""" columns = [ - ("layer", "right"), - ("done", "right"), - ("total", "right"), - ("%", "right"), + ("layer", "center"), ("queued", "right"), + ("completed", "right"), + ("total", "right"), + ("progress", "right"), ("failed", "right"), - ("busy", "left"), + ("workers", "right"), + ("busy", "right"), ] rows = [] - for layer, done, count, q, f, wb in zip( - layers, completed, layer_counts, queued, failed, worker_busy + for layer, done, count, q, f, w, b in zip( + layers, completed, layer_counts, queued, failed, workers, busy ): pct = math.floor((done / count) * 100) if count else 0 rows.append( ( str(layer), + f"{q:,}", f"{done:,}", f"{count:,}", f"{pct}%", - f"{q:,}", f"{f:,}", - str(wb), + str(w), + str(b), ) ) return _sized_table(columns, rows) def _status_renderable( - imanager, layers, layer_counts, job_type, completed, queued, failed, worker_busy + imanager, + layers, + layer_counts, + job_type, + completed, + queued, + failed, + workers, + busy, ): """Combine header rows + per-layer table inside one Panel; job_type goes in the title.""" body = Group( *_header_renderables(imanager), Rule(style="dim"), - _status_table(layers, layer_counts, completed, queued, failed, worker_busy), + _status_table(layers, layer_counts, completed, queued, failed, workers, busy), ) return Panel( body, From 12b68d1848d9e2704daa51486854f804bd8ae079 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 00:58:13 +0000 Subject: [PATCH 241/367] fix(ingest): route OCDBT populate writes through the coordinator spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers were setting OCDBT_COORDINATOR_HOST/PORT env vars before opening the base kvstore. The tensorstore binary doesn't reference those strings — the OCDBT driver looks at the spec's `coordinator: {address: ...}` field (or the `ocdbt_coordinator` Context resource), nothing else. So every "distributed" populate worker has been writing direct, racing the shared manifest, and emitting orphan d/ files: ~634 GiB of d/ at under 3% of layer 3 done before this was caught. open_base_ocdbt takes an optional coordinator_address and injects it into the kvstore spec. populate_chunk forwards an address when one is passed (distributed) and writes direct when none is (single-process / notebooks), since uncoordinated single-writer is fine. cluster.create_parent_chunk is the only distributed caller and is where the fail-loud lives: it reads the address from Redis via get_coordinator_address and raises if unset, so a worker can never silently regress to the env-var no-op. Verified by opening an OCDBT kvstore with coordinator=localhost:1 — the first write raises UNAVAILABLE, confirming the spec field is the actual routing knob. --- pychunkedgraph/graph/ocdbt/main.py | 13 ++++++++++- pychunkedgraph/ingest/cluster.py | 7 ++++-- pychunkedgraph/ingest/ocdbt.py | 37 +++++++++++++++++++----------- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index cfaaef690..ccc684ba9 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -101,19 +101,30 @@ def wipe_base_ocdbt(ws_path: str): pass -def open_base_ocdbt(ws_path: str, config: OcdbtConfig): +def open_base_ocdbt( + ws_path: str, config: OcdbtConfig, coordinator_address: str | None = None +): """Open the existing base OCDBT (read/write) for populating during ingest. Used by per-chunk ingest tasks that copy precomputed data into the shared base. NOT used at runtime — runtime always goes through the per-CG fork spec via ``get_seg_source_and_destination_ocdbt``. + ``coordinator_address`` (``"host:port"``) routes every OCDBT commit + through a ``DistributedCoordinatorServer`` so parallel workers don't + race the shared manifest's CAS — the only thing that prevents the + orphan ``d/`` file explosion. Required for any concurrent writer; the + arg is optional so single-process callers (e.g. tests, notebooks) can + skip it. + Returns (src_list, dst_list, resolutions). """ base = _base_ocdbt_path(ws_path) scales = _read_source_scales(ws_path) resolutions = [s["resolution"] for s in scales] base_kvstore = {"driver": "ocdbt", "base": base, "config": config.ts_config()} + if coordinator_address: + base_kvstore["coordinator"] = {"address": coordinator_address} src_list, dst_list = [], [] for i in range(len(scales)): diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index 47b9c3aea..bf3ed8dba 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -18,7 +18,7 @@ from .utils import chunk_id_str, get_chunks_not_done, randomize_grid_points from .manager import IngestionManager -from .ocdbt import populate_chunk +from .ocdbt import get_coordinator_address, populate_chunk from .ran_agglomeration import ( get_active_edges, read_raw_edge_data, @@ -84,7 +84,10 @@ def create_parent_chunk( ): ws = imanager.cg.meta.data_source.WATERSHED if not is_chunk_populated(ws, parent_layer, parent_coords): - populate_chunk(imanager, ws, parent_layer, parent_coords) + address = get_coordinator_address(imanager.redis) + populate_chunk( + imanager, ws, parent_layer, parent_coords, coordinator_address=address + ) _post_task_completion(imanager, parent_layer, parent_coords) diff --git a/pychunkedgraph/ingest/ocdbt.py b/pychunkedgraph/ingest/ocdbt.py index 36f348fb7..312f83ce0 100644 --- a/pychunkedgraph/ingest/ocdbt.py +++ b/pychunkedgraph/ingest/ocdbt.py @@ -55,12 +55,17 @@ def coordinator(redis): logger.note("OCDBT Coordinator advertisement cleared.") -def _apply_coordinator_env(redis) -> None: - """Worker-side: copy advertised coordinator address from Redis into env - vars so this process's OCDBT commits route through the coordinator. +def get_coordinator_address(redis) -> str: + """Return the advertised ``"host:port"`` for the OCDBT coordinator. - Fails loudly if the address isn't advertised. Uncoordinated parallel - commits race the shared manifest and leak orphan ``d/`` files — the + The address goes into the OCDBT kvstore spec's ``coordinator`` field — + the only routing knob tensorstore actually honors (verified against + the tensorstore binary; ``OCDBT_COORDINATOR_HOST/PORT`` env vars are + not consulted). + + Distributed callers MUST go through this getter so the populate fails + loudly when the coordinator isn't advertised — uncoordinated parallel + commits race the shared manifest and leak orphan ``d/`` files, the exact bug this code exists to prevent. """ host = redis.get(_COORD_HOST_KEY) @@ -72,20 +77,26 @@ def _apply_coordinator_env(redis) -> None: "Run `flask ingest layer N` (with N == ocdbt_populate_layer) to " "start the coordinator before queuing populate workers." ) - environ[_COORD_HOST_KEY] = host.decode() - environ[_COORD_PORT_KEY] = port.decode() + return f"{host.decode()}:{port.decode()}" -def populate_chunk(imanager, ws: str, layer: int, coords) -> None: +def populate_chunk( + imanager, ws: str, layer: int, coords, coordinator_address: str | None = None +) -> None: """One LN parent-layer task's OCDBT populate. - Routes through the advertised coordinator (mandatory — raises if it - isn't running), copies the base-resolution bbox at every scale under - one atomic transaction, and records the per-chunk completion marker. + When ``coordinator_address`` is set, every commit routes through that + server (mandatory for distributed workers — see ``get_coordinator_address``). + Single-process callers (notebooks, local one-off runs) can omit it and + write directly; safe as long as no other writer is committing concurrently. + + Copies the base-resolution bbox at every scale under one atomic + transaction and records the per-chunk completion marker. """ - _apply_coordinator_env(imanager.redis) cfg = OcdbtConfig.from_dict(imanager.ocdbt_config) - src_list, dst_list, resolutions = open_base_ocdbt(ws, cfg) + src_list, dst_list, resolutions = open_base_ocdbt( + ws, cfg, coordinator_address=coordinator_address + ) lo, hi = _layer_bbox(imanager.cg.meta, layer, coords) copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) mark_chunk_populated(ws, layer, coords) From 7802721b7c9a352d716c91864394caa29bcfa433 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 01:48:26 +0000 Subject: [PATCH 242/367] fix(ocdbt): drop atomic=True from copy_ws_bbox_multiscale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tensorstore's distributed-OCDBT path rejects cross-key atomic transactions — every populate task with the coordinator wired in was raising "Cannot read/write ... as single atomic transaction" the moment copy_ws_bbox_multiscale tried to span the info key and chunk keys (and across scales). The fix is just to drop atomic=True. The transaction object itself is what batches every per-scale .write() into one OCDBT commit; atomic only adds cross-key isolation, which we don't need here and which the coordinator path forbids. Verified by reproducing both shapes: * coord + atomic=True → fails immediately on info+chunk * coord + Transaction() → single d/ file per task (multi-scale) Concurrent-writer check under the coordinator: 8 threads × 20 commits produced 183 d/ files (≈1.14× overhead from manifest updates). Without the coordinator the same workload produced 1017 d/ files (6.4× orphan ratio), which matches the manifest-CAS-race behavior the coordinator fixes. --- pychunkedgraph/graph/ocdbt/main.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index ccc684ba9..284a2b313 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -337,15 +337,18 @@ def copy_ws_bbox_multiscale( bbox_lo: np.ndarray, bbox_hi: np.ndarray, ): - """Copy a base-resolution voxel bbox across all MIP scales under one atomic txn. - - ``ts.Transaction(atomic=True)`` is load-bearing: without it the precomputed - driver splits a multi-chunk ``.write()`` into multiple OCDBT sub-commits, - so file count would scale with the number of precomputed chunks inside - the bbox. With ``atomic=True``, every per-chunk underlying-kvstore write - inside one precomputed handle collapses into a single OCDBT commit — so - the d/ file count for one call to this function is constant in the - number of chunks inside the bbox; it only grows with scale count. + """Copy a base-resolution voxel bbox across all MIP scales under one + transaction so the whole multi-scale write lands as a single OCDBT commit. + + The transaction (not ``atomic=True``) is what's load-bearing: it batches + every per-chunk underlying-kvstore write across every scale into one + commit, so the d/ file count for one call is constant in bbox size and + grows only with scale count. ``atomic=True`` would add cross-key + isolation but is rejected by tensorstore's distributed-OCDBT path — + when the kvstore is opened with a ``coordinator``, atomic transactions + cannot span multiple keys (verified empirically). Non-atomic still + batches; the coordinator handles concurrency by serializing the commit + on the wire. Passing the source TensorStore directly into ``write(...)`` lets tensorstore stream the copy without materializing an intermediate @@ -354,7 +357,7 @@ def copy_ws_bbox_multiscale( """ assert len(src_list) == len(dst_list) == len(resolutions) base_res = np.array(resolutions[0]) - txn = ts.Transaction(atomic=True) + txn = ts.Transaction() for i, (src, dst) in enumerate(zip(src_list, dst_list)): factor = (np.array(resolutions[i]) / base_res).astype(int) x0, y0, z0 = bbox_lo // factor From 9c11b7f5554be433083872491f0614a82a9e6e96 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 01:58:02 +0000 Subject: [PATCH 243/367] chore(ocdbt): log per-task populate work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each populate task now prints a layer/coords header line, then the multiscale commit prints a tight summary of how many voxels landed across how many scales. Per-scale detail stays at debug level so the default operator view stays one informative line per task — enough to see what's being done and how much, without scrolling. --- pychunkedgraph/graph/ocdbt/main.py | 17 +++++++++++++++++ pychunkedgraph/ingest/cluster.py | 26 ++++++++++++++++---------- pychunkedgraph/ingest/ocdbt.py | 1 + 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index 284a2b313..67326f949 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -48,6 +48,14 @@ logger = get_logger(__name__) +def _humanize_count(n: int) -> str: + """Compact count for log lines: 1234567 → '1.2M', 950 → '950'.""" + for unit, scale in (("G", 1_000_000_000), ("M", 1_000_000), ("K", 1_000)): + if n >= scale: + return f"{n / scale:.1f}{unit}" + return str(n) + + def create_base_ocdbt(ws_path: str, config: OcdbtConfig): """One-time bootstrap: create the shared base OCDBT at ``/ocdbt/base/``. @@ -358,6 +366,8 @@ def copy_ws_bbox_multiscale( assert len(src_list) == len(dst_list) == len(resolutions) base_res = np.array(resolutions[0]) txn = ts.Transaction() + n_scales = 0 + total_voxels = 0 for i, (src, dst) in enumerate(zip(src_list, dst_list)): factor = (np.array(resolutions[i]) / base_res).astype(int) x0, y0, z0 = bbox_lo // factor @@ -365,10 +375,17 @@ def copy_ws_bbox_multiscale( if x1 <= x0 or y1 <= y0 or z1 <= z0: logger.debug(f"skipping empty region at scale {i}") continue + nvox = int((x1 - x0) * (y1 - y0) * (z1 - z0)) + n_scales += 1 + total_voxels += nvox + logger.debug(f"scale {i}: {nvox:,} voxels") dst.with_transaction(txn)[x0:x1, y0:y1, z0:z1].write( src[x0:x1, y0:y1, z0:z1] ).result() txn.commit_async().result() + logger.note( + f"OCDBT commit: {_humanize_count(total_voxels)} voxels, {n_scales} scales" + ) def _mode_downsample(data: np.ndarray, factors: tuple) -> np.ndarray: diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index bf3ed8dba..cad4043b3 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -66,17 +66,12 @@ def create_parent_chunk( parent_coords: Sequence[int], ) -> None: imanager = _get_imanager() - add_parent_chunk( - imanager.cg, - parent_layer, - parent_coords, - get_children_chunk_coords( - imanager.cg_meta, - parent_layer, - parent_coords, - ), - ) + # OCDBT populate runs FIRST so any failure here (coordinator missing, + # commit error, etc.) aborts the task BEFORE any graph mutation — + # otherwise an OCDBT failure could land after add_parent_chunk has + # already written parents, leaving the graph half-built and forcing + # corrupt-state retries. if ( imanager.ocdbt_seg and imanager.ocdbt_populate_base @@ -89,6 +84,17 @@ def create_parent_chunk( imanager, ws, parent_layer, parent_coords, coordinator_address=address ) + add_parent_chunk( + imanager.cg, + parent_layer, + parent_coords, + get_children_chunk_coords( + imanager.cg_meta, + parent_layer, + parent_coords, + ), + ) + _post_task_completion(imanager, parent_layer, parent_coords) diff --git a/pychunkedgraph/ingest/ocdbt.py b/pychunkedgraph/ingest/ocdbt.py index 312f83ce0..a305b01ca 100644 --- a/pychunkedgraph/ingest/ocdbt.py +++ b/pychunkedgraph/ingest/ocdbt.py @@ -98,6 +98,7 @@ def populate_chunk( ws, cfg, coordinator_address=coordinator_address ) lo, hi = _layer_bbox(imanager.cg.meta, layer, coords) + logger.note(f"L{layer} OCDBT populate {tuple(int(c) for c in coords)}") copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) mark_chunk_populated(ws, layer, coords) From 12cdebb6983725b6b1d73aeb5193af4da3568549 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 14:35:59 +0000 Subject: [PATCH 244/367] chore(ocdbt): per-task GCS dump on commit failures, gated on ERROR_DUMP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pychunkedgraph/graph/ocdbt/debug.py holds all the diagnostic plumbing — humanize_count, failure_envelope (host/pod/versions/ traceback/timestamp/coordinator env), bbox_failure_payload (per-scale shape/chunk/key-count/raw-bytes + src+dst kvstore specs), and dump_failure_to_gcs (writes JSON to $ERROR_DUMP/__.json). copy_ws_bbox_multiscale stays slim: when ERROR_DUMP is unset, no per-scale bookkeeping, no payload, no GCS write — happy path runs unchanged. When ERROR_DUMP is set, per-scale rows accumulate and on failure get serialized via the helpers. populate_chunk threads a /L/__ dump_tag so each failing task self- identifies in the bucket and multiple experiments share one dump root without collisions. --- pychunkedgraph/graph/ocdbt/debug.py | 143 ++++++++++++++++++++++++++++ pychunkedgraph/graph/ocdbt/main.py | 62 ++++++++---- pychunkedgraph/ingest/ocdbt.py | 4 +- 3 files changed, 189 insertions(+), 20 deletions(-) create mode 100644 pychunkedgraph/graph/ocdbt/debug.py diff --git a/pychunkedgraph/graph/ocdbt/debug.py b/pychunkedgraph/graph/ocdbt/debug.py new file mode 100644 index 000000000..b64376695 --- /dev/null +++ b/pychunkedgraph/graph/ocdbt/debug.py @@ -0,0 +1,143 @@ +"""Diagnostic plumbing for OCDBT failures. + +Humanize-count for log lines, generic failure envelope (host/pod/versions/ +traceback/timestamp), bbox-failure payload builder, and a GCS dump helper +that writes per-task forensic JSON under ``$ERROR_DUMP/__.json``. +Kept out of ``main.py`` and ``utils.py`` so the core OCDBT code stays +free of import bloat that's only used on failure paths. +""" + +import json +import logging +import os +import socket +import sys +import traceback +from datetime import datetime, timezone +from os import environ +from typing import Optional + +import tensorstore as ts + +_logger = logging.getLogger(__name__) + + +def humanize_count(n: int) -> str: + """Compact count for log lines: 1234567 → '1.2M', 950 → '950'.""" + for unit, scale in (("G", 1_000_000_000), ("M", 1_000_000), ("K", 1_000)): + if n >= scale: + return f"{n / scale:.1f}{unit}" + return str(n) + + +def failure_envelope(exc: BaseException, dump_tag: Optional[str]) -> dict: + """Generic metadata for any failure dump — host, pod, versions, + timestamp, traceback, coordinator env. Caller merges with the + failure-specific fields to build the final payload. + """ + return { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "dump_tag": dump_tag, + "host": { + "hostname": socket.gethostname(), + "pid": os.getpid(), + "pod_name": environ.get("MY_POD_NAME"), + "pod_ip": environ.get("MY_POD_IP"), + "node_name": environ.get("MY_NODE_NAME"), + }, + "versions": { + "tensorstore": getattr(ts, "__version__", None), + "python": sys.version, + }, + "ocdbt_coordinator_env": { + "OCDBT_COORDINATOR_HOST": environ.get("OCDBT_COORDINATOR_HOST"), + "OCDBT_COORDINATOR_PORT": environ.get("OCDBT_COORDINATOR_PORT"), + }, + "exception": { + "type": type(exc).__name__, + "module": type(exc).__module__, + "message": str(exc), + "traceback": traceback.format_exc(), + }, + } + + +def bbox_failure_payload( + exc: BaseException, + dump_tag: Optional[str], + bbox_lo, + bbox_hi, + resolutions, + per_scale, + dst_handle, + src_handle, +) -> dict: + """Build the full diagnostic dict for a ``copy_ws_bbox_multiscale`` + commit failure. + + Merges generic ``failure_envelope`` metadata with bbox-specific + fields (per-scale shape / chunk / key-count / raw-bytes, src+dst + kvstore specs). Spec dumps are wrapped in try/except so a malformed + handle doesn't shadow the original exception. + """ + try: + dst_spec = dst_handle.kvstore.spec().to_json() + except Exception as e: + dst_spec = f"" + try: + src_spec = src_handle.kvstore.spec().to_json() + except Exception as e: + src_spec = f"" + total_voxels = sum(p[2] for p in per_scale) + total_raw = sum(p[3] for p in per_scale) + total_keys = sum(p[5] for p in per_scale) + return { + **failure_envelope(exc, dump_tag), + "bbox_lo": [int(c) for c in bbox_lo], + "bbox_hi": [int(c) for c in bbox_hi], + "resolutions": [list(map(int, r)) for r in resolutions], + "n_scales": len(per_scale), + "total_voxels": total_voxels, + "total_raw_bytes": total_raw, + "total_keys": total_keys, + "per_scale": [ + { + "scale_index": i, + "dims": list(dims), + "voxels": nvox, + "raw_bytes": raw_bytes, + "chunk_shape": list(chunk_shape), + "n_keys": n_keys, + "max_raw_per_key_bytes": max_per_key, + } + for i, dims, nvox, raw_bytes, chunk_shape, n_keys, max_per_key in per_scale + ], + "dst_kvstore_spec": dst_spec, + "src_kvstore_spec": src_spec, + } + + +def dump_failure_to_gcs(payload: dict, dump_tag: str) -> Optional[str]: + """Write a per-task failure report to ``$ERROR_DUMP/__.json``. + + Returns the full path or None (env unset, dump_tag empty, or write + error). ``dump_tag`` carries the calling-context identifier (graph + id, layer, coords, …) so multiple experiments can share one + ``ERROR_DUMP`` bucket without collisions. + """ + root = environ.get("ERROR_DUMP", "").strip() + if not root or not dump_tag: + return None + if not root.endswith("/"): + root += "/" + utc = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + rel = f"{dump_tag}__{utc}.json" + full = root + rel + try: + ts.KvStore.open(root).result().write( + rel, json.dumps(payload, indent=2).encode("utf-8") + ).result() + return full + except Exception as e: + _logger.warning("failed to write ERROR_DUMP at %s: %r", full, e) + return None diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index 67326f949..89a4dfb5f 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -30,11 +30,14 @@ retained by default. """ +from os import environ + import numpy as np import tensorstore as ts from pychunkedgraph import get_logger +from .debug import bbox_failure_payload, dump_failure_to_gcs from .meta import OcdbtConfig from .utils import ( _base_ocdbt_path, @@ -48,14 +51,6 @@ logger = get_logger(__name__) -def _humanize_count(n: int) -> str: - """Compact count for log lines: 1234567 → '1.2M', 950 → '950'.""" - for unit, scale in (("G", 1_000_000_000), ("M", 1_000_000), ("K", 1_000)): - if n >= scale: - return f"{n / scale:.1f}{unit}" - return str(n) - - def create_base_ocdbt(ws_path: str, config: OcdbtConfig): """One-time bootstrap: create the shared base OCDBT at ``/ocdbt/base/``. @@ -344,6 +339,7 @@ def copy_ws_bbox_multiscale( resolutions, bbox_lo: np.ndarray, bbox_hi: np.ndarray, + dump_tag: str | None = None, ): """Copy a base-resolution voxel bbox across all MIP scales under one transaction so the whole multi-scale write lands as a single OCDBT commit. @@ -364,28 +360,56 @@ def copy_ws_bbox_multiscale( worth versus the read-into-numpy-then-write pattern. """ assert len(src_list) == len(dst_list) == len(resolutions) + dump_enabled = bool(environ.get("ERROR_DUMP")) base_res = np.array(resolutions[0]) txn = ts.Transaction() - n_scales = 0 - total_voxels = 0 + # per_scale rows are only populated when dump_enabled, so the failure + # path has enough context for the structured GCS dump without paying any + # bookkeeping cost on the happy path. + per_scale: list = [] for i, (src, dst) in enumerate(zip(src_list, dst_list)): factor = (np.array(resolutions[i]) / base_res).astype(int) x0, y0, z0 = bbox_lo // factor x1, y1, z1 = bbox_hi // factor if x1 <= x0 or y1 <= y0 or z1 <= z0: - logger.debug(f"skipping empty region at scale {i}") continue - nvox = int((x1 - x0) * (y1 - y0) * (z1 - z0)) - n_scales += 1 - total_voxels += nvox - logger.debug(f"scale {i}: {nvox:,} voxels") + if dump_enabled: + dims = (int(x1 - x0), int(y1 - y0), int(z1 - z0)) + nvox = dims[0] * dims[1] * dims[2] + bpv = int(np.dtype(dst.dtype.numpy_dtype).itemsize) + # The precomputed driver's read_chunk shape includes a channel + # axis; the spatial chunk shape is the first three dims. + chunk_shape = tuple(int(s) for s in dst.chunk_layout.read_chunk.shape[:3]) + n_keys = int( + np.prod( + [int(np.ceil(d / c)) if c else 0 for d, c in zip(dims, chunk_shape)] + ) + ) + max_raw_per_key = int(np.prod(chunk_shape)) * bpv + per_scale.append( + (i, dims, nvox, nvox * bpv, chunk_shape, n_keys, max_raw_per_key) + ) dst.with_transaction(txn)[x0:x1, y0:y1, z0:z1].write( src[x0:x1, y0:y1, z0:z1] ).result() - txn.commit_async().result() - logger.note( - f"OCDBT commit: {_humanize_count(total_voxels)} voxels, {n_scales} scales" - ) + try: + txn.commit_async().result() + except Exception as exc: + if dump_enabled: + payload = bbox_failure_payload( + exc, + dump_tag, + bbox_lo, + bbox_hi, + resolutions, + per_scale, + dst_list[0], + src_list[0], + ) + path = dump_failure_to_gcs(payload, dump_tag) + if path: + logger.note(f"OCDBT commit failure dump → {path}") + raise def _mode_downsample(data: np.ndarray, factors: tuple) -> np.ndarray: diff --git a/pychunkedgraph/ingest/ocdbt.py b/pychunkedgraph/ingest/ocdbt.py index a305b01ca..a8b10666f 100644 --- a/pychunkedgraph/ingest/ocdbt.py +++ b/pychunkedgraph/ingest/ocdbt.py @@ -98,8 +98,10 @@ def populate_chunk( ws, cfg, coordinator_address=coordinator_address ) lo, hi = _layer_bbox(imanager.cg.meta, layer, coords) + coord_str = "_".join(str(int(c)) for c in coords) + dump_tag = f"{imanager.cg.meta.graph_id}/L{layer}/{coord_str}" logger.note(f"L{layer} OCDBT populate {tuple(int(c) for c in coords)}") - copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi) + copy_ws_bbox_multiscale(src_list, dst_list, resolutions, lo, hi, dump_tag=dump_tag) mark_chunk_populated(ws, layer, coords) From c644efc9f838a7f212ba99481cd7cf0d69e67093 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 14:48:51 +0000 Subject: [PATCH 245/367] feat(ingest): purge_layer command for re-running from a previous backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flask ingest purge_layer ` drops all per-layer redis state — the RQ queue (deletes jobs along with it), every per-layer registry (failed, started, deferred, scheduled, finished, canceled, addressed by the registry's own .key so we don't hardcode RQ's internal key naming), and the pychunkedgraph completion set f"{N}c". Confirmation prompt gates the destructive action. Used when you restore the table from a previous layer's backup and need to re-enqueue from scratch — without this, stale queue + completion state from the aborted run would shadow the fresh re-run. --- pychunkedgraph/ingest/cli.py | 12 ++++++++++++ pychunkedgraph/ingest/utils.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index c98826346..56ac0219b 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -21,6 +21,7 @@ job_type_guard, print_completion_rate, print_status, + purge_layer_state, queue_layer_helper, requeue_chunk, ) @@ -217,3 +218,14 @@ def rate(layer: int, span: int): @job_type_guard(group_name) def run_tests(graph_id): run_all(ChunkedGraph(graph_id=graph_id)) + + +@ingest_cli.command("purge_layer") +@click.argument("layer", type=int) +@click.confirmation_option(prompt="Purge ALL redis state for this layer?") +@job_type_guard(group_name) +def purge_layer(layer: int): + """Drop the per-layer RQ queue + registries + completion set so the + layer can be re-run from a previous layer's backup.""" + purge_layer_state(get_redis_connection(), layer) + click.echo(f"purged redis state for layer {layer}") diff --git a/pychunkedgraph/ingest/utils.py b/pychunkedgraph/ingest/utils.py index 26885cbd6..fbd55dedc 100644 --- a/pychunkedgraph/ingest/utils.py +++ b/pychunkedgraph/ingest/utils.py @@ -17,6 +17,14 @@ from rich.table import Table from rich.text import Text from rq import Queue, Retry +from rq.registry import ( + CanceledJobRegistry, + DeferredJobRegistry, + FailedJobRegistry, + FinishedJobRegistry, + ScheduledJobRegistry, + StartedJobRegistry, +) from rq.worker_registration import WORKERS_BY_QUEUE_KEY from pychunkedgraph import get_logger @@ -444,6 +452,30 @@ def queue_layer_helper( logger.note(f"Queued {len(job_datas)} chunks.") +_RQ_REGISTRY_CLASSES = ( + FailedJobRegistry, + StartedJobRegistry, + DeferredJobRegistry, + ScheduledJobRegistry, + FinishedJobRegistry, + CanceledJobRegistry, +) + + +def purge_layer_state(redis, layer: int) -> None: + """Reset per-layer state so a layer can be re-run from a previous + layer's backup: drop the RQ queue (deletes jobs too), wipe each RQ + registry by its own ``.key`` attribute (so we don't hardcode RQ's + internal key naming), and clear the pychunkedgraph completion set + ``f"{layer}c"``. + """ + name = f"l{layer}" + Queue(name=name, connection=redis).delete(delete_jobs=True) + for cls in _RQ_REGISTRY_CLASSES: + redis.delete(cls(name=name, connection=redis).key) + redis.delete(f"{layer}c") + + def requeue_chunk(queue_name: str, chunk_info, atomic_fn, parent_fn): """Body of the ``chunk`` CLI command (shared by ingest and upgrade). From f9be4804e201e34a88908aef111d801ce8c4ec71 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 14:56:36 +0000 Subject: [PATCH 246/367] feat(ingest): retry runs full setup minus cg.create(); add --skip-queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure `flask ingest graph`: --retry/-r reuses the IngestionManager from redis but otherwise runs the same setup path as a fresh ingest — opens the existing table via imanager.cg, calls setup_base when OCDBT is enabled (idempotent: recreates the base + info if absent, reconciles config, forks for this CG), and enqueues. Skipping only cg.create() means an externally-wiped OCDBT base now recovers on retry without touching Bigtable. New --skip-queue/-s does setup but no L2 enqueue, for operators who want to verify the OCDBT base / fork state before queueing work. Short forms for retry/-r, skip-queue/-s, test/-t. --- pychunkedgraph/ingest/cli.py | 95 ++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 56ac0219b..a9e3f4ef5 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -28,7 +28,7 @@ from .simple_tests import run_all from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph -from ..graph.ocdbt import OcdbtConfig, fork_base_manifest +from ..graph.ocdbt import OcdbtConfig from ..utils.redis import get_redis_connection, keys as r_keys group_name = "ingest" @@ -52,18 +52,43 @@ def flush_redis(): @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True), required=False) @click.option("--raw", is_flag=True, help="Read edges from agglomeration output.") -@click.option("--retry", is_flag=True, help="Rerun without creating a new table.") -@click.option("--test", is_flag=True, help="Test 8 chunks at the center of dataset.") +@click.option( + "--retry", + "-r", + is_flag=True, + help="Re-run setup against the existing table (no cg.create()).", +) +@click.option( + "--skip-queue", + "-s", + is_flag=True, + help="Set up everything but don't enqueue L2 tasks.", +) +@click.option( + "--test", + "-t", + is_flag=True, + help="Test 8 chunks at the center of dataset.", +) @job_type_guard(group_name) def ingest_graph( graph_id: str, dataset: click.Path, raw: bool, retry: bool, + skip_queue: bool, test: bool, ): - """Main ingest command. Takes config from yaml, queues atomic tasks.""" + """Main ingest command. Takes config from yaml, queues atomic tasks. + + ``--retry`` reuses the existing IngestionManager from redis and skips + only ``cg.create()`` — everything else (OCDBT base + fork via + ``setup_base``, L2 enqueue) still runs, idempotently. Pair with + ``--skip-queue`` to perform setup without queueing any tasks. + """ redis = get_redis_connection() + if test: + configure_logging(level=DEBUG) if retry: imanager_pickle = redis.get(r_keys.INGESTION_MANAGER) @@ -72,42 +97,38 @@ def ingest_graph( f"--retry requires an existing `{group_name}` job in redis. " f"Run without --retry to start a new job." ) - if test: - configure_logging(level=DEBUG) imanager = IngestionManager.from_pickle(imanager_pickle) + cg = imanager.cg if imanager.ocdbt_seg: - ws = imanager.cg_meta.data_source.WATERSHED - fork_base_manifest(ws, graph_id, wipe_existing=True) + # setup_base is idempotent — recreates the OCDBT base + info + # if absent (e.g. after `gcloud storage rm -r .../ocdbt/`), + # reconciles config with the on-disk meta, and forks the + # manifest for this CG. Bigtable is left alone. + resolved = setup_base(cg, OcdbtConfig.from_dict(imanager.ocdbt_config)) + imanager.ocdbt_config = resolved.to_dict() + else: + if dataset is None: + raise click.ClickException("dataset is required unless --retry is passed.") + redis.set(r_keys.JOB_TYPE, group_name) + with open(dataset, "r") as stream: + config = yaml.safe_load(stream) + meta, ingest_config, client_info, ocdbt_config_dict = bootstrap( + graph_id, config, raw, test + ) + cg = ChunkedGraph(meta=meta, client_info=client_info) + cg.create() + ocdbt_cfg = OcdbtConfig.from_dict(ocdbt_config_dict) + if ocdbt_cfg.enabled: + resolved = setup_base(cg, ocdbt_cfg) + ocdbt_config_dict = resolved.to_dict() + imanager = IngestionManager( + ingest_config, + meta, + ocdbt_config=ocdbt_config_dict, + ) + + if not skip_queue: enqueue_l2_tasks(imanager, create_atomic_chunk) - os._exit(0) - - if dataset is None: - raise click.ClickException("dataset is required unless --retry is passed.") - - redis.set(r_keys.JOB_TYPE, group_name) - with open(dataset, "r") as stream: - config = yaml.safe_load(stream) - - if test: - configure_logging(level=DEBUG) - - meta, ingest_config, client_info, ocdbt_config_dict = bootstrap( - graph_id, config, raw, test - ) - cg = ChunkedGraph(meta=meta, client_info=client_info) - cg.create() - - ocdbt_cfg = OcdbtConfig.from_dict(ocdbt_config_dict) - if ocdbt_cfg.enabled: - resolved = setup_base(cg, ocdbt_cfg) - ocdbt_config_dict = resolved.to_dict() - - imanager = IngestionManager( - ingest_config, - meta, - ocdbt_config=ocdbt_config_dict, - ) - enqueue_l2_tasks(imanager, create_atomic_chunk) os._exit(0) From 5873aa895816ef5ee0c6a9f8310629276453e235 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 21:29:14 +0000 Subject: [PATCH 247/367] docs(ocdbt): tensorstore OCDBT reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-repo notes on every OCDBT spec field, config sub-field, ocdbt_coordinator resource field, DistributedCoordinatorServer constructor field, and runtime defaults — verified empirically against the venv's tensorstore binary by probing with intentional-bad-value plus spec round-trips. Captures the distributed-mode constraints: atomic transactions forbidden, cooperator-forwarded RPC bounded at gRPC's 4 MiB default with no Python knob to raise it, leases held per btree node so disjoint user-key writes still cross-forward. Catalogs the levers that do exist (max_decoded_node_bytes, max_inline_value_bytes, lease_duration) and ones that look promising but aren't fields. --- .../graph/ocdbt/TENSORSTORE_REFERENCE.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md diff --git a/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md b/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md new file mode 100644 index 000000000..0e2fedb57 --- /dev/null +++ b/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md @@ -0,0 +1,131 @@ +# tensorstore OCDBT reference + +Every entry below was verified by probing tensorstore directly (intentional-bad-value + spec round-trip) against the binary in this workspace's venv. Re-verify if the tensorstore version changes. + +## OCDBT kvstore spec — top-level fields + +Sibling of `driver: "ocdbt"`: + +| Field | Type | Default | Notes | +|---|---|---|---| +| `base` | kvstore spec or URL | — | underlying kvstore (gcs/file/s3/…) | +| `manifest` | kvstore spec or URL | (under `base`) | the manifest *can* live in a separate kvstore from data | +| `config` | object | `{}` | see Config sub-fields below | +| `assume_config` | bool | `false` | skip reading the existing config from the manifest (use with care) | +| `coordinator` | ocdbt_coordinator resource | named ref `"ocdbt_coordinator"` | enables distributed mode when set | +| `cache_pool` | cache_pool resource | named ref `"cache_pool"` | | +| `data_copy_concurrency` | data_copy_concurrency resource | named ref | | +| `target_data_file_size` | uint64 | driver default | when a single commit's d/ writes exceed this, the writer rolls a new d/ file | +| `experimental_read_coalescing_threshold_bytes` | uint64 | — | | +| `experimental_read_coalescing_merged_bytes` | uint64 | — | | +| `experimental_read_coalescing_interval` | uint64 | — | | +| `btree_node_data_prefix` | string | `"d/"` | path prefix for btree-node files | +| `value_data_prefix` | string | `"d/"` | path prefix for value files | +| `version_tree_node_data_prefix` | string | `"d/"` | path prefix for version-tree files | +| `path` | string | `""` | sub-prefix in the kvstore | + +**Not fields**: `data_file_prefixes`, `version_spec`, `recheck_cached*`, `transaction`, `btree_writer_concurrency`, `manifest_kind` (lives under `config`). + +## OCDBT `config` sub-fields + +| Field | Type | tensorstore default | Notes | +|---|---|---|---| +| `compression` | object | `{}` (none) | `{"id": "zstd", "level": N}` — zstd level 1–22 | +| `max_inline_value_bytes` | uint64 | `100` | values ≤ this size are inlined into btree leaves. **1 MiB is the hard ceiling** per our code's prior measurement note. Setting to 1 MiB avoids per-chunk out-of-line zstd-framing bloat. | +| `max_decoded_node_bytes` | uint64 | `8388608` (8 MiB) | btree node split threshold. Larger nodes → shallower tree → fewer per-commit node touches. Setting this *smaller* than the default INCREASES per-commit forwarded bytes — empirically went from ~8 MiB to ~23 MiB RPCs when set to 1 MiB. | +| `version_tree_arity_log2` | int | — | controls version tree branching; rarely tuned | +| `manifest_kind` | enum | `"single"` | `"single"` or `"numbered"` (manifest history retained — needed for time-travel reads) | +| `uuid` | string | (auto) | 32-hex per-base UUID assigned at create time | + +**Not fields**: `data_file_prefixes`, `data_file_prefix`, `btree_node_arity_log2`, `version_tree_node_arity`. + +## `ocdbt_coordinator` context resource + +| Field | Type | Default | Notes | +|---|---|---|---| +| `address` | string | — | `"host:port"` of the DistributedCoordinatorServer | +| `lease_duration` | duration string (`"1s"`, `"500ms"`, etc.) | — | how long a lease holder owns a btree node | +| `security` | object | `{method: "insecure"}` | requires `method` key. This build has **no** security methods registered (build flag) — all calls cleartext. | + +## `DistributedCoordinatorServer({...})` + +| Field | Type | Default | Notes | +|---|---|---|---| +| `bind_addresses` | list[string] | one ephemeral port | gRPC server bind address(es). `.port` after construction gives the ephemeral port. | +| `security` | object | insecure | same shape as the resource's security | + +**There is NO Python knob for the gRPC server's max-receive message size.** The 4 MiB default is set inside tensorstore's gRPC server builder. Confirmed by strings on the binary: no `TENSORSTORE_*` env var, no spec/resource field, no Context resource that maps to `grpc.max_receive_message_length`. + +## Distributed vs non-distributed write paths + +The OCDBT driver picks one of two compiled implementations at open time: + +- **non-distributed** (`btree_writer.cc`): coordinator absent. Each commit writes the manifest itself. Concurrent writers race the manifest CAS; losers retry; their pre-commit d/ writes become orphans. +- **distributed** (`distributed/btree_writer.cc`, `cooperator_*.cc`): coordinator present. One lease holder per btree node serializes commits. Other cooperators **forward their mutations over gRPC** to the lease holder. + +### Constraints unique to distributed mode + +1. **`ts.Transaction(atomic=True)` is incompatible.** "Cannot read/write … as single atomic transaction" — verified on (info + chunk) and on (cross-key). A plain `ts.Transaction()` still batches all writes into one OCDBT commit; only the *atomicity* across keys is lost. +2. **Cooperator-forwarded RPC ≤ ~4 MiB.** Carries (btree node delta) + (value bytes for keys committed into that node). +3. **Disjoint user-key writes still trigger forwarding.** Leases are per btree node, not per user-key range. Two workers writing distinct keys into the same node → one forwards to the other. + +## Cooperator batching + +`cooperator_submit_mutation_batch.cc` queues mutations and groups them by lease holder; each group becomes one `SubmitMutationBatch` RPC. Internal symbol of interest: `AddToPrioritizedQueue`. No Python-visible knob for batch size, queue policy, or batching window. + +What ACTUALLY changes RPC size (verified by production dumps): +- Default config → RPCs 5–8 MiB (one chunk's compressed_segmentation value + small node delta) +- Default config + `max_decoded_node_bytes=1 MiB` → RPCs up to 23 MiB (smaller nodes ≠ smaller RPCs; verified regression) +- Default config + dst chunk_size halved → RPCs grew to 12 MiB (more mutations per node → bigger batches) + +## Defaults visible from spec round-trip + +```json +{ + "assume_config": false, + "btree_node_data_prefix": "d/", + "config": {}, + "coordinator": "ocdbt_coordinator", + "cache_pool": "cache_pool", + "data_copy_concurrency": "data_copy_concurrency", + "value_data_prefix": "d/", + "version_tree_node_data_prefix": "d/" +} +``` + +## Env vars + +- `OCDBT_COORDINATOR_HOST`, `OCDBT_COORDINATOR_PORT`: **NO EFFECT**. Not referenced anywhere in the binary. Address must go in spec's `coordinator.address`. +- `TENSORSTORE_VERBOSE_LOGGING`: comma-separated tag list to stderr. Tags include `ocdbt`, `coordinator`. + +Other `TENSORSTORE_*` vars exist (CA paths, S3/GCS concurrency, etc.) — grep the binary. + +## On-disk layout + +- `manifest.ocdbt` at the base — root btree node + current data file refs. +- `d/` — directory of "data files" each holding concatenated values + (optionally) btree node bytes + version-tree node bytes. +- Each commit creates **at least one** d/ file holding all values + nodes for that commit, then a CAS-update of `manifest.ocdbt`. +- `target_data_file_size` controls when a single commit splits its d/ writes across files. + +## How this maps onto pychunkedgraph + +- `OcdbtConfig` (`pychunkedgraph/graph/ocdbt/meta.py`) → `compression`, `max_inline_value_bytes` (=1 MiB). +- `create_base_ocdbt` / `open_base_ocdbt` pass `config.ts_config()` so the same OCDBT config persists across opens. +- `populate_chunk` (`pychunkedgraph/ingest/ocdbt.py`) opens the base with `coordinator_address` (distributed mode). +- `copy_ws_bbox_multiscale` uses **non-atomic** `ts.Transaction()` because of the distributed-mode constraint above. +- `_dump_failure_to_gcs` writes JSON failure forensics when `ERROR_DUMP` env is set. + +## Empirically tried and ruled out + +- `OCDBT_COORDINATOR_HOST/PORT` env vars — no effect. +- Bumping gRPC max-receive via env / channel arg / spec field — no such knob. +- Smaller `dst chunk_size` alone — RPC size grew (more mutations per node). +- Smaller `max_decoded_node_bytes` alone — RPC size grew (more per-commit node touches). +- `--ocdbt-edges` legacy path — decommissioned, removed. +- `ts.Transaction(atomic=True)` with distributed coordinator — incompatible. + +## Open observations (not verified at production scale) + +- `lease_duration` may reduce cross-cooperator forwarding if held long enough that a worker's whole task lands on its own nodes. +- `target_data_file_size` may affect manifest growth but not RPC size. +- Switching dst encoding from `compressed_segmentation` to `raw` would make per-value size predictable (`chunk_volume × bytes_per_voxel`), bypassing the dense-region pathological CS encoding (one observed key encoded to 23 MiB at 256×256×64). From 6f9920ce810d7d0b78f6523ffdf4fbb4357e048f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 21:36:40 +0000 Subject: [PATCH 248/367] fix(ocdbt): lower max_inline_value_bytes so chunk values go out-of-line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading tensorstore's distributed btree_writer.cc StagePending: values ≤ max_inline_value_bytes are carried inline in the encoded mutation, so the cooperator's per-leaf WriteRequest packs all of them together. With the previous 1 MiB threshold, every compressed_segmentation chunk that landed below 1 MiB stayed inline and contributed its full bytes to the gRPC forward — which blows past tensorstore's hardcoded 4 MiB gRPC max-receive whenever a few inline chunks land on the same btree leaf. Dropping to 4 KiB pushes every chunk value through WriteData into a d/ file and replaces it in the mutation with an IndirectDataReference, so forwarded RPCs carry only small refs regardless of value size. Small metadata (info JSON, populate-marker files) still stays inline. Tradeoff: the old comment cited "7× GCS bloat" at the 100-byte default — that ratio came from per-chunk independent zstd framing dominating tiny payloads, not chunk-sized ones. At 4 KiB+ payloads the per-chunk zstd overhead is in the single-digit-percent range. Reference doc updated to credit max_inline_value_bytes as the actual RPC-size lever and correct the cooperator-batching section. --- .../graph/ocdbt/TENSORSTORE_REFERENCE.md | 17 +++++++------ pychunkedgraph/graph/ocdbt/meta.py | 25 ++++++++++++++----- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md b/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md index 0e2fedb57..b47f47c47 100644 --- a/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md +++ b/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md @@ -31,7 +31,7 @@ Sibling of `driver: "ocdbt"`: | Field | Type | tensorstore default | Notes | |---|---|---|---| | `compression` | object | `{}` (none) | `{"id": "zstd", "level": N}` — zstd level 1–22 | -| `max_inline_value_bytes` | uint64 | `100` | values ≤ this size are inlined into btree leaves. **1 MiB is the hard ceiling** per our code's prior measurement note. Setting to 1 MiB avoids per-chunk out-of-line zstd-framing bloat. | +| `max_inline_value_bytes` | uint64 | `100` | values ≤ this size live inline in the btree leaf bytes; larger values get written to a d/ file and the mutation carries only an `IndirectDataReference`. In distributed mode this **directly bounds cooperator-forwarded RPC size**: inline values are carried inside the `WriteRequest.mutations` field, so a leaf's batch blows past the 4 MiB gRPC max-receive whenever multiple inline values pile up on one node. Source: `distributed/btree_writer.cc` `StagePending`. Setting low (≤ a few KB) pushes chunk values out-of-line → small mutations → small RPCs. | | `max_decoded_node_bytes` | uint64 | `8388608` (8 MiB) | btree node split threshold. Larger nodes → shallower tree → fewer per-commit node touches. Setting this *smaller* than the default INCREASES per-commit forwarded bytes — empirically went from ~8 MiB to ~23 MiB RPCs when set to 1 MiB. | | `version_tree_arity_log2` | int | — | controls version tree branching; rarely tuned | | `manifest_kind` | enum | `"single"` | `"single"` or `"numbered"` (manifest history retained — needed for time-travel reads) | @@ -71,12 +71,15 @@ The OCDBT driver picks one of two compiled implementations at open time: ## Cooperator batching -`cooperator_submit_mutation_batch.cc` queues mutations and groups them by lease holder; each group becomes one `SubmitMutationBatch` RPC. Internal symbol of interest: `AddToPrioritizedQueue`. No Python-visible knob for batch size, queue policy, or batching window. +`cooperator_submit_mutation_batch.cc` `SendToPeer` is the gRPC sender. The `WriteRequest` proto has `repeated bytes mutations` — each entry is one encoded `BtreeNodeWriteMutation` destined for the same leaf. The encoded mutation embeds the value_reference inline if it's an `absl::Cord`, or carries just an `IndirectDataReference` (small struct) otherwise. So **what's actually on the wire per RPC = (small request header) + Σ encoded mutations**, and each encoded mutation's size is dominated by its value bytes IF the value is inline. -What ACTUALLY changes RPC size (verified by production dumps): -- Default config → RPCs 5–8 MiB (one chunk's compressed_segmentation value + small node delta) -- Default config + `max_decoded_node_bytes=1 MiB` → RPCs up to 23 MiB (smaller nodes ≠ smaller RPCs; verified regression) -- Default config + dst chunk_size halved → RPCs grew to 12 MiB (more mutations per node → bigger batches) +Threshold for inline-vs-ref is `max_inline_value_bytes` (see config table). That's the real lever for RPC size. + +What changes RPC size (verified by production dumps): +- `max_inline_value_bytes=1 MiB`, default node bytes → RPCs 5–8 MiB (inline chunks pile up in the batch) +- `max_inline_value_bytes=1 MiB` + `max_decoded_node_bytes=1 MiB` → RPCs up to 23 MiB (smaller nodes ≠ smaller RPCs) +- `max_inline_value_bytes=1 MiB` + dst `chunk_size` halved → RPCs grew to 12 MiB (more mutations per node → bigger batches) +- `max_inline_value_bytes=4 KiB` (chunks go out-of-line) → mutations carry only refs; RPC = small header + N×(key + ref + generation) → fits 4 MiB regardless of value sizes (this is the path our code takes) ## Defaults visible from spec round-trip @@ -109,7 +112,7 @@ Other `TENSORSTORE_*` vars exist (CA paths, S3/GCS concurrency, etc.) — grep t ## How this maps onto pychunkedgraph -- `OcdbtConfig` (`pychunkedgraph/graph/ocdbt/meta.py`) → `compression`, `max_inline_value_bytes` (=1 MiB). +- `OcdbtConfig` (`pychunkedgraph/graph/ocdbt/meta.py`) → `compression: zstd 12`, `max_inline_value_bytes = 4 KiB`. The 4 KiB threshold keeps small metadata (info JSON, populate markers) inline while forcing every chunk value out-of-line into d/ files — this is what keeps cooperator RPCs under the 4 MiB gRPC ceiling. - `create_base_ocdbt` / `open_base_ocdbt` pass `config.ts_config()` so the same OCDBT config persists across opens. - `populate_chunk` (`pychunkedgraph/ingest/ocdbt.py`) opens the base with `coordinator_address` (distributed mode). - `copy_ws_bbox_multiscale` uses **non-atomic** `ts.Transaction()` because of the distributed-mode constraint above. diff --git a/pychunkedgraph/graph/ocdbt/meta.py b/pychunkedgraph/graph/ocdbt/meta.py index d4c79dc89..df18b4dca 100644 --- a/pychunkedgraph/graph/ocdbt/meta.py +++ b/pychunkedgraph/graph/ocdbt/meta.py @@ -21,12 +21,25 @@ class OcdbtConfig: populate_layer: int = 3 sv_split_threshold: int = 10 compression: Dict = field(default_factory=lambda: {"id": "zstd", "level": 12}) - # Inline chunk values into B+tree leaves so they share the leaf's zstd - # compression context. Default tensorstore value (100 bytes) puts every - # chunk in its own out-of-line blob with independent zstd framing → - # ~7× bloat on GCS. 1 MiB is tensorstore's hard ceiling for this field - # and captures every compressed_segmentation chunk we've measured. - max_inline_value_bytes: int = 1048576 + # Inline-vs-out-of-line threshold. Values ≤ this size live in the btree + # leaf bytes; larger values get written to a d/ file and the mutation + # carries only an IndirectDataReference. This directly determines + # cooperator-forwarded RPC size in distributed mode: inline values are + # carried inside the gRPC WriteRequest's `mutations` field, so a leaf's + # batch can blow past tensorstore's hardcoded 4 MiB gRPC max-receive + # whenever multiple inline values pile up on the same node. Verified + # by reading btree_writer.cc StagePending in v0.1.81. + # + # 4 KiB keeps small metadata (info JSON ~1.5 KB, populate-marker files) + # inline while forcing every segmentation chunk value out-of-line — + # chunks compress to 100s of KB even for the smallest scales. With + # chunk bytes out-of-line the WriteRequest stays tiny regardless of + # how many keys a worker commits at once. Tradeoff vs the previous + # 1 MiB cap: each chunk now has its own zstd-framed d/ blob instead of + # sharing a leaf's compression context, which can cost a few percent + # of compression ratio (much less than the originally-feared "7× + # bloat", which only applied at the 100-byte default). + max_inline_value_bytes: int = 4096 @classmethod def from_dict(cls, d: Optional[Dict]) -> "OcdbtConfig": From 2617e01814bf8dd36009d8d6420675efd1120393 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 21 May 2026 22:09:43 +0000 Subject: [PATCH 249/367] fix(ocdbt): retry transient tensorstore errors in marker IO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tensorstore raises ValueError with an absl status-code prefix on transport-level failures. A DNS hiccup or a GCS 5xx in is_chunk_populated or mark_chunk_populated was killing the whole populate task. Wrap the four marker / populate-meta helpers in a tenacity retry that matches UNAVAILABLE / DEADLINE_EXCEEDED / ABORTED / INTERNAL prefixes only — NOT_FOUND, INVALID_ARGUMENT, RESOURCE_EXHAUSTED still propagate. --- pychunkedgraph/graph/ocdbt/utils.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pychunkedgraph/graph/ocdbt/utils.py b/pychunkedgraph/graph/ocdbt/utils.py index 6158eda53..4c04e5799 100644 --- a/pychunkedgraph/graph/ocdbt/utils.py +++ b/pychunkedgraph/graph/ocdbt/utils.py @@ -10,6 +10,25 @@ import numpy as np import tensorstore as ts +from tenacity import ( + retry, + retry_if_exception_message, + stop_after_attempt, + wait_exponential, +) + +# tensorstore raises ValueError with an absl/grpc status-code prefix. Retry +# only the transient classes — DNS hiccups, deadline-blown reads, server +# 5xx — so a single flaky GCS call doesn't kill the populate task. Persistent +# errors (NOT_FOUND, INVALID_ARGUMENT, RESOURCE_EXHAUSTED, …) propagate. +_transient = retry( + retry=retry_if_exception_message( + match=r"^(UNAVAILABLE|DEADLINE_EXCEEDED|ABORTED|INTERNAL):" + ), + stop=stop_after_attempt(5), + wait=wait_exponential(multiplier=0.5, min=0.5, max=8), + reraise=True, +) def _ensure_trailing_slash(path: str) -> str: @@ -71,6 +90,7 @@ def _schema_from_src(src_handle) -> dict: ) +@_transient def is_chunk_populated(ws_path: str, layer: int, coords) -> bool: """Check whether this chunk's precomputed→OCDBT copy has already completed. @@ -84,12 +104,14 @@ def is_chunk_populated(ws_path: str, layer: int, coords) -> bool: return result.value is not None and len(result.value) > 0 +@_transient def mark_chunk_populated(ws_path: str, layer: int, coords) -> None: """Record that this chunk's precomputed→OCDBT copy completed.""" kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() kvs.write(_marker_key(layer, coords), b"1").result() +@_transient def read_populate_meta(ws_path: str) -> Optional[dict]: """Return the per-base populate config dict, or None if not yet written.""" kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() @@ -99,6 +121,7 @@ def read_populate_meta(ws_path: str) -> Optional[dict]: return json.loads(r.value) +@_transient def write_populate_meta(ws_path: str, meta: dict) -> None: """Persist the per-base populate config (layer, etc.) alongside markers.""" kvs = ts.KvStore.open(_populate_markers_path(ws_path)).result() From 6b373a6561684b6feb39458e5dceed9a11281f20 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 22 May 2026 16:17:56 +0000 Subject: [PATCH 250/367] feat(ingest): consolidate OCDBT lifecycle under `ingest layer`; add task-body mode flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ingest layer N` now owns the whole OCDBT lifecycle when N matches ocdbt_populate_layer: idempotently runs setup_base (base + fork + config reconcile), starts the coordinator, queues tasks. Moves setup_base out of `ingest graph` so there's a single command that manages OCDBT. Re-pickles the IngestionManager so workers see the resolved config. Adds three flags to `ingest layer`: --queue-only/-q : skip the coordinator (one is running elsewhere). --ocdbt-only/-o : workers run only OCDBT populate, skipping add_parent_chunk. For re-populating a freshly created/wiped OCDBT base against an already-built bigtable graph. --ingest-only/-i : workers run only add_parent_chunk, skipping OCDBT populate. For rebuilding bigtable against an existing OCDBT base. Refactors create_parent_chunk to a single function with a `mode` parameter bound at queue time via functools.partial — no duplicated OCDBT-populate block. _post_task_completion runs unconditionally so layer-progress tracking in redis stays consistent across modes. Adds IngestionManager.is_ocdbt_populate_layer(layer) so the compound guard isn't repeated at every call site. --- pychunkedgraph/ingest/cli.py | 98 +++++++++++++++++++++++--------- pychunkedgraph/ingest/cluster.py | 46 +++++++++------ pychunkedgraph/ingest/manager.py | 11 ++++ 3 files changed, 110 insertions(+), 45 deletions(-) diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index a9e3f4ef5..811f19891 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -5,6 +5,7 @@ """ import os +from functools import partial from time import sleep from pychunkedgraph import configure_logging, DEBUG @@ -81,10 +82,13 @@ def ingest_graph( ): """Main ingest command. Takes config from yaml, queues atomic tasks. + Purely about the bigtable graph: creates the table and enqueues L2 + tasks. OCDBT base + fork creation happens in ``ingest layer N`` when + N matches ``ocdbt_populate_layer``; that's the single owner of the + OCDBT lifecycle. + ``--retry`` reuses the existing IngestionManager from redis and skips - only ``cg.create()`` — everything else (OCDBT base + fork via - ``setup_base``, L2 enqueue) still runs, idempotently. Pair with - ``--skip-queue`` to perform setup without queueing any tasks. + ``cg.create()``. Pair with ``--skip-queue`` to skip L2 enqueue too. """ redis = get_redis_connection() if test: @@ -98,14 +102,6 @@ def ingest_graph( f"Run without --retry to start a new job." ) imanager = IngestionManager.from_pickle(imanager_pickle) - cg = imanager.cg - if imanager.ocdbt_seg: - # setup_base is idempotent — recreates the OCDBT base + info - # if absent (e.g. after `gcloud storage rm -r .../ocdbt/`), - # reconciles config with the on-disk meta, and forks the - # manifest for this CG. Bigtable is left alone. - resolved = setup_base(cg, OcdbtConfig.from_dict(imanager.ocdbt_config)) - imanager.ocdbt_config = resolved.to_dict() else: if dataset is None: raise click.ClickException("dataset is required unless --retry is passed.") @@ -117,10 +113,6 @@ def ingest_graph( ) cg = ChunkedGraph(meta=meta, client_info=client_info) cg.create() - ocdbt_cfg = OcdbtConfig.from_dict(ocdbt_config_dict) - if ocdbt_cfg.enabled: - resolved = setup_base(cg, ocdbt_cfg) - ocdbt_config_dict = resolved.to_dict() imanager = IngestionManager( ingest_config, meta, @@ -157,32 +149,84 @@ def pickle_imanager(graph_id: str, dataset: click.Path, raw: bool): @ingest_cli.command("layer") @click.argument("parent_layer", type=int) +@click.option( + "--queue-only", + "-q", + is_flag=True, + help="Only enqueue tasks; do not start the OCDBT coordinator. " + "Use when a coordinator is already running in another process.", +) +@click.option( + "--ocdbt-only", + "-o", + is_flag=True, + help="Workers run only OCDBT populate (skip add_parent_chunk). " + "Requires the OCDBT populate layer.", +) +@click.option( + "--ingest-only", + "-i", + is_flag=True, + help="Workers run only add_parent_chunk (skip OCDBT populate). " + "Use when the OCDBT base is already populated for this layer.", +) @job_type_guard(group_name) -def queue_layer(parent_layer): +def queue_layer(parent_layer, queue_only, ocdbt_only, ingest_only): """ Queue all chunk tasks at a given layer. Must be used when all the chunks at `parent_layer - 1` have completed. - When this layer is the OCDBT populate layer, also start a - ``DistributedCoordinatorServer`` so every worker's commit routes through - one process — eliminates manifest-CAS races and orphan ``d/`` files. - Stays in the foreground until killed. + When this layer is the OCDBT populate layer, this command also owns the + OCDBT lifecycle: idempotently creates the base + fork via ``setup_base`` + and starts a ``DistributedCoordinatorServer`` so every worker's commit + routes through one process (eliminates manifest-CAS races and orphan + ``d/`` files). Stays in the foreground until killed. + + Flags: + ``--queue-only`` skips the coordinator (one is assumed running elsewhere). + ``--ocdbt-only`` task body = OCDBT populate only. + ``--ingest-only`` task body = add_parent_chunk only. """ assert parent_layer > 2, "This command is for layers 3 and above." + if ocdbt_only and ingest_only: + raise click.ClickException( + "--ocdbt-only and --ingest-only are mutually exclusive." + ) redis = get_redis_connection() imanager = IngestionManager.from_pickle(redis.get(r_keys.INGESTION_MANAGER)) - if ( - imanager.ocdbt_seg - and imanager.ocdbt_populate_base - and parent_layer == imanager.ocdbt_populate_layer - ): + is_populate_layer = imanager.is_ocdbt_populate_layer(parent_layer) + if ocdbt_only and not is_populate_layer: + raise click.ClickException( + "--ocdbt-only requires running at the OCDBT populate layer." + ) + + if is_populate_layer: + # Single owner of the OCDBT lifecycle: create base + fork if + # missing, reconcile config with on-disk meta, then re-pickle + # imanager so queued workers read the resolved config. + resolved = setup_base(imanager.cg, OcdbtConfig.from_dict(imanager.ocdbt_config)) + imanager.ocdbt_config = resolved.to_dict() + imanager.redis.set(r_keys.INGESTION_MANAGER, imanager.serialized(pickled=True)) + + mode = "ocdbt" if ocdbt_only else ("ingest" if ingest_only else "full") + task_fn = ( + partial(create_parent_chunk, mode=mode) + if mode != "full" + else create_parent_chunk + ) + + # Coordinator only matters when OCDBT populate will actually run. + needs_coordinator = ( + is_populate_layer and mode in ("full", "ocdbt") and not queue_only + ) + if needs_coordinator: with coordinator(imanager.redis): - queue_layer_helper(parent_layer, imanager, create_parent_chunk) + queue_layer_helper(parent_layer, imanager, task_fn) while True: sleep(60) else: - queue_layer_helper(parent_layer, imanager, create_parent_chunk) + queue_layer_helper(parent_layer, imanager, task_fn) @ingest_cli.command("status") diff --git a/pychunkedgraph/ingest/cluster.py b/pychunkedgraph/ingest/cluster.py index cad4043b3..36d111f1a 100644 --- a/pychunkedgraph/ingest/cluster.py +++ b/pychunkedgraph/ingest/cluster.py @@ -64,19 +64,28 @@ def _post_task_completion( def create_parent_chunk( parent_layer: int, parent_coords: Sequence[int], + mode: str = "full", ) -> None: + """One parent-chunk task. ``mode`` (bound at queue time via partial) + selects which halves run: + ``full`` : OCDBT populate (if eligible) + add_parent_chunk + ``ocdbt`` : only OCDBT populate (skip add_parent_chunk) + ``ingest`` : only add_parent_chunk (skip OCDBT populate) + + ``_post_task_completion`` always runs so the layer's progress tracking + in redis stays consistent. + + OCDBT populate runs FIRST so any failure aborts the task BEFORE graph + mutation; otherwise a half-built graph would force corrupt-state retries. + """ imanager = _get_imanager() - # OCDBT populate runs FIRST so any failure here (coordinator missing, - # commit error, etc.) aborts the task BEFORE any graph mutation — - # otherwise an OCDBT failure could land after add_parent_chunk has - # already written parents, leaving the graph half-built and forcing - # corrupt-state retries. - if ( - imanager.ocdbt_seg - and imanager.ocdbt_populate_base - and parent_layer == imanager.ocdbt_populate_layer - ): + do_ocdbt = mode in ("full", "ocdbt") and imanager.is_ocdbt_populate_layer( + parent_layer + ) + do_ingest = mode in ("full", "ingest") + + if do_ocdbt: ws = imanager.cg.meta.data_source.WATERSHED if not is_chunk_populated(ws, parent_layer, parent_coords): address = get_coordinator_address(imanager.redis) @@ -84,16 +93,17 @@ def create_parent_chunk( imanager, ws, parent_layer, parent_coords, coordinator_address=address ) - add_parent_chunk( - imanager.cg, - parent_layer, - parent_coords, - get_children_chunk_coords( - imanager.cg_meta, + if do_ingest: + add_parent_chunk( + imanager.cg, parent_layer, parent_coords, - ), - ) + get_children_chunk_coords( + imanager.cg_meta, + parent_layer, + parent_coords, + ), + ) _post_task_completion(imanager, parent_layer, parent_coords) diff --git a/pychunkedgraph/ingest/manager.py b/pychunkedgraph/ingest/manager.py index 407a31ed8..566558e05 100644 --- a/pychunkedgraph/ingest/manager.py +++ b/pychunkedgraph/ingest/manager.py @@ -65,6 +65,17 @@ def ocdbt_populate_base(self) -> bool: def ocdbt_populate_layer(self) -> int: return int(self.ocdbt_config.get("populate_layer", 3)) + def is_ocdbt_populate_layer(self, layer: int) -> bool: + """True iff OCDBT is enabled, base-populate is on, AND the given + layer matches the configured populate layer. Single guard for any + code that branches on 'should this layer touch OCDBT?'. + """ + return ( + self.ocdbt_seg + and self.ocdbt_populate_base + and layer == self.ocdbt_populate_layer + ) + def serialized(self, pickled=False): params = { "config": self._config, From 8ca9743fca7e9d2f457c08410ce0fded5f25dfc7 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sat, 23 May 2026 20:26:13 +0000 Subject: [PATCH 251/367] tmp: disable ocdbt seg read for meshing --- pychunkedgraph/graph/utils/generic.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 64d37a5bc..8cb88aa3c 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -163,11 +163,11 @@ def get_local_segmentation(meta, bbox_start, bbox_end, mip: int = 0) -> np.ndarr """ xL, yL, zL = bbox_start xH, yH, zH = bbox_end - if meta.ocdbt_seg: - # mip > 0 reads from a coarser scale; saves bandwidth and is what - # meshing wants when it operates at a non-base MIP. - store = meta.ws_ocdbt if mip == 0 else meta.ws_ocdbt_scales[mip] - return store[xL:xH, yL:yH, zL:zH].read().result() + # if meta.ocdbt_seg: + # # mip > 0 reads from a coarser scale; saves bandwidth and is what + # # meshing wants when it operates at a non-base MIP. + # store = meta.ws_ocdbt if mip == 0 else meta.ws_ocdbt_scales[mip] + # return store[xL:xH, yL:yH, zL:zH].read().result() return meta.cv[xL:xH, yL:yH, zL:zH] From 0cb09c1cf0d3a8837dbfbf9a3b684bd73c416d11 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 24 May 2026 12:51:28 +0000 Subject: [PATCH 252/367] fix(ocdbt): self-heal stale fork manifests; re-enable OCDBT seg reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setup_base` calls `fork_base_manifest` once at graph creation, before populate has committed most of its writes to base. Any base commit after fork-creation doesn't propagate into the fork's manifest, so kvstack-routed reads through the fork miss every chunk written to base afterwards — meshing (and any other OCDBT seg read) returns zeros. Adds `ensure_fork_synced(ws, graph_id)` in graph/ocdbt/main.py and calls it from `ChunkedGraphMeta.ws_ocdbt_scales` lazy init. The helper re-snapshots the fork manifest from base when the fork's data prefix is empty (no edits) and the manifests differ. Edit-free is the safety guard: an SV-split writes value/btree files under _d/ before updating the fork manifest, so a non-empty prefix means an edit either landed or is in flight and must be preserved. The check is race-free vs concurrent populate (which writes only to base). Re-enables the OCDBT path in `get_local_segmentation` that was temporarily disabled while the kvstack reads returned zeros. --- pychunkedgraph/graph/meta.py | 6 +++ pychunkedgraph/graph/ocdbt/__init__.py | 2 + pychunkedgraph/graph/ocdbt/main.py | 52 ++++++++++++++++++++++++++ pychunkedgraph/graph/utils/generic.py | 10 ++--- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index e527f875b..d4331f3aa 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -11,6 +11,7 @@ from pychunkedgraph.graph.ocdbt import ( OcdbtConfig, build_cg_ocdbt_spec, + ensure_fork_synced, fork_exists, get_seg_source_and_destination_ocdbt, read_populate_meta, @@ -195,6 +196,11 @@ def ws_ocdbt_scales(self): f"ocdbt fork missing at {ws}/ocdbt/{self.graph_id}/ — " "create it via fork_base_manifest or the seg_ocdbt notebook" ) + # Refresh the fork manifest from base if it's stale and edit-free. + # See ensure_fork_synced docstring; without this, post-fork-creation + # populate writes to base are invisible through the kvstack view + # and reads return zeros. + ensure_fork_synced(ws, self.graph_id) _, self._ws_ocdbt_scales, self._ws_ocdbt_resolutions = ( get_seg_source_and_destination_ocdbt( ws, self.graph_id, self.ocdbt_config diff --git a/pychunkedgraph/graph/ocdbt/__init__.py b/pychunkedgraph/graph/ocdbt/__init__.py index 0bbd5d156..633964e1c 100644 --- a/pychunkedgraph/graph/ocdbt/__init__.py +++ b/pychunkedgraph/graph/ocdbt/__init__.py @@ -22,6 +22,7 @@ copy_ws_chunk, copy_ws_chunk_multiscale, create_base_ocdbt, + ensure_fork_synced, fork_base_manifest, get_seg_source_and_destination_ocdbt, open_base_ocdbt, @@ -41,6 +42,7 @@ "copy_ws_chunk", "copy_ws_chunk_multiscale", "create_base_ocdbt", + "ensure_fork_synced", "fork_base_manifest", "fork_exists", "get_seg_source_and_destination_ocdbt", diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index 89a4dfb5f..e6f0baf91 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -46,6 +46,7 @@ _read_source_scales, _schema_from_src, base_exists, + fork_exists, ) logger = get_logger(__name__) @@ -233,6 +234,57 @@ def fork_base_manifest(ws_path: str, graph_id: str, wipe_existing: bool = False) fork_kvs.write("manifest.ocdbt", manifest).result() +def ensure_fork_synced(ws_path: str, graph_id: str) -> bool: + """Self-heal stale fork manifests by re-snapshotting from base. + + ``setup_base`` calls ``fork_base_manifest`` once at graph creation — + before populate has committed most of its writes to base. Any base + commits after that don't propagate into the fork's manifest, so + kvstack-routed reads through the fork miss every chunk written to + base after fork creation. Symptom: meshing reads return all zeros. + + This helper refreshes the fork's manifest from base whenever both + are true: + 1. Fork's manifest differs from base's manifest (stale snapshot). + 2. The fork's ``_d/`` data prefix is empty (no edits yet). + + Edit-free is the safety guard: any SV-split write puts value/btree + files under that prefix BEFORE updating the fork manifest, so a + non-empty prefix means an edit either landed or is in flight, and + we must not overwrite the fork manifest in either case. With an + empty prefix the refresh is race-free vs concurrent populate (which + writes only to base, never to the fork). + + Returns True if the fork manifest was refreshed. + """ + if not fork_exists(ws_path, graph_id): + return False + base = _base_ocdbt_path(ws_path) + fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") + base_kvs = ts.KvStore.open(base).result() + fork_kvs = ts.KvStore.open(fork_dir).result() + base_manifest = base_kvs.read("manifest.ocdbt").result().value + fork_manifest = fork_kvs.read("manifest.ocdbt").result().value + if base_manifest == fork_manifest: + return False + data_prefix = f"{graph_id}_d/" + edit_files = fork_kvs.list( + ts.KvStore.KeyRange(data_prefix, data_prefix[:-1] + chr(ord("/") + 1)) + ).result() + if len(edit_files) > 0: + # Fork has edits; can't safely overwrite its manifest. + logger.warning( + f"fork {fork_dir} has {len(edit_files)} edit files but its " + f"manifest is stale vs base. Auto-refresh skipped to preserve " + f"edits. Call fork_base_manifest(..., wipe_existing=True) " + f"explicitly if you want to drop edits and re-snapshot." + ) + return False + fork_kvs.write("manifest.ocdbt", base_manifest).result() + logger.note(f"refreshed fork manifest at {fork_dir} from base (no edits)") + return True + + def get_seg_source_and_destination_ocdbt( ws_path: str, graph_id: str, diff --git a/pychunkedgraph/graph/utils/generic.py b/pychunkedgraph/graph/utils/generic.py index 8cb88aa3c..64d37a5bc 100644 --- a/pychunkedgraph/graph/utils/generic.py +++ b/pychunkedgraph/graph/utils/generic.py @@ -163,11 +163,11 @@ def get_local_segmentation(meta, bbox_start, bbox_end, mip: int = 0) -> np.ndarr """ xL, yL, zL = bbox_start xH, yH, zH = bbox_end - # if meta.ocdbt_seg: - # # mip > 0 reads from a coarser scale; saves bandwidth and is what - # # meshing wants when it operates at a non-base MIP. - # store = meta.ws_ocdbt if mip == 0 else meta.ws_ocdbt_scales[mip] - # return store[xL:xH, yL:yH, zL:zH].read().result() + if meta.ocdbt_seg: + # mip > 0 reads from a coarser scale; saves bandwidth and is what + # meshing wants when it operates at a non-base MIP. + store = meta.ws_ocdbt if mip == 0 else meta.ws_ocdbt_scales[mip] + return store[xL:xH, yL:yH, zL:zH].read().result() return meta.cv[xL:xH, yL:yH, zL:zH] From 81752e4fdce3ff67bdf03b4f93f4a09f4ec212e5 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 13:34:26 +0000 Subject: [PATCH 253/367] feat(meshing): emit `dynamic_mesh_dir` in info JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unsharded-dynamic-mesh subdir name has been a load-bearing constant ("dynamic") shared implicitly between mesh_worker (reads `custom_data["mesh"].get("dynamic_mesh_dir", "dynamic")`) and neuroglancer (hardcodes the same literal in graphene/backend.ts). Surface it through: - `handle_info` (the Flask `/info` endpoint NG fetches) — so `info["dynamic_mesh_dir"]` is exposed alongside `mesh_dir`. - `get_json_info` (used internally by meshing modules to build CloudVolume `info=` kwargs) — for consistency. Default preserves existing datasets. Requires a matching NG-side patch (graphene backend) to read this field instead of the hardcoded literal. --- pychunkedgraph/app/segmentation/common.py | 9 ++++++++- pychunkedgraph/meshing/meshgen_utils.py | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 33a232547..978b8f331 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -106,11 +106,18 @@ def handle_info(table_id): combined_info["verify_mesh"] = cg.meta.custom_data.get("mesh", {}).get( "verify", False ) - mesh_dir = cg.meta.custom_data.get("mesh", {}).get("dir", None) + mesh_meta = cg.meta.custom_data.get("mesh", {}) + mesh_dir = mesh_meta.get("dir", None) if mesh_dir is not None: combined_info["mesh_dir"] = mesh_dir elif combined_info.get("mesh_dir", None) is not None: combined_info["mesh_dir"] = "graphene_meshes" + # `dynamic_mesh_dir` lets a dataset name the unsharded dynamic-mesh + # subdir explicitly. Default `"dynamic"` matches mesh_worker.py's + # fallback and NG's current hardcoded subdir name in graphene + # backend.ts (`${fragmentUrl}dynamic/`). NG must read + # this info field before non-default values route correctly. + combined_info["dynamic_mesh_dir"] = mesh_meta.get("dynamic_mesh_dir", "dynamic") return jsonify(combined_info) diff --git a/pychunkedgraph/meshing/meshgen_utils.py b/pychunkedgraph/meshing/meshgen_utils.py index 1a35f5f73..db8f6d092 100644 --- a/pychunkedgraph/meshing/meshgen_utils.py +++ b/pychunkedgraph/meshing/meshgen_utils.py @@ -145,7 +145,15 @@ def get_json_info(cg): dataset_info = cg.meta.dataset_info dummy_app_info = {"app": {"supported_api_versions": [0, 1]}} info = {**dataset_info, **dummy_app_info} - info["mesh"] = cg.meta.custom_data.get("mesh", {}).get("dir", "graphene_meshes") + mesh_meta = cg.meta.custom_data.get("mesh", {}) + info["mesh"] = mesh_meta.get("dir", "graphene_meshes") + # `dynamic_mesh_dir` lets a dataset name the unsharded dynamic-mesh + # subdir explicitly. Default `"dynamic"` matches the mesh worker's + # fallback and NG's current hardcoded subdir name — see the + # spelunker-ocdbt graphene backend (looks up + # `dynamic/`). NG must be patched to read + # this info field before non-default values route correctly. + info["dynamic_mesh_dir"] = mesh_meta.get("dynamic_mesh_dir", "dynamic") info_str = dumps(info) return loads(info_str) From 086771ff09a879232c83e8b277f21f0f54b6fa39 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 14:28:18 +0000 Subject: [PATCH 254/367] feat(graph): seamless setup for copied bigtables (mesh dir + ocdbt fork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a bigtable row-copy clones an existing graph into a new graph_id, two graph_id-bearing pieces of runtime state must be reconciled before the new table is usable: 1. `custom_data["mesh"]["dynamic_mesh_dir"]` carries forward the source table's value, which means the new table's edited mesh fragments would write into the source's dynamic mesh dir and overwrite each other at the same fragment-id keys. Extends the existing `graph_id != self.graph_id` shim in ChunkedGraph.__init__ to also rewrite this field — only when the key is present in the copied custom_data — using the new graph_id directly: custom_data["mesh"]["dynamic_mesh_dir"] = f"dynamic_{graph_id}" `mesh.dir` (initial sharded meshes) is dataset-scoped and intentionally shared, so it's not touched. 2. The per-CG OCDBT fork directory doesn't exist for the new graph_id, so `ws_ocdbt_scales` would fire `assert fork_exists` and any OCDBT-side read (meshing, sv-lookup, edits) would blow up. Replaces the assertion with `if not fork_exists: fork_base_manifest(...)`. Idempotent and race-safe: concurrent opens write identical base-manifest bytes to the same path; can't race with an edit because edits pre-suppose the fork. `ensure_fork_synced` immediately after keeps it in step with base. Net effect: copy the bigtable rows, then `ChunkedGraph(graph_id=...)` once. The shim updates meta in a single update_meta call and the ws_ocdbt_scales lazy-init creates+syncs the fork. No operator step. --- pychunkedgraph/graph/chunkedgraph.py | 16 ++++++++++++---- pychunkedgraph/graph/meta.py | 12 ++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 63791a932..12e4f58c8 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -3,6 +3,7 @@ import time import typing import datetime +from copy import deepcopy from itertools import chain from functools import reduce @@ -71,13 +72,20 @@ def __init__( self._cache_service = None self.mock_edges = None # hack for unit tests - # shim to update graph_id in meta for copied graphs + # Shim for copied bigtables: rewrite graph_id-bearing fields in one + # update_meta call. Bigtable row-copies preserve the source table's + # values for `graph_config.ID` and `custom_data["mesh"]["dynamic_mesh_dir"]` + # — left as-is, x0's edited meshes would alias clean's at the same + # fragment-id keys. `mesh.dir` (initial sharded meshes) is dataset- + # scoped and intentionally shared, so it's not rewritten here. if graph_id != self.graph_id: gc = self.meta.graph_config._asdict() gc["ID"] = graph_id - new_meta = ChunkedGraphMeta( - GraphConfig(**gc), self.meta.data_source, self.meta.custom_data - ) + cd = deepcopy(self.meta.custom_data) + mesh = cd.get("mesh") + if mesh is not None and "dynamic_mesh_dir" in mesh: + mesh["dynamic_mesh_dir"] = f"dynamic_{graph_id}" + new_meta = ChunkedGraphMeta(GraphConfig(**gc), self.meta.data_source, cd) self.update_meta(new_meta, overwrite=True) self._meta = new_meta diff --git a/pychunkedgraph/graph/meta.py b/pychunkedgraph/graph/meta.py index d4331f3aa..2c4525cf4 100644 --- a/pychunkedgraph/graph/meta.py +++ b/pychunkedgraph/graph/meta.py @@ -12,6 +12,7 @@ OcdbtConfig, build_cg_ocdbt_spec, ensure_fork_synced, + fork_base_manifest, fork_exists, get_seg_source_and_destination_ocdbt, read_populate_meta, @@ -192,10 +193,13 @@ def ws_ocdbt_scales(self): assert self.ocdbt_seg, "make sure this pcg has segmentation in ocdbt format" if self._ws_ocdbt_scales is None: ws = self.data_source.WATERSHED - assert fork_exists(ws, self.graph_id), ( - f"ocdbt fork missing at {ws}/ocdbt/{self.graph_id}/ — " - "create it via fork_base_manifest or the seg_ocdbt notebook" - ) + # Auto-create the fork on first open if missing — e.g. after a + # bigtable copy that gave us a new graph_id. Idempotent and + # race-safe: concurrent opens write identical base-manifest + # bytes to the same path. Can't race with an edit because an + # edit pre-supposes the fork exists. + if not fork_exists(ws, self.graph_id): + fork_base_manifest(ws, self.graph_id) # Refresh the fork manifest from base if it's stale and edit-free. # See ensure_fork_synced docstring; without this, post-fork-creation # populate writes to base are invisible through the kvstack view From fdff144d505a842bf2fd185f7026c2eff3313cd4 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 14:52:01 +0000 Subject: [PATCH 255/367] fix(deps): pin docker base image digest; regenerate requirements.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two symptoms of the same root cause — upstream `tiangolo/uwsgi-nginx-flask:python3.12` was rebuilt, its digest changed, and that broke us two ways: 1) Stage-1 cache invalidated → builds went from ~1.5min to 7+min (full `conda env create` re-runs on every CI build because the FROM layer hash now changes whenever upstream rebuilds the tag). 2) The new base image carried a newer transitive Python package set into the conda env, leaving `importlib_metadata` in a partial-overlay state in `/app/venv` — `__init__.py` from one version on top of file tree from another, missing `_context.py`. Imports through messagingclient → google.cloud.pubsub_v1 → opentelemetry → opentelemetry.util._importlib_metadata crashed with `ModuleNotFoundError: No module named 'importlib_metadata._context'`. Pin the base image by digest so subsequent builds get the same Python file set and the layer cache stays warm. Bump opentelemetry (via pip-compile --upgrade) to 1.42.x, which dropped the third-party `importlib_metadata` wrapper in favor of stdlib `importlib.metadata` — verified inside the 1.42.1 wheel (opentelemetry/util/_importlib_metadata.py imports `from importlib.metadata import ...`). With opentelemetry no longer pulling in the third-party package, no Python 3.12 consumer remains, so pip-compile correctly removed `importlib-metadata` from the lockfile entirely. Net dep changes: opentelemetry-api/sdk 1.39.1 → 1.42.1 opentelemetry-semantic-conventions 0.60b1 → 0.63b1 google-cloud-pubsub 2.35.0 → 2.38.0 importlib-metadata 8.7.1 → dropped + routine minor bumps across pip-compiled deps requirements.in unchanged. Bump the Dockerfile digest manually when you want a fresher base. --- Dockerfile | 7 ++- requirements.txt | 137 ++++++++++++++++++++++------------------------- 2 files changed, 71 insertions(+), 73 deletions(-) diff --git a/Dockerfile b/Dockerfile index 968f93043..2c394a889 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,11 @@ # syntax=docker/dockerfile:1 ARG PYTHON_VERSION=3.12 -ARG BASE_IMAGE=tiangolo/uwsgi-nginx-flask:python${PYTHON_VERSION} +# Pin by digest. Without it, upstream rebuilds of the +# `python3.12` tag invalidate the Stage-1 cache and pull in newer +# transitive Python packages (e.g. importlib_metadata) that conflict +# with our requirements.txt pins. Bump the digest manually when you +# want to pull a fresher base. +ARG BASE_IMAGE=tiangolo/uwsgi-nginx-flask:python${PYTHON_VERSION}@sha256:329d84f4cc50ccd14d60eb02384713b4ae8723eddefda9fda342c7c3f17cdcb1 ###################################################### diff --git a/requirements.txt b/requirements.txt index c0d5fe147..8c4a4d032 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,20 +2,20 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile requirements.in +# pip-compile --output-file=requirements.txt requirements.in # -attrs==25.4.0 +attrs==26.1.0 # via # jsonschema # referencing blinker==1.9.0 # via flask -boto3==1.42.53 +boto3==1.43.14 # via # cloud-files # cloud-volume # task-queue -botocore==1.42.53 +botocore==1.43.14 # via # boto3 # s3transfer @@ -23,21 +23,21 @@ brotli==1.2.0 # via # cloud-files # urllib3 -cachetools==7.0.1 +cachetools==7.1.4 # via # -r requirements.in # middle-auth-client -certifi==2026.1.4 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography -chardet==5.2.0 +chardet==7.4.3 # via # cloud-files # cloud-volume -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -click==8.3.1 +click==8.4.1 # via # -r requirements.in # cloud-files @@ -46,22 +46,22 @@ click==8.3.1 # microviewer # rq # task-queue -cloud-files==6.2.1 +cloud-files==6.3.0 # via # -r requirements.in # cloud-volume # datastoreflex -cloud-volume==12.10.0 +cloud-volume==12.13.1 # via -r requirements.in -compressed-segmentation==2.3.2 +compressed-segmentation==2.3.3 # via cloud-volume -connected-components-3d==3.26.1 +connected-components-3d==3.28.0 # via -r requirements.in crc32c==2.8 # via cloud-files -croniter==6.0.0 +croniter==6.2.2 # via rq -cryptography==46.0.5 +cryptography==48.0.0 # via google-auth datastoreflex==0.5.0 # via -r requirements.in @@ -79,7 +79,7 @@ edt==3.1.1 # via -r requirements.in fasteners==0.20 # via cloud-files -fastremap==1.17.7 +fastremap==1.19.0 # via # -r requirements.in # cloud-volume @@ -93,19 +93,19 @@ flask-cors==6.0.2 # via -r requirements.in furl==2.1.4 # via middle-auth-client -gevent==25.9.1 +gevent==26.5.0 # via # cloud-files # cloud-volume # task-queue -google-api-core[grpc]==2.30.0 +google-api-core[grpc]==2.30.3 # via # google-cloud-bigtable # google-cloud-core # google-cloud-datastore # google-cloud-pubsub # google-cloud-storage -google-auth==2.48.0 +google-auth==2.53.0 # via # cloud-files # cloud-volume @@ -116,9 +116,9 @@ google-auth==2.48.0 # google-cloud-pubsub # google-cloud-storage # task-queue -google-cloud-bigtable==2.35.0 +google-cloud-bigtable==2.38.0 # via kvdbclient -google-cloud-core==2.5.0 +google-cloud-core==2.6.0 # via # cloud-files # cloud-volume @@ -126,13 +126,13 @@ google-cloud-core==2.5.0 # google-cloud-datastore # google-cloud-storage # task-queue -google-cloud-datastore==2.23.0 +google-cloud-datastore==2.24.0 # via # -r requirements.in # datastoreflex -google-cloud-pubsub==2.35.0 +google-cloud-pubsub==2.38.0 # via messagingclient -google-cloud-storage==3.9.0 +google-cloud-storage==3.10.1 # via # cloud-files # cloud-volume @@ -142,37 +142,36 @@ google-crc32c==1.8.0 # google-cloud-bigtable # google-cloud-storage # google-resumable-media -google-resumable-media==2.8.0 +google-resumable-media==2.9.0 # via google-cloud-storage -googleapis-common-protos[grpc]==1.72.0 +googleapis-common-protos[grpc]==1.75.0 # via # google-api-core # grpc-google-iam-v1 # grpcio-status -greenlet==3.3.1 +greenlet==3.5.1 # via gevent -grpc-google-iam-v1==0.14.3 +grpc-google-iam-v1==0.14.4 # via # google-cloud-bigtable # google-cloud-pubsub -grpcio==1.78.0 +grpcio==1.80.0 # via # google-api-core + # google-cloud-bigtable # google-cloud-datastore # google-cloud-pubsub # googleapis-common-protos # grpc-google-iam-v1 # grpcio-status -grpcio-status==1.78.0 +grpcio-status==1.80.0 # via # google-api-core # google-cloud-pubsub -idna==3.11 +idna==3.16 # via requests -imageio==2.37.2 +imageio==2.37.3 # via scikit-image -importlib-metadata==8.7.1 - # via opentelemetry-api inflection==0.5.1 # via python-jsonschema-objects iniconfig==2.3.0 @@ -187,7 +186,7 @@ jmespath==1.1.0 # via # boto3 # botocore -json5==0.13.0 +json5==0.14.0 # via cloud-volume jsonschema==4.26.0 # via @@ -197,7 +196,7 @@ jsonschema-specifications==2025.9.1 # via jsonschema kvdbclient==0.7.0 # via -r requirements.in -lazy-loader==0.4 +lazy-loader==0.5 # via scikit-image markdown==3.10.2 # via python-jsonschema-objects @@ -212,7 +211,7 @@ mdurl==0.1.2 # via markdown-it-py messagingclient==0.4.0 # via -r requirements.in -microviewer==1.20.0 +microviewer==1.21.0 # via cloud-volume middle-auth-client==3.19.2 # via -r requirements.in @@ -226,7 +225,7 @@ networkx==3.6.1 # cloud-volume # osteoid # scikit-image -numpy==2.4.2 +numpy==2.4.6 # via # -r requirements.in # cloud-files @@ -250,29 +249,29 @@ numpy==2.4.2 # tifffile # tinybrain # zmesh -opentelemetry-api==1.39.1 +opentelemetry-api==1.42.1 # via # google-cloud-pubsub # opentelemetry-sdk # opentelemetry-semantic-conventions -opentelemetry-sdk==1.39.1 +opentelemetry-sdk==1.42.1 # via google-cloud-pubsub -opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions==0.63b1 # via opentelemetry-sdk orderedmultidict==1.0.2 # via furl -orjson==3.11.7 +orjson==3.11.9 # via # cloud-files # task-queue osteoid==0.6.0 # via cloud-volume -packaging==26.0 +packaging==26.2 # via # lazy-loader # pytest # scikit-image -pandas==3.0.1 +pandas==3.0.3 # via -r requirements.in pathos==0.3.5 # via @@ -281,7 +280,7 @@ pathos==0.3.5 # task-queue pbr==7.0.3 # via task-queue -pillow==12.1.1 +pillow==12.2.0 # via # imageio # scikit-image @@ -293,13 +292,13 @@ pox==0.3.7 # via pathos ppft==1.7.8 # via pathos -proto-plus==1.27.1 +proto-plus==1.28.0 # via # google-api-core # google-cloud-bigtable # google-cloud-datastore # google-cloud-pubsub -protobuf==6.33.5 +protobuf==6.33.6 # via # -r requirements.in # cloud-files @@ -314,23 +313,23 @@ protobuf==6.33.5 # proto-plus psutil==7.2.2 # via cloud-volume -pyasn1==0.6.2 +pyasn1==0.6.3 # via # pyasn1-modules # rsa pyasn1-modules==0.4.2 # via google-auth -pybind11==3.0.2 +pybind11==3.0.4 # via osteoid pycparser==3.0 # via cffi -pygments==2.19.2 +pygments==2.20.0 # via # pytest # rich pysimdjson==7.0.2 # via cloud-volume -pytest==9.0.2 +pytest==9.0.3 # via compressed-segmentation python-dateutil==2.9.0.post0 # via @@ -338,17 +337,15 @@ python-dateutil==2.9.0.post0 # cloud-volume # croniter # pandas -python-json-logger==4.0.0 +python-json-logger==4.1.0 # via -r requirements.in python-jsonschema-objects==0.5.7 # via cloud-volume -pytz==2025.2 - # via - # croniter - # kvdbclient +pytz==2026.2 + # via kvdbclient pyyaml==6.0.3 # via -r requirements.in -redis==7.2.0 +redis==7.4.0 # via # -r requirements.in # rq @@ -356,7 +353,7 @@ referencing==0.37.0 # via # jsonschema # jsonschema-specifications -requests==2.32.5 +requests==2.34.2 # via # -r requirements.in # cloud-files @@ -372,13 +369,11 @@ rpds-py==0.30.0 # via # jsonschema # referencing -rq==2.6.1 +rq==2.9.0 # via -r requirements.in rsa==4.9.1 - # via - # cloud-files - # google-auth -s3transfer==0.16.0 + # via cloud-files +s3transfer==0.17.0 # via boto3 scikit-image==0.26.0 # via -r requirements.in @@ -402,9 +397,9 @@ tenacity==9.1.4 # cloud-volume # kvdbclient # task-queue -tensorstore==0.1.81 +tensorstore==0.1.84 # via -r requirements.in -tifffile==2026.3.3 +tifffile==2026.5.15 # via scikit-image tinybrain==1.7.0 # via -r requirements.in @@ -420,24 +415,22 @@ typing-extensions==4.15.0 # opentelemetry-sdk # opentelemetry-semantic-conventions # referencing -urllib3[brotli]==2.6.3 +urllib3[brotli]==2.7.0 # via # botocore # cloud-files # cloud-volume # requests -werkzeug==3.1.6 +werkzeug==3.1.8 # via # -r requirements.in # flask # flask-cors -zipp==3.23.0 - # via importlib-metadata -zmesh==1.10.0 +zmesh==1.13.1 # via -r requirements.in -zope-event==6.1 +zope-event==6.2 # via gevent -zope-interface==8.2 +zope-interface==8.4 # via gevent zstandard==0.25.0 # via From 0cdcb9686254bed4035ddd9fb954b539d006abfc Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 18:12:44 +0000 Subject: [PATCH 256/367] test(graph): align stale tests with current bootstrap/ws_cv/ocdbt source - conftest bootstrap fixtures unpack the OcdbtConfig slot - ws_cv mock assertions accept the loader's two CloudVolume constructions (info-fetch + cached-handle with info=) - ws_ocdbt tests bypass OCDBT meta resolution and the fork-sync side-channel that hit real GCS, and relax the call-args check now that get_seg_source_and_destination_ocdbt takes the OcdbtConfig positionally - cleanup_partial_writes patches _read_source_scales at the call site (ocdbt.main) instead of the package re-export --- pychunkedgraph/tests/conftest.py | 4 +- pychunkedgraph/tests/graph/test_meta.py | 50 ++++++++++++++++---- pychunkedgraph/tests/graph/test_stuck_ops.py | 7 ++- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/pychunkedgraph/tests/conftest.py b/pychunkedgraph/tests/conftest.py index 0e737f7f5..fa4683253 100644 --- a/pychunkedgraph/tests/conftest.py +++ b/pychunkedgraph/tests/conftest.py @@ -180,7 +180,7 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) "ingest_config": {}, } - meta, _, client_info = bootstrap("test", config=config) + meta, _, client_info, _ = bootstrap("test", config=config) graph = ChunkedGraph(graph_id="test", meta=meta, client_info=client_info) graph.mock_edges = Edges([], []) graph.meta._ws_cv = CloudVolumeMock() @@ -247,7 +247,7 @@ def _cgraph(request, n_layers=10, atomic_chunk_bounds: np.ndarray = np.array([]) "ingest_config": {}, } - meta, _, client_info = bootstrap("test", config=config) + meta, _, client_info, _ = bootstrap("test", config=config) graph = ChunkedGraph(graph_id="test", meta=meta, client_info=client_info) # No mock_edges - use real I/O via file:// protocol graph.meta._ws_cv = CloudVolumeMock() diff --git a/pychunkedgraph/tests/graph/test_meta.py b/pychunkedgraph/tests/graph/test_meta.py index d8f8134d9..7ee2896a4 100644 --- a/pychunkedgraph/tests/graph/test_meta.py +++ b/pychunkedgraph/tests/graph/test_meta.py @@ -425,7 +425,10 @@ def test_ws_cv_redis_cached(self, mock_get_redis, mock_cv_cls): @patch("pychunkedgraph.graph.meta.CloudVolume") @patch("pychunkedgraph.graph.meta.get_redis_connection") def test_ws_cv_redis_failure_fallback(self, mock_get_redis, mock_cv_cls): - """When redis raises, ws_cv falls back to direct CloudVolume.""" + """When redis raises, ws_cv still fetches `.info` (via the loader) and + then constructs the cached CloudVolume with that info — it just skips + writing the info back to redis. + """ gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) meta = ChunkedGraphMeta(gc, ds) @@ -439,8 +442,12 @@ def test_ws_cv_redis_failure_fallback(self, mock_get_redis, mock_cv_cls): result = meta.ws_cv assert result is mock_cv_instance - # Should have been called without info kwarg (fallback) - mock_cv_cls.assert_called_with("gs://bucket/ws", progress=False) + # Loader path runs even when redis is dead — fetch .info first… + mock_cv_cls.assert_any_call("gs://bucket/ws", progress=False) + # …then construct the cached handle with explicit info=. + mock_cv_cls.assert_any_call( + "gs://bucket/ws", info={"scales": []}, progress=False + ) @patch("pychunkedgraph.graph.meta.CloudVolume") @patch("pychunkedgraph.graph.meta.get_redis_connection") @@ -462,9 +469,14 @@ def test_ws_cv_caches_to_redis(self, mock_get_redis, mock_cv_cls): result = meta.ws_cv assert result is mock_cv_instance - # The fallback CloudVolume call (no info= kwarg) - mock_cv_cls.assert_called_with("gs://bucket/ws", progress=False) - # Should try to cache in redis + # Cache-miss path: loader fetches .info, then handle is constructed + # with explicit info=, and the info gets written to redis. + mock_cv_cls.assert_any_call("gs://bucket/ws", progress=False) + mock_cv_cls.assert_any_call( + "gs://bucket/ws", + info={"scales": [{"resolution": [8, 8, 40]}]}, + progress=False, + ) mock_redis.set.assert_called_once() @patch("pychunkedgraph.graph.meta.CloudVolume") @@ -582,9 +594,17 @@ def test_ws_ocdbt_asserts_when_not_ocdbt(self): with pytest.raises(AssertionError, match="ocdbt"): _ = meta.ws_ocdbt + @patch("pychunkedgraph.graph.meta.read_populate_meta", return_value=None) + @patch("pychunkedgraph.graph.meta.ensure_fork_synced") @patch("pychunkedgraph.graph.meta.fork_exists", return_value=True) @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") - def test_ws_ocdbt_returns_base_scale(self, mock_get_ocdbt, _mock_fork_exists): + def test_ws_ocdbt_returns_base_scale( + self, + mock_get_ocdbt, + _mock_fork_exists, + _mock_ensure_synced, + _mock_read_populate_meta, + ): gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) @@ -601,11 +621,23 @@ def test_ws_ocdbt_returns_base_scale(self, mock_get_ocdbt, _mock_fork_exists): assert meta.ws_ocdbt is mock_dst_base assert meta.ws_ocdbt_scales == [mock_dst_base, mock_dst_mip1] assert meta.ws_ocdbt_resolutions == [[4, 4, 40], [8, 8, 40]] - mock_get_ocdbt.assert_called_once_with("gs://bucket/ws", "test_graph") + # `get_seg_source_and_destination_ocdbt` is called with a third positional + # arg (the resolved OcdbtConfig) — verify only the (ws, graph_id) part; + # the OcdbtConfig.resolve contract has its own unit tests. + mock_get_ocdbt.assert_called_once() + assert mock_get_ocdbt.call_args.args[:2] == ("gs://bucket/ws", "test_graph") + @patch("pychunkedgraph.graph.meta.read_populate_meta", return_value=None) + @patch("pychunkedgraph.graph.meta.ensure_fork_synced") @patch("pychunkedgraph.graph.meta.fork_exists", return_value=True) @patch("pychunkedgraph.graph.meta.get_seg_source_and_destination_ocdbt") - def test_ws_ocdbt_cached(self, mock_get_ocdbt, _mock_fork_exists): + def test_ws_ocdbt_cached( + self, + mock_get_ocdbt, + _mock_fork_exists, + _mock_ensure_synced, + _mock_read_populate_meta, + ): gc = GraphConfig(ID="test_graph", CHUNK_SIZE=[64, 64, 64]) ds = DataSource(WATERSHED="gs://bucket/ws", DATA_VERSION=4) meta = ChunkedGraphMeta(gc, ds, custom_data={"seg": {"ocdbt": True}}) diff --git a/pychunkedgraph/tests/graph/test_stuck_ops.py b/pychunkedgraph/tests/graph/test_stuck_ops.py index 823c7595e..d3e8c3b8d 100644 --- a/pychunkedgraph/tests/graph/test_stuck_ops.py +++ b/pychunkedgraph/tests/graph/test_stuck_ops.py @@ -354,7 +354,12 @@ def test_cleanup_reverts_partial_writes_to_pre_op(self, local_ocdbt): "compressed_segmentation_block_size": [8, 8, 8], } ] - with patch.object(ocdbt_mod, "_read_source_scales", return_value=fake_scales): + # Patch the binding in `ocdbt.main` (where it's actually called). + # The package re-export in `ocdbt/__init__.py` is a separate name + # binding and patching it has no effect on main.py's local one. + with patch.object( + ocdbt_mod.main, "_read_source_scales", return_value=fake_scales + ): reverted = stuck_ops.cleanup_partial_writes(cg, op_id) assert reverted == 1 From 1fea204e0de5e2c965051085803866f1490114f9 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 18:35:05 +0000 Subject: [PATCH 257/367] fix(ocdbt): on-disk config wins on open; never assert in-code defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an existing OCDBT must not embed a `config` block — tensorstore validates every field against the on-disk manifest and raises FAILED_PRECONDITION on any drift. Drift bricks every base that was created with the older default for `max_inline_value_bytes`. Only `create_base_ocdbt` keeps `config`; `build_cg_ocdbt_spec` and `open_base_ocdbt` no longer pass it. Functions still accept the `OcdbtConfig` arg so callers don't change; it's just not asserted into the open-time spec. --- .../graph/ocdbt/TENSORSTORE_REFERENCE.md | 2 +- pychunkedgraph/graph/ocdbt/main.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md b/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md index b47f47c47..7faf4f140 100644 --- a/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md +++ b/pychunkedgraph/graph/ocdbt/TENSORSTORE_REFERENCE.md @@ -113,7 +113,7 @@ Other `TENSORSTORE_*` vars exist (CA paths, S3/GCS concurrency, etc.) — grep t ## How this maps onto pychunkedgraph - `OcdbtConfig` (`pychunkedgraph/graph/ocdbt/meta.py`) → `compression: zstd 12`, `max_inline_value_bytes = 4 KiB`. The 4 KiB threshold keeps small metadata (info JSON, populate markers) inline while forcing every chunk value out-of-line into d/ files — this is what keeps cooperator RPCs under the 4 MiB gRPC ceiling. -- `create_base_ocdbt` / `open_base_ocdbt` pass `config.ts_config()` so the same OCDBT config persists across opens. +- `create_base_ocdbt` is the **only** path that embeds `config.ts_config()` in its kvstore spec — that write persists the values into `manifest.ocdbt`. Every open path (`open_base_ocdbt`, `build_cg_ocdbt_spec`) omits the `config` block: tensorstore would otherwise assert our in-code defaults against the on-disk manifest and raise `FAILED_PRECONDITION` on any drift, bricking every existing base. On-disk wins. - `populate_chunk` (`pychunkedgraph/ingest/ocdbt.py`) opens the base with `coordinator_address` (distributed mode). - `copy_ws_bbox_multiscale` uses **non-atomic** `ts.Transaction()` because of the distributed-mode constraint above. - `_dump_failure_to_gcs` writes JSON failure forensics when `ERROR_DUMP` env is set. diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index e6f0baf91..53af80784 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -126,7 +126,12 @@ def open_base_ocdbt( base = _base_ocdbt_path(ws_path) scales = _read_source_scales(ws_path) resolutions = [s["resolution"] for s in scales] - base_kvstore = {"driver": "ocdbt", "base": base, "config": config.ts_config()} + # No `config` block: the base already exists (created by + # `create_base_ocdbt`), so its on-disk manifest is authoritative. + # Embedding `config.ts_config()` here would assert our in-code + # defaults against whatever is persisted and raise + # FAILED_PRECONDITION on any drift. + base_kvstore = {"driver": "ocdbt", "base": base} if coordinator_address: base_kvstore["coordinator"] = {"address": coordinator_address} @@ -193,13 +198,18 @@ def build_cg_ocdbt_spec( "base": _ensure_trailing_slash(fork_dir + data_prefix), } + # No `config` block: this spec opens an existing OCDBT (the shared + # base + this fork's manifest+data layers). Tensorstore validates + # every field of `config` against the on-disk manifest and raises + # FAILED_PRECONDITION on mismatch, so embedding our in-code defaults + # here would break any base that was created with different values + # (e.g. an older default for `max_inline_value_bytes`). On-disk wins. spec = { "driver": "ocdbt", "base": { "driver": "kvstack", "layers": [base_layer, fork_manifest_layer, fork_data_layer], }, - "config": config.ts_config(), # Steer every kind of OCDBT write under `_d/` so the # fork_data_layer catches them. "value_data_prefix": data_prefix, From 949f8b8834bdb3cb1d87e01fa1191fca48d22124 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 18:54:05 +0000 Subject: [PATCH 258/367] fix(ocdbt): ensure_fork_synced acts only pre-edit; drop noisy steady-state warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function's sole job is closing the setup-base race: snapshot the fork manifest from base before the fork's first edit. Once any edit lands the function has nothing to do — base is immutable post-setup, so manifests *will* diverge by design as the fork moves forward. Short-circuit on edit-files first (lists `_d/`, no manifest reads on the hot path) and remove the warning that fired on every runtime open of every edited CG with a misleading "stale vs base" framing. --- pychunkedgraph/graph/ocdbt/main.py | 60 +++++++++++++----------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index 53af80784..6525682d8 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -245,50 +245,42 @@ def fork_base_manifest(ws_path: str, graph_id: str, wipe_existing: bool = False) def ensure_fork_synced(ws_path: str, graph_id: str) -> bool: - """Self-heal stale fork manifests by re-snapshotting from base. - - ``setup_base`` calls ``fork_base_manifest`` once at graph creation — - before populate has committed most of its writes to base. Any base - commits after that don't propagate into the fork's manifest, so - kvstack-routed reads through the fork miss every chunk written to - base after fork creation. Symptom: meshing reads return all zeros. - - This helper refreshes the fork's manifest from base whenever both - are true: - 1. Fork's manifest differs from base's manifest (stale snapshot). - 2. The fork's ``_d/`` data prefix is empty (no edits yet). - - Edit-free is the safety guard: any SV-split write puts value/btree - files under that prefix BEFORE updating the fork manifest, so a - non-empty prefix means an edit either landed or is in flight, and - we must not overwrite the fork manifest in either case. With an - empty prefix the refresh is race-free vs concurrent populate (which - writes only to base, never to the fork). - - Returns True if the fork manifest was refreshed. + """Sync fork manifest to base — but only before the fork's first edit. + + Invariant we enforce: a fresh, edit-free fork must reflect base's + *current* manifest at open time. ``setup_base`` calls + ``fork_base_manifest`` once at graph creation, possibly before + populate has committed most of its writes; any subsequent populate + commit to base would otherwise be invisible through the fork + (symptom: meshing reads return zeros). We close that window by + re-snapshotting on the first runtime open before any edit lands. + + Once the fork has any edit (anything under ``_d/``), the + function has no work to do: base is immutable post-setup, so the + fork manifest cannot fall behind in any way that matters — its + divergence from base is just the fork's own forward progress. + Edit files are stable, so listing the prefix is a sufficient + short-circuit and skips reading both manifests on every runtime + open. + + Returns True iff the fork manifest was refreshed. """ if not fork_exists(ws_path, graph_id): return False - base = _base_ocdbt_path(ws_path) fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") - base_kvs = ts.KvStore.open(base).result() fork_kvs = ts.KvStore.open(fork_dir).result() - base_manifest = base_kvs.read("manifest.ocdbt").result().value - fork_manifest = fork_kvs.read("manifest.ocdbt").result().value - if base_manifest == fork_manifest: - return False data_prefix = f"{graph_id}_d/" edit_files = fork_kvs.list( ts.KvStore.KeyRange(data_prefix, data_prefix[:-1] + chr(ord("/") + 1)) ).result() if len(edit_files) > 0: - # Fork has edits; can't safely overwrite its manifest. - logger.warning( - f"fork {fork_dir} has {len(edit_files)} edit files but its " - f"manifest is stale vs base. Auto-refresh skipped to preserve " - f"edits. Call fork_base_manifest(..., wipe_existing=True) " - f"explicitly if you want to drop edits and re-snapshot." - ) + # Steady state — fork has progressed forward by design. + return False + base = _base_ocdbt_path(ws_path) + base_kvs = ts.KvStore.open(base).result() + base_manifest = base_kvs.read("manifest.ocdbt").result().value + fork_manifest = fork_kvs.read("manifest.ocdbt").result().value + if base_manifest == fork_manifest: return False fork_kvs.write("manifest.ocdbt", base_manifest).result() logger.note(f"refreshed fork manifest at {fork_dir} from base (no edits)") From b0d1a81e1cb49132f84c465ffbce39ad1436fdc1 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 21:27:10 +0000 Subject: [PATCH 259/367] feat(meshing): one-shot mesh meta setup + `ingest mesh_meta` CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writes every mesh.* field a new or freshly-copied graph needs to serve fragments — watershed-cv `info["mesh"]`, sharded mesh spec per layer, `info["mesh_metadata"]` (uniform draco grid + dynamic dir), and the bigtable `custom_data["mesh"]` block including `initial_ts`. Static values come from a `mesh_config:` block in the dataset yaml, parsed via the new `MeshConfig` dataclass (mirrors `OcdbtConfig`). No in-code defaults — yaml must supply every static field; only the graph-id-derived `dynamic_mesh_dir` is optional. `initial_ts` is set once and preserved on re-runs (changing it would reclassify node ids and silently break served manifests). When unset, it's derived from a root id sampled near the volume center. The CLI subcommand is `ingest mesh_meta GRAPH_ID DATASET_YAML`. It does not gate on redis ingest progress — the operator decides when the graph is ready. --- pychunkedgraph/ingest/cli.py | 25 ++++++ pychunkedgraph/meshing/meta.py | 68 +++++++++++++++ pychunkedgraph/meshing/setup.py | 147 ++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 pychunkedgraph/meshing/meta.py create mode 100644 pychunkedgraph/meshing/setup.py diff --git a/pychunkedgraph/ingest/cli.py b/pychunkedgraph/ingest/cli.py index 811f19891..47659d1ca 100644 --- a/pychunkedgraph/ingest/cli.py +++ b/pychunkedgraph/ingest/cli.py @@ -30,6 +30,8 @@ from .create.parent_layer import add_parent_chunk from ..graph.chunkedgraph import ChunkedGraph from ..graph.ocdbt import OcdbtConfig +from ..meshing.meta import MeshConfig +from ..meshing.setup import setup_mesh_meta from ..utils.redis import get_redis_connection, keys as r_keys group_name = "ingest" @@ -124,6 +126,29 @@ def ingest_graph( os._exit(0) +@ingest_cli.command("mesh_meta") +@click.argument("graph_id", type=str) +@click.argument("dataset", type=click.Path(exists=True)) +@job_type_guard(group_name) +def mesh_meta(graph_id: str, dataset: click.Path): + """Set up every mesh.* metadata field for GRAPH_ID from DATASET yaml. + + Reads ``mesh_config:`` from the yaml, applies it to the graph. Run + once per new/copied graph, after the operator has verified initial + ingest (including the root layer) is complete — no automatic gate. + """ + with open(dataset, "r") as stream: + config = yaml.safe_load(stream) + if "mesh_config" not in config: + raise click.ClickException( + f"{dataset} has no `mesh_config:` block — required for mesh_meta." + ) + mesh_cfg = MeshConfig.from_dict(config["mesh_config"]) + cg = ChunkedGraph(graph_id=graph_id) + result = setup_mesh_meta(cg, mesh_cfg) + click.echo(f"mesh meta written for {graph_id}: {result}") + + @ingest_cli.command("imanager") @click.argument("graph_id", type=str) @click.argument("dataset", type=click.Path(exists=True)) diff --git a/pychunkedgraph/meshing/meta.py b/pychunkedgraph/meshing/meta.py new file mode 100644 index 000000000..2969ea19a --- /dev/null +++ b/pychunkedgraph/meshing/meta.py @@ -0,0 +1,68 @@ +"""MeshConfig dataclass — single source of truth for per-CG mesh setup values. + +Read from the dataset yaml under a ``mesh_config:`` block, exactly like +``OcdbtConfig`` is read from ``ocdbt_config:``. Every static field is +required — the helper that applies it (``setup_mesh_meta``) does not +substitute defaults for missing yaml entries. The only optional field +is :attr:`dynamic_mesh_dir`, which is graph-id-derived and filled in by +:meth:`with_graph_id` when omitted from the yaml. + +Example yaml block:: + + mesh_config: + dir: graphene_meshes + mip: 0 + max_layer: 6 + max_error: 40 + chunk_size: [512, 512, 256] + minishard_bits: {2: 1, 3: 3, 4: 6, 5: 9, 6: 12} + # dynamic_mesh_dir: my_custom_dir # optional; default "dynamic_" +""" + +from dataclasses import asdict, dataclass, replace +from typing import Dict, List, Optional + + +@dataclass +class MeshConfig: + """Per-CG mesh setup config. See module docstring for yaml schema.""" + + dir: str + mip: int + max_layer: int + max_error: int + chunk_size: List[int] + minishard_bits: Dict[int, int] + dynamic_mesh_dir: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict) -> "MeshConfig": + """Build from a yaml-parsed dict. + + Unknown keys are dropped (so older yamls don't break newer code). + ``minishard_bits`` keys are coerced to ``int`` so the yaml is + tolerant of bare-int vs quoted-string keys. + """ + if not d: + raise ValueError( + "MeshConfig.from_dict: empty config — yaml `mesh_config:` " + "block is required" + ) + known = {f for f in cls.__dataclass_fields__} + kwargs = {k: v for k, v in d.items() if k in known} + if "minishard_bits" in kwargs: + kwargs["minishard_bits"] = { + int(k): int(v) for k, v in kwargs["minishard_bits"].items() + } + if "chunk_size" in kwargs: + kwargs["chunk_size"] = [int(x) for x in kwargs["chunk_size"]] + return cls(**kwargs) + + def with_graph_id(self, graph_id: str) -> "MeshConfig": + """Return a copy with ``dynamic_mesh_dir`` filled in if unset.""" + if self.dynamic_mesh_dir is not None: + return self + return replace(self, dynamic_mesh_dir=f"dynamic_{graph_id}") + + def to_dict(self) -> Dict: + return asdict(self) diff --git a/pychunkedgraph/meshing/setup.py b/pychunkedgraph/meshing/setup.py new file mode 100644 index 000000000..41f2d3b74 --- /dev/null +++ b/pychunkedgraph/meshing/setup.py @@ -0,0 +1,147 @@ +"""One-shot mesh metadata setup for a CG. + +Writes every mesh-related field a new or freshly-copied graph needs +before any mesh fragment can be served: + + 1. ``cg.meta.ws_cv.info["mesh"]`` (mesh dir in the watershed cv info.json) + 2. ``cg.meta.ws_cv.mesh.meta.info`` (per-layer sharded mesh spec) + 3. ``cg.meta.ws_cv.info["mesh_metadata"]`` (uniform draco grid + dynamic dir) + 4. ``cg.meta.custom_data["mesh"]`` (CG bigtable meta block) + +Idempotent: re-running overwrites the same fields with the current +inputs, with one exception — ``initial_ts`` is preserved if already +set, because changing it after the fact would reclassify every node +id and silently break served manifests. +""" + +import logging + +import numpy as np + +from ..graph.chunkedgraph import ChunkedGraph +from .meshgen import get_draco_encoding_settings_for_chunk +from .meta import MeshConfig + +logger = logging.getLogger(__name__) + + +def derive_initial_ts(cg: ChunkedGraph) -> int: + """Unix-seconds timestamp of a root id sampled from the dataset center. + + ``mesh.initial_ts`` is the threshold ``segregate_node_ids`` (see + ``meshing/manifest/utils.py``) uses to classify root ids as initial + vs post-ingest. It must sit above the last initial-ingest commit + and below any post-ingest commit. Picking a root id near the + volume center and using its commit timestamp satisfies both bounds + for any graph that completed initial ingest. + + Walks shells outward from the center of the L2 chunk grid (L1 + shares L2's coordinate grid) and returns the timestamp of the root + of the first SV found. + """ + hi = np.asarray(cg.meta.layer_chunk_bounds[2]) + center = hi // 2 + for r in range(int(hi.max()) + 1): + box = ( + np.array( + np.meshgrid( + np.arange(-r, r + 1), + np.arange(-r, r + 1), + np.arange(-r, r + 1), + indexing="ij", + ) + ) + .reshape(3, -1) + .T + ) + shell = box[np.max(np.abs(box), axis=1) == r] + coords = np.unique(np.clip(center + shell, 0, hi - 1), axis=0) + for c in coords: + chunk_id = cg.get_chunk_id(layer=1, x=int(c[0]), y=int(c[1]), z=int(c[2])) + svs = list(cg.range_read_chunk(chunk_id)) + if svs: + sv = svs[len(svs) // 2] + root = cg.get_root(sv) + ts = cg.get_node_timestamps(np.array([root]), return_numpy=False)[0] + return int(ts.timestamp()) + raise RuntimeError("derive_initial_ts: no SVs found anywhere in the volume") + + +def setup_mesh_meta( + cg: ChunkedGraph, + mesh_config: MeshConfig, +) -> dict: + """Write every mesh.* metadata field this graph needs to serve meshes. + + Writes go to two places: the watershed CloudVolume (steps 1-3, via + ``info.json`` / ``mesh/info`` on GCS) and the CG's bigtable meta + block (step 4). + + ``initial_ts`` is set once and never overwritten — if the existing + bigtable mesh meta already has one, it is reused as-is. Otherwise + it is derived via :func:`derive_initial_ts` and persisted. + + Returns the mesh meta dict persisted into bigtable. + """ + cfg = mesh_config.with_graph_id(cg.graph_id) + existing_mesh = cg.meta.custom_data.get("mesh", {}) + existing_ts = existing_mesh.get("initial_ts") + initial_ts = int(existing_ts) if existing_ts is not None else derive_initial_ts(cg) + if existing_ts is not None: + logger.info("preserving existing initial_ts=%d", initial_ts) + + # 1. watershed CV info — mesh dir. + cg.meta.ws_cv.info["mesh"] = cfg.dir + cg.meta.ws_cv.commit_info() + logger.info("wrote ws_cv.info['mesh']=%r", cfg.dir) + + # 2. sharded mesh spec — same template per layer, layer-specific bits. + layer_shard_spec = { + "@type": "neuroglancer_uint64_sharded_v1", + "preshift_bits": 0, + "hash": "murmurhash3_x86_128", + "shard_bits": 0, + "minishard_index_encoding": "gzip", + "data_encoding": "raw", + } + sharding = { + str(layer): {**layer_shard_spec, "minishard_bits": int(bits)} + for layer, bits in cfg.minishard_bits.items() + if layer <= cfg.max_layer + } + mesh_spec = { + "@type": "neuroglancer_legacy_mesh", + "spatial_index": None, + "mip": int(cfg.mip), + "chunk_size": list(cfg.chunk_size), + "sharding": sharding, + } + cg.meta.ws_cv.mesh.meta.info = mesh_spec + cg.meta.ws_cv.mesh.meta.commit_info() + logger.info("wrote sharded mesh spec for layers %s", sorted(sharding.keys())) + + # 3. uniform draco grid size, derived from layer-2 draco settings. + draco = get_draco_encoding_settings_for_chunk( + cg, cg.get_chunk_id(layer=2, x=0, y=0, z=0), mip=cfg.mip + ) + grid_size = draco["quantization_range"] / (2 ** draco["quantization_bits"] - 1) + cg.meta.ws_cv.info["mesh_metadata"] = { + "uniform_draco_grid_size": grid_size, + "unsharded_mesh_dir": "dynamic", + } + cg.meta.ws_cv.commit_info() + logger.info("wrote mesh_metadata uniform_draco_grid_size=%s", grid_size) + + # 4. CG-side bigtable meta block. + mesh_meta = { + "max_layer": int(cfg.max_layer), + "dynamic_mesh_dir": cfg.dynamic_mesh_dir, + "mip": int(cfg.mip), + "max_error": int(cfg.max_error), + "dir": cfg.dir, + "initial_ts": int(initial_ts), + } + cg.meta.custom_data["mesh"] = mesh_meta + cg.update_meta(cg.meta, overwrite=True) + logger.info("wrote cg.meta.custom_data['mesh']=%r", mesh_meta) + return mesh_meta From 01b179180cdc990d4e16599ed72f69a4d7613e39 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Mon, 25 May 2026 21:51:03 +0000 Subject: [PATCH 260/367] feat(meshing): 3-day TTL on manifest cache + admin endpoint to clear a graph Every cached manifest fragment (initial + dynamic, present fragments and DOES_NOT_EXIST markers) now expires after 3 days. Bounds the cache against stale "X" markers that survived a remesh and against fragments whose path/offset changed under them. New POST /table//clear_manifest_cache (no node_id) wipes every cached fragment under the graph's namespace via SCAN+DEL, batched so a large fragment set doesn't block redis. Reuses the existing admin permission decorator used by the per-node clear route. --- pychunkedgraph/app/meshing/common.py | 10 +++++- pychunkedgraph/app/meshing/v1/routes.py | 10 +++++- pychunkedgraph/graph/chunkedgraph.py | 15 +++++++++ pychunkedgraph/meshing/manifest/cache.py | 43 +++++++++++++++++++++--- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/pychunkedgraph/app/meshing/common.py b/pychunkedgraph/app/meshing/common.py index 10306543a..ca5277762 100644 --- a/pychunkedgraph/app/meshing/common.py +++ b/pychunkedgraph/app/meshing/common.py @@ -15,7 +15,6 @@ from pychunkedgraph.meshing.manifest import get_children_before_start_layer from pychunkedgraph.meshing.manifest import ManifestCache - __meshing_url_prefix__ = os.environ.get("MESHING_URL_PREFIX", "meshing") @@ -180,3 +179,12 @@ def _remeshing(serialized_cg_info, lvl2_nodes): def clear_manifest_cache(cg, node_id): node_ids = get_children_before_start_layer(cg, node_id, start_layer=2) ManifestCache(cg.graph_id).clear_fragments(node_ids) + + +def clear_manifest_cache_all(cg) -> int: + """Delete every cached manifest fragment for this graph. + + Returns the number of redis keys deleted across both initial and + dynamic caches (they share the ``:`` namespace). + """ + return ManifestCache(cg.graph_id).clear_namespace() diff --git a/pychunkedgraph/app/meshing/v1/routes.py b/pychunkedgraph/app/meshing/v1/routes.py index dda067e90..8ee249942 100644 --- a/pychunkedgraph/app/meshing/v1/routes.py +++ b/pychunkedgraph/app/meshing/v1/routes.py @@ -9,7 +9,6 @@ from pychunkedgraph.app.app_utils import get_cg from pychunkedgraph.app.app_utils import remap_public - bp = Blueprint( "pcg_meshing_v1", __name__, url_prefix=f"/{common.__meshing_url_prefix__}/api/v1" ) @@ -98,3 +97,12 @@ def handle_remesh(table_id): def handle_clear_manifest_cache(table_id, node_id): cg = get_cg(table_id) common.clear_manifest_cache(cg, node_id) + + +@bp.route("/table//clear_manifest_cache", methods=["POST"]) +@auth_requires_permission("admin") +def handle_clear_manifest_cache_all(table_id): + """Drop every cached manifest fragment for this graph.""" + cg = get_cg(table_id) + deleted = common.clear_manifest_cache_all(cg) + return {"deleted": deleted} diff --git a/pychunkedgraph/graph/chunkedgraph.py b/pychunkedgraph/graph/chunkedgraph.py index 12e4f58c8..1b1405217 100644 --- a/pychunkedgraph/graph/chunkedgraph.py +++ b/pychunkedgraph/graph/chunkedgraph.py @@ -1054,6 +1054,21 @@ def get_chunk_coordinates_multiple(self, node_or_chunk_ids: typing.Sequence): assert len(layers) == 0 or np.all(layers == layers[0]), "must be same layer." return chunk_utils.get_chunk_coordinates_multiple(self.meta, node_or_chunk_ids) + def get_chunk_center_voxel(self, node_or_chunk_id: basetypes.NODE_ID) -> np.ndarray: + """Approximate base-resolution voxel coord at the chunk's center. + + Useful for debugging: feed the returned ``[x, y, z]`` to NGL's + position bar to navigate to where a chunk lives in the volume. + Layer L chunk side = ``CHUNK_SIZE * 2 ** (L - 2)`` base voxels. + """ + layer = int(self.get_chunk_layer(node_or_chunk_id)) + cx, cy, cz = self.get_chunk_coordinates(node_or_chunk_id) + chunk_size = np.asarray(self.meta.graph_config.CHUNK_SIZE, dtype=int) * ( + 2 ** (layer - 2) + ) + origin = self.meta.voxel_bounds[:, 0] + np.array([cx, cy, cz]) * chunk_size + return (origin + chunk_size // 2).astype(int) + def get_chunk_id( self, node_id: basetypes.NODE_ID = None, diff --git a/pychunkedgraph/meshing/manifest/cache.py b/pychunkedgraph/meshing/manifest/cache.py index f38a830c2..0decc3a65 100644 --- a/pychunkedgraph/meshing/manifest/cache.py +++ b/pychunkedgraph/meshing/manifest/cache.py @@ -12,6 +12,8 @@ DOES_NOT_EXIST = "X" INITIAL_PATH_PREFIX = "initial_path_prefix" +MANIFEST_TTL_SECONDS = 3 * 24 * 3600 + REDIS_HOST = os.environ.get("MANIFEST_CACHE_REDIS_HOST", "localhost") REDIS_PORT = os.environ.get("MANIFEST_CACHE_REDIS_PORT", "6379") REDIS_PASSWORD = os.environ.get("MANIFEST_CACHE_REDIS_PASSWORD", "") @@ -74,6 +76,27 @@ def clear_fragments(self, node_ids) -> None: keys = [f"{self.namespace}:{n}" for n in node_ids] REDIS.delete(*keys) + def clear_namespace(self, batch_size: int = 1000) -> int: + """Delete every key under this graph_id's namespace. + + SCAN-based pattern delete (non-blocking on the redis side). + Returns the number of keys deleted. + """ + if REDIS is None: + return 0 + + pattern = f"{self.namespace}:*" + deleted = 0 + batch = [] + for key in REDIS.scan_iter(match=pattern, count=batch_size): + batch.append(key) + if len(batch) >= batch_size: + deleted += REDIS.delete(*batch) + batch.clear() + if batch: + deleted += REDIS.delete(*batch) + return deleted + def _get_cached_initial_fragments(self, node_ids: List[np.uint64]): if REDIS is None: return {}, node_ids, [] @@ -140,10 +163,16 @@ def _set_cached_initial_fragments( for node_id, fragment_info in fragments_d.items(): path, offset, size = fragment_info key = f"{self.namespace}:{node_id}" - pipeline.set(key, f"{path[prefix_idx:]}:{offset}:{size}") + pipeline.set( + key, f"{path[prefix_idx:]}:{offset}:{size}", ex=MANIFEST_TTL_SECONDS + ) for node_id in not_existing: - pipeline.set(f"{self.namespace}:{node_id}", DOES_NOT_EXIST) + pipeline.set( + f"{self.namespace}:{node_id}", + DOES_NOT_EXIST, + ex=MANIFEST_TTL_SECONDS, + ) pipeline.execute() @@ -155,9 +184,15 @@ def _set_cached_dynamic_fragments( pipeline = REDIS.pipeline() for node_id, fragment in fragments_d.items(): - pipeline.set(f"{self.namespace}:{node_id}", fragment) + pipeline.set( + f"{self.namespace}:{node_id}", fragment, ex=MANIFEST_TTL_SECONDS + ) for node_id in not_existing: - pipeline.set(f"{self.namespace}:{node_id}", DOES_NOT_EXIST) + pipeline.set( + f"{self.namespace}:{node_id}", + DOES_NOT_EXIST, + ex=MANIFEST_TTL_SECONDS, + ) pipeline.execute() From 6d8caa711089f9128609df6613991ac9a89727d1 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 26 May 2026 21:06:18 +0000 Subject: [PATCH 261/367] feat(debug): per-block memory + IO tracking on HierarchicalProfiler profile() gains three opt-in kwargs (with_memory, with_rss, counters) that capture tracemalloc Python heap peak, psutil RSS peak via a 50 ms sampler thread, and per-call deltas on a caller-supplied counter dict. Results land in a new self.blocks list of BlockMetrics; metrics_report() prints them as a fixed-width table. Default-off so existing profile("name") callers see no behavior change. Sampler and tracemalloc state are torn down on context exit. --- pychunkedgraph/debug/profiler.py | 185 ++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 3 deletions(-) diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/debug/profiler.py index b74ddac76..09d39ebf1 100644 --- a/pychunkedgraph/debug/profiler.py +++ b/pychunkedgraph/debug/profiler.py @@ -1,17 +1,76 @@ from typing import Dict from typing import List +from typing import Optional from typing import Tuple import os +import threading import time +import tracemalloc from collections import defaultdict from contextlib import contextmanager +from dataclasses import dataclass, field + +import psutil + + +@dataclass +class BlockMetrics: + """Per-block metrics captured by HierarchicalProfiler.profile().""" + + path: str + elapsed_s: float + py_heap_peak_bytes: int = 0 + rss_start_bytes: int = 0 + rss_peak_bytes: int = 0 + counter_deltas: Dict[str, int] = field(default_factory=dict) + + +class _RSSSampler: + """Daemon thread that samples process RSS and tracks the max. + + Internal to this module; not part of the profiler's public API. + """ + + def __init__(self, interval_s: float = 0.050): + self._interval = interval_s + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._proc = psutil.Process() + self.start_rss = 0 + self.peak_rss = 0 + + def start(self) -> None: + self.start_rss = self._proc.memory_info().rss + self.peak_rss = self.start_rss + self._stop.clear() + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=1.0) + self._thread = None + + def _loop(self) -> None: + while not self._stop.is_set(): + try: + rss = self._proc.memory_info().rss + if rss > self.peak_rss: + self.peak_rss = rss + except Exception: + pass + self._stop.wait(self._interval) class HierarchicalProfiler: """ Hierarchical profiler for detailed timing breakdowns. Tracks timing at multiple levels and prints a breakdown at the end. + + Optional per-block memory + IO collection is opt-in via kwargs on + profile(); see BlockMetrics for the captured fields. """ def __init__(self, enabled: bool = True): @@ -20,18 +79,51 @@ def __init__(self, enabled: bool = True): self.call_counts: Dict[str, int] = defaultdict(int) self.stack: List[Tuple[str, float]] = [] self.current_path: List[str] = [] + self.blocks: List[BlockMetrics] = [] @contextmanager - def profile(self, name: str): - """Context manager for profiling a code block.""" + def profile( + self, + name: str, + *, + with_memory: bool = False, + with_rss: bool = False, + counters: Optional[Dict[str, int]] = None, + ): + """Context manager for profiling a code block. + + Default behavior (no kwargs) records only timing into + `self.timings` / `self.call_counts`, matching the original + implementation. + + Optional kwargs collect extra metrics into `self.blocks`: + - with_memory: tracemalloc Python heap peak per block. + - with_rss: psutil RSS peak via a 50 ms sampler thread. + - counters: caller-supplied dict; per-key deltas recorded + (after - before for keys present at exit). + """ if not self.enabled: yield return full_path = ".".join(self.current_path + [name]) self.current_path.append(name) - start_time = time.perf_counter() + started_tracemalloc = False + if with_memory: + if not tracemalloc.is_tracing(): + tracemalloc.start() + started_tracemalloc = True + tracemalloc.reset_peak() + + sampler: Optional[_RSSSampler] = None + if with_rss: + sampler = _RSSSampler() + sampler.start() + + counters_before = dict(counters) if counters is not None else None + + start_time = time.perf_counter() try: yield finally: @@ -40,6 +132,35 @@ def profile(self, name: str): self.call_counts[full_path] += 1 self.current_path.pop() + py_peak = 0 + if with_memory: + _curr, py_peak = tracemalloc.get_traced_memory() + if started_tracemalloc: + tracemalloc.stop() + + rss_start = 0 + rss_peak = 0 + if sampler is not None: + sampler.stop() + rss_start = sampler.start_rss + rss_peak = sampler.peak_rss + + counter_deltas: Dict[str, int] = {} + if counters is not None and counters_before is not None: + for k, v_after in counters.items(): + counter_deltas[k] = v_after - counters_before.get(k, 0) + + self.blocks.append( + BlockMetrics( + path=full_path, + elapsed_s=elapsed, + py_heap_peak_bytes=int(py_peak), + rss_start_bytes=int(rss_start), + rss_peak_bytes=int(rss_peak), + counter_deltas=counter_deltas, + ) + ) + def print_report(self, operation_id=None): """Print a detailed timing breakdown.""" if not self.enabled or not self.timings: @@ -93,12 +214,70 @@ def print_report(self, operation_id=None): print("=" * 80 + "\n") + def metrics_report(self, operation_id=None) -> None: + """Print a fixed-width table over self.blocks (memory + IO view). + + Columns: path, elapsed_s, py_peak_MB, rss_start_MB, rss_peak_MB, + plus one column per counter key that appears in any block. + Counterpart to print_report (which covers timing-only). + """ + if not self.enabled or not self.blocks: + return + + counter_keys: List[str] = [] + seen_keys: set = set() + for b in self.blocks: + for k in b.counter_deltas: + if k not in seen_keys: + seen_keys.add(k) + counter_keys.append(k) + + cols = [ + "path", + "elapsed_s", + "py_peak_MB", + "rss_start_MB", + "rss_peak_MB", + ] + counter_keys + widths = [len(c) for c in cols] + rows: List[List[str]] = [] + for b in self.blocks: + row = [ + b.path, + f"{b.elapsed_s:.3f}", + f"{b.py_heap_peak_bytes / 1e6:.1f}", + f"{b.rss_start_bytes / 1e6:.1f}", + f"{b.rss_peak_bytes / 1e6:.1f}", + ] + for k in counter_keys: + row.append(str(b.counter_deltas.get(k, 0))) + rows.append(row) + for row in rows: + for i, v in enumerate(row): + if len(v) > widths[i]: + widths[i] = len(v) + + def fmt(values: List[str]) -> str: + return " ".join(v.ljust(widths[i]) for i, v in enumerate(values)) + + print("\n" + "=" * 80) + print( + f"METRICS REPORT{f' (operation_id={operation_id})' if operation_id else ''}" + ) + print("=" * 80) + print(fmt(cols)) + print(fmt(["-" * w for w in widths])) + for row in rows: + print(fmt(row)) + print("=" * 80 + "\n") + def reset(self): """Reset all timing data.""" self.timings.clear() self.call_counts.clear() self.stack.clear() self.current_path.clear() + self.blocks.clear() # Global profiler instance - enable via environment variable From fcf37a70a7729fdf485d00f0a7e2fd2aff09d344 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Tue, 26 May 2026 23:30:39 +0000 Subject: [PATCH 262/367] feat(graph): PCG_DRY_RUN env var to suppress edit-flow writes Adds pychunkedgraph.graph.dry_run.is_dry_run() (strict "1" match on PCG_DRY_RUN) and wires it through every write site in the SV-split edit flow: - operation._write short-circuits via is_dry_run() instead of the cg.meta.READ_ONLY @property (which stays in place for unrelated callers). - A new GraphEditOperation._persist_rows centralizes BT mutation writes from _apply and _create_log_record paths; the three external cg.client.write callsites in operation.py collapse to one-liners. - ocdbt.write_seg_chunks returns early so OCDBT delta writes are skipped. - RootLock / IndefiniteRootLock / L2ChunkLock / IndefiniteL2ChunkLock __enter__ and __exit__ gain an early-return placed before the existing privileged_mode check, so dry-run wins regardless of replay state. Replaces the harness-side monkey-patch design with a single global toggle: setting PCG_DRY_RUN=1 makes any edit operation a read-only dry run. Default-off; "true" / "0" / "" / unset all leave production behavior unchanged. --- pychunkedgraph/graph/dry_run.py | 18 ++++++++++++++++++ pychunkedgraph/graph/locks.py | 18 ++++++++++++++++++ pychunkedgraph/graph/ocdbt/main.py | 3 +++ pychunkedgraph/graph/operation.py | 15 +++++++++++---- 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 pychunkedgraph/graph/dry_run.py diff --git a/pychunkedgraph/graph/dry_run.py b/pychunkedgraph/graph/dry_run.py new file mode 100644 index 000000000..7075dde60 --- /dev/null +++ b/pychunkedgraph/graph/dry_run.py @@ -0,0 +1,18 @@ +import os + +DRY_RUN_ENV = "PCG_DRY_RUN" + + +def is_dry_run() -> bool: + """True iff ``PCG_DRY_RUN=1`` in the environment. + + When true, every write function in the edit flow + (``operation._write``, the operation log writes, ``write_seg_chunks``, + and the lock acquire/release paths) returns early without + persisting. Used by debug tooling to re-run edits against + production BT/OCDBT state without mutating it. + + Strict ``"1"`` match so unset / empty / ``"true"`` / typos do not + accidentally trigger in production. + """ + return os.environ.get(DRY_RUN_ENV) == "1" diff --git a/pychunkedgraph/graph/locks.py b/pychunkedgraph/graph/locks.py index bdff4f789..7f8304c27 100644 --- a/pychunkedgraph/graph/locks.py +++ b/pychunkedgraph/graph/locks.py @@ -12,6 +12,7 @@ from . import attributes, exceptions, serializers from .types import empty_1d from .lineage import lineage_graph +from .dry_run import is_dry_run logger = get_logger(__name__) @@ -58,6 +59,9 @@ def __enter__(self): if not self.operation_id: self.operation_id = self.cg.id_client.create_operation_id() + if is_dry_run(): + return self + if self.privileged_mode: return self @@ -83,6 +87,8 @@ def __enter__(self): return self def __exit__(self, exception_type, exception_value, traceback): + if is_dry_run(): + return if self.lock_acquired: max_workers = min(8, max(1, len(self.locked_root_ids))) with ThreadPoolExecutor(max_workers=max_workers) as executor: @@ -139,6 +145,8 @@ def __init__( self.future_root_ids_d = future_root_ids_d def __enter__(self): + if is_dry_run(): + return self if self.privileged_mode: return self if not self.cg.client.renew_locks(self.root_ids, self.operation_id): @@ -166,6 +174,8 @@ def __enter__(self): return self def __exit__(self, exception_type, exception_value, traceback): + if is_dry_run(): + return if exception_type is not None: # Partial bigtable hierarchy writes may have landed before # the exception propagated. Keep the indefinite cells held @@ -371,6 +381,8 @@ def __init__( self.acquired_keys: list = [] def __enter__(self): + if is_dry_run(): + return self if self.privileged_mode: # Replay path: the crashed op's `IndefiniteL2ChunkLock` cells # are still set on these chunks (that's what's blocking new @@ -408,6 +420,8 @@ def __enter__(self): ) def __exit__(self, exception_type, exception_value, traceback): + if is_dry_run(): + return self._release_acquired() def _release_acquired(self): @@ -482,6 +496,8 @@ def __init__( self.acquired_keys: list = [] def __enter__(self): + if is_dry_run(): + return self if self.privileged_mode: # Recovery path: crashed op's indefinite cells already exist # under this op_id. Populate acquired_keys so __exit__'s @@ -507,6 +523,8 @@ def __enter__(self): return self def __exit__(self, exception_type, exception_value, traceback): + if is_dry_run(): + return if exception_type is not None: # Partial OCDBT seg / bigtable SV-hierarchy writes may have # landed before the exception propagated. Leave the diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index 6525682d8..89b344ec0 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -39,6 +39,7 @@ from .debug import bbox_failure_payload, dump_failure_to_gcs from .meta import OcdbtConfig +from ..dry_run import is_dry_run from .utils import ( _base_ocdbt_path, _ensure_trailing_slash, @@ -570,6 +571,8 @@ def write_seg_chunks(meta, seg_writes): L2 chunk's x/y/z extent and ``data`` is the 3D label block (shape matches the slice extents). """ + if is_dry_run(): + return futures = [ meta.ws_ocdbt[voxel_slices + (slice(None),)].write(data[..., np.newaxis]) for voxel_slices, data in seg_writes diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 60a2305c5..da95dc31c 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -24,6 +24,7 @@ from . import edits_sv from . import types from .ocdbt import write_seg_chunks +from .dry_run import is_dry_run from pychunkedgraph.graph import attributes from .edges import Edges from .edges.utils import get_edges_status @@ -448,7 +449,7 @@ def execute( operation_ts=override_ts if override_ts else timestamp, status=attributes.OperationLogs.StatusCodes.CREATED.value, ) - self.cg.client.write([log_record_before_edit]) + self._persist_rows([log_record_before_edit]) try: with TimeIt(f"{op_type}.apply", self.cg.graph_id, lock.operation_id): @@ -456,7 +457,7 @@ def execute( operation_id=lock.operation_id, timestamp=override_ts if override_ts else timestamp, ) - if self.cg.meta.READ_ONLY: + if is_dry_run(): # return without persisting changes return GraphEditOperation.Result( operation_id=lock.operation_id, @@ -484,7 +485,7 @@ def execute( status=attributes.OperationLogs.StatusCodes.EXCEPTION.value, exception=repr(err), ) - self.cg.client.write([log_record_error]) + self._persist_rows([log_record_error]) raise Exception(err) from err with TimeIt(f"{op_type}.write", self.cg.graph_id, lock.operation_id): @@ -555,6 +556,12 @@ def _write( seg_bbox=getattr(self, "seg_bboxes", None) or None, ) + def _persist_rows(self, rows): + """Persist BT mutation rows; no-op under ``PCG_DRY_RUN=1``.""" + if is_dry_run(): + return + self.cg.client.write(rows) + class MergeOperation(GraphEditOperation): """Merge Operation: Connect *known* pairs of supervoxels by adding a (weighted) edge. @@ -964,7 +971,7 @@ def _apply( privileged_mode=self.privileged_mode, ): write_seg_chunks(self.cg.meta, sv_result.seg_writes) - self.cg.client.write(sv_result.bigtable_rows) + self._persist_rows(sv_result.bigtable_rows) self.seg_bboxes = sv_result.seg_bboxes self.source_ids = sv_result.source_ids_fresh self.sink_ids = sv_result.sink_ids_fresh From a3dc41fba42164a407dc81b332986e27a6cfe64b Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 27 May 2026 00:39:16 +0000 Subject: [PATCH 263/367] feat(debug): SV-split dry-run profile harness split_profile drives an SV split end-to-end under PCG_DRY_RUN=1, records per-stage timing + memory + IO into a HierarchicalProfiler, and snapshots each stage's intermediate value into SplitInputs so the user can replay a single heavy stage standalone after editing its source. The full run is pickled under /pcg_split_profile/// with a collision check on save and the cache path echoed at the end of run_split_profile so the run can be copied for retention before the OS tmp policy reclaims it. Adds dry_run_scope() context manager next to is_dry_run() so the harness (and any future caller) sets/restores PCG_DRY_RUN through one helper rather than poking os.environ. --- pychunkedgraph/debug/split_profile.py | 313 ++++++++++++++++++++++++++ pychunkedgraph/graph/dry_run.py | 20 ++ 2 files changed, 333 insertions(+) create mode 100644 pychunkedgraph/debug/split_profile.py diff --git a/pychunkedgraph/debug/split_profile.py b/pychunkedgraph/debug/split_profile.py new file mode 100644 index 000000000..a8ee55f18 --- /dev/null +++ b/pychunkedgraph/debug/split_profile.py @@ -0,0 +1,313 @@ +"""Re-runnable dry-run profile harness for SV splits. + +Drives an SV-split operation end-to-end under ``PCG_DRY_RUN=1`` so no +BT or OCDBT state is mutated, captures per-stage timing + memory + IO +metrics into a ``HierarchicalProfiler`` (one ``BlockMetrics`` row per +stage), and snapshots each stage's intermediate result into a +``SplitInputs`` dataclass that's persisted alongside the profiler. + +The persisted run lets the user iterate on a single heavy stage in +isolation (e.g. profile just ``split_supervoxels`` after editing it) +without re-running the prior stages. +""" + +import hashlib +import json +import pickle +import tempfile +from contextlib import contextmanager, redirect_stdout +from dataclasses import dataclass +from io import StringIO +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +from pychunkedgraph.app.segmentation.common import _get_sources_and_sinks +from pychunkedgraph.debug.profiler import HierarchicalProfiler +from pychunkedgraph.graph import edits, edits_sv +from pychunkedgraph.graph import utils as _utils_pkg +from pychunkedgraph.graph.dry_run import dry_run_scope +from pychunkedgraph.graph.operation import Cut, MulticutOperation, SvSplitRequired +from pychunkedgraph.graph.utils import generic as _utils_generic +from pychunkedgraph.graph.utils import id_helpers as _utils_id_helpers + +_CACHE_ROOT = Path(tempfile.gettempdir()) / "pcg_split_profile" + + +@dataclass +class SplitInputs: + """Per-stage inputs/outputs captured during a ``run_split_profile`` run. + + Persisted to disk alongside the profiler so single-stage replays + can reuse the inputs without re-running prior stages. Every field + matches the exact value at the corresponding call site in + ``MulticutOperation._apply``. + """ + + operation_id: Optional[int] = None + timestamp: Any = None + source_ids_pre: Optional[np.ndarray] = None + sink_ids_pre: Optional[np.ndarray] = None + source_coords: Optional[np.ndarray] = None + sink_coords: Optional[np.ndarray] = None + sv_remapping: Optional[dict] = None + plan_tasks: Any = None + plan_chunk_ids: Any = None + sv_result: Any = None + cut: Any = None + + +def _payload_canonical(payload: dict) -> str: + """Canonical JSON encoding used for hashing and collision detection.""" + return json.dumps(payload, sort_keys=True) + + +def _payload_sha(payload: dict) -> str: + """First 8 hex chars of sha256 over the canonical-JSON payload.""" + return hashlib.sha256(_payload_canonical(payload).encode()).hexdigest()[:8] + + +def run_dir(cg, payload: dict) -> Path: + """Cache directory for ``(cg.graph_id, payload)`` under system tmp.""" + return _CACHE_ROOT / cg.graph_id / _payload_sha(payload) + + +@contextmanager +def count_io(cg): + """Count BT row reads + OCDBT bytes read for the wrapped block. + + Wraps ``cg.client.read_nodes`` and ``cg.client.read_log_entries`` + (BT reads), plus ``get_local_segmentation`` at every top-level + binding site reached from the SV-split flow (OCDBT reads). All + originals are restored on exit. + """ + counters: Dict[str, int] = { + "bt_row_reads": 0, + "bt_log_reads": 0, + "ocdbt_reads": 0, + "ocdbt_bytes": 0, + } + + orig_read_nodes = cg.client.read_nodes + + def wrap_read_nodes(*a, **k): + result = orig_read_nodes(*a, **k) + counters["bt_row_reads"] += len(result) if result is not None else 0 + return result + + orig_read_log = cg.client.read_log_entries + + def wrap_read_log(*a, **k): + result = orig_read_log(*a, **k) + counters["bt_log_reads"] += len(result) if result is not None else 0 + return result + + cg.client.read_nodes = wrap_read_nodes + cg.client.read_log_entries = wrap_read_log + + # Patch every binding of get_local_segmentation reached from the + # SV-split flow. The source module is _utils_generic; the others + # imported it by name at module load time, so they hold separate + # references that need their own swap. + seg_modules = [_utils_generic, _utils_pkg, edits_sv, _utils_id_helpers] + orig_seg_fns = {m: m.get_local_segmentation for m in seg_modules} + + def wrap_get_local_seg(meta, bbox_start, bbox_end, mip=0): + # Always call the source function so we don't double-count if + # one wrapped binding calls another. + arr = orig_seg_fns[_utils_generic](meta, bbox_start, bbox_end, mip) + counters["ocdbt_bytes"] += int(arr.nbytes) + counters["ocdbt_reads"] += 1 + return arr + + for m in seg_modules: + m.get_local_segmentation = wrap_get_local_seg + + try: + yield counters + finally: + cg.client.read_nodes = orig_read_nodes + cg.client.read_log_entries = orig_read_log + for m, fn in orig_seg_fns.items(): + m.get_local_segmentation = fn + + +def build_op( + cg, + payload: dict, + *, + user_id: str = "dry_run_profile", + bbox_offset: Tuple[int, int, int] = (240, 240, 24), +) -> MulticutOperation: + """Decode a /split JSON payload and instantiate ``MulticutOperation``. + + Mirrors ``ChunkedGraph.remove_edges`` direct instantiation pattern. + The caller drives ``op.execute()``. + """ + source_ids, sink_ids, source_coords, sink_coords = _get_sources_and_sinks( + cg, payload + ) + op = MulticutOperation( + cg, + user_id=user_id, + source_ids=source_ids, + sink_ids=sink_ids, + source_coords=source_coords, + sink_coords=sink_coords, + bbox_offset=bbox_offset, + ) + return op + + +def annotate_chunks(cg, chunk_ids) -> List[str]: + """Annotate each chunk id with its NGL-navigable center voxel.""" + out: List[str] = [] + for cid in chunk_ids: + coord = cg.get_chunk_center_voxel(int(cid)).tolist() + out.append(f"{int(cid):#x} -> voxel {coord}") + return out + + +def _save_run( + cg, + payload: dict, + profiler: HierarchicalProfiler, + inputs: SplitInputs, +) -> Path: + """Write run artifacts under ``run_dir``; raise on payload-hash collision.""" + target = run_dir(cg, payload) + payload_path = target / "payload.json" + incoming = _payload_canonical(payload) + if payload_path.exists(): + existing = payload_path.read_text() + if existing != incoming: + raise RuntimeError( + f"payload-hash collision at {target}: " + "existing payload != incoming payload" + ) + target.mkdir(parents=True, exist_ok=True) + with open(target / "inputs.pkl", "wb") as f: + pickle.dump(inputs, f) + with open(target / "profiler.pkl", "wb") as f: + pickle.dump(profiler, f) + buf = StringIO() + with redirect_stdout(buf): + profiler.metrics_report() + (target / "metrics.txt").write_text(buf.getvalue()) + payload_path.write_text(incoming) + return target + + +def load_run(cg, payload: dict) -> Tuple[HierarchicalProfiler, SplitInputs]: + """Restore a prior ``run_split_profile`` result from disk.""" + target = run_dir(cg, payload) + with open(target / "profiler.pkl", "rb") as f: + profiler = pickle.load(f) + with open(target / "inputs.pkl", "rb") as f: + inputs = pickle.load(f) + return profiler, inputs + + +def run_split_profile(cg, payload: dict) -> Tuple[HierarchicalProfiler, SplitInputs]: + """Full-flow SV-split harness: dry-run, count IO, profile stages. + + Returns ``(profiler, inputs)``. The profiler holds per-stage + ``BlockMetrics`` rows in ``profiler.blocks``; ``inputs`` holds the + intermediate value captured at each stage so the user can replay + a single stage standalone. + + Writes the run to ``run_dir(cg, payload)`` on completion and prints + the cache path so the user can ``cp -r`` it to keep before the OS + tmp policy reclaims it. + """ + profiler = HierarchicalProfiler(enabled=True) + inputs = SplitInputs() + + op = build_op(cg, payload) + inputs.source_ids_pre = op.source_ids.copy() + inputs.sink_ids_pre = op.sink_ids.copy() + inputs.source_coords = op.source_coords + inputs.sink_coords = op.sink_coords + + with dry_run_scope(), count_io(cg) as counters: + # Snapshot originals before patching so the finally block can + # restore them even if op.execute() raises. + orig_run_multicut = MulticutOperation._run_multicut + orig_plan_sv_splits = edits_sv.plan_sv_splits + orig_split_supervoxels = edits_sv.split_supervoxels + orig_remove_edges = edits.remove_edges + + # _run_multicut is called twice (initial + post-SV-split retry). + # The counter distinguishes the two block names. + mincut_call_count = [0] + + # Class-method patch — MulticutOperation uses __slots__, so + # instance patching raises AttributeError. + def wrap_run_multicut(self_op, operation_id): + mincut_call_count[0] += 1 + name = f"mincut_{mincut_call_count[0]}" + with profiler.profile( + name, with_memory=True, with_rss=True, counters=counters + ): + result = orig_run_multicut(self_op, operation_id) + if mincut_call_count[0] == 1 and isinstance(result, SvSplitRequired): + inputs.sv_remapping = result.sv_remapping + elif isinstance(result, Cut): + inputs.cut = result + return result + + def wrap_plan_sv_splits(*a, **k): + with profiler.profile( + "plan_sv_splits", + with_memory=True, + with_rss=True, + counters=counters, + ): + result = orig_plan_sv_splits(*a, **k) + inputs.plan_tasks, inputs.plan_chunk_ids = result + return result + + def wrap_split_supervoxels(*a, **k): + # split_supervoxels uses keyword-only args; operation_id and + # timestamp aren't otherwise exposed on the op, so capture + # them here for standalone replay. + if "operation_id" in k: + inputs.operation_id = k["operation_id"] + if "timestamp" in k: + inputs.timestamp = k["timestamp"] + with profiler.profile( + "split_supervoxels", + with_memory=True, + with_rss=True, + counters=counters, + ): + result = orig_split_supervoxels(*a, **k) + inputs.sv_result = result + return result + + def wrap_remove_edges(*a, **k): + with profiler.profile( + "remove_edges", + with_memory=True, + with_rss=True, + counters=counters, + ): + return orig_remove_edges(*a, **k) + + MulticutOperation._run_multicut = wrap_run_multicut + edits_sv.plan_sv_splits = wrap_plan_sv_splits + edits_sv.split_supervoxels = wrap_split_supervoxels + edits.remove_edges = wrap_remove_edges + + try: + op.execute() + finally: + MulticutOperation._run_multicut = orig_run_multicut + edits_sv.plan_sv_splits = orig_plan_sv_splits + edits_sv.split_supervoxels = orig_split_supervoxels + edits.remove_edges = orig_remove_edges + + target = _save_run(cg, payload, profiler, inputs) + print(f"[split_profile] run cached at {target}") + return profiler, inputs diff --git a/pychunkedgraph/graph/dry_run.py b/pychunkedgraph/graph/dry_run.py index 7075dde60..985d3e3ff 100644 --- a/pychunkedgraph/graph/dry_run.py +++ b/pychunkedgraph/graph/dry_run.py @@ -1,4 +1,5 @@ import os +from contextlib import contextmanager DRY_RUN_ENV = "PCG_DRY_RUN" @@ -16,3 +17,22 @@ def is_dry_run() -> bool: accidentally trigger in production. """ return os.environ.get(DRY_RUN_ENV) == "1" + + +@contextmanager +def dry_run_scope(): + """Set ``PCG_DRY_RUN=1`` for the duration of the block; restore on exit. + + Single point for set/restore of the env var. The caller's + pre-existing value (including absence) is restored even if the + block raises. + """ + prev = os.environ.get(DRY_RUN_ENV) + os.environ[DRY_RUN_ENV] = "1" + try: + yield + finally: + if prev is None: + os.environ.pop(DRY_RUN_ENV, None) + else: + os.environ[DRY_RUN_ENV] = prev From 50d41cf8150975d31219917c3b2b509cc409b58a Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 27 May 2026 02:07:54 +0000 Subject: [PATCH 264/367] feat(debug): clean per-stage metrics + standalone replay helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit metrics_report now prints a compact human-readable table — no banner, auto-scaled time (ms / s) and bytes (KB / MB / GB), thousands-separator counters, signed rss delta, and all-zero counter columns hidden. Format helpers _fmt_time / _fmt_bytes / _fmt_count live module-private. print_report stays unchanged for timing-only callers. split_profile.profile_call(cg, name, fn, *args, **kwargs) wraps one callable under dry_run_scope + count_io + profiler.profile, returning (profiler, result). Lets the user replay a single stage standalone after editing its source — pairs with load_run() to iterate on one heavy stage without redoing the full flow. ocdbt.ensure_fork_synced honors PCG_DRY_RUN — a defensive gate on the one OCDBT write outside the gated edit flow. Steady-state no-op for graphs with prior edits. --- pychunkedgraph/debug/profiler.py | 89 +++++++++++++++++++-------- pychunkedgraph/debug/split_profile.py | 33 +++++++++- pychunkedgraph/graph/ocdbt/main.py | 2 + 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/debug/profiler.py index 09d39ebf1..7b00b0b10 100644 --- a/pychunkedgraph/debug/profiler.py +++ b/pychunkedgraph/debug/profiler.py @@ -14,6 +14,37 @@ import psutil +def _fmt_time(s: float) -> str: + """Auto-scale seconds → ``12.3 ms`` / ``1.23 s``.""" + if s < 1.0: + return f"{s * 1000:.1f} ms" + return f"{s:.2f} s" + + +def _fmt_bytes(n: int, *, signed: bool = False) -> str: + """Auto-scale bytes (binary) → ``512 B`` / ``1.5 KB`` / ``45.6 MB`` / ``1.23 GB``. + + With ``signed=True``, positive values get a ``+`` prefix (for delta columns). + """ + if signed: + sign = "+" if n > 0 else "-" if n < 0 else "" + else: + sign = "-" if n < 0 else "" + n = abs(int(n)) + if n < 1024: + return f"{sign}{n} B" + if n < 1024**2: + return f"{sign}{n / 1024:.1f} KB" + if n < 1024**3: + return f"{sign}{n / 1024**2:.1f} MB" + return f"{sign}{n / 1024**3:.2f} GB" + + +def _fmt_count(n: int) -> str: + """Thousands-separator integer: ``1234567`` → ``1,234,567``.""" + return f"{int(n):,}" + + @dataclass class BlockMetrics: """Per-block metrics captured by HierarchicalProfiler.profile().""" @@ -215,15 +246,18 @@ def print_report(self, operation_id=None): print("=" * 80 + "\n") def metrics_report(self, operation_id=None) -> None: - """Print a fixed-width table over self.blocks (memory + IO view). + """Print a compact, human-readable table over self.blocks. - Columns: path, elapsed_s, py_peak_MB, rss_start_MB, rss_peak_MB, - plus one column per counter key that appears in any block. + Columns: stage, wall, py_peak, rss_Δ (signed), plus one column + per counter key that has a non-zero value in at least one block. Counterpart to print_report (which covers timing-only). """ if not self.enabled or not self.blocks: return + # Collect counter keys in first-seen order; drop ones that are + # zero in every block (e.g., bt_log_reads is usually 0 for SV + # splits and only adds noise). counter_keys: List[str] = [] seen_keys: set = set() for b in self.blocks: @@ -231,45 +265,48 @@ def metrics_report(self, operation_id=None) -> None: if k not in seen_keys: seen_keys.add(k) counter_keys.append(k) + counter_keys = [ + k + for k in counter_keys + if any(b.counter_deltas.get(k, 0) for b in self.blocks) + ] + + cols = ["stage", "wall", "py_peak", "rss_Δ"] + counter_keys + + def fmt_counter(key: str, val: int) -> str: + if key.endswith("_bytes"): + return _fmt_bytes(val) + return _fmt_count(val) - cols = [ - "path", - "elapsed_s", - "py_peak_MB", - "rss_start_MB", - "rss_peak_MB", - ] + counter_keys - widths = [len(c) for c in cols] rows: List[List[str]] = [] for b in self.blocks: row = [ b.path, - f"{b.elapsed_s:.3f}", - f"{b.py_heap_peak_bytes / 1e6:.1f}", - f"{b.rss_start_bytes / 1e6:.1f}", - f"{b.rss_peak_bytes / 1e6:.1f}", + _fmt_time(b.elapsed_s), + _fmt_bytes(b.py_heap_peak_bytes), + _fmt_bytes(b.rss_peak_bytes - b.rss_start_bytes, signed=True), ] for k in counter_keys: - row.append(str(b.counter_deltas.get(k, 0))) + row.append(fmt_counter(k, b.counter_deltas.get(k, 0))) rows.append(row) + + widths = [len(c) for c in cols] for row in rows: for i, v in enumerate(row): if len(v) > widths[i]: widths[i] = len(v) - def fmt(values: List[str]) -> str: + def line(values: List[str]) -> str: return " ".join(v.ljust(widths[i]) for i, v in enumerate(values)) - print("\n" + "=" * 80) - print( - f"METRICS REPORT{f' (operation_id={operation_id})' if operation_id else ''}" - ) - print("=" * 80) - print(fmt(cols)) - print(fmt(["-" * w for w in widths])) + title = "metrics report" + if operation_id is not None: + title = f"{title} (operation_id={operation_id})" + print(title) + print(line(cols)) + print(line(["-" * w for w in widths])) for row in rows: - print(fmt(row)) - print("=" * 80 + "\n") + print(line(row)) def reset(self): """Reset all timing data.""" diff --git a/pychunkedgraph/debug/split_profile.py b/pychunkedgraph/debug/split_profile.py index a8ee55f18..4f38b40d2 100644 --- a/pychunkedgraph/debug/split_profile.py +++ b/pychunkedgraph/debug/split_profile.py @@ -14,6 +14,7 @@ import hashlib import json import pickle +import sys import tempfile from contextlib import contextmanager, redirect_stdout from dataclasses import dataclass @@ -133,6 +134,22 @@ def wrap_get_local_seg(meta, bbox_start, bbox_end, mip=0): m.get_local_segmentation = fn +def profile_call(cg, name, fn, *args, **kwargs): + """Profile a single callable under dry-run with IO counters. + + Standalone replay helper for per-stage profiling (e.g. after + editing a single function's source). Opens ``dry_run_scope`` + + ``count_io``, runs ``profiler.profile(name, with_memory=True, + with_rss=True, counters=counters)`` around ``fn(*args, **kwargs)``, + returns ``(profiler, result)``. The profiler has exactly one block. + """ + profiler = HierarchicalProfiler(enabled=True) + with dry_run_scope(), count_io(cg) as counters: + with profiler.profile(name, with_memory=True, with_rss=True, counters=counters): + result = fn(*args, **kwargs) + return profiler, result + + def build_op( cg, payload: dict, @@ -300,14 +317,26 @@ def wrap_remove_edges(*a, **k): edits_sv.split_supervoxels = wrap_split_supervoxels edits.remove_edges = wrap_remove_edges + exec_exc: Optional[BaseException] = None try: op.execute() + except Exception as e: + exec_exc = e + print( + f"[split_profile] op.execute() raised " f"{type(e).__name__}: {e}", + file=sys.stderr, + ) finally: MulticutOperation._run_multicut = orig_run_multicut edits_sv.plan_sv_splits = orig_plan_sv_splits edits_sv.split_supervoxels = orig_split_supervoxels edits.remove_edges = orig_remove_edges - target = _save_run(cg, payload, profiler, inputs) - print(f"[split_profile] run cached at {target}") + # Always try to save what's been captured so far, even on exception. + # Wrapped so a save failure doesn't mask the original exec_exc. + try: + target = _save_run(cg, payload, profiler, inputs) + print(f"[split_profile] run cached at {target}") + except Exception as save_err: + print(f"[split_profile] cache save failed: {save_err}", file=sys.stderr) return profiler, inputs diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index 89b344ec0..f4e56ebcb 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -266,6 +266,8 @@ def ensure_fork_synced(ws_path: str, graph_id: str) -> bool: Returns True iff the fork manifest was refreshed. """ + if is_dry_run(): + return False if not fork_exists(ws_path, graph_id): return False fork_dir = _ensure_trailing_slash(f"{ws_path.rstrip('/')}/ocdbt/{graph_id}") From a3b83e08b3dabf09dc76b37e04fc7ca2aa2a5405 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Wed, 27 May 2026 16:48:00 +0000 Subject: [PATCH 265/367] feat(debug): leaf-level profile() blocks across SV split path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline get_profiler().profile() blocks now wrap the suspect operations inside split_supervoxel, update_edges, and split_supervoxel_helper — seg_read, binary_seg, geodesic_split, build_coords, kdtrees, get_subgraph, edge_dedup, get_roots_inner, _get_new_edges, connect_seeds, split_growing, etc. Each captures wall time, Python heap peak, RSS peak, and IO counters as one BlockMetrics row. HierarchicalProfiler.__init__ gains with_memory / with_rss instance defaults (both True) and a default_counters attribute, so inline call sites stay short — just `with _prof.profile(name):`. run_split_profile drives the global profiler (reset + enable on entry, disable on exit) so all inline blocks land in one profiler.blocks list. Drops the previous top-level profile() wrappers around the mincut / plan / split_supervoxels / remove_edges stages; the wrappers stay as capture-only hooks for SplitInputs replay. New overwrite=False kwarg on run_split_profile; when True, wipes the cache dir before starting. Profiler stays a no-op when not explicitly enabled. --- pychunkedgraph/debug/profiler.py | 28 ++++++- pychunkedgraph/debug/split_profile.py | 105 ++++++++++++-------------- pychunkedgraph/graph/cutting_sv.py | 87 +++++++++++---------- pychunkedgraph/graph/edges_sv.py | 68 ++++++++++------- pychunkedgraph/graph/edits_sv.py | 84 ++++++++++++--------- 5 files changed, 206 insertions(+), 166 deletions(-) diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/debug/profiler.py index 7b00b0b10..108bb3eb1 100644 --- a/pychunkedgraph/debug/profiler.py +++ b/pychunkedgraph/debug/profiler.py @@ -104,21 +104,36 @@ class HierarchicalProfiler: profile(); see BlockMetrics for the captured fields. """ - def __init__(self, enabled: bool = True): + def __init__( + self, + enabled: bool = True, + *, + with_memory: bool = True, + with_rss: bool = True, + ): self.enabled = enabled self.timings: Dict[str, List[float]] = defaultdict(list) self.call_counts: Dict[str, int] = defaultdict(int) self.stack: List[Tuple[str, float]] = [] self.current_path: List[str] = [] self.blocks: List[BlockMetrics] = [] + # Per-instance defaults so inline profile() call sites stay short + # (no per-block kwargs). Callers can override per-call. + self.with_memory_default = with_memory + self.with_rss_default = with_rss + # Optional caller-set default counters dict used when profile() + # is called without an explicit `counters=` kwarg. Lets inline + # profile() blocks in production code pick up an outer harness's + # IO counters without needing to thread them through. + self.default_counters: Optional[Dict[str, int]] = None @contextmanager def profile( self, name: str, *, - with_memory: bool = False, - with_rss: bool = False, + with_memory: Optional[bool] = None, + with_rss: Optional[bool] = None, counters: Optional[Dict[str, int]] = None, ): """Context manager for profiling a code block. @@ -137,6 +152,13 @@ def profile( yield return + if with_memory is None: + with_memory = self.with_memory_default + if with_rss is None: + with_rss = self.with_rss_default + if counters is None: + counters = self.default_counters + full_path = ".".join(self.current_path + [name]) self.current_path.append(name) diff --git a/pychunkedgraph/debug/split_profile.py b/pychunkedgraph/debug/split_profile.py index 4f38b40d2..817f3c207 100644 --- a/pychunkedgraph/debug/split_profile.py +++ b/pychunkedgraph/debug/split_profile.py @@ -14,6 +14,7 @@ import hashlib import json import pickle +import shutil import sys import tempfile from contextlib import contextmanager, redirect_stdout @@ -25,8 +26,11 @@ import numpy as np from pychunkedgraph.app.segmentation.common import _get_sources_and_sinks -from pychunkedgraph.debug.profiler import HierarchicalProfiler -from pychunkedgraph.graph import edits, edits_sv +from pychunkedgraph.debug.profiler import ( + HierarchicalProfiler, + get_profiler, +) +from pychunkedgraph.graph import edits_sv from pychunkedgraph.graph import utils as _utils_pkg from pychunkedgraph.graph.dry_run import dry_run_scope from pychunkedgraph.graph.operation import Cut, MulticutOperation, SvSplitRequired @@ -145,7 +149,7 @@ def profile_call(cg, name, fn, *args, **kwargs): """ profiler = HierarchicalProfiler(enabled=True) with dry_run_scope(), count_io(cg) as counters: - with profiler.profile(name, with_memory=True, with_rss=True, counters=counters): + with profiler.profile(name, counters=counters): result = fn(*args, **kwargs) return profiler, result @@ -226,19 +230,37 @@ def load_run(cg, payload: dict) -> Tuple[HierarchicalProfiler, SplitInputs]: return profiler, inputs -def run_split_profile(cg, payload: dict) -> Tuple[HierarchicalProfiler, SplitInputs]: - """Full-flow SV-split harness: dry-run, count IO, profile stages. +def run_split_profile( + cg, payload: dict, *, overwrite: bool = False +) -> Tuple[HierarchicalProfiler, SplitInputs]: + """Drive an SV split under dry-run with per-stage metrics captured. - Returns ``(profiler, inputs)``. The profiler holds per-stage - ``BlockMetrics`` rows in ``profiler.blocks``; ``inputs`` holds the - intermediate value captured at each stage so the user can replay - a single stage standalone. + Returns ``(profiler, inputs)``. Uses the global profiler so inline + ``get_profiler().profile()`` blocks inside the SV-split call path + are captured automatically. ``inputs`` holds each stage's + intermediate values for standalone replay. - Writes the run to ``run_dir(cg, payload)`` on completion and prints - the cache path so the user can ``cp -r`` it to keep before the OS - tmp policy reclaims it. + ``overwrite=True`` wipes any existing cached run for this payload + before starting. + + Always writes a cache (profiler + inputs + metrics.txt) to + ``run_dir(cg, payload)`` on completion — even when ``op.execute()`` + raises — and prints the cache path. """ - profiler = HierarchicalProfiler(enabled=True) + target_dir = run_dir(cg, payload) + if target_dir.exists(): + if overwrite: + shutil.rmtree(target_dir) + else: + raise FileExistsError( + f"cached run already exists at {target_dir}; " + "pass overwrite=True to wipe and re-run, or " + "load_run(cg, payload) to read it" + ) + + profiler = get_profiler() + profiler.reset() + profiler.enabled = True inputs = SplitInputs() op = build_op(cg, payload) @@ -248,26 +270,20 @@ def run_split_profile(cg, payload: dict) -> Tuple[HierarchicalProfiler, SplitInp inputs.sink_coords = op.sink_coords with dry_run_scope(), count_io(cg) as counters: - # Snapshot originals before patching so the finally block can - # restore them even if op.execute() raises. + profiler.default_counters = counters + + # Capture-only wrappers for SplitInputs replay — no profile() + # blocks. The real per-step metrics come from inline profile() + # blocks inside the called functions. orig_run_multicut = MulticutOperation._run_multicut orig_plan_sv_splits = edits_sv.plan_sv_splits orig_split_supervoxels = edits_sv.split_supervoxels - orig_remove_edges = edits.remove_edges - # _run_multicut is called twice (initial + post-SV-split retry). - # The counter distinguishes the two block names. mincut_call_count = [0] - # Class-method patch — MulticutOperation uses __slots__, so - # instance patching raises AttributeError. def wrap_run_multicut(self_op, operation_id): + result = orig_run_multicut(self_op, operation_id) mincut_call_count[0] += 1 - name = f"mincut_{mincut_call_count[0]}" - with profiler.profile( - name, with_memory=True, with_rss=True, counters=counters - ): - result = orig_run_multicut(self_op, operation_id) if mincut_call_count[0] == 1 and isinstance(result, SvSplitRequired): inputs.sv_remapping = result.sv_remapping elif isinstance(result, Cut): @@ -275,53 +291,26 @@ def wrap_run_multicut(self_op, operation_id): return result def wrap_plan_sv_splits(*a, **k): - with profiler.profile( - "plan_sv_splits", - with_memory=True, - with_rss=True, - counters=counters, - ): - result = orig_plan_sv_splits(*a, **k) + result = orig_plan_sv_splits(*a, **k) inputs.plan_tasks, inputs.plan_chunk_ids = result return result def wrap_split_supervoxels(*a, **k): - # split_supervoxels uses keyword-only args; operation_id and - # timestamp aren't otherwise exposed on the op, so capture - # them here for standalone replay. if "operation_id" in k: inputs.operation_id = k["operation_id"] if "timestamp" in k: inputs.timestamp = k["timestamp"] - with profiler.profile( - "split_supervoxels", - with_memory=True, - with_rss=True, - counters=counters, - ): - result = orig_split_supervoxels(*a, **k) + result = orig_split_supervoxels(*a, **k) inputs.sv_result = result return result - def wrap_remove_edges(*a, **k): - with profiler.profile( - "remove_edges", - with_memory=True, - with_rss=True, - counters=counters, - ): - return orig_remove_edges(*a, **k) - MulticutOperation._run_multicut = wrap_run_multicut edits_sv.plan_sv_splits = wrap_plan_sv_splits edits_sv.split_supervoxels = wrap_split_supervoxels - edits.remove_edges = wrap_remove_edges - exec_exc: Optional[BaseException] = None try: op.execute() except Exception as e: - exec_exc = e print( f"[split_profile] op.execute() raised " f"{type(e).__name__}: {e}", file=sys.stderr, @@ -330,10 +319,12 @@ def wrap_remove_edges(*a, **k): MulticutOperation._run_multicut = orig_run_multicut edits_sv.plan_sv_splits = orig_plan_sv_splits edits_sv.split_supervoxels = orig_split_supervoxels - edits.remove_edges = orig_remove_edges + profiler.default_counters = None + + # Disable so the global profiler is a no-op for callers outside + # this harness (production code paths included). + profiler.enabled = False - # Always try to save what's been captured so far, even on exception. - # Wrapped so a save failure doesn't mask the original exec_exc. try: target = _save_run(cg, payload, profiler, inputs) print(f"[split_profile] run cached at {target}") diff --git a/pychunkedgraph/graph/cutting_sv.py b/pychunkedgraph/graph/cutting_sv.py index d1869951c..45309ebb1 100644 --- a/pychunkedgraph/graph/cutting_sv.py +++ b/pychunkedgraph/graph/cutting_sv.py @@ -19,6 +19,8 @@ ball, ) # keep only ball; use ndi.binary_dilation everywhere +from pychunkedgraph.debug.profiler import get_profiler + # ---------- Fast CC wrappers ---------- try: import cc3d @@ -1270,52 +1272,55 @@ def split_supervoxel_helper( ): voxel_size = np.array(voxel_size) downsample = voxel_size.max() // voxel_size + _prof = get_profiler() # 1) Connect seed teams first - A_aug, B_aug, okA, okB = connect_both_seeds_via_ridge( - binary_seg, - source_coords, - sink_coords, - voxel_size=voxel_size, - downsample=downsample, - vol_order="xyz", - vox_order="xyz", - seed_order="xyz", - snap_method="kdtree", - snap_kwargs=dict( - use_boundary=False, # disables boundary-only snapping for maximum safety - downsample=False, # avoids losing candidates - method="kdtree", - ), - verbose=verbose, - ) + with _prof.profile("connect_seeds"): + A_aug, B_aug, okA, okB = connect_both_seeds_via_ridge( + binary_seg, + source_coords, + sink_coords, + voxel_size=voxel_size, + downsample=downsample, + vol_order="xyz", + vox_order="xyz", + seed_order="xyz", + snap_method="kdtree", + snap_kwargs=dict( + use_boundary=False, # disables boundary-only snapping for maximum safety + downsample=False, # avoids losing candidates + method="kdtree", + ), + verbose=verbose, + ) if not (okA and okB): raise RuntimeError( "In-mask connection failed for at least one team; skipping split." ) # 2) Run the corridor-free splitter with same snapping settings - return split_supervoxel_growing( - binary_seg, - A_aug, - B_aug, - voxel_size=voxel_size, - vol_order="xyz", - vox_order="xyz", - seed_order="xyz", - halo=1, - gamma_neck=1.6, - narrow_band_rel=0.08, - nb_dilate=1, - downsample_geodesic=(1, 2, 2), - enforce_single_cc=True, - raise_if_seed_split=True, - raise_if_multi_cc=True, - verbose=verbose, - snap_method="kdtree", - snap_kwargs=dict( - use_boundary=False, # match the connector for consistency - downsample=False, - method="kdtree", - ), - ) + with _prof.profile("split_growing"): + return split_supervoxel_growing( + binary_seg, + A_aug, + B_aug, + voxel_size=voxel_size, + vol_order="xyz", + vox_order="xyz", + seed_order="xyz", + halo=1, + gamma_neck=1.6, + narrow_band_rel=0.08, + nb_dilate=1, + downsample_geodesic=(1, 2, 2), + enforce_single_cc=True, + raise_if_seed_split=True, + raise_if_multi_cc=True, + verbose=verbose, + snap_method="kdtree", + snap_kwargs=dict( + use_boundary=False, # match the connector for consistency + downsample=False, + method="kdtree", + ), + ) diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index 19a2c2eb3..bc6e89d4a 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -38,6 +38,7 @@ import numpy as np from pychunkedgraph import get_logger +from pychunkedgraph.debug.profiler import get_profiler from pychunkedgraph.graph import attributes, basetypes, serializers from pychunkedgraph.graph.exceptions import PostconditionError from scipy.spatial import cKDTree @@ -351,52 +352,61 @@ def update_edges( new_id_label_map: dict = None, ): old_new_map = dict(old_new_map) + _prof = get_profiler() t0 = time.time() - coords_by_label = build_coords_by_label(new_seg) - new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) - new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] + with _prof.profile("build_coords"): + coords_by_label = build_coords_by_label(new_seg) + with _prof.profile("kdtrees"): + new_ids = np.array( + list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID + ) + new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] logger.note( f"build_coords {len(coords_by_label)} labels, {len(new_ids)} fragment trees ({time.time() - t0:.2f}s)" ) t0 = time.time() - _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) - edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) + with _prof.profile("get_subgraph"): + _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) + edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) logger.note( f"get_subgraph {len(edges_.get_pairs())} edges ({time.time() - t0:.2f}s)" ) - edges = edges_.get_pairs() - affinities = edges_.affinities - areas = edges_.areas + with _prof.profile("edge_dedup"): + edges = edges_.get_pairs() + affinities = edges_.affinities + areas = edges_.areas - edges = np.sort(edges, axis=1) - _, edges_idx = np.unique(edges, axis=0, return_index=True) - edges_idx = edges_idx[edges[edges_idx, 0] != edges[edges_idx, 1]] + edges = np.sort(edges, axis=1) + _, edges_idx = np.unique(edges, axis=0, return_index=True) + edges_idx = edges_idx[edges[edges_idx, 0] != edges[edges_idx, 1]] - edges = edges[edges_idx] - affinities = affinities[edges_idx] - areas = areas[edges_idx] + edges = edges[edges_idx] + affinities = affinities[edges_idx] + areas = areas[edges_idx] t0 = time.time() - all_edge_svs = np.unique(edges) - all_roots = cg.get_roots(all_edge_svs) - sv_root_map = dict(zip(all_edge_svs, all_roots)) + with _prof.profile("get_roots_inner"): + all_edge_svs = np.unique(edges) + all_roots = cg.get_roots(all_edge_svs) + sv_root_map = dict(zip(all_edge_svs, all_roots)) logger.note(f"get_roots {len(all_edge_svs)} svs ({time.time() - t0:.2f}s)") t0 = time.time() - result = _get_new_edges( - (edges, affinities, areas), - old_new_map, - coords_by_label, - root_id, - sv_root_map, - cg, - new_kdtrees, - new_ids, - new_id_label_map, - threshold=cg.meta.sv_split_threshold, - ) + with _prof.profile("_get_new_edges"): + result = _get_new_edges( + (edges, affinities, areas), + old_new_map, + coords_by_label, + root_id, + sv_root_map, + cg, + new_kdtrees, + new_ids, + new_id_label_map, + threshold=cg.meta.sv_split_threshold, + ) logger.note(f"_get_new_edges {result[0].shape} ({time.time() - t0:.2f}s)") validate_split_edges(result[0], result[1], old_new_map, new_id_label_map) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index be605c6e4..547fcca88 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -12,6 +12,7 @@ import numpy as np from pychunkedgraph import get_logger +from pychunkedgraph.debug.profiler import get_profiler from pychunkedgraph.graph import ( attributes, cache as cache_utils, @@ -424,8 +425,10 @@ def split_supervoxel( # new fragments. bbs_ = np.clip(bbs - 1, vol_start, vol_end) bbe_ = np.clip(bbe + 1, vol_start, vol_end) + _prof = get_profiler() t0 = time.time() - seg = get_local_segmentation(cg.meta, bbs_, bbe_).squeeze() + with _prof.profile("seg_read"): + seg = get_local_segmentation(cg.meta, bbs_, bbe_).squeeze() logger.note(f"segmentation read {seg.shape} ({time.time() - t0:.2f}s)") # Narrow the rep to pieces actually present in the bbox seg. Pieces @@ -441,32 +444,36 @@ def split_supervoxel( f"({len(rep_pieces) - len(cut_supervoxels)} rep pieces outside bbox)" ) - binary_seg = np.isin(seg, supervoxel_ids) - voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) + with _prof.profile("binary_seg"): + binary_seg = np.isin(seg, supervoxel_ids) + voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) t0 = time.time() - split_result = split_supervoxel_helper( - binary_seg[voxel_overlap_crop], - source_coords - bbs, - sink_coords - bbs, - cg.meta.resolution, - verbose=verbose, - ) + with _prof.profile("geodesic_split"): + split_result = split_supervoxel_helper( + binary_seg[voxel_overlap_crop], + source_coords - bbs, + sink_coords - bbs, + cg.meta.resolution, + verbose=verbose, + ) logger.note(f"split computation {split_result.shape} ({time.time() - t0:.2f}s)") chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) t0 = time.time() - results, change_chunks = _update_chunks( - cg, chunks_bbox_map, seg[voxel_overlap_crop], split_result, bbs - ) + with _prof.profile("chunk_updates"): + results, change_chunks = _update_chunks( + cg, chunks_bbox_map, seg[voxel_overlap_crop], split_result, bbs + ) logger.note( f"chunk updates {len(chunks_bbox_map)} chunks, " f"{len(change_chunks)} with splits ({time.time() - t0:.2f}s)" ) - seg_cropped = seg[voxel_overlap_crop].copy() - new_seg, old_new_map, new_id_label_map = _parse_results( - results, seg_cropped, bbs, bbe - ) + with _prof.profile("parse_results"): + seg_cropped = seg[voxel_overlap_crop].copy() + new_seg, old_new_map, new_id_label_map = _parse_results( + results, seg_cropped, bbs, bbe + ) logger.note( f"old_new_map: {len(old_new_map)} SVs split, whole_sv: {len(cut_supervoxels)} SVs" ) @@ -474,31 +481,36 @@ def split_supervoxel( if unsplit: logger.note(f"unsplit SVs (kept IDs): {unsplit}") - sv_ids = fastremap.unique(seg) - roots = cg.get_roots(sv_ids) - sv_root_map = dict(zip(sv_ids, roots)) + with _prof.profile("get_roots"): + sv_ids = fastremap.unique(seg) + roots = cg.get_roots(sv_ids) + sv_root_map = dict(zip(sv_ids, roots)) root = sv_root_map[sv_id] logger.note(f"{sv_id} -> {root}") - root_mask = fastremap.remap(seg, sv_root_map, in_place=False) == root - seg[~root_mask] = 0 - sv_ids = fastremap.unique(seg) - seg[voxel_overlap_crop] = new_seg + with _prof.profile("remap_to_root"): + root_mask = fastremap.remap(seg, sv_root_map, in_place=False) == root + seg[~root_mask] = 0 + sv_ids = fastremap.unique(seg) + seg[voxel_overlap_crop] = new_seg t0 = time.time() - edges_tuple = update_edges( - cg, - root, - np.array([bbs, bbe]), - seg, - old_new_map, - new_id_label_map, - ) + with _prof.profile("update_edges"): + edges_tuple = update_edges( + cg, + root, + np.array([bbs, bbe]), + seg, + old_new_map, + new_id_label_map, + ) logger.note(f"edge update ({time.time() - t0:.2f}s)") - rows0 = copy_parents_and_add_lineage( - cg, operation_id, old_new_map, time_stamp=time_stamp - ) - rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) + with _prof.profile("copy_parents"): + rows0 = copy_parents_and_add_lineage( + cg, operation_id, old_new_map, time_stamp=time_stamp + ) + with _prof.profile("add_new_edges"): + rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) rows = rows0 + rows1 # Prepare per-chunk OCDBT write payloads. The caller batches these From f01eacc8dcf2a256d0f31e7949d7d97dc330394d Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 28 May 2026 00:56:36 +0000 Subject: [PATCH 266/367] perf(sv-split): collapse build_coords + remap_to_root + binary_seg via fastremap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_coords_by_label now delegates to fastremap.point_cloud — a single C++ pass that emits uint16 coords grouped by label, treating 0 as background. Before the call, update_edges masks new_seg in place with fastremap.mask_except(new_seg, new_ids ∪ all_edge_svs) so point_cloud only emits coords for labels the downstream readers (kdtrees + coords_by_label.get in _get_new_edges) will actually query. update_edges is reordered so get_subgraph and get_roots_inner run first to supply that label set. The caller does not read seg after update_edges returns, so the in-place mutation is safe. remap_to_root drops fastremap.remap(..., in_place=False) — which allocated a full-size shadow array of seg just to compare against the target root — for fastremap.mask_except(seg, root_labels, in_place=True). Same filter, no shadow. binary_seg replaces np.isin(seg, supervoxel_ids) — which allocates int-array auxiliaries the size of seg — with a per-SV `==` / `|=` loop. Computed over seg[voxel_overlap_crop] directly so the boolean is only as big as the region split_supervoxel_helper consumes. Empty supervoxel_ids returns all-False, matching the prior np.isin contract. split_supervoxel computed fastremap.unique(seg) twice — once for cut_supervoxels narrowing, once in get_roots — on the same unmutated seg. Hoisted into a single seg_unique block whose result both readers reuse, so the sort buffer is paid once at low rss residual. Harness-side: metrics_report and print_report no longer gate on self.enabled (the harness disables the profiler before printing, which was silencing the reports). run_split_profile saves the cache before disabling. The metrics table adds cum_wall (from the first profile() block since reset), rss_start, rss_peak columns; drops the noisy ocdbt_reads counter. --- pychunkedgraph/debug/profiler.py | 48 ++++++-- pychunkedgraph/debug/split_profile.py | 8 +- pychunkedgraph/graph/cutting_sv.py | 52 +++++---- pychunkedgraph/graph/edges_sv.py | 34 ++++-- pychunkedgraph/graph/edits_sv.py | 38 +++++-- pychunkedgraph/tests/graph/test_cutting_sv.py | 106 ++++++++++++++++++ 6 files changed, 232 insertions(+), 54 deletions(-) diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/debug/profiler.py index 108bb3eb1..d39e9224f 100644 --- a/pychunkedgraph/debug/profiler.py +++ b/pychunkedgraph/debug/profiler.py @@ -55,6 +55,9 @@ class BlockMetrics: rss_start_bytes: int = 0 rss_peak_bytes: int = 0 counter_deltas: Dict[str, int] = field(default_factory=dict) + # Wall-clock time when this block finished, measured relative to + # the first profile() entry since the profiler was reset. + wall_end_s: float = 0.0 class _RSSSampler: @@ -117,6 +120,9 @@ def __init__( self.stack: List[Tuple[str, float]] = [] self.current_path: List[str] = [] self.blocks: List[BlockMetrics] = [] + # perf_counter at the first profile() entry since reset. + # Used to stamp each block's wall_end_s for cumulative-wall view. + self._base_perf: Optional[float] = None # Per-instance defaults so inline profile() call sites stay short # (no per-block kwargs). Callers can override per-call. self.with_memory_default = with_memory @@ -177,10 +183,13 @@ def profile( counters_before = dict(counters) if counters is not None else None start_time = time.perf_counter() + if self._base_perf is None: + self._base_perf = start_time try: yield finally: - elapsed = time.perf_counter() - start_time + end_time = time.perf_counter() + elapsed = end_time - start_time self.timings[full_path].append(elapsed) self.call_counts[full_path] += 1 self.current_path.pop() @@ -211,12 +220,13 @@ def profile( rss_start_bytes=int(rss_start), rss_peak_bytes=int(rss_peak), counter_deltas=counter_deltas, + wall_end_s=end_time - self._base_perf, ) ) def print_report(self, operation_id=None): """Print a detailed timing breakdown.""" - if not self.enabled or not self.timings: + if not self.timings: return print("\n" + "=" * 80) @@ -267,19 +277,26 @@ def print_report(self, operation_id=None): print("=" * 80 + "\n") + # Counter keys that are uninformative for the SV-split flow and + # only add visual noise to the report. + _SKIP_COUNTERS = ("ocdbt_reads",) + def metrics_report(self, operation_id=None) -> None: """Print a compact, human-readable table over self.blocks. - Columns: stage, wall, py_peak, rss_Δ (signed), plus one column - per counter key that has a non-zero value in at least one block. - Counterpart to print_report (which covers timing-only). + Columns: stage, wall, cum_wall, py_peak, rss_start, rss_peak, + rss_Δ (signed), plus one column per counter key that has a + non-zero value in at least one block. ``cum_wall`` is wall + time elapsed from the first ``profile()`` block since reset. + rss_start / rss_peak are absolute process RSS; rss_Δ is the + new-allocation delta inside the block. """ - if not self.enabled or not self.blocks: + if not self.blocks: return # Collect counter keys in first-seen order; drop ones that are # zero in every block (e.g., bt_log_reads is usually 0 for SV - # splits and only adds noise). + # splits and only adds noise) or in the static skip list. counter_keys: List[str] = [] seen_keys: set = set() for b in self.blocks: @@ -290,10 +307,19 @@ def metrics_report(self, operation_id=None) -> None: counter_keys = [ k for k in counter_keys - if any(b.counter_deltas.get(k, 0) for b in self.blocks) + if k not in self._SKIP_COUNTERS + and any(b.counter_deltas.get(k, 0) for b in self.blocks) ] - cols = ["stage", "wall", "py_peak", "rss_Δ"] + counter_keys + cols = [ + "stage", + "wall", + "cum_wall", + "py_peak", + "rss_start", + "rss_peak", + "rss_Δ", + ] + counter_keys def fmt_counter(key: str, val: int) -> str: if key.endswith("_bytes"): @@ -305,7 +331,10 @@ def fmt_counter(key: str, val: int) -> str: row = [ b.path, _fmt_time(b.elapsed_s), + _fmt_time(getattr(b, "wall_end_s", 0.0)), _fmt_bytes(b.py_heap_peak_bytes), + _fmt_bytes(b.rss_start_bytes), + _fmt_bytes(b.rss_peak_bytes), _fmt_bytes(b.rss_peak_bytes - b.rss_start_bytes, signed=True), ] for k in counter_keys: @@ -337,6 +366,7 @@ def reset(self): self.stack.clear() self.current_path.clear() self.blocks.clear() + self._base_perf = None # Global profiler instance - enable via environment variable diff --git a/pychunkedgraph/debug/split_profile.py b/pychunkedgraph/debug/split_profile.py index 817f3c207..15dc14279 100644 --- a/pychunkedgraph/debug/split_profile.py +++ b/pychunkedgraph/debug/split_profile.py @@ -321,13 +321,13 @@ def wrap_split_supervoxels(*a, **k): edits_sv.split_supervoxels = orig_split_supervoxels profiler.default_counters = None - # Disable so the global profiler is a no-op for callers outside - # this harness (production code paths included). - profiler.enabled = False - try: target = _save_run(cg, payload, profiler, inputs) print(f"[split_profile] run cached at {target}") except Exception as save_err: print(f"[split_profile] cache save failed: {save_err}", file=sys.stderr) + + # Disable so the global profiler is a no-op for callers outside + # this harness (production code paths included). + profiler.enabled = False return profiler, inputs diff --git a/pychunkedgraph/graph/cutting_sv.py b/pychunkedgraph/graph/cutting_sv.py index 45309ebb1..ea5cd06fe 100644 --- a/pychunkedgraph/graph/cutting_sv.py +++ b/pychunkedgraph/graph/cutting_sv.py @@ -1,7 +1,8 @@ from time import perf_counter +import fastremap import numpy as np -from typing import Dict, Tuple, Optional, Sequence +from typing import Dict, Iterable, Tuple, Optional, Sequence from scipy.spatial import cKDTree # EDT backends: prefer Seung-Lab edt, fallback to scipy.ndimage @@ -1164,38 +1165,43 @@ def build_kdtrees_by_label( def build_coords_by_label( vol: np.ndarray, *, + labels: Optional[Iterable[int]] = None, background: int = 0, min_points: int = 1, dtype: np.dtype = np.float32, ) -> Dict[int, np.ndarray]: - """Group voxel coordinates by label without building kdtrees. - - Returns mapping label -> (N, 3) coordinate array in (z, y, x) order. + """Group voxel coords by label via ``fastremap.point_cloud``. + + Returns ``{label: (M_label, 3) coords in (z, y, x)}`` cast to + ``dtype``. ``fastremap.point_cloud`` is a C++ single-pass + implementation that emits ``uint16`` coords grouped by label, + treating ``0`` as background. + + ``labels`` restricts the output dict; the underlying C++ scan + visits every voxel regardless (faster and lighter than a + label-filtered Python scan). ``min_points`` drops labels with + fewer than that many voxels. ``background != 0`` removes that + label from the result after the call. """ if vol.ndim != 3: raise ValueError("`vol` must be a 3D array.") - Z, Y, X = vol.shape - - flat = vol.ravel() - nz = np.flatnonzero(flat) if background == 0 else np.flatnonzero(flat != background) - if nz.size == 0: - return {} - - labels = flat[nz] - z, y, x = np.unravel_index(nz, (Z, Y, X)) - coords = np.column_stack((z, y, x)).astype(dtype, copy=False) - - order = np.argsort(labels, kind="mergesort") - labels_sorted = labels[order] - starts = np.flatnonzero(np.r_[True, labels_sorted[1:] != labels_sorted[:-1]]) - ends = np.r_[starts[1:], labels_sorted.size] + raw = fastremap.point_cloud(vol) + if background != 0: + raw.pop(background, None) + + if labels is not None: + wanted = {int(x) for x in labels} + if not wanted: + return {} + items = ((k, v) for k, v in raw.items() if int(k) in wanted) + else: + items = raw.items() result: Dict[int, np.ndarray] = {} - for s, e in zip(starts, ends): - n = e - s - if n < min_points: + for k, coords in items: + if coords.shape[0] < min_points: continue - result[int(labels_sorted[s])] = coords[order[s:e]] + result[int(k)] = coords.astype(dtype, copy=False) return result diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index bc6e89d4a..49c0692a5 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -353,17 +353,7 @@ def update_edges( ): old_new_map = dict(old_new_map) _prof = get_profiler() - t0 = time.time() - with _prof.profile("build_coords"): - coords_by_label = build_coords_by_label(new_seg) - with _prof.profile("kdtrees"): - new_ids = np.array( - list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID - ) - new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] - logger.note( - f"build_coords {len(coords_by_label)} labels, {len(new_ids)} fragment trees ({time.time() - t0:.2f}s)" - ) + new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) t0 = time.time() with _prof.profile("get_subgraph"): @@ -393,6 +383,28 @@ def update_edges( sv_root_map = dict(zip(all_edge_svs, all_roots)) logger.note(f"get_roots {len(all_edge_svs)} svs ({time.time() - t0:.2f}s)") + # Coords are only ever read for new fragment ids (kdtrees) and for + # partners queried via coords_by_label.get(...) in _get_new_edges. + # Partners can only come from subgraph-edge endpoints, so this + # union is a tight superset of every key that gets looked up. + t0 = time.time() + with _prof.profile("build_coords"): + # Zero out every label whose coords nothing downstream will + # query, in place. fastremap.point_cloud (called by + # build_coords_by_label) groups by every nonzero label in the + # vol, so trimming the input is the only way to shrink its + # C++ scan; the labels= kwarg only filters the result dict + # after the scan. Caller does not read seg after update_edges + # returns, so the in-place mutation is safe. + wanted_labels = np.union1d(new_ids, all_edge_svs) + fastremap.mask_except(new_seg, list(wanted_labels), in_place=True) + coords_by_label = build_coords_by_label(new_seg) + with _prof.profile("kdtrees"): + new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] + logger.note( + f"build_coords {len(coords_by_label)} labels, {len(new_ids)} fragment trees ({time.time() - t0:.2f}s)" + ) + t0 = time.time() with _prof.profile("_get_new_edges"): result = _get_new_edges( diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index 547fcca88..e07a20992 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -431,12 +431,19 @@ def split_supervoxel( seg = get_local_segmentation(cg.meta, bbs_, bbe_).squeeze() logger.note(f"segmentation read {seg.shape} ({time.time() - t0:.2f}s)") + with _prof.profile("seg_unique"): + # Computed once and reused: seg is not mutated until + # remap_to_root, so both the cut_supervoxels narrowing below + # and the get_roots block downstream get the same answer + # without paying twice for the (seg-size) sort buffer. + sv_ids = fastremap.unique(seg) + # Narrow the rep to pieces actually present in the bbox seg. Pieces # of the rep whose voxels lie outside the seed-driven bbox don't # appear in `seg` and so don't contribute to `binary_seg` anyway — # carrying them in `cut_supervoxels` is just log noise plus inflated # `unsplit` diff churn. - seg_ids = {int(x) for x in fastremap.unique(seg) if x != 0} + seg_ids = {int(x) for x in sv_ids if x != 0} cut_supervoxels = rep_pieces & seg_ids supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) logger.note( @@ -445,12 +452,24 @@ def split_supervoxel( ) with _prof.profile("binary_seg"): - binary_seg = np.isin(seg, supervoxel_ids) + # OR a per-SV boolean comparison instead of np.isin: for a + # handful of supervoxel_ids, each `seg == sv` is a single C + # pass with no transient int-array buffers, vs. np.isin's + # internal sort+search that allocates auxiliaries the size + # of seg. Empty supervoxel_ids ⇒ all-False, matching np.isin. + # Compute the boolean only over the overlap crop — that's + # the only region split_supervoxel_helper consumes, so + # allocating a full-bbox boolean and then slicing wastes + # ~seg.size bytes. voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) + seg_overlap = seg[voxel_overlap_crop] + binary_seg = np.zeros(seg_overlap.shape, dtype=bool) + for sv in supervoxel_ids: + binary_seg |= seg_overlap == sv t0 = time.time() with _prof.profile("geodesic_split"): split_result = split_supervoxel_helper( - binary_seg[voxel_overlap_crop], + binary_seg, source_coords - bbs, sink_coords - bbs, cg.meta.resolution, @@ -482,16 +501,21 @@ def split_supervoxel( logger.note(f"unsplit SVs (kept IDs): {unsplit}") with _prof.profile("get_roots"): - sv_ids = fastremap.unique(seg) + # sv_ids reused from the seg_unique block above (seg is not + # mutated between then and here). roots = cg.get_roots(sv_ids) sv_root_map = dict(zip(sv_ids, roots)) root = sv_root_map[sv_id] logger.note(f"{sv_id} -> {root}") with _prof.profile("remap_to_root"): - root_mask = fastremap.remap(seg, sv_root_map, in_place=False) == root - seg[~root_mask] = 0 - sv_ids = fastremap.unique(seg) + # Zero out every label whose root != `root`, in place. The + # prior implementation materialized a full-size shadow array + # via fastremap.remap(in_place=False) just to compare against + # root; mask_except achieves the same filter at C speed + # without the shadow allocation. + root_labels = [int(sv) for sv, r in sv_root_map.items() if r == root] + fastremap.mask_except(seg, root_labels, in_place=True) seg[voxel_overlap_crop] = new_seg t0 = time.time() with _prof.profile("update_edges"): diff --git a/pychunkedgraph/tests/graph/test_cutting_sv.py b/pychunkedgraph/tests/graph/test_cutting_sv.py index a2b29ac74..e0964fcd6 100644 --- a/pychunkedgraph/tests/graph/test_cutting_sv.py +++ b/pychunkedgraph/tests/graph/test_cutting_sv.py @@ -19,6 +19,7 @@ _upsample_bool, _upsample_labels, build_kdtrees_by_label, + build_coords_by_label, pairwise_min_distance_two_sets, split_supervoxel_growing, connect_both_seeds_via_ridge, @@ -486,6 +487,111 @@ def test_uint64_labels(self): assert int(2**60) in trees +# ============================================================ +# Tests: select_voxel_indices + build_coords_by_label +# ============================================================ +def _reference_coords_by_label(vol, *, labels=None, background=0, min_points=1): + """Reference: brute-force np.argwhere(vol == k), restricted to labels. + + Used to verify build_coords_by_label against the algorithmic + definition (independent of its implementation). + """ + present = set(int(x) for x in np.unique(vol) if int(x) != background) + keys = present if labels is None else present & {int(x) for x in labels} + result = {} + for k in keys: + pts = np.argwhere(vol == k) + if pts.shape[0] < min_points: + continue + result[k] = pts.astype(np.float32) + return result + + +def _assert_dict_equal(actual, expected): + assert set(actual.keys()) == set(expected.keys()) + for k in expected: + a = actual[k][np.lexsort(actual[k].T[::-1])] + e = expected[k][np.lexsort(expected[k].T[::-1])] + assert a.shape == e.shape + np.testing.assert_array_equal(a, e) + + +class TestBuildCoordsByLabel: + def test_matches_reference_full_scan(self): + rng = np.random.default_rng(0) + vol = rng.integers(0, 4, size=(6, 5, 7), dtype=np.uint64) + actual = build_coords_by_label(vol) + expected = _reference_coords_by_label(vol) + _assert_dict_equal(actual, expected) + + def test_labels_filter_restricts_keys(self): + vol = np.zeros((5, 5, 5), dtype=np.uint64) + vol[1, 1, 1] = 7 + vol[2, 3, 0] = 8 + vol[0, 0, 3] = 9 + out = build_coords_by_label(vol, labels=[7, 9]) + assert set(out.keys()) == {7, 9} + assert 8 not in out + + def test_labels_filter_matches_reference(self): + rng = np.random.default_rng(1) + vol = rng.integers(0, 5, size=(5, 6, 4), dtype=np.uint64) + wanted = [1, 3] + actual = build_coords_by_label(vol, labels=wanted) + expected = _reference_coords_by_label(vol, labels=wanted) + _assert_dict_equal(actual, expected) + + def test_labels_filter_keeps_label_with_no_voxels_absent(self): + vol = np.zeros((3, 3, 3), dtype=np.uint64) + vol[1, 1, 1] = 5 + out = build_coords_by_label(vol, labels=[5, 42]) + assert set(out.keys()) == {5} + + def test_empty_labels(self): + vol = np.ones((3, 3, 3), dtype=np.uint64) + out = build_coords_by_label(vol, labels=[]) + assert out == {} + + def test_min_points_filter(self): + vol = np.zeros((4, 4, 4), dtype=np.uint64) + vol[0, 0, 0] = 1 + vol[1, 1, 1] = 2 + vol[1, 1, 2] = 2 + vol[1, 2, 1] = 2 + out = build_coords_by_label(vol, min_points=2) + assert 1 not in out + assert 2 in out + assert out[2].shape == (3, 3) + + def test_empty_volume(self): + vol = np.zeros((3, 3, 3), dtype=np.uint64) + assert build_coords_by_label(vol) == {} + + def test_background_nonzero(self): + vol = np.full((4, 4, 4), 99, dtype=np.uint64) + vol[2, 2, 2] = 5 + out = build_coords_by_label(vol, background=99) + assert set(out.keys()) == {5} + np.testing.assert_array_equal(out[5], np.array([[2, 2, 2]], dtype=np.float32)) + + def test_uint64_labels(self): + vol = np.zeros((4, 4, 4), dtype=np.uint64) + big = np.uint64(2**60) + vol[1, 1, 1] = big + out = build_coords_by_label(vol) + assert int(big) in out + + def test_dtype_float32_default(self): + vol = np.zeros((3, 3, 3), dtype=np.uint64) + vol[0, 0, 0] = 1 + out = build_coords_by_label(vol) + assert out[1].dtype == np.float32 + + def test_non_3d_raises(self): + with pytest.raises(ValueError, match="3D"): + build_coords_by_label(np.zeros((5, 5), dtype=np.uint64)) + + # ============================================================ # Tests: pairwise_min_distance_two_sets # ============================================================ From b3887d46a0369ae5ec7752de52fb8c40ce50d291 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 28 May 2026 14:51:43 +0000 Subject: [PATCH 267/367] perf(sv-split): confine enforce_cc label-3 dilation to its bounding box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolve_label3_touching_vectorized decided each stray label-3 component's side by dilating the label-1 and label-2 masks over the whole volume and intersecting with the component mask — two full-volume binary_dilations that dominated enforce_cc. The label-3 voxels occupy a small region, so dilate the side masks only inside the label-3 bounding box, padded by one voxel so the dilation still reaches the bordering side voxels. The per-component border counts are identical to the full-volume dilation, so the split result is unchanged; only the dilated region shrinks to the label-3 neighborhood. Enforcement stays at full resolution after upsampling; moving it to the downsampled grid fragments the labels under the foreground mask and breaks the single-connected-component guarantee _update_chunks relies on (covered by test_single_cc_guarantee_with_downsample). Profiler-side: metrics_report lays the blocks out as a tree (one column per nesting level, parent-first). Inline profile() blocks remain only on the steps still time- or memory-suspect (>=2s wall or >=~0.5GB rss); the cheap steps earlier profiling cleared are unwrapped to keep the report focused. --- pychunkedgraph/debug/profiler.py | 65 ++++++++-- pychunkedgraph/graph/cutting_sv.py | 115 +++++++++++------- pychunkedgraph/graph/edges_sv.py | 59 ++++----- pychunkedgraph/graph/edits_sv.py | 34 +++--- pychunkedgraph/tests/graph/test_cutting_sv.py | 25 ++++ 5 files changed, 193 insertions(+), 105 deletions(-) diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/debug/profiler.py index d39e9224f..7eccdd8b1 100644 --- a/pychunkedgraph/debug/profiler.py +++ b/pychunkedgraph/debug/profiler.py @@ -290,6 +290,13 @@ def metrics_report(self, operation_id=None) -> None: time elapsed from the first ``profile()`` block since reset. rss_start / rss_peak are absolute process RSS; rss_Δ is the new-allocation delta inside the block. + + Rows are laid out as a tree: parent-first pre-order so each + group reads top-down (rollup, then per-step breakdown). Each + nesting level gets its own column (L0, L1, …); a block's name + sits in the column matching its depth, deeper columns blank. + Top-level groups keep execution order. No blank separator + rows. """ if not self.blocks: return @@ -311,8 +318,47 @@ def metrics_report(self, operation_id=None) -> None: and any(b.counter_deltas.get(k, 0) for b in self.blocks) ] - cols = [ - "stage", + def fmt_counter(key: str, val: int) -> str: + if key.endswith("_bytes"): + return _fmt_bytes(val) + return _fmt_count(val) + + # Build a parent-first pre-order over the dotted paths so each + # group reads top-down. self.blocks is in completion order + # (children before parents); order_idx preserves that as the + # tie-break for sibling ordering and top-level group order. + by_path: Dict[str, BlockMetrics] = {} + order_idx: Dict[str, int] = {} + for i, b in enumerate(self.blocks): + by_path[b.path] = b + order_idx[b.path] = i + + children: Dict[str, List[str]] = defaultdict(list) + roots: List[str] = [] + for b in self.blocks: + if "." in b.path: + children[b.path.rsplit(".", 1)[0]].append(b.path) + else: + roots.append(b.path) + roots.sort(key=lambda p: order_idx[p]) + for kids in children.values(): + kids.sort(key=lambda p: order_idx[p]) + + ordered: List[str] = [] + + def _visit(path: str) -> None: + ordered.append(path) + for child in children.get(path, []): + _visit(child) + + for r in roots: + _visit(r) + + # One name column per nesting level; a block's leaf name sits + # in the column matching its depth. + max_depth = max(p.count(".") for p in ordered) + level_cols = [f"L{i}" for i in range(max_depth + 1)] + metric_cols = [ "wall", "cum_wall", "py_peak", @@ -320,16 +366,15 @@ def metrics_report(self, operation_id=None) -> None: "rss_peak", "rss_Δ", ] + counter_keys - - def fmt_counter(key: str, val: int) -> str: - if key.endswith("_bytes"): - return _fmt_bytes(val) - return _fmt_count(val) + cols = level_cols + metric_cols rows: List[List[str]] = [] - for b in self.blocks: - row = [ - b.path, + for path in ordered: + b = by_path[path] + depth = path.count(".") + level_cells = [""] * len(level_cols) + level_cells[depth] = path.rsplit(".", 1)[-1] + row = level_cells + [ _fmt_time(b.elapsed_s), _fmt_time(getattr(b, "wall_end_s", 0.0)), _fmt_bytes(b.py_heap_peak_bytes), diff --git a/pychunkedgraph/graph/cutting_sv.py b/pychunkedgraph/graph/cutting_sv.py index ea5cd06fe..74928c68d 100644 --- a/pychunkedgraph/graph/cutting_sv.py +++ b/pychunkedgraph/graph/cutting_sv.py @@ -393,6 +393,8 @@ def log(msg: str): if verbose: print(msg, flush=True) + _prof = get_profiler() + def _bbox_pad_zyx(points_zyx, shape, pad=(24, 48, 48)): pts = np.asarray(points_zyx, int) if pts.size == 0: @@ -485,8 +487,9 @@ def _snap(pts_zyx, name): # Back to ZYX return snapped_xyz[:, [2, 1, 0]] - A_zyx = _snap(A_in_zyx, "A") - B_zyx = _snap(B_in_zyx, "B") + with _prof.profile("snap_seeds"): + A_zyx = _snap(A_in_zyx, "A") + B_zyx = _snap(B_in_zyx, "B") if len(A_zyx) == 0 or len(B_zyx) == 0: log("[connect] after snapping, one side has no seeds; skipping connection") @@ -734,6 +737,8 @@ def log(msg: str): if verbose: print(msg, flush=True) + _prof = get_profiler() + # Helpers reused from the module: _cc_label_26, _largest_component_id, _to_internal_zyx_volume, _from_internal_zyx_volume # _seeds_to_zyx, _compute_edt, etc. are assumed available. @@ -785,13 +790,25 @@ def _resolve_label3_touching_vectorized( log(f"[touching] no label-3 components | {perf_counter()-t0:.3f}s") return 0, 0 + # Per label-3 component, count how many of its voxels border each + # side (26-conn), and assign it to the side it borders more. This + # is the dilate-side-mask-and-intersect-with-comp3 test, but run + # only inside the label-3 bounding box (+1 halo) instead of over + # the whole volume — the +1 halo captures the side voxels just + # outside the label-3 region that the dilation reaches, so the + # counts are identical to a full-volume dilation. t1 = perf_counter() struc = np.ones((3, 3, 3), bool) - N1 = ndi.binary_dilation(out_labels == 1, structure=struc) & (comp3 > 0) - N2 = ndi.binary_dilation(out_labels == 2, structure=struc) & (comp3 > 0) - - cnt1 = np.bincount(comp3[N1], minlength=n3 + 1) - cnt2 = np.bincount(comp3[N2], minlength=n3 + 1) + nz = np.argwhere(comp3 > 0) + lo = np.maximum(nz.min(0) - 1, 0) + hi = np.minimum(nz.max(0) + 2, comp3.shape) + sl = tuple(slice(int(a), int(b)) for a, b in zip(lo, hi)) + comp3_sl = comp3[sl] + m3_sl = comp3_sl > 0 + N1 = ndi.binary_dilation(out_labels[sl] == 1, structure=struc) & m3_sl + N2 = ndi.binary_dilation(out_labels[sl] == 2, structure=struc) & m3_sl + cnt1 = np.bincount(comp3_sl[N1], minlength=n3 + 1) + cnt2 = np.bincount(comp3_sl[N2], minlength=n3 + 1) assign = np.zeros(n3 + 1, dtype=np.int16) # 0=undecided, 1 or 2 otherwise assign[cnt1 > cnt2] = 1 @@ -890,8 +907,9 @@ def _snap_ZYX(pts_zyx, tagname): ) return snapped_xyz[:, [2, 1, 0]] - A = _snap_ZYX(A_all, "A@snap") - B = _snap_ZYX(B_all, "B@snap") + with _prof.profile("snap_seeds"): + A = _snap_ZYX(A_all, "A@snap") + B = _snap_ZYX(B_all, "B@snap") log(f"[seeds] A={len(A)}, B={len(B)}") out_zyx = np.zeros_like(sv_zyx, dtype=np.int16) @@ -902,19 +920,20 @@ def _snap_ZYX(pts_zyx, tagname): # Tight bbox ROI around mask with halo t_bbox = perf_counter() - Z, Y, X = sv_zyx.shape - coords = np.argwhere(sv_zyx) - z0, y0, x0 = coords.min(0) - z1, y1, x1 = coords.max(0) + 1 - z0h = max(z0 - halo, 0) - y0h = max(y0 - halo, 0) - x0h = max(x0 - halo, 0) - z1h = min(z1 + halo, Z) - y1h = min(y1 + halo, Y) - x1h = min(x1 + halo, X) - sv = sv_zyx[z0h:z1h, y0h:y1h, x0h:x1h] - A_roi = A - np.array([z0h, y0h, x0h]) - B_roi = B - np.array([z0h, y0h, x0h]) + with _prof.profile("roi_crop"): + Z, Y, X = sv_zyx.shape + coords = np.argwhere(sv_zyx) + z0, y0, x0 = coords.min(0) + z1, y1, x1 = coords.max(0) + 1 + z0h = max(z0 - halo, 0) + y0h = max(y0 - halo, 0) + x0h = max(x0 - halo, 0) + z1h = min(z1 + halo, Z) + y1h = min(y1 + halo, Y) + x1h = min(x1 + halo, X) + sv = sv_zyx[z0h:z1h, y0h:y1h, x0h:x1h] + A_roi = A - np.array([z0h, y0h, x0h]) + B_roi = B - np.array([z0h, y0h, x0h]) log( f"[crop] ROI shape (internal): {sv.shape} (halo {halo}) | {perf_counter()-t_bbox:.3f}s" ) @@ -1028,32 +1047,40 @@ def _to_ds(pts): out_zyx[z0h:z1h, y0h:y1h, x0h:x1h][sub_labels == 2] = 2 log("[writeback] labels written to full volume") - # Enforce single CC per label - if enforce_single_cc: - keptA, movedA = _enforce_single_component( - out_zyx, 1, A, allow3=allow_third_label - ) - keptB, movedB = _enforce_single_component( - out_zyx, 2, B, allow3=allow_third_label - ) - log( - f"[single-cc] label1 kept {keptA}, moved {movedA} -> 3; label2 kept {keptB}, moved {movedB} -> 3" - ) - - # Resolve 3-touching - moved1, moved2 = _resolve_label3_touching_vectorized(out_zyx, A, B, sampling) - if moved1 or moved2: + # Enforce single CC per label (full res). The upsampled labeling can + # fragment under the foreground mask, so enforcement must run here, + # not on the DS grid. The label-3 resolution inside uses + # cc3d.contacts for adjacency instead of two full-volume dilations. + with _prof.profile("enforce_cc"): if enforce_single_cc: - keptA, movedA = _enforce_single_component( - out_zyx, 1, A, allow3=allow_third_label - ) - keptB, movedB = _enforce_single_component( - out_zyx, 2, B, allow3=allow_third_label - ) + with _prof.profile("label1"): + keptA, movedA = _enforce_single_component( + out_zyx, 1, A, allow3=allow_third_label + ) + with _prof.profile("label2"): + keptB, movedB = _enforce_single_component( + out_zyx, 2, B, allow3=allow_third_label + ) log( - f"[single-cc 2nd] label1 kept {keptA}, moved {movedA}; label2 kept {keptB}, moved {movedB}" + f"[single-cc] label1 kept {keptA}, moved {movedA} -> 3; label2 kept {keptB}, moved {movedB} -> 3" ) + with _prof.profile("resolve3"): + moved1, moved2 = _resolve_label3_touching_vectorized( + out_zyx, A, B, sampling + ) + if moved1 or moved2: + if enforce_single_cc: + keptA, movedA = _enforce_single_component( + out_zyx, 1, A, allow3=allow_third_label + ) + keptB, movedB = _enforce_single_component( + out_zyx, 2, B, allow3=allow_third_label + ) + log( + f"[single-cc 2nd] label1 kept {keptA}, moved {movedA}; label2 kept {keptB}, moved {movedB}" + ) + # Final check for lab in (1, 2): _, ncomp = _cc_label_26(out_zyx == lab) diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index 49c0692a5..d1c404f15 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -356,31 +356,28 @@ def update_edges( new_ids = np.array(list(set.union(*old_new_map.values())), dtype=basetypes.NODE_ID) t0 = time.time() - with _prof.profile("get_subgraph"): - _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) - edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) + _, edges_tuple = cg.get_subgraph(root_id, bbox, bbox_is_coordinate=True) + edges_ = reduce(lambda x, y: x + y, edges_tuple, Edges([], [])) logger.note( f"get_subgraph {len(edges_.get_pairs())} edges ({time.time() - t0:.2f}s)" ) - with _prof.profile("edge_dedup"): - edges = edges_.get_pairs() - affinities = edges_.affinities - areas = edges_.areas + edges = edges_.get_pairs() + affinities = edges_.affinities + areas = edges_.areas - edges = np.sort(edges, axis=1) - _, edges_idx = np.unique(edges, axis=0, return_index=True) - edges_idx = edges_idx[edges[edges_idx, 0] != edges[edges_idx, 1]] + edges = np.sort(edges, axis=1) + _, edges_idx = np.unique(edges, axis=0, return_index=True) + edges_idx = edges_idx[edges[edges_idx, 0] != edges[edges_idx, 1]] - edges = edges[edges_idx] - affinities = affinities[edges_idx] - areas = areas[edges_idx] + edges = edges[edges_idx] + affinities = affinities[edges_idx] + areas = areas[edges_idx] t0 = time.time() - with _prof.profile("get_roots_inner"): - all_edge_svs = np.unique(edges) - all_roots = cg.get_roots(all_edge_svs) - sv_root_map = dict(zip(all_edge_svs, all_roots)) + all_edge_svs = np.unique(edges) + all_roots = cg.get_roots(all_edge_svs) + sv_root_map = dict(zip(all_edge_svs, all_roots)) logger.note(f"get_roots {len(all_edge_svs)} svs ({time.time() - t0:.2f}s)") # Coords are only ever read for new fragment ids (kdtrees) and for @@ -399,26 +396,24 @@ def update_edges( wanted_labels = np.union1d(new_ids, all_edge_svs) fastremap.mask_except(new_seg, list(wanted_labels), in_place=True) coords_by_label = build_coords_by_label(new_seg) - with _prof.profile("kdtrees"): - new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] + new_kdtrees = [cKDTree(coords_by_label[int(k)]) for k in new_ids] logger.note( f"build_coords {len(coords_by_label)} labels, {len(new_ids)} fragment trees ({time.time() - t0:.2f}s)" ) t0 = time.time() - with _prof.profile("_get_new_edges"): - result = _get_new_edges( - (edges, affinities, areas), - old_new_map, - coords_by_label, - root_id, - sv_root_map, - cg, - new_kdtrees, - new_ids, - new_id_label_map, - threshold=cg.meta.sv_split_threshold, - ) + result = _get_new_edges( + (edges, affinities, areas), + old_new_map, + coords_by_label, + root_id, + sv_root_map, + cg, + new_kdtrees, + new_ids, + new_id_label_map, + threshold=cg.meta.sv_split_threshold, + ) logger.note(f"_get_new_edges {result[0].shape} ({time.time() - t0:.2f}s)") validate_split_edges(result[0], result[1], old_new_map, new_id_label_map) diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index e07a20992..bc09c14c7 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -479,10 +479,9 @@ def split_supervoxel( chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) t0 = time.time() - with _prof.profile("chunk_updates"): - results, change_chunks = _update_chunks( - cg, chunks_bbox_map, seg[voxel_overlap_crop], split_result, bbs - ) + results, change_chunks = _update_chunks( + cg, chunks_bbox_map, seg[voxel_overlap_crop], split_result, bbs + ) logger.note( f"chunk updates {len(chunks_bbox_map)} chunks, " f"{len(change_chunks)} with splits ({time.time() - t0:.2f}s)" @@ -508,15 +507,14 @@ def split_supervoxel( root = sv_root_map[sv_id] logger.note(f"{sv_id} -> {root}") - with _prof.profile("remap_to_root"): - # Zero out every label whose root != `root`, in place. The - # prior implementation materialized a full-size shadow array - # via fastremap.remap(in_place=False) just to compare against - # root; mask_except achieves the same filter at C speed - # without the shadow allocation. - root_labels = [int(sv) for sv, r in sv_root_map.items() if r == root] - fastremap.mask_except(seg, root_labels, in_place=True) - seg[voxel_overlap_crop] = new_seg + # Zero out every label whose root != `root`, in place. The prior + # implementation materialized a full-size shadow array via + # fastremap.remap(in_place=False) just to compare against root; + # mask_except achieves the same filter at C speed without the + # shadow allocation. + root_labels = [int(sv) for sv, r in sv_root_map.items() if r == root] + fastremap.mask_except(seg, root_labels, in_place=True) + seg[voxel_overlap_crop] = new_seg t0 = time.time() with _prof.profile("update_edges"): edges_tuple = update_edges( @@ -529,12 +527,10 @@ def split_supervoxel( ) logger.note(f"edge update ({time.time() - t0:.2f}s)") - with _prof.profile("copy_parents"): - rows0 = copy_parents_and_add_lineage( - cg, operation_id, old_new_map, time_stamp=time_stamp - ) - with _prof.profile("add_new_edges"): - rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) + rows0 = copy_parents_and_add_lineage( + cg, operation_id, old_new_map, time_stamp=time_stamp + ) + rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) rows = rows0 + rows1 # Prepare per-chunk OCDBT write payloads. The caller batches these diff --git a/pychunkedgraph/tests/graph/test_cutting_sv.py b/pychunkedgraph/tests/graph/test_cutting_sv.py index e0964fcd6..a165a415c 100644 --- a/pychunkedgraph/tests/graph/test_cutting_sv.py +++ b/pychunkedgraph/tests/graph/test_cutting_sv.py @@ -742,6 +742,31 @@ def test_with_downsample_geodesic(self): assert np.any(result == 1) assert np.any(result == 2) + def test_single_cc_guarantee_with_downsample(self): + """Each output label must remain a single connected component + after the DS-grid enforce_cc rewrite. raise_if_multi_cc=True so + the function's own guard trips if the guarantee is broken.""" + mask, seeds_a, seeds_b = _make_dumbbell_mask(shape=(20, 30, 30)) + result = split_supervoxel_growing( + mask, + seeds_a, + seeds_b, + voxel_size=(1.0, 1.0, 1.0), + vol_order="zyx", + vox_order="zyx", + seed_order="zyx", + downsample_geodesic=(1, 2, 2), + enforce_single_cc=True, + raise_if_multi_cc=True, + snap_kwargs=dict(use_boundary=False, downsample=False), + verbose=False, + ) + assert np.any(result == 1) + assert np.any(result == 2) + for lab in (1, 2): + _, ncomp = _cc_label_26(result == lab) + assert ncomp == 1, f"label {lab} split into {ncomp} components" + # ============================================================ # Tests: connect_both_seeds_via_ridge From 568a375b5627218fab82f0813e1aedb97d033ecb Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 28 May 2026 15:07:06 +0000 Subject: [PATCH 268/367] fix(sv-split): pin get_roots reads to parent_ts for deterministic replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SV-split path called cg.get_roots() without a timestamp in both split_supervoxel (for building sv_root_map) and update_edges (for mapping edge SVs to roots). Every other operation in the codebase pins these reads to parent_ts — the pre-op read snapshot passed to execute() — so a stuck-op replay sees the same graph state as the original attempt. Threads parent_ts from MulticutOperation._apply() → split_supervoxels → split_supervoxel → update_edges, and passes it to both get_roots calls. Normal first-attempt runs pass parent_ts=None, which resolves to current time as before; only replay paths (stuck_ops.replay) supply a pinned snapshot. --- pychunkedgraph/graph/edges_sv.py | 3 ++- pychunkedgraph/graph/edits_sv.py | 6 +++++- pychunkedgraph/graph/operation.py | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/edges_sv.py index d1c404f15..dd53dd5ac 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/edges_sv.py @@ -350,6 +350,7 @@ def update_edges( new_seg: np.ndarray, old_new_map: dict, new_id_label_map: dict = None, + parent_ts: datetime = None, ): old_new_map = dict(old_new_map) _prof = get_profiler() @@ -376,7 +377,7 @@ def update_edges( t0 = time.time() all_edge_svs = np.unique(edges) - all_roots = cg.get_roots(all_edge_svs) + all_roots = cg.get_roots(all_edge_svs, time_stamp=parent_ts) sv_root_map = dict(zip(all_edge_svs, all_roots)) logger.note(f"get_roots {len(all_edge_svs)} svs ({time.time() - t0:.2f}s)") diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/edits_sv.py index bc09c14c7..7079022d6 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/edits_sv.py @@ -246,6 +246,7 @@ def split_supervoxels( sink_ids: np.ndarray, operation_id: int, timestamp: datetime = None, + parent_ts: datetime = None, ) -> SplitResult: """Pure planner for the SV-split step. Returns a `SplitResult` with all the data the caller needs to persist under locks. @@ -288,6 +289,7 @@ def split_supervoxels( operation_id, sv_remapping=sv_remapping, time_stamp=timestamp, + parent_ts=parent_ts, ) seg_bboxes.append(out.seg_bbox) source_ids_fresh[task.src_mask] = out.src_new_ids @@ -392,6 +394,7 @@ def split_supervoxel( *, sv_remapping: dict, time_stamp: datetime = None, + parent_ts: datetime = None, verbose: bool = False, ) -> SvSplitOutcome: """Split one cross-chunk-connected SV into connected components. @@ -502,7 +505,7 @@ def split_supervoxel( with _prof.profile("get_roots"): # sv_ids reused from the seg_unique block above (seg is not # mutated between then and here). - roots = cg.get_roots(sv_ids) + roots = cg.get_roots(sv_ids, time_stamp=parent_ts) sv_root_map = dict(zip(sv_ids, roots)) root = sv_root_map[sv_id] logger.note(f"{sv_id} -> {root}") @@ -524,6 +527,7 @@ def split_supervoxel( seg, old_new_map, new_id_label_map, + parent_ts=parent_ts, ) logger.note(f"edge update ({time.time() - t0:.2f}s)") diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index da95dc31c..549562ab0 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -963,6 +963,7 @@ def _apply( sink_ids=self.sink_ids, operation_id=operation_id, timestamp=timestamp, + parent_ts=self.parent_ts, ) with locks.IndefiniteL2ChunkLock( self.cg, From 6cd5e2e021327a9dc57c656aa8c70cb014fef0ed Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 28 May 2026 20:30:48 +0000 Subject: [PATCH 269/367] refactor: consolidate sv-split into a package with co-located docs Group the supervoxel-split modules under graph/sv_split/ (cutting, edges, edits, profile, debug) and move the five splitting design docs into the same package as README/algorithm/design/edges/recovery, so the split flow and its documentation live behind one boundary instead of being scattered across graph/, debug/, and docs/. sv_split/__init__ exposes only cutting/edges/edits; profile and debug stay out of it to keep their app-layer (Flask) and dry-run deps off the core import path. Promote the hierarchical profiler out of debug/ into a standalone profiler/ package: hierarchical (HierarchicalProfiler, BlockMetrics, RSS sampler), utils (formatters), main (global instance + accessors). operation.py calls plan_sv_splits / split_supervoxels via the sv_split.edits module object rather than a direct symbol import, so profile.py's capture wrappers patch the live attribute at call time. Repoint stuck_ops.py's docstring at the relocated recovery doc. --- pychunkedgraph/app/segmentation/common.py | 2 +- pychunkedgraph/graph/edits.py | 2 +- pychunkedgraph/graph/ocdbt/main.py | 2 +- pychunkedgraph/graph/operation.py | 6 +- .../graph/sv_split/README.md | 13 +++- pychunkedgraph/graph/sv_split/__init__.py | 5 ++ pychunkedgraph/graph/sv_split/algorithm.md | 67 +++++++++++++++++++ .../{cutting_sv.py => sv_split/cutting.py} | 2 +- .../sv_split.py => graph/sv_split/debug.py} | 10 ++- pychunkedgraph/graph/sv_split/design.md | 64 ++++++++++++++++++ .../graph/sv_split/edges.md | 11 ++- .../graph/{edges_sv.py => sv_split/edges.py} | 4 +- .../graph/{edits_sv.py => sv_split/edits.py} | 6 +- .../sv_split/profile.py} | 21 +++--- .../graph/sv_split/recovery.md | 7 ++ pychunkedgraph/profiler/__init__.py | 6 ++ .../profiler.py => profiler/hierarchical.py} | 52 +------------- pychunkedgraph/profiler/main.py | 21 ++++++ pychunkedgraph/profiler/utils.py | 29 ++++++++ pychunkedgraph/repair/stuck_ops.py | 4 +- pychunkedgraph/tests/graph/test_cutting_sv.py | 4 +- pychunkedgraph/tests/graph/test_edges_sv.py | 4 +- pychunkedgraph/tests/graph/test_edits_sv.py | 4 +- pychunkedgraph/tests/graph/test_ocdbt.py | 2 +- 24 files changed, 253 insertions(+), 95 deletions(-) rename docs/sv_splitting.md => pychunkedgraph/graph/sv_split/README.md (95%) create mode 100644 pychunkedgraph/graph/sv_split/__init__.py create mode 100644 pychunkedgraph/graph/sv_split/algorithm.md rename pychunkedgraph/graph/{cutting_sv.py => sv_split/cutting.py} (99%) rename pychunkedgraph/{debug/sv_split.py => graph/sv_split/debug.py} (98%) create mode 100644 pychunkedgraph/graph/sv_split/design.md rename docs/sv_splitting_edges.md => pychunkedgraph/graph/sv_split/edges.md (97%) rename pychunkedgraph/graph/{edges_sv.py => sv_split/edges.py} (99%) rename pychunkedgraph/graph/{edits_sv.py => sv_split/edits.py} (99%) rename pychunkedgraph/{debug/split_profile.py => graph/sv_split/profile.py} (95%) rename docs/sv_splitting_recovery.md => pychunkedgraph/graph/sv_split/recovery.md (98%) create mode 100644 pychunkedgraph/profiler/__init__.py rename pychunkedgraph/{debug/profiler.py => profiler/hierarchical.py} (90%) create mode 100644 pychunkedgraph/profiler/main.py create mode 100644 pychunkedgraph/profiler/utils.py diff --git a/pychunkedgraph/app/segmentation/common.py b/pychunkedgraph/app/segmentation/common.py index 978b8f331..6ca817420 100644 --- a/pychunkedgraph/app/segmentation/common.py +++ b/pychunkedgraph/app/segmentation/common.py @@ -27,7 +27,7 @@ ) from pychunkedgraph.graph.analysis import pathing from pychunkedgraph.graph.misc import get_contact_sites -from pychunkedgraph.debug.sv_split import check_unsplit_sv_bridges +from pychunkedgraph.graph.sv_split.debug import check_unsplit_sv_bridges from pychunkedgraph.graph.operation import GraphEditOperation from pychunkedgraph.graph import basetypes from pychunkedgraph.meshing import mesh_analysis diff --git a/pychunkedgraph/graph/edits.py b/pychunkedgraph/graph/edits.py index 779743740..dc04c86a7 100644 --- a/pychunkedgraph/graph/edits.py +++ b/pychunkedgraph/graph/edits.py @@ -12,7 +12,7 @@ import numpy as np from pychunkedgraph import get_logger -from pychunkedgraph.debug.profiler import HierarchicalProfiler, get_profiler +from pychunkedgraph.profiler import HierarchicalProfiler, get_profiler from . import types from pychunkedgraph.graph import attributes diff --git a/pychunkedgraph/graph/ocdbt/main.py b/pychunkedgraph/graph/ocdbt/main.py index f4e56ebcb..f24ad6a74 100644 --- a/pychunkedgraph/graph/ocdbt/main.py +++ b/pychunkedgraph/graph/ocdbt/main.py @@ -553,7 +553,7 @@ def propagate_to_coarser_scales(dst_scales, resolutions, base_slices): def write_seg_chunks(meta, seg_writes): """Write a flat batch of pre-sliced L2 chunks to OCDBT in parallel. - ``seg_writes`` is the aggregated output of ``edits_sv.split_supervoxels`` + ``seg_writes`` is the aggregated output of ``sv_split.edits.split_supervoxels`` across every rep in an operation — each pair is one L2 chunk's worth of ``(voxel_slices, data)``. Flattening across reps matters: one ``write_seg_chunks`` call fires every chunk write in one parallel diff --git a/pychunkedgraph/graph/operation.py b/pychunkedgraph/graph/operation.py index 549562ab0..7b5d72777 100644 --- a/pychunkedgraph/graph/operation.py +++ b/pychunkedgraph/graph/operation.py @@ -21,7 +21,7 @@ from . import locks from . import edits -from . import edits_sv +from . import sv_split from . import types from .ocdbt import write_seg_chunks from .dry_run import is_dry_run @@ -941,7 +941,7 @@ def _apply( # only. A worker death inside it leaves the indefinite # cell set on every chunk row in scope, blocking future # ops until operator replay clears them. - tasks, chunk_ids = edits_sv.plan_sv_splits( + tasks, chunk_ids = sv_split.edits.plan_sv_splits( self.cg, sv_remapping=result.sv_remapping, source_ids=self.source_ids, @@ -955,7 +955,7 @@ def _apply( operation_id, privileged_mode=self.privileged_mode, ): - sv_result = edits_sv.split_supervoxels( + sv_result = sv_split.edits.split_supervoxels( self.cg, tasks=tasks, sv_remapping=result.sv_remapping, diff --git a/docs/sv_splitting.md b/pychunkedgraph/graph/sv_split/README.md similarity index 95% rename from docs/sv_splitting.md rename to pychunkedgraph/graph/sv_split/README.md index d6b56a94b..0eae7565f 100644 --- a/docs/sv_splitting.md +++ b/pychunkedgraph/graph/sv_split/README.md @@ -115,11 +115,11 @@ The split flow closes both: For each cross-chunk representative being split, take the envelope of the chunks its cross-chunk-connected pieces live in — the exact chunks whose voxels will be rewritten. Expand by one voxel (= at most one L2 chunk of margin in each direction), because the edge-routing step reads a 1-voxel shell outside the rewritten region to see neighboring supervoxels' labels. Union the per-representative chunk sets, sort deterministically so workers with overlapping sets never acquire in opposite orders, lock once. -The envelope comes from the supervoxels' own chunk coordinates — no coordinate padding, no resolution-axis assumption. The chunks locked are exactly the chunks the split will touch, plus the 1-chunk margin the shell read requires. +The chunks locked are exactly the chunks the split will touch, plus the 1-chunk margin the shell read requires. ### How the write scope is kept minimal -Only chunks that actually receive new supervoxel IDs get written to storage. Gap chunks that happen to sit inside an envelope but contain no cross-chunk-connected pieces, and neighbor chunks read only for the edge-routing shell, are never written. The segmentation backend is append-only, so writing unchanged bytes would inflate the on-disk delta for no real change. Writing exactly the changed chunks keeps the delta proportional to the user's edit. +Only chunks that actually receive new supervoxel IDs get written to storage. Gap chunks that happen to sit inside an envelope but contain no cross-chunk-connected pieces, and neighbor chunks read only for the edge-routing shell, are never written. The segmentation backend is append-only, so writing unchanged bytes would inflate the on-disk delta for no real change. ### Why the post-split ID refresh is safe without an extra read @@ -129,7 +129,7 @@ The in-memory segmentation block produced by the split is bitwise identical to w ### Worker crash mid-write -A worker that dies — or raises from the persist block — inside the indefinite L2 chunk lock's scope leaves the lock cells set and the op-log row's `L2ChunkLockScope` populated with the exact chunks being written. Future ops on any of those chunks refuse to start — the crashed state is isolated, not amplified. An operator runs the recovery flow described in [sv_splitting_recovery.md](sv_splitting_recovery.md) to revert the partial writes and replay the op. +A worker that dies — or raises from the persist block — inside the indefinite L2 chunk lock's scope leaves the lock cells set and the op-log row's `L2ChunkLockScope` populated with the exact chunks being written. Future ops on any of those chunks refuse to start — the crashed state is isolated, not amplified. An operator runs the recovery flow described in [recovery.md](recovery.md) to revert the partial writes and replay the op. ## Invariants @@ -138,3 +138,10 @@ A worker that dies — or raises from the persist block — inside the indefinit - Supervoxel-level writes touch only chunks whose voxels actually changed. Gap chunks between cross-chunk-connected pieces and neighbor chunks read for edge routing are untouched. - After the commit, readers at the operation's timestamp see new supervoxel IDs in the cut region and new roots reflecting the cut. - Coarser MIP levels are eventually consistent with the base scale, lagging at most until the async downsample worker processes the operation's pubsub message. + +## Related docs + +- [Algorithm](algorithm.md) — the voxel-level geodesic cut. +- [Design](design.md) — rationale behind the cut. +- [Edges](edges.md) — edge re-routing after the cut. +- [Recovery](recovery.md) — replay / recovery of interrupted splits. diff --git a/pychunkedgraph/graph/sv_split/__init__.py b/pychunkedgraph/graph/sv_split/__init__.py new file mode 100644 index 000000000..7067100af --- /dev/null +++ b/pychunkedgraph/graph/sv_split/__init__.py @@ -0,0 +1,5 @@ +""" +Supervoxel splitting. +""" + +from . import cutting, edges, edits diff --git a/pychunkedgraph/graph/sv_split/algorithm.md b/pychunkedgraph/graph/sv_split/algorithm.md new file mode 100644 index 000000000..8c383829d --- /dev/null +++ b/pychunkedgraph/graph/sv_split/algorithm.md @@ -0,0 +1,67 @@ +# SV splitting — voxel-level cut algorithm + +This is the algorithmic core of SV splitting: given the voxels of one +supervoxel and two cut seeds, partition every voxel into a *source* side and a +*sink* side. It lives in `graph/sv_split/cutting.py` (`split_supervoxel`, +`enforce_cc`) plus the coord extraction helper `build_coords_by_label`. + +The cut is a **geodesic region grow**, not a voxel graph-mincut: each voxel is +assigned to whichever seed is nearer in geodesic (anisotropy-aware) travel +cost across the supervoxel's interior. + +## Inputs + +- `coords` — `(N, 3)` global voxel coordinates of the supervoxel. +- `seed_source`, `seed_sink` — global-voxel coordinates of the two cut seeds. +- `resolution` — voxel size in nm `(x, y, z)`; supplies anisotropy. +- `voxel_offset` — volume origin (chunk corner). + +## Steps + +1. **ROI restriction.** Compute the foreground bounding box from `coords` and + work inside it only. A dense occupancy volume is allocated over the ROI, not + over the whole chunk, so cost scales with foreground extent. +2. **Seed snapping.** Build a KDTree over the foreground voxel coordinates and + snap each seed to its nearest occupied voxel. Seeds need not lie exactly on + a foreground voxel. +3. **EDT → speed / travel-cost map.** A Euclidean distance transform (sampled + by `resolution`) gives each voxel its distance to the boundary. Speed favors + the interior; geodesic travel cost is `1 / speed`, so paths hug the medial + region rather than skimming the surface. +4. **Geodesic arrival.** `MCP_Geometric` (with `sampling=resolution`, so the + metric is anisotropy-aware) computes arrival cost from each seed to every + voxel. Each voxel is assigned to the side whose seed it reaches more cheaply. +5. **Narrow-band proximity boost.** Voxels within `narrow_band_width` of the + opposing side receive a cost boost (`proximity_boost`) so the boundary + tracks the geometric midline more closely. +6. **Optional downsampled grid.** When `downsample` is set, the geodesic grow + runs on a downsampled grid and is upsampled back. Upsampling happens *before* + single-CC enforcement, because upsampling under the foreground mask can + fragment a label into disconnected pieces. +7. **Single-CC enforcement** (`enforce_cc`, when `enforce_single_cc=True`). + For each side, keep the largest / seeded 26-connected component; relabel + stray components to a transient label `3`. Each label-`3` component is then + dilated **within its own bounding box** and reassigned to side 1 or 2 by + which side it borders more, with EDT distance as the tie-break. Confining + the dilation to the component's bbox is exact: per-component border counts + are identical to a full-volume dilation. + +## The single-CC invariant + +`_update_chunks` (`graph/sv_split/edits.py`) assigns **exactly one new +supervoxel id per distinct label value per chunk** and performs **no connected +-component analysis of its own**. It relies entirely on the cut having already +produced single-CC labels. Therefore every output label out of `enforce_cc` +**must already be a single connected component** — if a side were left in two +pieces, both pieces would collapse into one new supervoxel id and the graph +would gain a spuriously-connected supervoxel. + +This is why single-CC is enforced at full resolution and why label-`3` strays +are resolved rather than dropped. + +## Related docs + +- [Overview](README.md) — where this fits in the edit pipeline. +- [Design](design.md) — rationale for the choices above. +- [Edges](edges.md) — edge re-routing after the cut. +- [Recovery](recovery.md) — replay-safety of splits. diff --git a/pychunkedgraph/graph/cutting_sv.py b/pychunkedgraph/graph/sv_split/cutting.py similarity index 99% rename from pychunkedgraph/graph/cutting_sv.py rename to pychunkedgraph/graph/sv_split/cutting.py index 74928c68d..2b633ddc2 100644 --- a/pychunkedgraph/graph/cutting_sv.py +++ b/pychunkedgraph/graph/sv_split/cutting.py @@ -20,7 +20,7 @@ ball, ) # keep only ball; use ndi.binary_dilation everywhere -from pychunkedgraph.debug.profiler import get_profiler +from pychunkedgraph.profiler import get_profiler # ---------- Fast CC wrappers ---------- try: diff --git a/pychunkedgraph/debug/sv_split.py b/pychunkedgraph/graph/sv_split/debug.py similarity index 98% rename from pychunkedgraph/debug/sv_split.py rename to pychunkedgraph/graph/sv_split/debug.py index 8caca0fc2..ba6fe51a5 100644 --- a/pychunkedgraph/debug/sv_split.py +++ b/pychunkedgraph/graph/sv_split/debug.py @@ -5,10 +5,10 @@ import numpy as np import fastremap -from ..app.app_utils import handle_supervoxel_id_lookup -from ..graph import attributes -from ..graph.chunkedgraph import ChunkedGraph -from ..graph.edges import Edges +from pychunkedgraph.app.app_utils import handle_supervoxel_id_lookup +from pychunkedgraph.graph import attributes +from pychunkedgraph.graph.chunkedgraph import ChunkedGraph +from pychunkedgraph.graph.edges import Edges def get_subgraph_edges(cg: ChunkedGraph, root_id, bbox): @@ -339,8 +339,6 @@ def trace_stale_sv(cg: ChunkedGraph, sv_id, bbox=None, root_id=None): )[0] chunk_ids = np.unique(cg.get_chunk_ids_from_node_ids(l2ids)) - from ..io.edges import get_chunk_edges - chunk_edges_d = cg.read_chunk_edges(chunk_ids) chunk_edges_all = reduce( lambda x, y: x + y, chunk_edges_d.values(), Edges([], []) diff --git a/pychunkedgraph/graph/sv_split/design.md b/pychunkedgraph/graph/sv_split/design.md new file mode 100644 index 000000000..c44273f63 --- /dev/null +++ b/pychunkedgraph/graph/sv_split/design.md @@ -0,0 +1,64 @@ +# SV splitting — design rationale + +Why the [cut](algorithm.md) is shaped the way it is. + +## Geodesic region-grow, not a voxel graph-mincut + +The split assigns each voxel to the nearer seed by geodesic travel cost rather +than solving a min-cut on a voxel adjacency graph. A region grow over an +anisotropy-aware geodesic metric follows the object's medial geometry and +produces a smooth boundary near the midline, without building and cutting a +large per-voxel graph for every split. + +## Snap seeds to the boundary + +Cut seeds come from operator clicks / mincut output and need not land exactly +on a foreground voxel. Snapping each seed (via KDTree) to the nearest occupied +voxel makes the grow well-defined regardless of where the seed falls, and keeps +the two arrival fields rooted inside the object. + +## Single-CC enforced at full resolution + +Each output side must be a single connected component because the downstream +writer assigns one new supervoxel id per label and does no CC of its own (see +the [single-CC invariant](algorithm.md#the-single-cc-invariant)). +Enforcement runs at full resolution: if the geodesic grow is done on a +downsampled grid, the result is upsampled *first*, because upsampling under the +foreground mask can fragment a label into disconnected pieces — enforcing CC +before that would let the fragments through. + +## Resolve strays, don't drop them + +Small components cut off from a seeded body are relabeled to a transient label +and reassigned to whichever side they border more (EDT tie-break) rather than +discarded. Dropping voxels would lose mass; merging blindly could bridge the +two sides. Border-count reassignment keeps every voxel while respecting the +cut. + +## Label-3 dilation confined to its bounding box + +Reassigning a stray component only needs its local border with each side, so +the dilation is run inside the component's own bounding box rather than over +the whole volume. The per-component border counts are identical either way, so +this is an exact optimization, not an approximation. + +## Single-pass coord extraction, in-place masking + +`build_coords_by_label` groups voxels by label in a single `np.unique` pass and +masks in place, instead of allocating a volume-sized boolean array per label. +The cut likewise works inside the foreground bounding box. Both choices bound +work to the foreground extent rather than the chunk volume. + +## Reads pinned to `parent_ts` + +All graph reads during a split are pinned to the operation's `parent_ts` so a +replay sees the same graph state and allocates the same new supervoxel ids. +This is what makes interrupted splits safe to re-run (see +[Recovery](recovery.md)). + +## Related docs + +- [Overview](README.md) +- [Algorithm](algorithm.md) +- [Edges](edges.md) +- [Recovery](recovery.md) diff --git a/docs/sv_splitting_edges.md b/pychunkedgraph/graph/sv_split/edges.md similarity index 97% rename from docs/sv_splitting_edges.md rename to pychunkedgraph/graph/sv_split/edges.md index d19bc08e8..1552bd3a6 100644 --- a/docs/sv_splitting_edges.md +++ b/pychunkedgraph/graph/sv_split/edges.md @@ -14,7 +14,7 @@ inputs from voxel-level split ├─ old_new_map which old SVs got split, and into which new IDs └─ new_id_label_map for each new ID, which side of the cut it's on -update_edges (edges_sv.py): +update_edges (sv_split/edges.py): 1. fetch atomic subgraph inside bbox, rooted at the rep's root 2. dedupe edges, drop self-loops 3. group by partner-root vs split-root → active / inactive @@ -25,7 +25,7 @@ update_edges (edges_sv.py): 5. validate (no cross-label inf bridges, no self-loops, completeness) 6. return new (edges, affinities, areas) -add_new_edges (edges_sv.py): +add_new_edges (sv_split/edges.py): 1. duplicate bidirectional, group by L2 parent chunk 2. per chunk: append to SplitEdges (history) and rewrite CompactedSplitEdges (snapshot, with stale rows filtered) @@ -130,3 +130,10 @@ Both writes use `time_stamp=task.operation_ts`, so all rows from one op land at - No inf-affinity edge crosses cut-sides through an unsplit partner. - Every cross-chunk piece of the rep that the bbox didn't include keeps its old ID and its existing edges resolve unchanged (because no edge in those rows references the now-split SVs at endpoints — the routing only touches edges whose endpoints are in the bbox or its 1-voxel shell). - `SplitEdges` and `CompactedSplitEdges` agree at the latest timestamp: the compacted snapshot is the result of replaying the history through the stale-edge filter. + +## Related docs + +- [Overview](README.md) +- [Algorithm](algorithm.md) — the cut that produces the labeling this step consumes. +- [Design](design.md) +- [Recovery](recovery.md) diff --git a/pychunkedgraph/graph/edges_sv.py b/pychunkedgraph/graph/sv_split/edges.py similarity index 99% rename from pychunkedgraph/graph/edges_sv.py rename to pychunkedgraph/graph/sv_split/edges.py index dd53dd5ac..2d49af158 100644 --- a/pychunkedgraph/graph/edges_sv.py +++ b/pychunkedgraph/graph/sv_split/edges.py @@ -38,11 +38,11 @@ import numpy as np from pychunkedgraph import get_logger -from pychunkedgraph.debug.profiler import get_profiler +from pychunkedgraph.profiler import get_profiler from pychunkedgraph.graph import attributes, basetypes, serializers from pychunkedgraph.graph.exceptions import PostconditionError from scipy.spatial import cKDTree -from pychunkedgraph.graph.cutting_sv import build_coords_by_label +from .cutting import build_coords_by_label from pychunkedgraph.graph.edges import Edges if TYPE_CHECKING: diff --git a/pychunkedgraph/graph/edits_sv.py b/pychunkedgraph/graph/sv_split/edits.py similarity index 99% rename from pychunkedgraph/graph/edits_sv.py rename to pychunkedgraph/graph/sv_split/edits.py index 7079022d6..3b458f615 100644 --- a/pychunkedgraph/graph/edits_sv.py +++ b/pychunkedgraph/graph/sv_split/edits.py @@ -12,7 +12,7 @@ import numpy as np from pychunkedgraph import get_logger -from pychunkedgraph.debug.profiler import get_profiler +from pychunkedgraph.profiler import get_profiler from pychunkedgraph.graph import ( attributes, cache as cache_utils, @@ -20,8 +20,8 @@ serializers, ) from pychunkedgraph.graph.chunks.utils import chunks_overlapping_bbox -from pychunkedgraph.graph.cutting_sv import split_supervoxel_helper -from pychunkedgraph.graph.edges_sv import update_edges, add_new_edges +from .cutting import split_supervoxel_helper +from .edges import update_edges, add_new_edges from pychunkedgraph.graph.utils import get_local_segmentation if TYPE_CHECKING: diff --git a/pychunkedgraph/debug/split_profile.py b/pychunkedgraph/graph/sv_split/profile.py similarity index 95% rename from pychunkedgraph/debug/split_profile.py rename to pychunkedgraph/graph/sv_split/profile.py index 15dc14279..15092477c 100644 --- a/pychunkedgraph/debug/split_profile.py +++ b/pychunkedgraph/graph/sv_split/profile.py @@ -26,11 +26,8 @@ import numpy as np from pychunkedgraph.app.segmentation.common import _get_sources_and_sinks -from pychunkedgraph.debug.profiler import ( - HierarchicalProfiler, - get_profiler, -) -from pychunkedgraph.graph import edits_sv +from pychunkedgraph.profiler import HierarchicalProfiler, get_profiler +from . import edits from pychunkedgraph.graph import utils as _utils_pkg from pychunkedgraph.graph.dry_run import dry_run_scope from pychunkedgraph.graph.operation import Cut, MulticutOperation, SvSplitRequired @@ -115,7 +112,7 @@ def wrap_read_log(*a, **k): # SV-split flow. The source module is _utils_generic; the others # imported it by name at module load time, so they hold separate # references that need their own swap. - seg_modules = [_utils_generic, _utils_pkg, edits_sv, _utils_id_helpers] + seg_modules = [_utils_generic, _utils_pkg, edits, _utils_id_helpers] orig_seg_fns = {m: m.get_local_segmentation for m in seg_modules} def wrap_get_local_seg(meta, bbox_start, bbox_end, mip=0): @@ -276,8 +273,8 @@ def run_split_profile( # blocks. The real per-step metrics come from inline profile() # blocks inside the called functions. orig_run_multicut = MulticutOperation._run_multicut - orig_plan_sv_splits = edits_sv.plan_sv_splits - orig_split_supervoxels = edits_sv.split_supervoxels + orig_plan_sv_splits = edits.plan_sv_splits + orig_split_supervoxels = edits.split_supervoxels mincut_call_count = [0] @@ -305,8 +302,8 @@ def wrap_split_supervoxels(*a, **k): return result MulticutOperation._run_multicut = wrap_run_multicut - edits_sv.plan_sv_splits = wrap_plan_sv_splits - edits_sv.split_supervoxels = wrap_split_supervoxels + edits.plan_sv_splits = wrap_plan_sv_splits + edits.split_supervoxels = wrap_split_supervoxels try: op.execute() @@ -317,8 +314,8 @@ def wrap_split_supervoxels(*a, **k): ) finally: MulticutOperation._run_multicut = orig_run_multicut - edits_sv.plan_sv_splits = orig_plan_sv_splits - edits_sv.split_supervoxels = orig_split_supervoxels + edits.plan_sv_splits = orig_plan_sv_splits + edits.split_supervoxels = orig_split_supervoxels profiler.default_counters = None try: diff --git a/docs/sv_splitting_recovery.md b/pychunkedgraph/graph/sv_split/recovery.md similarity index 98% rename from docs/sv_splitting_recovery.md rename to pychunkedgraph/graph/sv_split/recovery.md index cc9efaa57..1c22e973e 100644 --- a/docs/sv_splitting_recovery.md +++ b/pychunkedgraph/graph/sv_split/recovery.md @@ -64,3 +64,10 @@ If the replay itself fails — for example, the operator's judgment about the wo - Cleanup only touches chunks in the stuck op's scope. Neighbor state and any concurrent ops' changes are preserved byte-for-byte. - The replay sees a consistent world: pre-op values on the stuck op's own chunks (from the cleanup), current state on every other chunk (from the latest manifest). - After successful replay, the op-log row is at `SUCCESS`, all indefinite cells previously held by that op are released, and the affected chunks are available to new ops. The op's original intent — the edit the user asked for — is realized with a fresh set of supervoxel IDs. + +## Related docs + +- [Overview](README.md) +- [Algorithm](algorithm.md) +- [Design](design.md) — why reads are pinned to `parent_ts`. +- [Edges](edges.md) diff --git a/pychunkedgraph/profiler/__init__.py b/pychunkedgraph/profiler/__init__.py new file mode 100644 index 000000000..7db7575f3 --- /dev/null +++ b/pychunkedgraph/profiler/__init__.py @@ -0,0 +1,6 @@ +""" +Hierarchical profiling. +""" + +from .hierarchical import BlockMetrics, HierarchicalProfiler +from .main import PROFILER_ENABLED, get_profiler, reset_profiler diff --git a/pychunkedgraph/debug/profiler.py b/pychunkedgraph/profiler/hierarchical.py similarity index 90% rename from pychunkedgraph/debug/profiler.py rename to pychunkedgraph/profiler/hierarchical.py index 7eccdd8b1..030c23552 100644 --- a/pychunkedgraph/debug/profiler.py +++ b/pychunkedgraph/profiler/hierarchical.py @@ -3,7 +3,6 @@ from typing import Optional from typing import Tuple -import os import threading import time import tracemalloc @@ -13,36 +12,7 @@ import psutil - -def _fmt_time(s: float) -> str: - """Auto-scale seconds → ``12.3 ms`` / ``1.23 s``.""" - if s < 1.0: - return f"{s * 1000:.1f} ms" - return f"{s:.2f} s" - - -def _fmt_bytes(n: int, *, signed: bool = False) -> str: - """Auto-scale bytes (binary) → ``512 B`` / ``1.5 KB`` / ``45.6 MB`` / ``1.23 GB``. - - With ``signed=True``, positive values get a ``+`` prefix (for delta columns). - """ - if signed: - sign = "+" if n > 0 else "-" if n < 0 else "" - else: - sign = "-" if n < 0 else "" - n = abs(int(n)) - if n < 1024: - return f"{sign}{n} B" - if n < 1024**2: - return f"{sign}{n / 1024:.1f} KB" - if n < 1024**3: - return f"{sign}{n / 1024**2:.1f} MB" - return f"{sign}{n / 1024**3:.2f} GB" - - -def _fmt_count(n: int) -> str: - """Thousands-separator integer: ``1234567`` → ``1,234,567``.""" - return f"{int(n):,}" +from .utils import _fmt_time, _fmt_bytes, _fmt_count @dataclass @@ -412,23 +382,3 @@ def reset(self): self.current_path.clear() self.blocks.clear() self._base_perf = None - - -# Global profiler instance - enable via environment variable -PROFILER_ENABLED = os.environ.get("PCG_PROFILER_ENABLED", "0") == "1" -_profiler: HierarchicalProfiler = None - - -def get_profiler() -> HierarchicalProfiler: - """Get or create the global profiler instance.""" - global _profiler - if _profiler is None: - _profiler = HierarchicalProfiler(enabled=PROFILER_ENABLED) - return _profiler - - -def reset_profiler(): - """Reset the global profiler.""" - global _profiler - if _profiler is not None: - _profiler.reset() diff --git a/pychunkedgraph/profiler/main.py b/pychunkedgraph/profiler/main.py new file mode 100644 index 000000000..63661e234 --- /dev/null +++ b/pychunkedgraph/profiler/main.py @@ -0,0 +1,21 @@ +import os + +from .hierarchical import HierarchicalProfiler + +PROFILER_ENABLED = os.environ.get("PCG_PROFILER_ENABLED", "0") == "1" +_profiler: HierarchicalProfiler = None + + +def get_profiler() -> HierarchicalProfiler: + """Get or create the global profiler instance.""" + global _profiler + if _profiler is None: + _profiler = HierarchicalProfiler(enabled=PROFILER_ENABLED) + return _profiler + + +def reset_profiler(): + """Reset the global profiler.""" + global _profiler + if _profiler is not None: + _profiler.reset() diff --git a/pychunkedgraph/profiler/utils.py b/pychunkedgraph/profiler/utils.py new file mode 100644 index 000000000..d2925730c --- /dev/null +++ b/pychunkedgraph/profiler/utils.py @@ -0,0 +1,29 @@ +def _fmt_time(s: float) -> str: + """Auto-scale seconds → ``12.3 ms`` / ``1.23 s``.""" + if s < 1.0: + return f"{s * 1000:.1f} ms" + return f"{s:.2f} s" + + +def _fmt_bytes(n: int, *, signed: bool = False) -> str: + """Auto-scale bytes (binary) → ``512 B`` / ``1.5 KB`` / ``45.6 MB`` / ``1.23 GB``. + + With ``signed=True``, positive values get a ``+`` prefix (for delta columns). + """ + if signed: + sign = "+" if n > 0 else "-" if n < 0 else "" + else: + sign = "-" if n < 0 else "" + n = abs(int(n)) + if n < 1024: + return f"{sign}{n} B" + if n < 1024**2: + return f"{sign}{n / 1024:.1f} KB" + if n < 1024**3: + return f"{sign}{n / 1024**2:.1f} MB" + return f"{sign}{n / 1024**3:.2f} GB" + + +def _fmt_count(n: int) -> str: + """Thousands-separator integer: ``1234567`` → ``1,234,567``.""" + return f"{int(n):,}" diff --git a/pychunkedgraph/repair/stuck_ops.py b/pychunkedgraph/repair/stuck_ops.py index 4f22b508f..2b9064dca 100644 --- a/pychunkedgraph/repair/stuck_ops.py +++ b/pychunkedgraph/repair/stuck_ops.py @@ -16,8 +16,8 @@ IDs, and `IndefiniteL2ChunkLock`'s privileged-mode exit deletes the crashed op's pre-existing cells. -See `docs/sv_splitting_recovery.md` for the full architecture and -correctness argument. +See `pychunkedgraph/graph/sv_split/recovery.md` for the full +architecture and correctness argument. """ import argparse diff --git a/pychunkedgraph/tests/graph/test_cutting_sv.py b/pychunkedgraph/tests/graph/test_cutting_sv.py index a165a415c..41c767747 100644 --- a/pychunkedgraph/tests/graph/test_cutting_sv.py +++ b/pychunkedgraph/tests/graph/test_cutting_sv.py @@ -1,10 +1,10 @@ -"""Tests for pychunkedgraph.graph.cutting_sv""" +"""Tests for pychunkedgraph.graph.sv_split.cutting""" import numpy as np import pytest from scipy.spatial import cKDTree -from pychunkedgraph.graph.cutting_sv import ( +from pychunkedgraph.graph.sv_split.cutting import ( _cc_label_26, _largest_component_id, _to_zyx_sampling, diff --git a/pychunkedgraph/tests/graph/test_edges_sv.py b/pychunkedgraph/tests/graph/test_edges_sv.py index d8ad0f2ba..9265cf85c 100644 --- a/pychunkedgraph/tests/graph/test_edges_sv.py +++ b/pychunkedgraph/tests/graph/test_edges_sv.py @@ -1,4 +1,4 @@ -"""Comprehensive tests for pychunkedgraph.graph.edges_sv — edge routing after SV split.""" +"""Comprehensive tests for pychunkedgraph.graph.sv_split.edges — edge routing after SV split.""" import numpy as np import pytest @@ -6,7 +6,7 @@ from pychunkedgraph.graph import basetypes from pychunkedgraph.graph.exceptions import PostconditionError -from pychunkedgraph.graph.edges_sv import ( +from pychunkedgraph.graph.sv_split.edges import ( _get_new_edges, _match_by_label, _match_by_proximity, diff --git a/pychunkedgraph/tests/graph/test_edits_sv.py b/pychunkedgraph/tests/graph/test_edits_sv.py index 34a4de109..1d852b433 100644 --- a/pychunkedgraph/tests/graph/test_edits_sv.py +++ b/pychunkedgraph/tests/graph/test_edits_sv.py @@ -1,11 +1,11 @@ -"""Tests for pychunkedgraph.graph.edits_sv""" +"""Tests for pychunkedgraph.graph.sv_split.edits""" import numpy as np import pytest from collections import defaultdict from unittest.mock import MagicMock, patch -from pychunkedgraph.graph.edits_sv import ( +from pychunkedgraph.graph.sv_split.edits import ( _coords_bbox, _voxel_crop, _parse_results, diff --git a/pychunkedgraph/tests/graph/test_ocdbt.py b/pychunkedgraph/tests/graph/test_ocdbt.py index 501229ada..e1694f004 100644 --- a/pychunkedgraph/tests/graph/test_ocdbt.py +++ b/pychunkedgraph/tests/graph/test_ocdbt.py @@ -439,7 +439,7 @@ def test_repeated_update_reflects_latest_base(self, local_ocdbt): class TestWriteSegChunks: """`write_seg_chunks` now takes a flat list of (slices, data) pairs. - `edits_sv.split_supervoxels` is responsible for producing this list + `sv_split.edits.split_supervoxels` is responsible for producing this list across all reps so the outer rep loop is a pure data gather — tensorstore writes fire in one parallel batch. """ From 9430f46e36966c78b8e4526d3e516372902dbd43 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 28 May 2026 22:30:15 +0000 Subject: [PATCH 270/367] perf(sv-split): window seed snapping to a bbox around the seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed snapping ran a cKDTree over every voxel of the supervoxel, twice per split (~47s, ~40% of a split), because the caller disables the boundary-only and downsample candidate reductions for safety. Add a use_bbox lever to snap_seeds_to_segment that restricts the candidate scan to a box around the seeds — the nearest true voxel is always near a seed, so the snapped result is identical while the KDTree shrinks by orders of magnitude. A grow-until-nonempty loop guarantees the window always contains the true nearest voxel, so the box size is never a correctness constraint. Both split_supervoxel_helper call sites opt in. Also: add a plain-terms algorithm overview, and correct the README bbox description (the read/cut region is the seed envelope plus a one-chunk margin, not the rep's full piece-set envelope). --- pychunkedgraph/graph/sv_split/README.md | 4 +- pychunkedgraph/graph/sv_split/algorithm.md | 22 ++++ pychunkedgraph/graph/sv_split/cutting.py | 104 +++++++++++++----- pychunkedgraph/tests/graph/test_cutting_sv.py | 102 +++++++++++++++++ 4 files changed, 201 insertions(+), 31 deletions(-) diff --git a/pychunkedgraph/graph/sv_split/README.md b/pychunkedgraph/graph/sv_split/README.md index 0eae7565f..8cdede87b 100644 --- a/pychunkedgraph/graph/sv_split/README.md +++ b/pychunkedgraph/graph/sv_split/README.md @@ -38,7 +38,7 @@ Resolve coords → current supervoxel IDs at those pixels │ │ L2 CHUNK LOCK (spatial; sparse set + 1-chunk margin) │ │ │ │ │ │ │ │ for each cross-chunk rep linking source to sink: │ │ -│ │ bbs/bbe ◄ envelope of rep's pieces' chunk coords │ │ +│ │ bbs/bbe ◄ envelope of src+sink seeds + 1-chunk margin │ │ │ │ read seg in [bbs-1, bbe+1] │ │ │ │ (1-voxel shell → anchor voxels for edge routing) │ │ │ │ compute voxel-level cut between seeds │ │ @@ -113,7 +113,7 @@ The split flow closes both: ### How the spatial lock set is computed -For each cross-chunk representative being split, take the envelope of the chunks its cross-chunk-connected pieces live in — the exact chunks whose voxels will be rewritten. Expand by one voxel (= at most one L2 chunk of margin in each direction), because the edge-routing step reads a 1-voxel shell outside the rewritten region to see neighboring supervoxels' labels. Union the per-representative chunk sets, sort deterministically so workers with overlapping sets never acquire in opposite orders, lock once. +For each cross-chunk representative being split, the read/cut region is the base-voxel envelope of that rep's source and sink seed coordinates, padded by one CG chunk on each side. The cut surface lives between the seeds, so pieces of the rep far from both seeds never participate — the seed envelope is the region that gets read and rewritten, not the rep's full piece-set envelope, which for an SV cut across many chunks can be orders of magnitude larger. To derive the lock set, expand that envelope by one voxel (because the edge-routing step reads a 1-voxel shell outside the rewritten region to see neighboring supervoxels' labels), map it to the overlapping L2 chunks, union the per-representative chunk sets, and sort deterministically so workers with overlapping sets never acquire in opposite orders. The chunks locked are exactly the chunks the split will touch, plus the 1-chunk margin the shell read requires. diff --git a/pychunkedgraph/graph/sv_split/algorithm.md b/pychunkedgraph/graph/sv_split/algorithm.md index 8c383829d..17e82e352 100644 --- a/pychunkedgraph/graph/sv_split/algorithm.md +++ b/pychunkedgraph/graph/sv_split/algorithm.md @@ -9,6 +9,28 @@ The cut is a **geodesic region grow**, not a voxel graph-mincut: each voxel is assigned to whichever seed is nearer in geodesic (anisotropy-aware) travel cost across the supervoxel's interior. +## In plain terms + +The cut works on a boolean mask of the one supervoxel being split: a voxel is +*true* if it belongs to that supervoxel and *false* otherwise. Call the true +voxels "the supervoxel's voxels". + +1. **Snap the seeds.** The user clicks a source point and a sink point. Those + clicks need not land exactly on one of the supervoxel's voxels, so each seed + is moved to the nearest true voxel. This keeps the next step starting inside + the object. +2. **Flood from each seed.** From the source seed, compute the cheapest path to + every true voxel, where travelling through the thick interior is cheap and + skimming the thin surface is expensive; do the same from the sink seed. +3. **Assign each voxel to its nearer seed.** A voxel goes to the source side if + the source seed reaches it more cheaply, otherwise the sink side. The cut + surface is simply where the two floods meet — it naturally lands at the + object's thinnest neck. +4. **Clean up.** Make sure each side is a single connected piece, reassigning + any stray fragments to the side they border most. + +The rest of this document is the precise version of those four steps. + ## Inputs - `coords` — `(N, 3)` global voxel coordinates of the supervoxel. diff --git a/pychunkedgraph/graph/sv_split/cutting.py b/pychunkedgraph/graph/sv_split/cutting.py index 2b633ddc2..ec164703d 100644 --- a/pychunkedgraph/graph/sv_split/cutting.py +++ b/pychunkedgraph/graph/sv_split/cutting.py @@ -197,6 +197,8 @@ def snap_seeds_to_segment( downsample_mode="stride", # 'stride' or 'random' downsample_stride=2, # used if mode='stride' downsample_target=None, # used if mode='random' + use_bbox=False, + bbox_pad_phys=None, # physical pad per side; None -> derived from voxel_size rng=None, return_index=False, leafsize=16, @@ -223,6 +225,14 @@ def snap_seeds_to_segment( downsample_mode : 'stride' or 'random' for boundary sampling. downsample_stride : If stride mode, use every Nth boundary voxel. downsample_target : If random mode, target number of boundary points to keep. + use_bbox : If True, restrict the candidate scan to a bounding box around + the seeds instead of the whole mask. The nearest true voxel is + always near a seed, so this returns the identical snapped voxel + while building a far smaller KDTree. The box grows until it + contains a true voxel, so it never changes the result. + bbox_pad_phys : Physical pad (same units as voxel_size) added on each side of + the seed bbox. None -> derived from voxel_size. Only a starting + size; the grow loop makes it correctness-independent. rng : Optional np.random.Generator for reproducible random sampling. return_index : If True, also return indices of nearest boundary points. leafsize : cKDTree leafsize parameter. @@ -252,33 +262,74 @@ def snap_seeds_to_segment( if mask_order not in ("zyx", "xyz"): raise ValueError("mask_order must be 'zyx' or 'xyz'") - # Optional boundary extraction for speed - tb = perf_counter() - if use_boundary: - candidate_mask = _extract_mask_boundary(mask, erosion_iters=erosion_iters) - # Fallback to full mask if boundary is empty - if not candidate_mask.any(): - candidate_mask = mask - log(f"[{tag}] boundary empty → fallback to full mask") - else: - candidate_mask = mask - log(f"[{tag}] candidate extraction | {perf_counter()-tb:.3f}s") + # Prepare seeds array (needed up-front for the bbox window below) + seeds_xyz = np.asarray(seeds_xyz, dtype=np.float64) + if seeds_xyz.ndim == 1: + seeds_xyz = seeds_xyz[None, :] + if seeds_xyz.shape[1] != 3: + raise ValueError("seeds_xyz must be shape (N, 3)") - # Obtain candidate voxel coordinates in XYZ order - tc = perf_counter() + # Full-mask voxel bounds (XYZ), used for the final clip regardless of windowing. if mask_order == "zyx": - # mask shape is (Z, Y, X), np.where -> (z, y, x) - zc, yc, xc = np.where(candidate_mask) - points_xyz = np.stack([xc, yc, zc], axis=1) max_x, max_y, max_z = mask.shape[2] - 1, mask.shape[1] - 1, mask.shape[0] - 1 else: - # mask shape is (X, Y, Z), np.where -> (x, y, z) - xc, yc, zc = np.where(candidate_mask) - points_xyz = np.stack([xc, yc, zc], axis=1) max_x, max_y, max_z = mask.shape[0] - 1, mask.shape[1] - 1, mask.shape[2] - 1 - log( - f"[{tag}] candidate coordinates | {perf_counter()-tc:.3f}s (n={len(points_xyz)})" - ) + + # Axis order of `mask` as XYZ indices: voxel (x, y, z) lives at mask[idx]. + ax_xyz = (2, 1, 0) if mask_order == "zyx" else (0, 1, 2) + + def _candidates_xyz(window_mask, origin_xyz): + """np.where over a (cropped) mask → candidate voxel coords in XYZ. + + `origin_xyz` is the XYZ coordinate of the crop's [0,0,0] corner, + added back so coords are global. Honors use_boundary exactly as + the full-mask path does. + """ + if use_boundary: + cand = _extract_mask_boundary(window_mask, erosion_iters=erosion_iters) + if not cand.any(): + cand = window_mask + log(f"[{tag}] boundary empty → fallback to full mask") + else: + cand = window_mask + wc = np.where(cand) # tuple in mask-axis order + pts = np.stack([wc[ax_xyz[0]], wc[ax_xyz[1]], wc[ax_xyz[2]]], axis=1) + return pts + np.asarray(origin_xyz, dtype=pts.dtype) + + tb = perf_counter() + if use_bbox and seeds_xyz.shape[0] > 0: + # Per-axis voxel pad derived from physical pad / voxel_size (anisotropy + # correct, no hardcoded axis). The window grows until it contains a true + # voxel, so the pad is only a starting size — never a correctness bound. + vsize_xyz = np.asarray(voxel_size, dtype=np.float64) + if bbox_pad_phys is None: + bbox_pad_phys = float(vsize_xyz.max()) + seed_min_xyz = np.floor(seeds_xyz.min(axis=0)).astype(np.int64) + seed_max_xyz = np.ceil(seeds_xyz.max(axis=0)).astype(np.int64) + full_max_xyz = np.array([max_x, max_y, max_z], dtype=np.int64) + pad_phys = float(bbox_pad_phys) + points_xyz = np.empty((0, 3), dtype=np.int64) + while True: + pad_vox = np.ceil(pad_phys / vsize_xyz).astype(np.int64) + lo_xyz = np.maximum(seed_min_xyz - pad_vox, 0) + hi_xyz = np.minimum(seed_max_xyz + pad_vox, full_max_xyz) + # Slice the mask in its own axis order. + lo_mask = lo_xyz[list(ax_xyz)] + hi_mask = hi_xyz[list(ax_xyz)] + sl = tuple(slice(int(lo_mask[a]), int(hi_mask[a]) + 1) for a in range(3)) + points_xyz = _candidates_xyz(mask[sl], lo_xyz) + if points_xyz.shape[0] > 0: + break + if np.all(lo_xyz == 0) and np.all(hi_xyz == full_max_xyz): + # Window already spans the full mask and it is still empty. + break + pad_phys *= 2.0 + log(f"[{tag}] bbox candidate scan | {perf_counter()-tb:.3f}s") + else: + points_xyz = _candidates_xyz(mask, (0, 0, 0)) + log(f"[{tag}] candidate scan | {perf_counter()-tb:.3f}s") + + log(f"[{tag}] candidate coordinates (n={len(points_xyz)})") if points_xyz.shape[0] == 0: raise ValueError( @@ -299,13 +350,6 @@ def snap_seeds_to_segment( after = len(points_xyz) log(f"[{tag}] downsample points {before} → {after} | {perf_counter()-td:.3f}s") - # Prepare seeds array - seeds_xyz = np.asarray(seeds_xyz, dtype=np.float64) - if seeds_xyz.ndim == 1: - seeds_xyz = seeds_xyz[None, :] - if seeds_xyz.shape[1] != 3: - raise ValueError("seeds_xyz must be shape (N, 3)") - # Scale coordinates to physical space to respect anisotropy vx, vy, vz = voxel_size scale = np.array([vx, vy, vz], dtype=np.float64) @@ -1322,6 +1366,7 @@ def split_supervoxel_helper( snap_kwargs=dict( use_boundary=False, # disables boundary-only snapping for maximum safety downsample=False, # avoids losing candidates + use_bbox=True, # window candidates to a box around the seeds; same result method="kdtree", ), verbose=verbose, @@ -1354,6 +1399,7 @@ def split_supervoxel_helper( snap_kwargs=dict( use_boundary=False, # match the connector for consistency downsample=False, + use_bbox=True, # window candidates to a box around the seeds; same result method="kdtree", ), ) diff --git a/pychunkedgraph/tests/graph/test_cutting_sv.py b/pychunkedgraph/tests/graph/test_cutting_sv.py index 41c767747..a4138769b 100644 --- a/pychunkedgraph/tests/graph/test_cutting_sv.py +++ b/pychunkedgraph/tests/graph/test_cutting_sv.py @@ -392,6 +392,108 @@ def test_invalid_mask_order(self): ) +class TestSnapSeedsBboxEquivalence: + """use_bbox=True must return the identical snapped voxel as the + full-mask scan. The bbox restriction is purely a candidate-set + optimization; a different nearest voxel would be a correctness + regression, which is exactly what these cases probe.""" + + def _both(self, seeds, mask, *, mask_order, voxel_size, bbox_pad_phys): + full = snap_seeds_to_segment( + seeds, + mask, + mask_order=mask_order, + voxel_size=voxel_size, + use_boundary=False, + downsample=False, + use_bbox=False, + ) + boxed = snap_seeds_to_segment( + seeds, + mask, + mask_order=mask_order, + voxel_size=voxel_size, + use_boundary=False, + downsample=False, + use_bbox=True, + bbox_pad_phys=bbox_pad_phys, + ) + return full, boxed + + def test_seed_outside_isotropic(self): + mask = np.zeros((30, 30, 30), dtype=bool) + mask[10:20, 10:20, 10:20] = True + seeds = np.array([[0.0, 0.0, 0.0]]) # outside; nearest is a corner + full, boxed = self._both( + seeds, + mask, + mask_order="zyx", + voxel_size=(1.0, 1.0, 1.0), + bbox_pad_phys=2.0, # smaller than the seed->mask gap, forces grow + ) + np.testing.assert_array_equal(full, boxed) + + def test_anisotropic_voxel_size(self): + # Anisotropy means the nearest voxel by physical distance need not + # be the nearest by index distance; the bbox pad is in voxels per + # axis, so this is the case most likely to expose a wrong window. + mask = np.zeros((40, 40, 10), dtype=bool) + mask[15:25, 15:25, 3:7] = True + seeds = np.array([[0.0, 0.0, 0.0]]) + full, boxed = self._both( + seeds, + mask, + mask_order="zyx", + voxel_size=(8.0, 8.0, 40.0), + bbox_pad_phys=8.0, + ) + np.testing.assert_array_equal(full, boxed) + + def test_multiple_far_apart_seeds(self): + mask = np.zeros((30, 30, 30), dtype=bool) + mask[2:6, 2:6, 2:6] = True + mask[24:28, 24:28, 24:28] = True + seeds = np.array([[0.0, 0.0, 0.0], [29.0, 29.0, 29.0]]) + full, boxed = self._both( + seeds, + mask, + mask_order="zyx", + voxel_size=(1.0, 1.0, 1.0), + bbox_pad_phys=1.0, + ) + np.testing.assert_array_equal(full, boxed) + + def test_xyz_mask_order(self): + mask_xyz = np.zeros((20, 24, 16), dtype=bool) + mask_xyz[6:14, 6:18, 4:12] = True + seeds = np.array([[0.0, 0.0, 0.0]]) + full, boxed = self._both( + seeds, + mask_xyz, + mask_order="xyz", + voxel_size=(1.0, 1.0, 1.0), + bbox_pad_phys=2.0, + ) + np.testing.assert_array_equal(full, boxed) + + def test_irregular_shape_grow_loop(self): + # Dumbbell: two blobs joined by a thin neck. A seed near one blob + # with a tiny pad starts with an empty window and must grow. + mask = np.zeros((40, 20, 20), dtype=bool) + mask[2:10, 6:14, 6:14] = True + mask[30:38, 6:14, 6:14] = True + mask[10:30, 9:11, 9:11] = True # neck + seeds = np.array([[10.0, 10.0, 1.0]]) # outside, off the z=0 end + full, boxed = self._both( + seeds, + mask, + mask_order="zyx", + voxel_size=(1.0, 1.0, 1.0), + bbox_pad_phys=1.0, + ) + np.testing.assert_array_equal(full, boxed) + + # ============================================================ # Tests: EDT # ============================================================ From a8a60fcc2f808b72945db2e6204899be3bc253fa Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Thu, 28 May 2026 23:54:11 +0000 Subject: [PATCH 271/367] perf(sv-split): skip label-3 reassignment scan when no strays exist The stray-fragment resolver's first action is a full-volume connected-components labeling of label-3 voxels, after which it early-returns when there are none. Splits that produce no strays still paid for that whole-volume scan just to find nothing to do. Guard the resolver behind a label-3 voxel count so the CC labeling runs only when a fragment was actually stranded; the count is created solely by the single-CC enforcement above, so an empty check is exactly the resolver's own no-op condition. Route cutting.py's per-stage logging through the package logger (logger.debug, with the label-3 count at logger.note) instead of the verbose-gated print helper, and drop the now-unused verbose parameter from the split functions and their callers. --- pychunkedgraph/graph/sv_split/cutting.py | 179 +++++++++--------- pychunkedgraph/graph/sv_split/edits.py | 2 - pychunkedgraph/tests/graph/test_cutting_sv.py | 9 - 3 files changed, 93 insertions(+), 97 deletions(-) diff --git a/pychunkedgraph/graph/sv_split/cutting.py b/pychunkedgraph/graph/sv_split/cutting.py index ec164703d..3ebdf85d5 100644 --- a/pychunkedgraph/graph/sv_split/cutting.py +++ b/pychunkedgraph/graph/sv_split/cutting.py @@ -20,8 +20,11 @@ ball, ) # keep only ball; use ndi.binary_dilation everywhere +from pychunkedgraph import get_logger from pychunkedgraph.profiler import get_profiler +logger = get_logger(__name__) + # ---------- Fast CC wrappers ---------- try: import cc3d @@ -202,7 +205,6 @@ def snap_seeds_to_segment( rng=None, return_index=False, leafsize=16, - log=lambda x: None, tag="snap", method="kdtree", # accepted for compatibility; only 'kdtree' currently ): @@ -251,7 +253,9 @@ def snap_seeds_to_segment( """ t0 = perf_counter() if method != "kdtree": - log(f"[{tag}] Warning: 'method={method}' not supported; using 'kdtree'.") + logger.debug( + f"[{tag}] Warning: 'method={method}' not supported; using 'kdtree'." + ) # Validate mask if mask.ndim != 3: @@ -289,7 +293,7 @@ def _candidates_xyz(window_mask, origin_xyz): cand = _extract_mask_boundary(window_mask, erosion_iters=erosion_iters) if not cand.any(): cand = window_mask - log(f"[{tag}] boundary empty → fallback to full mask") + logger.debug(f"[{tag}] boundary empty → fallback to full mask") else: cand = window_mask wc = np.where(cand) # tuple in mask-axis order @@ -324,12 +328,12 @@ def _candidates_xyz(window_mask, origin_xyz): # Window already spans the full mask and it is still empty. break pad_phys *= 2.0 - log(f"[{tag}] bbox candidate scan | {perf_counter()-tb:.3f}s") + logger.debug(f"[{tag}] bbox candidate scan | {perf_counter()-tb:.3f}s") else: points_xyz = _candidates_xyz(mask, (0, 0, 0)) - log(f"[{tag}] candidate scan | {perf_counter()-tb:.3f}s") + logger.debug(f"[{tag}] candidate scan | {perf_counter()-tb:.3f}s") - log(f"[{tag}] candidate coordinates (n={len(points_xyz)})") + logger.debug(f"[{tag}] candidate coordinates (n={len(points_xyz)})") if points_xyz.shape[0] == 0: raise ValueError( @@ -348,7 +352,9 @@ def _candidates_xyz(window_mask, origin_xyz): rng=rng, ) after = len(points_xyz) - log(f"[{tag}] downsample points {before} → {after} | {perf_counter()-td:.3f}s") + logger.debug( + f"[{tag}] downsample points {before} → {after} | {perf_counter()-td:.3f}s" + ) # Scale coordinates to physical space to respect anisotropy vx, vy, vz = voxel_size @@ -361,7 +367,7 @@ def _candidates_xyz(window_mask, origin_xyz): te = perf_counter() tree = cKDTree(points_scaled, leafsize=leafsize) _, nn_indices = tree.query(seeds_scaled, k=1, workers=-1) - log(f"[{tag}] KDTree build+query | {perf_counter()-te:.3f}s") + logger.debug(f"[{tag}] KDTree build+query | {perf_counter()-te:.3f}s") # Map back to integer voxel coords (XYZ) snapped_xyz = points_xyz[nn_indices].astype(np.int64) @@ -371,7 +377,9 @@ def _candidates_xyz(window_mask, origin_xyz): snapped_xyz[:, 1] = np.clip(snapped_xyz[:, 1], 0, max_y) snapped_xyz[:, 2] = np.clip(snapped_xyz[:, 2], 0, max_z) - log(f"[{tag}] snapped {len(seeds_xyz)} seeds | total {perf_counter()-t0:.3f}s") + logger.debug( + f"[{tag}] snapped {len(seeds_xyz)} seeds | total {perf_counter()-t0:.3f}s" + ) if return_index: return snapped_xyz, nn_indices else: @@ -381,7 +389,7 @@ def _candidates_xyz(window_mask, origin_xyz): # ============================================================ # EDT wrapper (Seung-Lab edt preferred, fallback to scipy) # ============================================================ -def _compute_edt(mask: np.ndarray, sampling_zyx, log=lambda x: None, tag="edt"): +def _compute_edt(mask: np.ndarray, sampling_zyx, tag="edt"): """ Compute Euclidean distance transform using Seung-Lab edt if available, otherwise fallback to scipy.ndimage.distance_transform_edt. @@ -392,11 +400,11 @@ def _compute_edt(mask: np.ndarray, sampling_zyx, log=lambda x: None, tag="edt"): t0 = perf_counter() if _HAVE_EDT_FAST: dist = _edt_fast(mask.astype(np.uint8, copy=False), anisotropy=sampling_zyx) - log(f"[{tag}] Seung-Lab edt | {perf_counter()-t0:.3f}s") + logger.debug(f"[{tag}] Seung-Lab edt | {perf_counter()-t0:.3f}s") return dist else: dist = ndi.distance_transform_edt(mask, sampling=sampling_zyx) - log(f"[{tag}] SciPy EDT | {perf_counter()-t0:.3f}s") + logger.debug(f"[{tag}] SciPy EDT | {perf_counter()-t0:.3f}s") return dist @@ -431,12 +439,7 @@ def connect_both_seeds_via_ridge( refine_fullres_when_fail: bool = True, snap_method: str = "kdtree", snap_kwargs: dict | None = None, - verbose: bool = False, ): - def log(msg: str): - if verbose: - print(msg, flush=True) - _prof = get_profiler() def _bbox_pad_zyx(points_zyx, shape, pad=(24, 48, 48)): @@ -483,10 +486,10 @@ def _mst_edges_phys(pts_zyx, sampling): return edges t0 = perf_counter() - log( + logger.debug( f"[connect] vol_order={vol_order}, vox_order={vox_order}, seed_order={seed_order}" ) - log( + logger.debug( f"[connect] mask shape: {binary_sv.shape}, ridge_power={ridge_power}, ds={downsample}" ) @@ -524,7 +527,6 @@ def _snap(pts_zyx, name): sampling[1], sampling[0], ), # convert ZYX->XYZ spacing - log=log, tag=f"{name}@snap", **snap_cfg, ) @@ -536,7 +538,9 @@ def _snap(pts_zyx, name): B_zyx = _snap(B_in_zyx, "B") if len(A_zyx) == 0 or len(B_zyx) == 0: - log("[connect] after snapping, one side has no seeds; skipping connection") + logger.debug( + "[connect] after snapping, one side has no seeds; skipping connection" + ) return ( _seeds_from_zyx(A_zyx, seed_order), _seeds_from_zyx(B_zyx, seed_order), @@ -549,7 +553,9 @@ def _snap(pts_zyx, name): np.vstack([A_zyx, B_zyx]), sv_zyx.shape, pad=roi_pad_zyx ) roi = sv_zyx[z0:z1, y0:y1, x0:x1] - log(f"[connect] ROI: z[{z0}:{z1}] y[{y0}:{y1}] x[{x0}:{x1}] → shape {roi.shape}") + logger.debug( + f"[connect] ROI: z[{z0}:{z1}] y[{y0}:{y1}] x[{x0}:{x1}] → shape {roi.shape}" + ) # Downsample ROI sz, sy, sx = map(int, downsample) @@ -559,7 +565,7 @@ def _snap(pts_zyx, name): else: roi_ds = roi sampling_ds = (sampling[0] * sz, sampling[1] * sy, sampling[2] * sx) - log( + logger.debug( f"[connect] ROI downsampled {roi.shape} -> {roi_ds.shape} | {perf_counter()-ti_ds:.3f}s" ) @@ -582,7 +588,6 @@ def _to_roi_ds_snapped(pts_zyx, name="seedDS"): mask=roi_ds, mask_order="zyx", voxel_size=(sampling_ds[2], sampling_ds[1], sampling_ds[0]), - log=log, tag=f"{name}@roi_ds", use_boundary=False, downsample=False, @@ -592,7 +597,7 @@ def _to_roi_ds_snapped(pts_zyx, name="seedDS"): return snapped_ds_zyx.astype(int) except ValueError as e: # If roi_ds is empty or degenerate, bail out gracefully: - log( + logger.debug( f"[{name}@roi_ds] snapping failed ({e}); falling back to nearest-int grid & mask check." ) approx = np.floor(seeds_ds + 0.5).astype(int) @@ -610,7 +615,7 @@ def _to_roi_ds_snapped(pts_zyx, name="seedDS"): okA = len(A_ds) >= 1 okB = len(B_ds) >= 1 if not (okA and okB): - log( + logger.debug( "[connect] seeds disappeared or failed to map on DS grid; consider smaller ds or use_boundary=False/downsample=False in snapping." ) return ( @@ -622,9 +627,9 @@ def _to_roi_ds_snapped(pts_zyx, name="seedDS"): # EDT and cost on DS ROI (Seung-Lab edt if available) t1 = perf_counter() - dist = _compute_edt(roi_ds, sampling_ds, log=log, tag="connect:EDT") + dist = _compute_edt(roi_ds, sampling_ds, tag="connect:EDT") if dist.max() <= 0: - log("[connect] empty EDT in ROI; skipping connection") + logger.debug("[connect] empty EDT in ROI; skipping connection") return ( _seeds_from_zyx(A_zyx, seed_order), _seeds_from_zyx(B_zyx, seed_order), @@ -635,7 +640,7 @@ def _to_roi_ds_snapped(pts_zyx, name="seedDS"): eps = 1e-6 cost = np.full_like(dn, 1e12, dtype=float) cost[roi_ds] = 1.0 / (eps + np.clip(dn[roi_ds], 0, 1) ** max(0.0, ridge_power)) - log(f"[connect] EDT/cost ready on DS-ROI | {perf_counter()-t1:.3f}s") + logger.debug(f"[connect] EDT/cost ready on DS-ROI | {perf_counter()-t1:.3f}s") # Shortest paths via MST def _path_mask_ds(start, end): @@ -645,14 +650,14 @@ def _path_mask_ds(start, end): mid = perf_counter() v = costs[tuple(end)] if not np.isfinite(v): - log( + logger.debug( f"[MCP] start={tuple(start)} -> end={tuple(end)} FAILED | setup+run={mid-tmcp:.3f}s" ) return None path = np.asarray(mcp.traceback(tuple(end)), int) m = np.zeros_like(roi_ds, bool) m[tuple(path.T)] = True - log( + logger.debug( f"[MCP] start={tuple(start)} -> end={tuple(end)} OK | total={perf_counter()-tmcp:.3f}s" ) return m @@ -666,14 +671,12 @@ def _augment_team_ds(team_name, pts_ds): for i, j in edges: m = _path_mask_ds(pts_ds[i], pts_ds[j]) if m is None: - log(f"[connect:{team_name}] DS path FAILED for edge {i}-{j}") + logger.debug(f"[connect:{team_name}] DS path FAILED for edge {i}-{j}") ok = False if refine_fullres_when_fail: # fallback full-res EDT and path tfr = perf_counter() - dist_fr = _compute_edt( - roi, sampling, log=log, tag="connect:EDT(fullres)" - ) + dist_fr = _compute_edt(roi, sampling, tag="connect:EDT(fullres)") dnm = dist_fr / (dist_fr.max() if dist_fr.max() > 0 else 1.0) cost_fr = np.full_like(dist_fr, 1e12, dtype=float) cost_fr[roi] = 1.0 / ( @@ -689,11 +692,11 @@ def _augment_team_ds(team_name, pts_ds): m_fr[tuple(path_fr.T)] = True m = m_fr[::sz, ::sy, ::sx] ok = True - log( + logger.debug( f"[connect:{team_name}] fallback full-res path OK | {perf_counter()-tfr:.3f}s" ) else: - log( + logger.debug( f"[connect:{team_name}] Full-res ROI path also FAILED for edge {i}-{j}" ) m = None @@ -706,10 +709,10 @@ def _augment_team_ds(team_name, pts_ds): pB_ds, okB2 = _augment_team_ds("B", B_ds) okA &= okA2 okB &= okB2 - log(f"[connect] MST+paths built | {perf_counter()-t_aug:.3f}s") + logger.debug(f"[connect] MST+paths built | {perf_counter()-t_aug:.3f}s") if not (okA and okB): - log( + logger.debug( "[connect] connection failed for at least one team — consider smaller downsample or refine_fullres_when_fail." ) return ( @@ -726,7 +729,7 @@ def _augment_team_ds(team_name, pts_ds): tpost = perf_counter() pA = ndi.binary_dilation(pA, structure=struc) & roi pB = ndi.binary_dilation(pB, structure=struc) & roi - log(f"[connect] postproc dilation on paths | {perf_counter()-tpost:.3f}s") + logger.debug(f"[connect] postproc dilation on paths | {perf_counter()-tpost:.3f}s") A_aug = set(map(tuple, A_zyx)) B_aug = set(map(tuple, B_zyx)) @@ -739,7 +742,7 @@ def _augment_team_ds(team_name, pts_ds): A_aug = _seeds_from_zyx(np.array(sorted(list(A_aug)), int), seed_order) B_aug = _seeds_from_zyx(np.array(sorted(list(B_aug)), int), seed_order) - log( + logger.debug( f"[connect] done; +{len(A_aug)-len(seeds_a)} vox for A, +{len(B_aug)-len(seeds_b)} for B | total {perf_counter()-t0:.3f}s" ) return A_aug, B_aug, True, True @@ -774,13 +777,7 @@ def split_supervoxel_growing( # snapping control (NEW) snap_method: str = "kdtree", snap_kwargs: dict | None = None, - # logging - verbose: bool = False, ): - def log(msg: str): - if verbose: - print(msg, flush=True) - _prof = get_profiler() # Helpers reused from the module: _cc_label_26, _largest_component_id, _to_internal_zyx_volume, _from_internal_zyx_volume @@ -794,7 +791,7 @@ def _enforce_single_component(out_labels, lab, seed_pts_global, allow3=True): return 0, 0 comp, ncomp = _cc_label_26(mask) if ncomp <= 1: - log(f"[single-cc:{lab}] ncomp=1 | {perf_counter()-t:.3f}s") + logger.debug(f"[single-cc:{lab}] ncomp=1 | {perf_counter()-t:.3f}s") return 1, 0 keep_ids = set() @@ -818,7 +815,7 @@ def _enforce_single_component(out_labels, lab, seed_pts_global, allow3=True): moved = int(bad_mask.sum()) if allow3 and moved: out_labels[bad_mask] = 3 - log( + logger.debug( f"[single-cc:{lab}] kept={len(keep_ids)}, moved_to_3={moved} | {perf_counter()-t:.3f}s" ) return len(keep_ids), moved @@ -829,9 +826,11 @@ def _resolve_label3_touching_vectorized( t0 = perf_counter() comp3, n3 = _cc_label_26(out_labels == 3) n3_vox = int((out_labels == 3).sum()) - log(f"[touching] n3 comps={n3}, vox={n3_vox}") + logger.debug(f"[touching] n3 comps={n3}, vox={n3_vox}") if n3 == 0: - log(f"[touching] no label-3 components | {perf_counter()-t0:.3f}s") + logger.debug( + f"[touching] no label-3 components | {perf_counter()-t0:.3f}s" + ) return 0, 0 # Per label-3 component, count how many of its voxels border each @@ -858,7 +857,7 @@ def _resolve_label3_touching_vectorized( assign[cnt1 > cnt2] = 1 assign[cnt2 > cnt1] = 2 undec = np.where(assign[1:] == 0)[0] + 1 - log( + logger.debug( f"[touching] maj→1={int((assign==1).sum())}, maj→2={int((assign==2).sum())}, ties={len(undec)} | {perf_counter()-t1:.3f}s" ) @@ -874,8 +873,8 @@ def _resolve_label3_touching_vectorized( sA[tuple(np.array(seedsA).T)] = True sB = np.zeros_like(out_labels, bool) sB[tuple(np.array(seedsB).T)] = True - dA = _compute_edt(~sA, sampling, log=log, tag="split:EDT(dA)") - dB = _compute_edt(~sB, sampling, log=log, tag="split:EDT(dB)") + dA = _compute_edt(~sA, sampling, tag="split:EDT(dA)") + dB = _compute_edt(~sB, sampling, tag="split:EDT(dB)") closer2 = (dB < dA) & (comp3 > 0) pref2 = np.bincount(comp3[closer2], minlength=n3 + 1) @@ -885,7 +884,7 @@ def _resolve_label3_touching_vectorized( choose2 = pref2[tie_ids] > (total[tie_ids] - pref2[tie_ids]) assign[tie_ids[choose2]] = 2 assign[tie_ids[~choose2]] = 1 - log( + logger.debug( f"[touching] tie-break EDT done: to2={int(choose2.sum())}, to1={int((~choose2).sum())} | {perf_counter()-t2:.3f}s" ) @@ -899,26 +898,28 @@ def _resolve_label3_touching_vectorized( moved2 = int(mask2.sum()) out_labels[mask2] = 2 - log( + logger.debug( f"[touching] reassigned 3→1: {moved1}, 3→2: {moved2} | total {perf_counter()-t0:.3f}s" ) return moved1, moved2 # ---------- begin ---------- t0 = perf_counter() - log(f"[init] vol_order={vol_order}, vox_order={vox_order}, seed_order={seed_order}") - log(f"[init] input volume shape: {binary_sv.shape}") + logger.debug( + f"[init] vol_order={vol_order}, vox_order={vox_order}, seed_order={seed_order}" + ) + logger.debug(f"[init] input volume shape: {binary_sv.shape}") # Convert input volumes and sampling into internal ZYX sv_zyx, _ = _to_internal_zyx_volume(binary_sv, vol_order) sampling = _to_zyx_sampling(voxel_size, vox_order) - log(f"[init] internal shape (z,y,x): {sv_zyx.shape}") - log(f"[init] sampling (z,y,x): {sampling}") + logger.debug(f"[init] internal shape (z,y,x): {sv_zyx.shape}") + logger.debug(f"[init] sampling (z,y,x): {sampling}") # SNAP seeds to mask using the same KDTree-based method A_all = _seeds_to_zyx(seeds_a, seed_order) B_all = _seeds_to_zyx(seeds_b, seed_order) - log("[snap] snapping seeds to segment mask...") + logger.debug("[snap] snapping seeds to segment mask...") snap_cfg = dict( use_boundary=True, @@ -945,7 +946,6 @@ def _snap_ZYX(pts_zyx, tagname): sampling[1], sampling[0], ), # convert ZYX→XYZ spacing - log=log, tag=tagname, **snap_cfg, ) @@ -954,11 +954,13 @@ def _snap_ZYX(pts_zyx, tagname): with _prof.profile("snap_seeds"): A = _snap_ZYX(A_all, "A@snap") B = _snap_ZYX(B_all, "B@snap") - log(f"[seeds] A={len(A)}, B={len(B)}") + logger.debug(f"[seeds] A={len(A)}, B={len(B)}") out_zyx = np.zeros_like(sv_zyx, dtype=np.int16) if A.size == 0 or B.size == 0 or not np.any(sv_zyx): - log("[seeds] missing seeds or empty SV; returning label=1 for entire SV") + logger.debug( + "[seeds] missing seeds or empty SV; returning label=1 for entire SV" + ) out_zyx[sv_zyx] = 1 return _from_internal_zyx_volume(out_zyx, vol_order) @@ -978,19 +980,19 @@ def _snap_ZYX(pts_zyx, tagname): sv = sv_zyx[z0h:z1h, y0h:y1h, x0h:x1h] A_roi = A - np.array([z0h, y0h, x0h]) B_roi = B - np.array([z0h, y0h, x0h]) - log( + logger.debug( f"[crop] ROI shape (internal): {sv.shape} (halo {halo}) | {perf_counter()-t_bbox:.3f}s" ) # Build travel cost via EDT (Seung-Lab edt if available) t1 = perf_counter() - dist = _compute_edt(sv, sampling, log=log, tag="split:EDT(mask)") + dist = _compute_edt(sv, sampling, tag="split:EDT(mask)") distn = dist / dist.max() if dist.max() > 0 else dist eps = 1e-6 speed = np.clip(distn ** max(gamma_neck, 0.0), eps, 1.0) travel_cost = np.full_like(speed, 1e12, dtype=float) travel_cost[sv] = 1.0 / speed[sv] - log( + logger.debug( f"[speed] EDT + speed map | {perf_counter()-t1:.3f}s (total {perf_counter()-t0:.3f}s)" ) @@ -998,7 +1000,7 @@ def _snap_ZYX(pts_zyx, tagname): use_ds = downsample_geodesic is not None if use_ds: dz, dy, dx = map(int, downsample_geodesic) - log(f"[geodesic] downsample grid: {downsample_geodesic}") + logger.debug(f"[geodesic] downsample grid: {downsample_geodesic}") cost_ds = travel_cost[::dz, ::dy, ::dx] mask_ds = sv[::dz, ::dy, ::dx] sampling_ds = (sampling[0] * dz, sampling[1] * dy, sampling[2] * dx) @@ -1014,9 +1016,9 @@ def _to_ds(pts): A_sub = _to_ds(A_roi) B_sub = _to_ds(B_roi) - log(f"[geodesic] seeds on DS grid: A={len(A_sub)}, B={len(B_sub)}") + logger.debug(f"[geodesic] seeds on DS grid: A={len(A_sub)}, B={len(B_sub)}") if len(A_sub) == 0 or len(B_sub) == 0: - log("[geodesic] DS removed all seeds; falling back to full-res") + logger.debug("[geodesic] DS removed all seeds; falling back to full-res") use_ds = False if not use_ds: cost_ds = travel_cost @@ -1033,7 +1035,7 @@ def _to_ds(pts): TB, _ = mcpB.find_costs(B_sub, find_all_ends=False) TA = np.where(mask_ds, TA, np.inf) TB = np.where(mask_ds, TB, np.inf) - log( + logger.debug( f"[geodesic] TA/TB computed | {perf_counter()-t2:.3f}s (total {perf_counter()-t0:.3f}s)" ) @@ -1048,8 +1050,8 @@ def _to_ds(pts): band = ndi.binary_dilation(band, structure=ball(nb_dilate)) & mask_ds if band.sum() < 64: band = mask_ds.copy() - log("[band] tiny band -> using full ROI on current grid") - log( + logger.debug("[band] tiny band -> using full ROI on current grid") + logger.debug( f"[band] voxels: {int(band.sum())} | {perf_counter()-t3:.3f}s (total {perf_counter()-t0:.3f}s)" ) @@ -1069,7 +1071,7 @@ def _to_ds(pts): sub_labels_ds[z, y, x] = 1 for z, y, x in B_sub: sub_labels_ds[z, y, x] = 2 - log( + logger.debug( f"[label] DS labeling done | {perf_counter()-t4:.3f}s (total {perf_counter()-t0:.3f}s)" ) @@ -1081,7 +1083,7 @@ def _to_ds(pts): sub_labels[z, y, x] = 1 for z, y, x in B_roi: sub_labels[z, y, x] = 2 - log(f"[label] upsampled DS→full ROI") + logger.debug(f"[label] upsampled DS→full ROI") else: sub_labels = sub_labels_ds @@ -1089,7 +1091,7 @@ def _to_ds(pts): out_zyx[sv_zyx] = 1 out_zyx[z0h:z1h, y0h:y1h, x0h:x1h][sub_labels == 1] = 1 out_zyx[z0h:z1h, y0h:y1h, x0h:x1h][sub_labels == 2] = 2 - log("[writeback] labels written to full volume") + logger.debug("[writeback] labels written to full volume") # Enforce single CC per label (full res). The upsampled labeling can # fragment under the foreground mask, so enforcement must run here, @@ -1105,14 +1107,22 @@ def _to_ds(pts): keptB, movedB = _enforce_single_component( out_zyx, 2, B, allow3=allow_third_label ) - log( + logger.debug( f"[single-cc] label1 kept {keptA}, moved {movedA} -> 3; label2 kept {keptB}, moved {movedB} -> 3" ) with _prof.profile("resolve3"): - moved1, moved2 = _resolve_label3_touching_vectorized( - out_zyx, A, B, sampling - ) + # Only reassign strays if any exist. Label 3 is created solely + # by _enforce_single_component above; with none present, + # _resolve_label3_touching_vectorized would early-return after a + # full-volume CC scan, so the np.any check skips that scan. + moved1 = moved2 = 0 + n3 = int(np.count_nonzero(out_zyx == 3)) + logger.note(f"resolve3: label-3 stray voxels: {n3}") + if n3: + moved1, moved2 = _resolve_label3_touching_vectorized( + out_zyx, A, B, sampling + ) if moved1 or moved2: if enforce_single_cc: keptA, movedA = _enforce_single_component( @@ -1121,7 +1131,7 @@ def _to_ds(pts): keptB, movedB = _enforce_single_component( out_zyx, 2, B, allow3=allow_third_label ) - log( + logger.debug( f"[single-cc 2nd] label1 kept {keptA}, moved {movedA}; label2 kept {keptB}, moved {movedB}" ) @@ -1133,9 +1143,9 @@ def _to_ds(pts): if raise_if_multi_cc: raise ValueError(msg) else: - log(msg) + logger.debug(msg) - log(f"[done] total elapsed {perf_counter()-t0:.3f}s") + logger.debug(f"[done] total elapsed {perf_counter()-t0:.3f}s") return _from_internal_zyx_volume(out_zyx, vol_order) @@ -1345,7 +1355,6 @@ def split_supervoxel_helper( source_coords: np.ndarray, sink_coords: np.ndarray, voxel_size: tuple, - verbose: bool = False, ): voxel_size = np.array(voxel_size) downsample = voxel_size.max() // voxel_size @@ -1369,7 +1378,6 @@ def split_supervoxel_helper( use_bbox=True, # window candidates to a box around the seeds; same result method="kdtree", ), - verbose=verbose, ) if not (okA and okB): raise RuntimeError( @@ -1394,7 +1402,6 @@ def split_supervoxel_helper( enforce_single_cc=True, raise_if_seed_split=True, raise_if_multi_cc=True, - verbose=verbose, snap_method="kdtree", snap_kwargs=dict( use_boundary=False, # match the connector for consistency diff --git a/pychunkedgraph/graph/sv_split/edits.py b/pychunkedgraph/graph/sv_split/edits.py index 3b458f615..9c5a8524a 100644 --- a/pychunkedgraph/graph/sv_split/edits.py +++ b/pychunkedgraph/graph/sv_split/edits.py @@ -395,7 +395,6 @@ def split_supervoxel( sv_remapping: dict, time_stamp: datetime = None, parent_ts: datetime = None, - verbose: bool = False, ) -> SvSplitOutcome: """Split one cross-chunk-connected SV into connected components. @@ -476,7 +475,6 @@ def split_supervoxel( source_coords - bbs, sink_coords - bbs, cg.meta.resolution, - verbose=verbose, ) logger.note(f"split computation {split_result.shape} ({time.time() - t0:.2f}s)") diff --git a/pychunkedgraph/tests/graph/test_cutting_sv.py b/pychunkedgraph/tests/graph/test_cutting_sv.py index a4138769b..b27142e44 100644 --- a/pychunkedgraph/tests/graph/test_cutting_sv.py +++ b/pychunkedgraph/tests/graph/test_cutting_sv.py @@ -773,7 +773,6 @@ def test_basic_split_xyz(self): vol_order="xyz", vox_order="xyz", seed_order="xyz", - verbose=False, snap_kwargs=dict(use_boundary=False, downsample=False), enforce_single_cc=True, raise_if_multi_cc=False, @@ -796,7 +795,6 @@ def test_basic_split_zyx(self): vol_order="zyx", vox_order="zyx", seed_order="zyx", - verbose=False, snap_kwargs=dict(use_boundary=False, downsample=False), enforce_single_cc=True, raise_if_multi_cc=False, @@ -818,7 +816,6 @@ def test_empty_seeds_returns_label1(self): vol_order="zyx", vox_order="zyx", seed_order="zyx", - verbose=False, snap_kwargs=dict(use_boundary=False, downsample=False), ) assert np.all(result[mask] == 1) @@ -835,7 +832,6 @@ def test_with_downsample_geodesic(self): vox_order="zyx", seed_order="zyx", downsample_geodesic=(1, 2, 2), - verbose=False, snap_kwargs=dict(use_boundary=False, downsample=False), enforce_single_cc=True, raise_if_multi_cc=False, @@ -861,7 +857,6 @@ def test_single_cc_guarantee_with_downsample(self): enforce_single_cc=True, raise_if_multi_cc=True, snap_kwargs=dict(use_boundary=False, downsample=False), - verbose=False, ) assert np.any(result == 1) assert np.any(result == 2) @@ -889,7 +884,6 @@ def test_basic_connection(self): vox_order="xyz", seed_order="xyz", downsample=(1, 1, 1), - verbose=False, snap_kwargs=dict(use_boundary=False, downsample=False), ) assert okA @@ -913,7 +907,6 @@ def test_single_seed_per_team(self): vol_order="xyz", seed_order="xyz", downsample=(1, 1, 1), - verbose=False, snap_kwargs=dict(use_boundary=False, downsample=False), ) assert okA @@ -934,7 +927,6 @@ def test_empty_seeds(self): vol_order="xyz", seed_order="xyz", downsample=(1, 1, 1), - verbose=False, snap_kwargs=dict(use_boundary=False, downsample=False), ) assert not okA @@ -955,7 +947,6 @@ def test_basic_split(self): seeds_a_xyz, seeds_b_xyz, voxel_size=(1.0, 1.0, 1.0), - verbose=False, ) assert result.shape == mask_xyz.shape assert np.any(result == 1) From 05b4373f42dea1a0a150ed5673f3156c4de298c3 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 29 May 2026 01:19:37 +0000 Subject: [PATCH 272/367] perf(sv-split): write fresh IDs into the seg crop in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supervoxel-split step copied the full overlap crop so the fresh IDs lived in a buffer separate from the later in-place masks. Write them straight into seg's crop view instead, and capture the OCDBT write payloads plus the source/sink id lookups from that crop before masking — so they keep the unmasked neighbour SV IDs the write must preserve. The root mask then runs only for update_edges, which reads only its own wanted-label set and so is unaffected by the masked neighbours. Peak memory drops by roughly the crop size; the only remaining copies are per-changed-chunk, proportional to the edit. --- pychunkedgraph/graph/sv_split/edits.py | 67 +++++++++++++------------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/pychunkedgraph/graph/sv_split/edits.py b/pychunkedgraph/graph/sv_split/edits.py index 9c5a8524a..399d82a3a 100644 --- a/pychunkedgraph/graph/sv_split/edits.py +++ b/pychunkedgraph/graph/sv_split/edits.py @@ -488,10 +488,12 @@ def split_supervoxel( f"{len(change_chunks)} with splits ({time.time() - t0:.2f}s)" ) + # Write fresh SV IDs into seg's crop in place (a view, no full-crop + # copy). _parse_results only writes; it never reads crop values. with _prof.profile("parse_results"): - seg_cropped = seg[voxel_overlap_crop].copy() + new_seg = seg[voxel_overlap_crop] new_seg, old_new_map, new_id_label_map = _parse_results( - results, seg_cropped, bbs, bbe + results, new_seg, bbs, bbe ) logger.note( f"old_new_map: {len(old_new_map)} SVs split, whole_sv: {len(cut_supervoxels)} SVs" @@ -500,22 +502,43 @@ def split_supervoxel( if unsplit: logger.note(f"unsplit SVs (kept IDs): {unsplit}") + # The OCDBT write payloads and the src/sink lookups must see the + # unmasked crop (neighbour SV IDs preserved) plus the fresh IDs. + # Capture them before the mask below zeros non-root labels, and + # before update_edges mutates seg in place. .copy() per changed + # chunk detaches each payload from seg; changed chunks only, so the + # copies stay proportional to the edit. The caller batches them into + # one parallel tensorstore write — no serial per-task loop. + seg_write_pairs: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = [] + for _, chunk_bbox in change_chunks: + lo, hi = chunk_bbox[0], chunk_bbox[1] + local_lo = lo - bbs + local_hi = hi - bbs + data = new_seg[ + local_lo[0] : local_hi[0], + local_lo[1] : local_hi[1], + local_lo[2] : local_hi[2], + ].copy() + voxel_slices = tuple(slice(int(s), int(e)) for s, e in zip(lo, hi)) + seg_write_pairs.append((voxel_slices, data)) + + local_src = (np.asarray(source_coords, dtype=int) - bbs).astype(int) + local_sink = (np.asarray(sink_coords, dtype=int) - bbs).astype(int) + src_new_ids = new_seg[tuple(local_src.T)].copy() + sink_new_ids = new_seg[tuple(local_sink.T)].copy() + with _prof.profile("get_roots"): - # sv_ids reused from the seg_unique block above (seg is not - # mutated between then and here). roots = cg.get_roots(sv_ids, time_stamp=parent_ts) sv_root_map = dict(zip(sv_ids, roots)) root = sv_root_map[sv_id] logger.note(f"{sv_id} -> {root}") - # Zero out every label whose root != `root`, in place. The prior - # implementation materialized a full-size shadow array via - # fastremap.remap(in_place=False) just to compare against root; - # mask_except achieves the same filter at C speed without the - # shadow allocation. + # Zero every label whose root != `root`, in place. update_edges only + # reads labels in its own wanted-set, so masking the crop's + # non-root neighbours here does not change its output. root_labels = [int(sv) for sv, r in sv_root_map.items() if r == root] fastremap.mask_except(seg, root_labels, in_place=True) - seg[voxel_overlap_crop] = new_seg + t0 = time.time() with _prof.profile("update_edges"): edges_tuple = update_edges( @@ -535,30 +558,6 @@ def split_supervoxel( rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) rows = rows0 + rows1 - # Prepare per-chunk OCDBT write payloads. The caller batches these - # across all tasks into one parallel tensorstore write — no serial - # per-task loop. - seg_write_pairs: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = [] - for _, chunk_bbox in change_chunks: - lo, hi = chunk_bbox[0], chunk_bbox[1] - local_lo = lo - bbs - local_hi = hi - bbs - data = new_seg[ - local_lo[0] : local_hi[0], - local_lo[1] : local_hi[1], - local_lo[2] : local_hi[2], - ] - voxel_slices = tuple(slice(int(s), int(e)) for s, e in zip(lo, hi)) - seg_write_pairs.append((voxel_slices, data)) - - # Per-coord fresh IDs: bit-identical to what a post-write seg read - # would return — new_seg is what the caller is about to write, and - # the caller holds the L2 chunk lock when it does, so the storage - # round-trip would see the same bytes. - local_src = (np.asarray(source_coords, dtype=int) - bbs).astype(int) - local_sink = (np.asarray(sink_coords, dtype=int) - bbs).astype(int) - src_new_ids = new_seg[tuple(local_src.T)] - sink_new_ids = new_seg[tuple(local_sink.T)] return SvSplitOutcome( seg_bbox=(bbs, bbe), src_new_ids=src_new_ids, From 33098030e37f1fa6167715296ec521decd0a99cc Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Fri, 29 May 2026 17:05:11 +0000 Subject: [PATCH 273/367] fix(sv-split): align chunk lattice to voxel offset, drop redundant root mask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make pinky (nonzero voxel_offset) SV splits work end to end. Three coupled changes plus the seg_unique optimization, all in the SV-split path: - chunks_overlapping_bbox gains an `origin` arg; the SV-split callers pass meta.voxel_bounds[:, 0] so the chunk lattice is anchored where PCG's chunks actually start. The origin-0 lattice put new supervoxel IDs in the wrong chunk on any offset dataset (and could split a grid block across two real chunks); zero-offset graphs were unaffected, which is why this stayed hidden. - Remove the root mask in _route_edges_and_rows. update_edges already trims seg to {new ids} ∪ {edge-partner svs} internally; the outer mask, keyed on pre-split ids, deleted the freshly written new-SV voxels from seg once those ids were written in place — update_edges then raised KeyError building their kdtrees. - seg_unique computes the distinct SV set per chunk over the segment-id field, restoring the chunk bits before the final union, instead of a full-uint64 unique over the whole read. Add _assert_same_chunk: every new supervoxel must share a chunk with the supervoxel it split from. split_supervoxel decomposed into _SplitCtx / stage helpers. profile.py saves the failing traceback to the run dir (load_traceback to read it back) and logs only the final exception line. --- pychunkedgraph/graph/chunks/utils.py | 24 +- pychunkedgraph/graph/sv_split/edits.py | 307 ++++++++++++++++------- pychunkedgraph/graph/sv_split/profile.py | 15 ++ 3 files changed, 247 insertions(+), 99 deletions(-) diff --git a/pychunkedgraph/graph/chunks/utils.py b/pychunkedgraph/graph/chunks/utils.py index 0e39fbf9f..cd3b96ccc 100644 --- a/pychunkedgraph/graph/chunks/utils.py +++ b/pychunkedgraph/graph/chunks/utils.py @@ -170,11 +170,9 @@ def _compute_chunk_id( ) -> np.uint64: s_bits_per_dim = meta.bitmasks[layer] if not (x < 2**s_bits_per_dim and y < 2**s_bits_per_dim and z < 2**s_bits_per_dim): - raise ValueError( - f"Coordinate is out of range \ + raise ValueError(f"Coordinate is out of range \ layer: {layer} bits/dim {s_bits_per_dim}. \ - [{x}, {y}, {z}]; max = {2 ** s_bits_per_dim}." - ) + [{x}, {y}, {z}]; max = {2 ** s_bits_per_dim}.") layer_offset = 64 - meta.graph_config.LAYER_ID_BITS x_offset = layer_offset - s_bits_per_dim y_offset = x_offset - s_bits_per_dim @@ -241,7 +239,9 @@ def get_bounding_children_chunks( @lru_cache() -def get_l2chunkids_along_boundary(cg_meta, mlayer: int, coord_a, coord_b, padding: int = 0): +def get_l2chunkids_along_boundary( + cg_meta, mlayer: int, coord_a, coord_b, padding: int = 0 +): """ Gets L2 Chunk IDs along opposing faces for larger chunks. If padding is enabled, more faces of L2 chunks are padded on both sides. @@ -284,17 +284,23 @@ def get_l2chunkids_along_boundary(cg_meta, mlayer: int, coord_a, coord_b, paddin return l2chunk_ids_a, l2chunk_ids_b -def chunks_overlapping_bbox(bbox_min, bbox_max, chunk_size) -> dict: +def chunks_overlapping_bbox(bbox_min, bbox_max, chunk_size, origin=0) -> dict: """ Find octree chunks overlapping with a bounding box in 3D and return a dictionary mapping chunk indices to clipped bounding boxes. + + `origin` is the voxel coordinate of chunk index (0, 0, 0). Pass + `meta.voxel_bounds[:, 0]` so the lattice aligns to the dataset's + chunks; the default 0 leaves the lattice anchored at the volume + origin. """ bbox_min = np.asarray(bbox_min, dtype=int) bbox_max = np.asarray(bbox_max, dtype=int) chunk_size = np.asarray(chunk_size, dtype=int) + origin = np.asarray(origin, dtype=int) - start_idx = np.floor_divide(bbox_min, chunk_size).astype(int) - end_idx = np.floor_divide(bbox_max, chunk_size).astype(int) + start_idx = np.floor_divide(bbox_min - origin, chunk_size).astype(int) + end_idx = np.floor_divide(bbox_max - origin, chunk_size).astype(int) ix = np.arange(start_idx[0], end_idx[0] + 1) iy = np.arange(start_idx[1], end_idx[1] + 1) @@ -302,7 +308,7 @@ def chunks_overlapping_bbox(bbox_min, bbox_max, chunk_size) -> dict: grid = np.stack(np.meshgrid(ix, iy, iz, indexing="ij"), axis=-1, dtype=int) grid = grid.reshape(-1, 3) - chunk_min = grid * chunk_size + chunk_min = grid * chunk_size + origin chunk_max = chunk_min + chunk_size clipped_min = np.maximum(chunk_min, bbox_min) clipped_max = np.minimum(chunk_max, bbox_max) diff --git a/pychunkedgraph/graph/sv_split/edits.py b/pychunkedgraph/graph/sv_split/edits.py index 399d82a3a..13b9b7878 100644 --- a/pychunkedgraph/graph/sv_split/edits.py +++ b/pychunkedgraph/graph/sv_split/edits.py @@ -50,6 +50,41 @@ class SvSplitTask: bbe: np.ndarray +@dataclass +class _SplitCtx: + """Per-task context shared across the split stage helpers. + + Holds the inputs that every stage threads through unchanged. `seg` is + mutated in place across stages (fresh IDs written, then root mask); + the reference is stable, so storing it here is sound. + """ + + cg: "ChunkedGraph" + seg: np.ndarray + bbs: np.ndarray + bbe: np.ndarray + bbs_: np.ndarray + bbe_: np.ndarray + sv_id: int + sv_ids: np.ndarray + source_coords: np.ndarray + sink_coords: np.ndarray + operation_id: int + time_stamp: datetime + parent_ts: datetime + + +@dataclass +class _ApplyResult: + """Outputs of `_apply_and_capture` consumed by the orchestrator.""" + + old_new_map: dict + new_id_label_map: dict + seg_write_pairs: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] + src_new_ids: np.ndarray + sink_new_ids: np.ndarray + + @dataclass class SvSplitOutcome: """Output of `split_supervoxel` for one task. Aggregated into @@ -134,7 +169,9 @@ def _l2_chunks_for_splits(cg: "ChunkedGraph", per_rep_bboxes: list) -> list[int] read_lo = np.clip(bbs - 1, vol_start, vol_end) read_hi = np.clip(bbe + 1, vol_start, vol_end) chunk_coords.update( - chunks_overlapping_bbox(read_lo, read_hi, chunk_size).keys() + chunks_overlapping_bbox( + read_lo, read_hi, chunk_size, origin=vol_start + ).keys() ) return sorted( int(cg.get_chunk_id(layer=2, x=x, y=y, z=z)) for (x, y, z) in chunk_coords @@ -364,6 +401,24 @@ def _voxel_crop(bbs, bbe, bbs_, bbe_): return voxel_overlap_crop +def _assert_same_chunk(cg: "ChunkedGraph", old_new_map: dict) -> None: + """Every new SV must live in the same chunk as the SV it split from. + + PCG segment IDs are unique only within a chunk; a split fragment that + landed in a different chunk than its parent would break the hierarchy. + """ + olds = np.fromiter(old_new_map.keys(), dtype=basetypes.NODE_ID) + news = np.fromiter( + (n for ns in old_new_map.values() for n in ns), dtype=basetypes.NODE_ID + ) + expected = np.repeat( + cg.get_chunk_ids_from_node_ids(olds), [len(ns) for ns in old_new_map.values()] + ) + assert np.array_equal( + cg.get_chunk_ids_from_node_ids(news), expected + ), "new supervoxel landed in a different chunk than the SV it split from" + + def _parse_results(results, seg, bbs, bbe): """Merge per-chunk split results into a single segmentation volume. @@ -387,44 +442,14 @@ def _parse_results(results, seg, bbs, bbe): return seg, old_new_map, new_id_label_map -def split_supervoxel( - cg: "ChunkedGraph", - task: SvSplitTask, - operation_id: int, - *, - sv_remapping: dict, - time_stamp: datetime = None, - parent_ts: datetime = None, -) -> SvSplitOutcome: - """Split one cross-chunk-connected SV into connected components. - - `task.bbs` / `task.bbe` are the base-voxel bbox covering the user's - source and sink seeds plus a one-chunk margin — `plan_sv_splits` - pre-computed this via `_coords_bbox`. The bbox is driven by where - the user wants the cut, not by the rep's full chunk envelope; rep - pieces outside the bbox aren't read and keep their existing IDs. +def _read_seg_and_ids(cg: "ChunkedGraph", bbs, bbe): + """Read seg over [bbs-1, bbe+1] and return its distinct SV IDs. - `time_stamp` is the op's logical write time; threaded through to - `copy_parents_and_add_lineage` + `add_new_edges` so every new-SV - mutation lands at the same timestamp. + The 1-voxel shell gives update_edges anchor voxels from neighbouring + SVs. Returns (seg, sv_ids, bbs_, bbe_). """ - sv_id = task.sv_id - source_coords = task.src_coords - sink_coords = task.sink_coords - bbs = task.bbs - bbe = task.bbe - vol_start = cg.meta.voxel_bounds[:, 0] vol_end = cg.meta.voxel_bounds[:, 1] - logger.note(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}") - logger.note(f"bbox: {(bbs, bbe)}") - - rep = sv_remapping.get(sv_id, sv_id) - rep_pieces = {int(sv) for sv, r in sv_remapping.items() if r == rep} - - # one voxel overlap for neighbors — update_edges needs anchor voxels - # from neighboring SVs to route existing cross-chunk edges onto the - # new fragments. bbs_ = np.clip(bbs - 1, vol_start, vol_end) bbe_ = np.clip(bbe + 1, vol_start, vol_end) _prof = get_profiler() @@ -434,17 +459,41 @@ def split_supervoxel( logger.note(f"segmentation read {seg.shape} ({time.time() - t0:.2f}s)") with _prof.profile("seg_unique"): - # Computed once and reused: seg is not mutated until - # remap_to_root, so both the cut_supervoxels narrowing below - # and the get_roots block downstream get the same answer - # without paying twice for the (seg-size) sort buffer. - sv_ids = fastremap.unique(seg) - - # Narrow the rep to pieces actually present in the bbox seg. Pieces - # of the rep whose voxels lie outside the seed-driven bbox don't - # appear in `seg` and so don't contribute to `binary_seg` anyway — - # carrying them in `cut_supervoxels` is just log noise plus inflated - # `unsplit` diff churn. + # Unique per chunk on the segment-id field only. Segment IDs are + # injective within a chunk and narrower than uint64, so the per- + # block sort is cheap; the chunk bits are OR'd back before the + # final union. The lattice is anchored at voxel_bounds[:, 0] so + # each block is exactly one chunk. Background 0 carries no chunk + # and is restored once at the end. + chunk_map = chunks_overlapping_bbox( + bbs_, bbe_, cg.meta.graph_config.CHUNK_SIZE, origin=vol_start + ) + parts = [] + has_zero = False + for (cx, cy, cz), cbbox in chunk_map.items(): + s, e = cbbox[0] - bbs_, cbbox[1] - bbs_ + sub = seg[s[0] : e[0], s[1] : e[1], s[2] : e[2]] + chunk_id = np.uint64(cg.get_chunk_id(layer=1, x=cx, y=cy, z=cz)) + limit = np.uint64(cg.get_segment_id_limit(chunk_id)) + narrow = np.min_scalar_type(int(limit)) + u = fastremap.unique((sub & limit).astype(narrow, copy=False)) + if u.size and u[0] == 0: + has_zero = True + u = u[1:] + parts.append(u.astype(np.uint64) | chunk_id) + sv_ids = np.unique(np.concatenate(parts)) if parts else np.array([], np.uint64) + if has_zero: + sv_ids = np.concatenate([[np.uint64(0)], sv_ids]) + return seg, sv_ids, bbs_, bbe_ + + +def _select_cut_supervoxels(sv_id, sv_ids, rep_pieces): + """Narrow the rep to the pieces actually present in the bbox seg. + + Rep pieces whose voxels lie outside the seed-driven bbox don't appear + in seg and contribute nothing to the cut. Returns (cut_supervoxels, + supervoxel_ids). + """ seg_ids = {int(x) for x in sv_ids if x != 0} cut_supervoxels = rep_pieces & seg_ids supervoxel_ids = np.array(list(cut_supervoxels), dtype=basetypes.NODE_ID) @@ -452,19 +501,21 @@ def split_supervoxel( f"whole sv {sv_id} -> {supervoxel_ids.tolist()} " f"({len(rep_pieces) - len(cut_supervoxels)} rep pieces outside bbox)" ) + return cut_supervoxels, supervoxel_ids + +def _compute_split(ctx: _SplitCtx, supervoxel_ids): + """Build the binary mask over the overlap crop and run the cut. + + Returns (split_result, voxel_overlap_crop). + """ + _prof = get_profiler() with _prof.profile("binary_seg"): - # OR a per-SV boolean comparison instead of np.isin: for a - # handful of supervoxel_ids, each `seg == sv` is a single C - # pass with no transient int-array buffers, vs. np.isin's - # internal sort+search that allocates auxiliaries the size - # of seg. Empty supervoxel_ids ⇒ all-False, matching np.isin. - # Compute the boolean only over the overlap crop — that's - # the only region split_supervoxel_helper consumes, so - # allocating a full-bbox boolean and then slicing wastes - # ~seg.size bytes. - voxel_overlap_crop = _voxel_crop(bbs, bbe, bbs_, bbe_) - seg_overlap = seg[voxel_overlap_crop] + # Per-SV OR over the overlap crop: each `== sv` is one C pass with + # no seg-size auxiliaries (unlike np.isin's sort+search), and the + # crop is the only region split_supervoxel_helper consumes. + voxel_overlap_crop = _voxel_crop(ctx.bbs, ctx.bbe, ctx.bbs_, ctx.bbe_) + seg_overlap = ctx.seg[voxel_overlap_crop] binary_seg = np.zeros(seg_overlap.shape, dtype=bool) for sv in supervoxel_ids: binary_seg |= seg_overlap == sv @@ -472,13 +523,31 @@ def split_supervoxel( with _prof.profile("geodesic_split"): split_result = split_supervoxel_helper( binary_seg, - source_coords - bbs, - sink_coords - bbs, - cg.meta.resolution, + ctx.source_coords - ctx.bbs, + ctx.sink_coords - ctx.bbs, + ctx.cg.meta.resolution, ) logger.note(f"split computation {split_result.shape} ({time.time() - t0:.2f}s)") + return split_result, voxel_overlap_crop + - chunks_bbox_map = chunks_overlapping_bbox(bbs, bbe, cg.meta.graph_config.CHUNK_SIZE) +def _apply_and_capture( + ctx: _SplitCtx, voxel_overlap_crop, split_result, cut_supervoxels +): + """Apply fresh IDs to seg's crop and capture the write/lookup outputs. + + Writes fresh SV IDs into seg's overlap crop in place (a view, no + full-crop copy; _parse_results only writes, never reads crop values), + then captures the OCDBT write payloads and the src/sink id lookups + while the crop still holds unmasked neighbour IDs. Everything here + runs before the root mask, which would otherwise zero the neighbour + IDs the write must preserve. Returns an `_ApplyResult`. + """ + cg, seg, bbs, bbe = ctx.cg, ctx.seg, ctx.bbs, ctx.bbe + _prof = get_profiler() + chunks_bbox_map = chunks_overlapping_bbox( + bbs, bbe, cg.meta.graph_config.CHUNK_SIZE, origin=cg.meta.voxel_bounds[:, 0] + ) t0 = time.time() results, change_chunks = _update_chunks( cg, chunks_bbox_map, seg[voxel_overlap_crop], split_result, bbs @@ -488,13 +557,12 @@ def split_supervoxel( f"{len(change_chunks)} with splits ({time.time() - t0:.2f}s)" ) - # Write fresh SV IDs into seg's crop in place (a view, no full-crop - # copy). _parse_results only writes; it never reads crop values. with _prof.profile("parse_results"): new_seg = seg[voxel_overlap_crop] new_seg, old_new_map, new_id_label_map = _parse_results( results, new_seg, bbs, bbe ) + _assert_same_chunk(cg, old_new_map) logger.note( f"old_new_map: {len(old_new_map)} SVs split, whole_sv: {len(cut_supervoxels)} SVs" ) @@ -502,13 +570,10 @@ def split_supervoxel( if unsplit: logger.note(f"unsplit SVs (kept IDs): {unsplit}") - # The OCDBT write payloads and the src/sink lookups must see the - # unmasked crop (neighbour SV IDs preserved) plus the fresh IDs. - # Capture them before the mask below zeros non-root labels, and - # before update_edges mutates seg in place. .copy() per changed - # chunk detaches each payload from seg; changed chunks only, so the - # copies stay proportional to the edit. The caller batches them into - # one parallel tensorstore write — no serial per-task loop. + # .copy() per changed chunk detaches each payload from seg before the + # mask / update_edges mutate it; changed chunks only, so the copies + # stay proportional to the edit. The caller batches them into one + # parallel tensorstore write. seg_write_pairs: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = [] for _, chunk_bbox in change_chunks: lo, hi = chunk_bbox[0], chunk_bbox[1] @@ -522,47 +587,109 @@ def split_supervoxel( voxel_slices = tuple(slice(int(s), int(e)) for s, e in zip(lo, hi)) seg_write_pairs.append((voxel_slices, data)) - local_src = (np.asarray(source_coords, dtype=int) - bbs).astype(int) - local_sink = (np.asarray(sink_coords, dtype=int) - bbs).astype(int) + local_src = (np.asarray(ctx.source_coords, dtype=int) - bbs).astype(int) + local_sink = (np.asarray(ctx.sink_coords, dtype=int) - bbs).astype(int) src_new_ids = new_seg[tuple(local_src.T)].copy() sink_new_ids = new_seg[tuple(local_sink.T)].copy() + return _ApplyResult( + old_new_map=old_new_map, + new_id_label_map=new_id_label_map, + seg_write_pairs=seg_write_pairs, + src_new_ids=src_new_ids, + sink_new_ids=sink_new_ids, + ) - with _prof.profile("get_roots"): - roots = cg.get_roots(sv_ids, time_stamp=parent_ts) - sv_root_map = dict(zip(sv_ids, roots)) - root = sv_root_map[sv_id] - logger.note(f"{sv_id} -> {root}") - # Zero every label whose root != `root`, in place. update_edges only - # reads labels in its own wanted-set, so masking the crop's - # non-root neighbours here does not change its output. - root_labels = [int(sv) for sv, r in sv_root_map.items() if r == root] - fastremap.mask_except(seg, root_labels, in_place=True) +def _route_edges_and_rows(ctx: _SplitCtx, old_new_map, new_id_label_map): + """Resolve the split's root, route edges, build bigtable rows. + + Returns the flat list of bigtable rows. + """ + cg, seg = ctx.cg, ctx.seg + _prof = get_profiler() + with _prof.profile("get_roots"): + roots = cg.get_roots(ctx.sv_ids, time_stamp=ctx.parent_ts) + sv_root_map = dict(zip(ctx.sv_ids, roots)) + root = sv_root_map[ctx.sv_id] + logger.note(f"{ctx.sv_id} -> {root}") t0 = time.time() with _prof.profile("update_edges"): edges_tuple = update_edges( cg, root, - np.array([bbs, bbe]), + np.array([ctx.bbs, ctx.bbe]), seg, old_new_map, new_id_label_map, - parent_ts=parent_ts, + parent_ts=ctx.parent_ts, ) logger.note(f"edge update ({time.time() - t0:.2f}s)") rows0 = copy_parents_and_add_lineage( - cg, operation_id, old_new_map, time_stamp=time_stamp + cg, ctx.operation_id, old_new_map, time_stamp=ctx.time_stamp ) - rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=time_stamp) - rows = rows0 + rows1 + rows1 = add_new_edges(cg, edges_tuple, old_new_map, time_stamp=ctx.time_stamp) + return rows0 + rows1 + + +def split_supervoxel( + cg: "ChunkedGraph", + task: SvSplitTask, + operation_id: int, + *, + sv_remapping: dict, + time_stamp: datetime = None, + parent_ts: datetime = None, +) -> SvSplitOutcome: + """Split one cross-chunk-connected SV into connected components. + + `task.bbs` / `task.bbe` are the base-voxel bbox covering the user's + source and sink seeds plus a one-chunk margin — `plan_sv_splits` + pre-computed this via `_coords_bbox`. The bbox is driven by where + the user wants the cut, not by the rep's full chunk envelope; rep + pieces outside the bbox aren't read and keep their existing IDs. + + `time_stamp` is the op's logical write time; threaded through to + `copy_parents_and_add_lineage` + `add_new_edges` so every new-SV + mutation lands at the same timestamp. + """ + sv_id = task.sv_id + bbs = task.bbs + bbe = task.bbe + + logger.note(f"cg.meta.ws_ocdbt: {cg.meta.ws_ocdbt.shape}; res {cg.meta.resolution}") + logger.note(f"bbox: {(bbs, bbe)}") + + rep = sv_remapping.get(sv_id, sv_id) + rep_pieces = {int(sv) for sv, r in sv_remapping.items() if r == rep} + + seg, sv_ids, bbs_, bbe_ = _read_seg_and_ids(cg, bbs, bbe) + ctx = _SplitCtx( + cg=cg, + seg=seg, + bbs=bbs, + bbe=bbe, + bbs_=bbs_, + bbe_=bbe_, + sv_id=sv_id, + sv_ids=sv_ids, + source_coords=task.src_coords, + sink_coords=task.sink_coords, + operation_id=operation_id, + time_stamp=time_stamp, + parent_ts=parent_ts, + ) + cut_supervoxels, supervoxel_ids = _select_cut_supervoxels(sv_id, sv_ids, rep_pieces) + split_result, voxel_overlap_crop = _compute_split(ctx, supervoxel_ids) + applied = _apply_and_capture(ctx, voxel_overlap_crop, split_result, cut_supervoxels) + rows = _route_edges_and_rows(ctx, applied.old_new_map, applied.new_id_label_map) return SvSplitOutcome( seg_bbox=(bbs, bbe), - src_new_ids=src_new_ids, - sink_new_ids=sink_new_ids, - seg_write_pairs=seg_write_pairs, + src_new_ids=applied.src_new_ids, + sink_new_ids=applied.sink_new_ids, + seg_write_pairs=applied.seg_write_pairs, bigtable_rows=rows, ) diff --git a/pychunkedgraph/graph/sv_split/profile.py b/pychunkedgraph/graph/sv_split/profile.py index 15092477c..1c6e135b8 100644 --- a/pychunkedgraph/graph/sv_split/profile.py +++ b/pychunkedgraph/graph/sv_split/profile.py @@ -17,6 +17,7 @@ import shutil import sys import tempfile +import traceback from contextlib import contextmanager, redirect_stdout from dataclasses import dataclass from io import StringIO @@ -227,6 +228,16 @@ def load_run(cg, payload: dict) -> Tuple[HierarchicalProfiler, SplitInputs]: return profiler, inputs +def load_traceback(cg, payload: dict) -> Optional[str]: + """Return the saved traceback for a run, or ``None`` if it succeeded. + + ``run_split_profile`` writes ``traceback.txt`` only when + ``op.execute()`` raised; its absence means the run completed. + """ + tb_path = run_dir(cg, payload) / "traceback.txt" + return tb_path.read_text() if tb_path.exists() else None + + def run_split_profile( cg, payload: dict, *, overwrite: bool = False ) -> Tuple[HierarchicalProfiler, SplitInputs]: @@ -305,6 +316,7 @@ def wrap_split_supervoxels(*a, **k): edits.plan_sv_splits = wrap_plan_sv_splits edits.split_supervoxels = wrap_split_supervoxels + tb_text = None try: op.execute() except Exception as e: @@ -312,6 +324,7 @@ def wrap_split_supervoxels(*a, **k): f"[split_profile] op.execute() raised " f"{type(e).__name__}: {e}", file=sys.stderr, ) + tb_text = traceback.format_exc() finally: MulticutOperation._run_multicut = orig_run_multicut edits.plan_sv_splits = orig_plan_sv_splits @@ -320,6 +333,8 @@ def wrap_split_supervoxels(*a, **k): try: target = _save_run(cg, payload, profiler, inputs) + if tb_text is not None: + (target / "traceback.txt").write_text(tb_text) print(f"[split_profile] run cached at {target}") except Exception as save_err: print(f"[split_profile] cache save failed: {save_err}", file=sys.stderr) From cfea904d27876c4e5a32a62d1f14c6e46bc6d0ce Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 18:47:35 +0000 Subject: [PATCH 274/367] Add scheduling package for weight-balanced work partitioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyChunkedGraph workloads are heavy-tailed: a few units (a giant supervoxel/component/parent) dwarf the median, so splitting work by count strands one worker on the giant while the rest idle. lpt_partition (Longest-Processing-Time) balances by a caller-supplied weight — the dominant item seeds its own bin and runs solo, and bins are returned heaviest-first so a pull-based pool dispatches it first. Domain-agnostic (WorkItem(payload, weight)); mesh stitching is the first caller, ingest component/chunk work can adopt it later. --- pychunkedgraph/scheduling/README.md | 50 ++++++++++++++ pychunkedgraph/scheduling/__init__.py | 3 + pychunkedgraph/scheduling/partition.py | 63 ++++++++++++++++++ pychunkedgraph/tests/scheduling/__init__.py | 0 .../tests/scheduling/test_partition.py | 66 +++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 pychunkedgraph/scheduling/README.md create mode 100644 pychunkedgraph/scheduling/__init__.py create mode 100644 pychunkedgraph/scheduling/partition.py create mode 100644 pychunkedgraph/tests/scheduling/__init__.py create mode 100644 pychunkedgraph/tests/scheduling/test_partition.py diff --git a/pychunkedgraph/scheduling/README.md b/pychunkedgraph/scheduling/README.md new file mode 100644 index 000000000..d2a2d6b5f --- /dev/null +++ b/pychunkedgraph/scheduling/README.md @@ -0,0 +1,50 @@ +# scheduling + +Generic, domain-agnostic load-balancing primitives for parallel work. Nothing +here knows about meshes, the graph, or any specific workload — callers describe +work and supply weights; this package decides how to split it across workers. + +## Why + +PyChunkedGraph workloads are **heavy-tailed**: most units are small, a few are +enormous (one supervoxel/component/parent can be orders of magnitude larger than +the median). Splitting such work into equal-*count* chunks strands a worker on +the one giant chunk while the rest sit idle — the classic distributed +**straggler**. The fix is to balance by *cost*, not count. + +## What it provides + +- **`WorkItem(payload, weight)`** — the shared unit of work. `payload` is the + caller's data (opaque to the scheduler); `weight` is a cheap cost proxy used + only to balance load (child count, component size, byte size, …). +- **`lpt_partition(items, n_bins)`** — Longest-Processing-Time-first + partitioning: sort heaviest-first, greedily place each item in the + currently-lightest bin. Makespan ≤ 4/3 × optimal, closer in practice. A + dominant item seeds its own bin and runs solo. Bins are returned + heaviest-load-first, so a pull-based pool dispatches the dominant bin first. +- **`n_bins_for(n_items, target_per_bin)`** — bin-count helper. + +## Usage + +```python +from pychunkedgraph.scheduling import WorkItem, lpt_partition, n_bins_for + +items = [WorkItem(payload=obj, weight=cost(obj)) for obj in work] +for bin_items in lpt_partition(items, n_bins_for(len(items), target_per_bin=32)): + dispatch([it.payload for it in bin_items]) # one worker batch +``` + +## Scope + +Partitioning only. It does **not** own the process pool, dispatch, result +collection, or any fork/teardown handling — those differ per caller (e.g. +mesh stitching streams results via `imap_unordered`; ingest writes directly via +`pool.map`). Each caller keeps its own pool and attaches its own shared context; +this package just decides which items go together. + +## Callers + +- `meshing/stitch` — balances per-parent stitch work by immediate-child count. +- ingest connected-component writes / chunk reads are natural future callers + (currently count-chunked); they can swap to weight-aware partitioning by + wrapping their units in `WorkItem`. diff --git a/pychunkedgraph/scheduling/__init__.py b/pychunkedgraph/scheduling/__init__.py new file mode 100644 index 000000000..3ef7e616f --- /dev/null +++ b/pychunkedgraph/scheduling/__init__.py @@ -0,0 +1,3 @@ +"""Generic scheduling / load-balancing primitives for parallel work.""" + +from .partition import WorkItem, lpt_partition, n_bins_for diff --git a/pychunkedgraph/scheduling/partition.py b/pychunkedgraph/scheduling/partition.py new file mode 100644 index 000000000..cc5af2f3c --- /dev/null +++ b/pychunkedgraph/scheduling/partition.py @@ -0,0 +1,63 @@ +"""Generic load-balanced partitioning for parallel work. + +Domain-agnostic: callers describe each unit of work as a ``WorkItem`` carrying +its payload and a cost ``weight``; the partitioner returns bins balanced by +summed weight. Used to split heavy-tailed workloads (per-node mesh stitching +today; connected-component writes and chunk reads in ingest are natural future +callers) across workers so one expensive item doesn't strand a worker while the +rest sit idle. +""" + +import heapq +import math +from dataclasses import dataclass, field +from typing import Any, List, Sequence + + +@dataclass(frozen=True) +class WorkItem: + """One unit of parallel work: the caller's ``payload`` plus its cost + ``weight`` (a proxy used only to balance load — e.g. child count, component + size). The scheduler never inspects ``payload``.""" + + payload: Any + weight: float = field(default=1.0) + + +def lpt_partition(items: Sequence[WorkItem], n_bins: int) -> List[List[WorkItem]]: + """Partition ``items`` into ``n_bins`` bins balanced by summed ``weight`` + using Longest-Processing-Time-first (LPT / greedy number partitioning): + sort heaviest-first, then place each item in the currently-lightest bin. + + Makespan (heaviest bin) is within 4/3 of optimal, and closer in practice as + the item count grows. A single dominant item seeds its own bin and stays + nearly alone — the point: it runs on one worker while the rest pack the + others, instead of stranding a worker behind it. + + Bins are returned heaviest-load first, so a pull-based pool dispatches the + dominant bin first. Empty bins (more bins than items) are dropped; ``n_bins`` + is clamped to ``[1, len(items)]``. + """ + if not items: + return [] + n_bins = max(1, min(n_bins, len(items))) + + ordered = sorted(items, key=lambda it: it.weight, reverse=True) + bins: List[List[WorkItem]] = [[] for _ in range(n_bins)] + loads = [0.0] * n_bins + heap = [(0.0, i) for i in range(n_bins)] + heapq.heapify(heap) + for item in ordered: + load, idx = heapq.heappop(heap) + bins[idx].append(item) + loads[idx] = load + item.weight + heapq.heappush(heap, (loads[idx], idx)) + + order = sorted(range(n_bins), key=lambda i: -loads[i]) + return [bins[i] for i in order if bins[i]] + + +def n_bins_for(n_items: int, target_per_bin: int) -> int: + """Bin count keeping each bin near ``target_per_bin`` items + (``ceil(n_items / target_per_bin)``), at least 1.""" + return max(1, math.ceil(n_items / max(1, target_per_bin))) diff --git a/pychunkedgraph/tests/scheduling/__init__.py b/pychunkedgraph/tests/scheduling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pychunkedgraph/tests/scheduling/test_partition.py b/pychunkedgraph/tests/scheduling/test_partition.py new file mode 100644 index 000000000..4a72bf227 --- /dev/null +++ b/pychunkedgraph/tests/scheduling/test_partition.py @@ -0,0 +1,66 @@ +"""Tests for pychunkedgraph.scheduling.partition (generic LPT partitioning).""" + +from pychunkedgraph.scheduling import WorkItem, lpt_partition, n_bins_for + + +def _loads(bins): + return [sum(it.weight for it in b) for b in bins] + + +class TestLptPartition: + def test_places_every_item_once(self): + items = [WorkItem(payload=i, weight=(i % 7) + 1) for i in range(100)] + bins = lpt_partition(items, 8) + placed = sorted(it.payload for b in bins for it in b) + assert placed == list(range(100)) + + def test_uniform_weights_split_evenly(self): + bins = lpt_partition([WorkItem(i, 1) for i in range(100)], 10) + loads = _loads(bins) + assert max(loads) - min(loads) <= 1 + + def test_bins_returned_heaviest_first(self): + items = [WorkItem(i, w) for i, w in enumerate([5, 1, 9, 3, 7, 2, 8])] + loads = _loads(lpt_partition(items, 3)) + assert loads == sorted(loads, reverse=True) + + def test_makespan_within_four_thirds_of_optimal(self): + weights = [50, 40, 30, 20] + [2] * 200 + items = [WorkItem(i, w) for i, w in enumerate(weights)] + bins = lpt_partition(items, 16) + lower_bound = max(max(weights), sum(weights) / len(bins)) + assert _loads(bins)[0] <= (4 / 3) * lower_bound + 1e-9 + + def test_dominant_item_isolated(self): + # one giant + many tiny: the giant's bin should carry essentially just it, + # not strand a worker behind a count-balanced batch of hangers-on. + items = [WorkItem("giant", 1000)] + [WorkItem(i, 1) for i in range(500)] + bins = lpt_partition(items, n_bins_for(len(items), target_per_bin=32)) + heaviest = bins[0] + assert any(it.payload == "giant" for it in heaviest) + assert _loads(bins)[0] <= 1000 + 5 # at most a few unit hangers-on + + def test_empty_input_yields_no_bins(self): + assert lpt_partition([], 8) == [] + + def test_single_item_one_bin(self): + bins = lpt_partition([WorkItem("x", 5)], 8) + assert len(bins) == 1 and bins[0][0].payload == "x" + + def test_more_bins_than_items_clamped_no_empties(self): + bins = lpt_partition([WorkItem(i, 1) for i in range(3)], 10) + assert len(bins) == 3 + assert all(b for b in bins) + + def test_default_weight_is_one(self): + assert WorkItem(payload="a").weight == 1.0 + + +class TestNBinsFor: + def test_ceil_division(self): + assert n_bins_for(100, 32) == 4 + assert n_bins_for(64, 32) == 2 + + def test_at_least_one(self): + assert n_bins_for(0, 32) == 1 + assert n_bins_for(5, 0) == 5 # target clamped to >=1 From d129f441e23023214bcae158716fa9af79ae8449 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 18:49:02 +0000 Subject: [PATCH 275/367] Make HierarchicalProfiler fold-on-record, fork-safe, and tree-reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold metrics on record so the block list is bounded by distinct stage paths, not call count — a per-fragment profiled loop across forked workers would otherwise grow unbounded and bloat the pickled result. Add sampled_rss (one RSS sample per block instead of a sampler thread per profile() call, fatal on hot substages) and call_count; render metrics/percentile reports as nested trees. Disabled profiler stays a true no-op; pickling drops the live process handle. --- pychunkedgraph/profiler/__init__.py | 1 + pychunkedgraph/profiler/hierarchical.py | 321 ++++++++++++++---- pychunkedgraph/tests/profiler/__init__.py | 0 .../tests/profiler/test_hierarchical.py | 199 +++++++++++ 4 files changed, 455 insertions(+), 66 deletions(-) create mode 100644 pychunkedgraph/tests/profiler/__init__.py create mode 100644 pychunkedgraph/tests/profiler/test_hierarchical.py diff --git a/pychunkedgraph/profiler/__init__.py b/pychunkedgraph/profiler/__init__.py index 7db7575f3..10044563b 100644 --- a/pychunkedgraph/profiler/__init__.py +++ b/pychunkedgraph/profiler/__init__.py @@ -4,3 +4,4 @@ from .hierarchical import BlockMetrics, HierarchicalProfiler from .main import PROFILER_ENABLED, get_profiler, reset_profiler +from .utils import cgroup_peak_bytes diff --git a/pychunkedgraph/profiler/hierarchical.py b/pychunkedgraph/profiler/hierarchical.py index 030c23552..bc4a50df9 100644 --- a/pychunkedgraph/profiler/hierarchical.py +++ b/pychunkedgraph/profiler/hierarchical.py @@ -3,6 +3,7 @@ from typing import Optional from typing import Tuple +import os import threading import time import tracemalloc @@ -21,6 +22,8 @@ class BlockMetrics: path: str elapsed_s: float + # Number of profile() calls folded into this block (1 per call, summed). + call_count: int = 1 py_heap_peak_bytes: int = 0 rss_start_bytes: int = 0 rss_peak_bytes: int = 0 @@ -28,6 +31,9 @@ class BlockMetrics: # Wall-clock time when this block finished, measured relative to # the first profile() entry since the profiler was reset. wall_end_s: float = 0.0 + # Process that recorded this block; lets a cross-process rollup compute + # per-worker lifetime peak RSS. 0 when unset (single-process use). + pid: int = 0 class _RSSSampler: @@ -87,9 +93,11 @@ def __init__( self.enabled = enabled self.timings: Dict[str, List[float]] = defaultdict(list) self.call_counts: Dict[str, int] = defaultdict(int) - self.stack: List[Tuple[str, float]] = [] self.current_path: List[str] = [] - self.blocks: List[BlockMetrics] = [] + # One folded BlockMetrics per path (bounded by distinct paths, not call + # count) so a hot per-call loop never grows an unbounded block list. + self._agg: Dict[str, BlockMetrics] = {} + self._order: List[str] = [] # perf_counter at the first profile() entry since reset. # Used to stamp each block's wall_end_s for cumulative-wall view. self._base_perf: Optional[float] = None @@ -102,6 +110,54 @@ def __init__( # profile() blocks in production code pick up an outer harness's # IO counters without needing to thread them through. self.default_counters: Optional[Dict[str, int]] = None + self._proc: Optional[psutil.Process] = None + + @property + def blocks(self) -> List[BlockMetrics]: + """One folded block per path, in first-seen order.""" + return [self._agg[p] for p in self._order] + + def __getstate__(self): + # _proc is a live psutil.Process handle — not picklable / not portable. + state = self.__dict__.copy() + state["_proc"] = None + return state + + def __setstate__(self, state): + self.__dict__.update(state) + + def _record(self, block: BlockMetrics) -> None: + """Fold ``block`` into the per-path aggregate (sum elapsed/counters, max + peaks, min-nonzero rss_start). Same math as ``from_blocks`` so in-process + and cross-process folds compose.""" + cur = self._agg.get(block.path) + if cur is None: + self._order.append(block.path) + self._agg[block.path] = block + return + cur.elapsed_s += block.elapsed_s + cur.call_count += block.call_count + cur.py_heap_peak_bytes = max(cur.py_heap_peak_bytes, block.py_heap_peak_bytes) + cur.rss_peak_bytes = max(cur.rss_peak_bytes, block.rss_peak_bytes) + if block.rss_start_bytes: + cur.rss_start_bytes = ( + block.rss_start_bytes + if not cur.rss_start_bytes + else min(cur.rss_start_bytes, block.rss_start_bytes) + ) + cur.wall_end_s = block.wall_end_s + for k, v in block.counter_deltas.items(): + cur.counter_deltas[k] = cur.counter_deltas.get(k, 0) + v + + def sample_rss(self) -> int: + """Current process RSS in bytes, or 0 when profiling is disabled. Gated + here so hot-loop call sites need no ``if enabled`` of their own; a + disabled profiler makes this a no-op like ``profile()``.""" + if not self.enabled: + return 0 + if self._proc is None: + self._proc = psutil.Process() + return self._proc.memory_info().rss @contextmanager def profile( @@ -110,6 +166,7 @@ def profile( *, with_memory: Optional[bool] = None, with_rss: Optional[bool] = None, + sampled_rss: bool = False, counters: Optional[Dict[str, int]] = None, ): """Context manager for profiling a code block. @@ -121,8 +178,14 @@ def profile( Optional kwargs collect extra metrics into `self.blocks`: - with_memory: tracemalloc Python heap peak per block. - with_rss: psutil RSS peak via a 50 ms sampler thread. + - sampled_rss: take RSS at enter/exit only (two memory_info calls, no + thread) — for hot, tight blocks where a sampler thread per call would + thrash; ``rss_peak`` is then max(enter, exit), not a true peak. - counters: caller-supplied dict; per-key deltas recorded (after - before for keys present at exit). + + Disabled (or when nested under a disabled profiler) this is a no-op, + so call sites need no ``if enabled`` guards. """ if not self.enabled: yield @@ -130,7 +193,7 @@ def profile( if with_memory is None: with_memory = self.with_memory_default - if with_rss is None: + if with_rss is None and not sampled_rss: with_rss = self.with_rss_default if counters is None: counters = self.default_counters @@ -149,6 +212,7 @@ def profile( if with_rss: sampler = _RSSSampler() sampler.start() + rss_enter = self.sample_rss() if sampled_rss else 0 counters_before = dict(counters) if counters is not None else None @@ -176,13 +240,16 @@ def profile( sampler.stop() rss_start = sampler.start_rss rss_peak = sampler.peak_rss + elif sampled_rss: + rss_start = rss_enter + rss_peak = max(rss_enter, self.sample_rss()) counter_deltas: Dict[str, int] = {} if counters is not None and counters_before is not None: for k, v_after in counters.items(): counter_deltas[k] = v_after - counters_before.get(k, 0) - self.blocks.append( + self._record( BlockMetrics( path=full_path, elapsed_s=elapsed, @@ -191,6 +258,7 @@ def profile( rss_peak_bytes=int(rss_peak), counter_deltas=counter_deltas, wall_end_s=end_time - self._base_perf, + pid=os.getpid(), ) ) @@ -251,6 +319,65 @@ def print_report(self, operation_id=None): # only add visual noise to the report. _SKIP_COUNTERS = ("ocdbt_reads",) + @staticmethod + def _tree_preorder(paths, sort_key): + """Parent-first pre-order over dotted ``paths`` (e.g. ``stitch.decode``), + siblings and roots ordered by ``sort_key`` (a ``path -> comparable``). + Returns the ordered path list — the shared layout for both reports.""" + children: Dict[str, List[str]] = defaultdict(list) + roots: List[str] = [] + for path in paths: + if "." in path: + children[path.rsplit(".", 1)[0]].append(path) + else: + roots.append(path) + roots.sort(key=sort_key) + for kids in children.values(): + kids.sort(key=sort_key) + + ordered: List[str] = [] + + def _visit(path): + ordered.append(path) + for child in children.get(path, []): + _visit(child) + + for root in roots: + _visit(root) + return ordered + + @staticmethod + def _print_tree_table(title, ordered_paths, metric_headers, metric_cells): + """Render a table whose row labels are ``ordered_paths`` split into one + name column per nesting depth (L0, L1, …) — a path's leaf name sits in + the column matching its depth — followed by ``metric_headers`` columns + filled from ``metric_cells`` (``path -> list[str]``). Shared by both + reports so the tree/column layout lives in one place.""" + max_depth = max(p.count(".") for p in ordered_paths) + level_cols = [f"L{i}" for i in range(max_depth + 1)] + cols = level_cols + list(metric_headers) + + rows: List[List[str]] = [] + for path in ordered_paths: + level_cells = [""] * len(level_cols) + level_cells[path.count(".")] = path.rsplit(".", 1)[-1] + rows.append(level_cells + list(metric_cells(path))) + + widths = [len(c) for c in cols] + for row in rows: + for i, value in enumerate(row): + widths[i] = max(widths[i], len(value)) + + def line(values): + return " ".join(v.ljust(widths[i]) for i, v in enumerate(values)) + + print(title) + print(line(cols)) + print(line(["-" * w for w in widths])) + for row in rows: + print(line(row)) + print() + def metrics_report(self, operation_id=None) -> None: """Print a compact, human-readable table over self.blocks. @@ -293,42 +420,13 @@ def fmt_counter(key: str, val: int) -> str: return _fmt_bytes(val) return _fmt_count(val) - # Build a parent-first pre-order over the dotted paths so each - # group reads top-down. self.blocks is in completion order - # (children before parents); order_idx preserves that as the - # tie-break for sibling ordering and top-level group order. - by_path: Dict[str, BlockMetrics] = {} - order_idx: Dict[str, int] = {} - for i, b in enumerate(self.blocks): - by_path[b.path] = b - order_idx[b.path] = i - - children: Dict[str, List[str]] = defaultdict(list) - roots: List[str] = [] - for b in self.blocks: - if "." in b.path: - children[b.path.rsplit(".", 1)[0]].append(b.path) - else: - roots.append(b.path) - roots.sort(key=lambda p: order_idx[p]) - for kids in children.values(): - kids.sort(key=lambda p: order_idx[p]) - - ordered: List[str] = [] - - def _visit(path: str) -> None: - ordered.append(path) - for child in children.get(path, []): - _visit(child) + # self.blocks is in completion order (children before parents); use it as + # the sibling/root tie-break so groups keep execution order. + by_path = {b.path: b for b in self.blocks} + order_idx = {b.path: i for i, b in enumerate(self.blocks)} + ordered = self._tree_preorder(by_path, sort_key=lambda p: order_idx[p]) - for r in roots: - _visit(r) - - # One name column per nesting level; a block's leaf name sits - # in the column matching its depth. - max_depth = max(p.count(".") for p in ordered) - level_cols = [f"L{i}" for i in range(max_depth + 1)] - metric_cols = [ + metric_headers = [ "wall", "cum_wall", "py_peak", @@ -336,49 +434,140 @@ def _visit(path: str) -> None: "rss_peak", "rss_Δ", ] + counter_keys - cols = level_cols + metric_cols - rows: List[List[str]] = [] - for path in ordered: + def metric_cells(path): b = by_path[path] - depth = path.count(".") - level_cells = [""] * len(level_cols) - level_cells[depth] = path.rsplit(".", 1)[-1] - row = level_cells + [ + cells = [ _fmt_time(b.elapsed_s), - _fmt_time(getattr(b, "wall_end_s", 0.0)), + _fmt_time(b.wall_end_s), _fmt_bytes(b.py_heap_peak_bytes), _fmt_bytes(b.rss_start_bytes), _fmt_bytes(b.rss_peak_bytes), _fmt_bytes(b.rss_peak_bytes - b.rss_start_bytes, signed=True), ] - for k in counter_keys: - row.append(fmt_counter(k, b.counter_deltas.get(k, 0))) - rows.append(row) - - widths = [len(c) for c in cols] - for row in rows: - for i, v in enumerate(row): - if len(v) > widths[i]: - widths[i] = len(v) - - def line(values: List[str]) -> str: - return " ".join(v.ljust(widths[i]) for i, v in enumerate(values)) + cells += [fmt_counter(k, b.counter_deltas.get(k, 0)) for k in counter_keys] + return cells title = "metrics report" if operation_id is not None: title = f"{title} (operation_id={operation_id})" - print(title) - print(line(cols)) - print(line(["-" * w for w in widths])) - for row in rows: - print(line(row)) + self._print_tree_table(title, ordered, metric_headers, metric_cells) def reset(self): """Reset all timing data.""" self.timings.clear() self.call_counts.clear() - self.stack.clear() self.current_path.clear() - self.blocks.clear() + self._agg.clear() + self._order.clear() self._base_perf = None + + @staticmethod + def percentile_report(blocks: List["BlockMetrics"], operation_id=None) -> None: + """Print per-stage distribution over ``blocks`` (one block per stage per + unit of work, e.g. per worker-batch). Sums hide stragglers; this shows + the spread. Columns: stage, n, wall p50/p90/p99/max, then ABSOLUTE + rss_peak p50/p99/max (memory is never summed). A stage whose wall max ≫ + p50 is the straggler; whose rss max ≫ p50 is the memory spike. Follows + with per-worker lifetime peak RSS so a leak (one worker climbing) shows.""" + if not blocks: + return + by_path: Dict[str, List["BlockMetrics"]] = defaultdict(list) + for b in blocks: + by_path[b.path].append(b) + + def pct(sorted_vals, q): + if not sorted_vals: + return 0.0 + idx = min(len(sorted_vals) - 1, int(round(q * (len(sorted_vals) - 1)))) + return sorted_vals[idx] + + total_of = {p: sum(b.elapsed_s for b in bl) for p, bl in by_path.items()} + ordered = HierarchicalProfiler._tree_preorder( + by_path, sort_key=lambda p: -total_of[p] + ) + + metric_headers = [ + "n", + "total", + "calls", + "t/call", + "wall p50", + "wall p90", + "wall p99", + "wall max", + "rss p50", + "rss p99", + "rss max", + ] + + def metric_cells(path): + bl = by_path[path] + walls = sorted(b.elapsed_s for b in bl) + peaks = sorted(b.rss_peak_bytes for b in bl) + total_s = total_of[path] + calls = sum(b.call_count for b in bl) + return [ + str(len(bl)), + _fmt_time(total_s), + _fmt_count(calls) if calls else "-", + _fmt_time(total_s / calls) if calls else "-", + _fmt_time(pct(walls, 0.50)), + _fmt_time(pct(walls, 0.90)), + _fmt_time(pct(walls, 0.99)), + _fmt_time(walls[-1]), + _fmt_bytes(pct(peaks, 0.50)), + _fmt_bytes(pct(peaks, 0.99)), + _fmt_bytes(peaks[-1]), + ] + + title = "percentile report" + if operation_id is not None: + title = f"{title} (operation_id={operation_id})" + HierarchicalProfiler._print_tree_table( + title, ordered, metric_headers, metric_cells + ) + + worker_peak: Dict[int, int] = defaultdict(int) + for b in blocks: + worker_peak[b.pid] = max(worker_peak[b.pid], b.rss_peak_bytes) + peaks = sorted(worker_peak.values()) + if peaks: + print( + f"per-worker lifetime peak RSS ({len(peaks)} workers): " + f"min {_fmt_bytes(peaks[0])}, p50 {_fmt_bytes(pct(peaks, 0.50))}, " + f"max {_fmt_bytes(peaks[-1])}" + ) + + @classmethod + def from_blocks(cls, blocks: List["BlockMetrics"]) -> "HierarchicalProfiler": + """Build a profiler whose report rolls up ``blocks`` collected across + multiple processes (each worker profiles locally and ships back its + ``self.blocks``). Same-path blocks are folded into one row: ``elapsed_s`` + and ``counter_deltas`` summed (total work across workers, which overlaps + in wall-clock); memory is absolute, never summed — ``rss_start`` is the + min (process floor when the stage first ran) and ``rss_peak`` the max + (worst the stage reached in any worker). ``timings`` / ``call_counts`` + are repopulated so ``print_report`` works too.""" + prof = cls(enabled=True) + for b in blocks: + prof.timings[b.path].append(b.elapsed_s) + prof.call_counts[b.path] += 1 + # copy so folding doesn't mutate the caller's blocks in place. + prof._record( + BlockMetrics( + path=b.path, + elapsed_s=b.elapsed_s, + call_count=b.call_count, + py_heap_peak_bytes=b.py_heap_peak_bytes, + rss_start_bytes=b.rss_start_bytes, + rss_peak_bytes=b.rss_peak_bytes, + counter_deltas=dict(b.counter_deltas), + ) + ) + + cum = 0.0 + for path in prof._order: + cum += prof._agg[path].elapsed_s + prof._agg[path].wall_end_s = cum + return prof diff --git a/pychunkedgraph/tests/profiler/__init__.py b/pychunkedgraph/tests/profiler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pychunkedgraph/tests/profiler/test_hierarchical.py b/pychunkedgraph/tests/profiler/test_hierarchical.py new file mode 100644 index 000000000..e02496aee --- /dev/null +++ b/pychunkedgraph/tests/profiler/test_hierarchical.py @@ -0,0 +1,199 @@ +"""Tests for pychunkedgraph.profiler.hierarchical (fold-on-record profiler). + +Behavior, not constants: a disabled profiler is a true no-op; profile() folds +same-path blocks; the hot path spawns no sampler thread (the regression that +corrupted stage timings); from_blocks composes across processes; the profiler +pickles (sv_split persists it); and the existing consumers (print_report / +metrics_report / read-only blocks) keep working. +""" + +import io +import pickle +import threading +from contextlib import redirect_stdout +from unittest import mock + +import psutil + +from pychunkedgraph.profiler import HierarchicalProfiler + + +def _loads(prof): + return prof.blocks + + +class TestNoOpWhenDisabled: + def test_no_memory_info_and_no_blocks(self): + prof = HierarchicalProfiler(enabled=False) + calls = {"n": 0} + real = psutil.Process.memory_info + + def counting(self): + calls["n"] += 1 + return real(self) + + with mock.patch.object(psutil.Process, "memory_info", counting): + for _ in range(500): + with prof.profile("x", sampled_rss=True): + pass + prof.sample_rss() + assert calls["n"] == 0 + assert prof.blocks == [] and not prof.timings + + +class TestLightweight: + def test_substage_blocks_spawn_no_thread(self): + # Regression guard: with with_rss_default=False (how the stitch worker is + # configured) a bare profile() block is timing-only — no sampler thread + # per call. A per-fragment thread would corrupt the stage timings. + prof = HierarchicalProfiler(enabled=True, with_memory=False, with_rss=False) + base = threading.active_count() + seen = [] + for name in ("decode", "transform", "merge", "encode"): + with prof.profile(name): + seen.append(threading.active_count()) + with prof.profile("stitch", sampled_rss=True): + seen.append(threading.active_count()) + assert all(c == base for c in seen), (seen, base) + + def test_sampled_rss_is_threadless(self): + prof = HierarchicalProfiler(enabled=True, with_rss=False) + rss = iter([100, 700]) + with mock.patch.object(prof, "sample_rss", lambda: next(rss)): + base = threading.active_count() + with prof.profile("s", sampled_rss=True): + during = threading.active_count() + assert during == base + b = prof.blocks[0] + assert b.rss_start_bytes == 100 and b.rss_peak_bytes == 700 + + +class TestFoldOnRecord: + def test_same_path_folds_to_one_block(self): + prof = HierarchicalProfiler(enabled=True, with_memory=False, with_rss=False) + perf = iter([0.0, 1.0, 0.0, 2.0, 0.0, 3.0]) + rss = iter([100, 500, 100, 200, 100, 400]) + with mock.patch("time.perf_counter", lambda: next(perf)), mock.patch.object( + prof, "sample_rss", lambda: next(rss) + ): + for _ in range(3): + with prof.profile("enc", sampled_rss=True): + pass + blocks = prof.blocks + assert len(blocks) == 1 + b = blocks[0] + assert abs(b.elapsed_s - 6.0) < 1e-9 # summed + assert b.rss_peak_bytes == 500 # max + assert b.rss_start_bytes == 100 # min-nonzero + assert b.call_count == 3 # 1 per call, summed + assert prof.call_counts["enc"] == 3 + + def test_rss_off_blocks_do_not_pin_start_to_zero(self): + prof = HierarchicalProfiler(enabled=True, with_rss=False) + rss = iter([300, 700]) # only the sampled_rss block samples + seq = iter([0.0, 0.1, 0.0, 0.1]) + with mock.patch("time.perf_counter", lambda: next(seq)), mock.patch.object( + prof, "sample_rss", lambda: next(rss) + ): + with prof.profile("s"): # no rss -> 0/0 + pass + with prof.profile("s", sampled_rss=True): # 300/700 + pass + b = prof.blocks[0] + assert b.rss_start_bytes == 300 and b.rss_peak_bytes == 700 + + def test_block_count_bounded_by_distinct_paths(self): + prof = HierarchicalProfiler(enabled=True, with_rss=False) + for _ in range(1000): + with prof.profile("a"): + pass + with prof.profile("b"): + pass + assert len(prof.blocks) == 2 + + +class TestFromBlocksComposes: + def test_cross_process_merge(self): + a = HierarchicalProfiler(enabled=True, with_rss=False) + sa = iter([0.0, 2.0]) + ra = iter([100, 400]) + with mock.patch("time.perf_counter", lambda: next(sa)), mock.patch.object( + a, "sample_rss", lambda: next(ra) + ): + with a.profile("m", sampled_rss=True): + pass + b = HierarchicalProfiler(enabled=True, with_rss=False) + sb = iter([0.0, 3.0]) + rb = iter([200, 900]) + with mock.patch("time.perf_counter", lambda: next(sb)), mock.patch.object( + b, "sample_rss", lambda: next(rb) + ): + with b.profile("m", sampled_rss=True): + pass + merged = HierarchicalProfiler.from_blocks(a.blocks + b.blocks).blocks[0] + assert abs(merged.elapsed_s - 5.0) < 1e-9 # summed + assert merged.rss_peak_bytes == 900 # max + assert merged.rss_start_bytes == 100 # min + assert merged.call_count == 2 + + +class TestConsumersIntact: + def test_pickle_round_trip_drops_proc(self): + prof = HierarchicalProfiler(enabled=True) + prof.sample_rss() + assert prof._proc is not None + restored = pickle.loads(pickle.dumps(prof)) + assert restored._proc is None + + def test_print_report_total_matches_timings(self): + prof = HierarchicalProfiler(enabled=True, with_rss=False) + seq = iter([0.0, 0.5, 0.0, 0.7]) + with mock.patch("time.perf_counter", lambda: next(seq)): + with prof.profile("a"): + pass + with prof.profile("b"): + pass + buf = io.StringIO() + with redirect_stdout(buf): + prof.print_report() + assert "PROFILER REPORT" in buf.getvalue() + assert abs(sum(sum(t) for t in prof.timings.values()) - 1.2) < 1e-9 + + def test_metrics_report_renders(self): + prof = HierarchicalProfiler(enabled=True, with_rss=False) + with prof.profile("a"): + pass + buf = io.StringIO() + with redirect_stdout(buf): + prof.metrics_report() + assert "metrics report" in buf.getvalue() + + def test_reports_nest_paths_into_level_columns(self): + # nested paths (stitch.decode) render in separate L0/L1 columns, the same + # tree layout for both reports; each table ends with a blank line. + prof = HierarchicalProfiler(enabled=True, with_rss=False) + with prof.profile("stitch"): + with prof.profile("decode"): + pass + for render in ( + lambda: prof.metrics_report(), + lambda: HierarchicalProfiler.percentile_report(prof.blocks), + ): + buf = io.StringIO() + with redirect_stdout(buf): + render() + out = buf.getvalue() + header = next(ln for ln in out.splitlines() if ln.startswith("L0")) + assert "L0" in header and "L1" in header, header + # the nested child's leaf name appears (not the dotted path). + assert "decode" in out and "stitch.decode" not in out + # the table block is followed by a blank line. + assert "\n\n" in out, "table must be followed by a blank line" + + def test_blocks_is_read_only(self): + prof = HierarchicalProfiler(enabled=True) + try: + prof.blocks = [] + except AttributeError: + return + raise AssertionError("blocks must be a read-only property") From c2c74051969d7ac69f666d8f865f347611e9b63f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 18:49:33 +0000 Subject: [PATCH 276/367] Add cgroup peak-memory reader for pod sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cgroup_peak_bytes reads the cgroup v2 memory high-water mark (v1 fallback) — the whole-cgroup peak k8s enforces — for sizing mesh worker pods, and is exported from the profiler package. Returns None when no cgroup memory file is present. --- pychunkedgraph/profiler/utils.py | 30 +++++++++++ pychunkedgraph/tests/profiler/test_utils.py | 58 +++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 pychunkedgraph/tests/profiler/test_utils.py diff --git a/pychunkedgraph/profiler/utils.py b/pychunkedgraph/profiler/utils.py index d2925730c..ebdf623d8 100644 --- a/pychunkedgraph/profiler/utils.py +++ b/pychunkedgraph/profiler/utils.py @@ -1,3 +1,33 @@ +import os +from typing import Optional + + +def cgroup_peak_bytes() -> Optional[int]: + """Peak memory of the whole control group (cgroup) in bytes, or ``None`` when + not running under a memory cgroup (e.g. a dev box or macOS). + + This is the high-water mark the kernel tracks for the entire cgroup — the + parent process plus every forked worker plus page cache it accounts — which + is exactly the figure a Kubernetes pod's memory limit / OOM killer enforces. + Use it to size pod memory requests/limits for the stitching job, since an + in-process per-worker RSS reading cannot see the concurrent whole-pod total. + + Reads cgroup v2 ``memory.peak`` first, then the v1 + ``memory.max_usage_in_bytes`` fallback. Returns ``None`` if neither is + readable so callers can degrade gracefully. + """ + for path in ( + "/sys/fs/cgroup/memory.peak", # cgroup v2 (the container's own cgroup root) + "/sys/fs/cgroup/memory/memory.max_usage_in_bytes", # cgroup v1 + ): + try: + with open(path) as handle: + return int(handle.read().strip()) + except (OSError, ValueError): + continue + return None + + def _fmt_time(s: float) -> str: """Auto-scale seconds → ``12.3 ms`` / ``1.23 s``.""" if s < 1.0: diff --git a/pychunkedgraph/tests/profiler/test_utils.py b/pychunkedgraph/tests/profiler/test_utils.py new file mode 100644 index 000000000..ba712e02a --- /dev/null +++ b/pychunkedgraph/tests/profiler/test_utils.py @@ -0,0 +1,58 @@ +"""Tests for pychunkedgraph.profiler.utils.cgroup_peak_bytes. + +Behavior across environments: reads cgroup v2 ``memory.peak`` when present, falls +back to cgroup v1 ``memory.max_usage_in_bytes``, and returns ``None`` when neither +is readable (dev box / macOS) so callers degrade gracefully. The k8s pod-sizing +figure depends on this, so the v2/v1/none paths are all pinned. +""" + +import builtins + +import pytest + +from pychunkedgraph.profiler.utils import cgroup_peak_bytes + +V2 = "/sys/fs/cgroup/memory.peak" +V1 = "/sys/fs/cgroup/memory/memory.max_usage_in_bytes" + + +def _fake_open(available): + """Return an ``open`` replacement that serves ``available`` {path: text} and + raises FileNotFoundError for anything else (mirrors a missing cgroup file).""" + real_open = builtins.open + + def opener(path, *args, **kwargs): + if path in available: + import io + + return io.StringIO(available[path]) + if path in (V2, V1): + raise FileNotFoundError(path) + return real_open(path, *args, **kwargs) + + return opener + + +def test_reads_cgroup_v2_peak(monkeypatch): + monkeypatch.setattr(builtins, "open", _fake_open({V2: "123456789\n"})) + assert cgroup_peak_bytes() == 123456789 + + +def test_falls_back_to_v1(monkeypatch): + monkeypatch.setattr(builtins, "open", _fake_open({V1: "987654321\n"})) + assert cgroup_peak_bytes() == 987654321 + + +def test_v2_preferred_over_v1(monkeypatch): + monkeypatch.setattr(builtins, "open", _fake_open({V2: "111\n", V1: "999\n"})) + assert cgroup_peak_bytes() == 111 + + +def test_none_when_no_cgroup(monkeypatch): + monkeypatch.setattr(builtins, "open", _fake_open({})) + assert cgroup_peak_bytes() is None + + +def test_none_on_garbage(monkeypatch): + monkeypatch.setattr(builtins, "open", _fake_open({V2: "max\n"})) + assert cgroup_peak_bytes() is None From d245d88f1718adca6564165ed9fd7f7a08a06963 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 18:50:35 +0000 Subject: [PATCH 277/367] Add parallel sharded mesh stitching, mesh-equivalent to the serial path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer-3+ stitching forked across a worker pool: the parent does only the serial work (one graph query, fan-out, collect, synthesize, upload), and each worker rebuilds its own cg/cv (tensorstore is fork-hostile) and byte-range-fetches only the fragments it needs. Parents are LPT-partitioned so the giant straggler runs solo in its own bin; each worker overlaps its reads with compute via a single-thread prefetch. Merge uses a dense lookup-table gather and float32 dedup — no per-vertex Python dict, no float64 upcast. Output is gated mesh-equivalent to the serial meshgen shard (crc32c, else per-label decoded-mesh parity). --- pychunkedgraph/meshing/stitch/README.md | 149 ++++++++++ pychunkedgraph/meshing/stitch/__init__.py | 5 + pychunkedgraph/meshing/stitch/task.py | 314 ++++++++++++++++++++++ pychunkedgraph/meshing/stitch/utils.py | 307 +++++++++++++++++++++ pychunkedgraph/meshing/stitch/worker.py | 224 +++++++++++++++ 5 files changed, 999 insertions(+) create mode 100644 pychunkedgraph/meshing/stitch/README.md create mode 100644 pychunkedgraph/meshing/stitch/__init__.py create mode 100644 pychunkedgraph/meshing/stitch/task.py create mode 100644 pychunkedgraph/meshing/stitch/utils.py create mode 100644 pychunkedgraph/meshing/stitch/worker.py diff --git a/pychunkedgraph/meshing/stitch/README.md b/pychunkedgraph/meshing/stitch/README.md new file mode 100644 index 000000000..95b3a08ad --- /dev/null +++ b/pychunkedgraph/meshing/stitch/README.md @@ -0,0 +1,149 @@ +# stitch — parallel sharded mesh stitching + +Parallel rewrite of `meshgen.chunk_initial_sharded_stitching_task` (layer-3+ mesh +stitching). Output is **mesh-equivalent** to the single-threaded `meshgen` shard +(crc32c / per-label decoded-mesh gate); correctness is the hard constraint, speed +is secondary. `meshing.meshing_sqs.MeshTask` dispatches this for layer-3+ chunks; +the old `meshgen` function is kept (deprecated) but no longer called. + +This file is the maintained design + performance reference for the package. Keep +it in sync when the scheduler, profiler integration, or the byte path changes. + +## What it does + +For a layer-L chunk, each valid multi-child parent's descendant fragments are +fetched from the lower-layer `initial` sharded meshes, transformed into the +parent's quantization, merged across the chunk boundary, re-encoded as draco, and +written to one output shard. + +## Module map + +| file | role | +|------|------| +| `task.py` | parent orchestration: whole-chunk scan → LPT batches → fork pool → collect → synthesize → upload. Public: `chunk_initial_sharded_stitching_task_mp`. | +| `worker.py` | per-process work: rebuilds its own `cg`/`cv`, resolves descendants, byte-range fetches fragments, stitches. Pure `with prof.profile(...)` instrumentation. | +| `utils.py` | `cg`-free geometry/transform/merge helpers (the byte math, mirrors `meshgen`), `MipInfo`, `derive_batch_size`, `sample_debug`. | +| `profile.py` | notebook-driven harness: run + crc32c/mesh compare against production + metrics report. Never writes production. | +| `_forkdiag.py` | one-shot fork-safety diagnostic (debug only). | + +Batch balancing lives in the generic `pychunkedgraph/scheduling` package +(`lpt_partition`), not here — see its README. + +## Design decisions (the load-bearing ones) + +**Fork safety.** tensorstore (the watershed `cv`) installs a fork-hostile guard; +a forked child that touches it `abort()`s. So workers never receive a live `cg`/ +`cv` — they rebuild from `cg.get_serialized_info()` (`{graph_id}`) + the graphene +`info` dict (the ingest pattern). The `mp.Pool` is forked **before** the parent +touches the network. Plain `fork`, no spawn. (`_forkdiag.py` localizes this.) + +**Memory.** Workers byte-range-fetch only the labels they need +(`readers[layer].get_data`), never whole shards, no cache — fetching whole shards +blows the per-worker footprint up by orders of magnitude. + +**Anti-straggler scheduling (LPT).** Per-parent cost is heavy-tailed: most parents +are small, a few are orders of magnitude larger (a single giant fragment can dwarf +the rest combined). A fixed-size batch that happens to hold the giant strands one +worker while the others idle. `_make_batches` uses `scheduling.lpt_partition` +(Longest-Processing-Time: sort heaviest-first by immediate-child count, assign each +to the least-loaded bin) so the giant **seeds its own bin and runs solo** while the +rest pack the others. Bins are yielded heaviest-first so the giant dispatches first. +Makespan floor is the giant's own solo encode — irreducible (see profile below). + +**Read/compute overlap.** Each batch is split into two halves; the second half's +fragments are prefetched on a single-thread `ThreadPoolExecutor` while the first +half stitches (GCS read is GIL-released IO, stitch is CPU). One fetch in flight → +at most two halves' bytes resident (lower peak than fetching the whole batch). + +**Profiling.** All measurement lives in `HierarchicalProfiler`; call sites are pure +`with prof.profile("name")` blocks. The worker times decode/transform/merge/encode +(per fragment/parent) and samples RSS **once per batch** (the `stitch` block, +`sampled_rss=True`) — the hot substages are timing-only. The harness flips +`get_profiler().enabled = True` (and `with_memory_default=False`, +`with_rss_default=False`) once before the fork; workers inherit it. Production +(env unset) is a true no-op. Per-batch profiler blocks are folded on record, so the +block list is bounded by the number of distinct stages, not the call count. + +## Measured profile (full run, 16 workers, LPT) + +Output `MESH-EQUIVALENT` to production. Shares are of aggregate worker CPU time +(sum across the process pool): + +| stage | share | nature | +|------|------|------| +| encode | ~47 % | one `DracoPy.encode_mesh_to_buffer` per parent (C, the floor) | +| decode | ~29 % | one `DracoPy.decode_buffer_to_mesh` per descendant fragment (C) | +| read | ~14 % | GCS byte-range fetch (IO; overlapped) | +| merge | ~4 % | numpy concat + dedup per parent | +| transform | ~2 % | numpy affine per fragment | +| graph | <1 % | bigtable descendant resolution | + +**The wall is the giant straggler's encode.** The single largest fragment's encode +is a double-digit percentage of total wall on its own — that one parent holds the +job open after the rest of the pool finishes. LPT isolates it (own solo bin, +dispatched first), but a single mesh's encode is irreducible: DracoPy has no +batch/streaming encode, and the fragment can't be split without changing output +bytes. Compute (encode+decode+merge) ≈ 80 % — cores are saturated; the residual is +this tail. + +## Batchability of decode/transform/merge/encode + +Researched (incl. feasibility of adding a batch API to DracoPy, which we own). +Bottom line: cores are already saturated by the process pool, so thread-level +re-parallelization of CPU work the pool already parallelizes is theater. Only +work-reducing or genuinely-idle-core changes help. + +| stage | batchable? | byte-safe? | payoff (cores saturated) | verdict | +|------|------|------|------|------| +| **decode** | yes — `decode_many` C++ loop in DracoPy, nogil/OpenMP; per-buffer calls are self-contained, no shared state | yes (results in input order) | ~0 common case; only the giant's thousands of fragment decodes vs 15 idle cores during its solo tail | **straggler-only, likely not worth it** | +| **encode** | no — giant straggler is ONE mesh = one un-batchable `encode_mesh` | n/a | none | **no** | +| **transform** | partial — group by source layer, stacked affine via `np.repeat` origins | yes (element-wise, no reduction) | ~1 % ceiling, eroded by grouping | **not worth it** | +| **merge** | already 1 op/parent; two work-reducing trims applied (below) | yes (byte-gated) | real — cuts CPU + transient allocs on big parents | **done** | + +Net: cores are already saturated by the process pool, so a DracoPy batch API +(`decode_many`/`encode_many`) only re-parallelizes work the pool already does — +worthless in the common case. Encode can never be batched (the giant straggler is a +single mesh). Decode batching is defensible only for the giant's fragment decodes +during its solo tail, and even that is undercut by its un-batchable encode. The full +source-cited feasibility study (DracoPy `.pyx`/`.h`/draco internals, the GIL/nogil +analysis, the cheaper zero-copy `np.frombuffer` alternative) lives outside the repo +at `dist/meshing-perf/dracopy-batch-feasibility.md` for review. + +## Applied byte-gated optimizations + +In `utils.merge_draco_meshes_across_boundaries_pure` (each verified mesh-equivalent +against production): +1. **Dense lookup-table gather for the index remap** — a `lut = np.empty(N, uint32)` + indexed by old vertex id and gathered as `new_faces = lut[faces]`, instead of a + per-vertex Python dict + `fastremap.remap`. Same result (dense integer remap), + no multi-million-entry dict on big parents. +2. **float32 dedup, index carried out of band** — the index column rides alongside + as `uint32` rather than `hstack`ed onto the vertices (which would upcast them to + float64 and double the `np.unique` working set); `np.unique` runs on float32 and + the vertices stay float32 through encode. + +Also: `meshgen.decode_draco_mesh_buffer` uses `np.asarray` (zero-copy view of the +DracoPy buffer, not a copy); descendant resolution and per-layer bucketing are +batched (one `get_downstream_multi_child_nodes` / one `get_chunk_layers` per call). + +## Verification + +Offline gates (pcg env, no live cg) — recreate under `/tmp` or promote to pytest: +- `scheduling` LPT: `pychunkedgraph/tests/scheduling/test_partition.py` (committed). +- `_make_batches` LPT contract (parent set + child lists preserved, giant isolated solo). +- `sample_debug` medium..tiny selection. +- prefetch `_split_in_two` / `_group_labels` (each parent stitched once with its own labels, overlap occurs, ≤2 halves resident). +- profiler: disabled = no-op (0 `memory_info`); substage blocks spawn no sampler thread; fold-on-record (sum elapsed/call_count, max rss, min-nonzero start); `from_blocks` composes; pickle drops `_proc`; consumers (`print_report`/`metrics_report`) intact. + +Live (notebook): `profile.run_parallel(cg, chunk_id, mip=0, out_subdir="test_par")` +then `profile.compare_against_production(cg, chunk_id, "test_par")` → must be +**MESH-EQUIVALENT** (crc32c fast path, else per-label decoded-mesh parity). The +compare fails fast on the first mismatched label by default; pass `fail_fast=False` +to scan all and report the total mismatch count. Use `max_parents=` for a fast +medium-sized preview (`out_subdir="test_debug"`, `subset=True` compare → +**SUBSET-EQUIVALENT**). + +## Config knobs + +- `n_processes` arg, or `PCG_MESH_STITCH_WORKERS` env (arg wins; else env; else cpu_count). +- `PCG_PROFILER_ENABLED=1` or the harness flipping `get_profiler().enabled` enables the report. diff --git a/pychunkedgraph/meshing/stitch/__init__.py b/pychunkedgraph/meshing/stitch/__init__.py new file mode 100644 index 000000000..94a2efb51 --- /dev/null +++ b/pychunkedgraph/meshing/stitch/__init__.py @@ -0,0 +1,5 @@ +from pychunkedgraph.meshing.stitch.task import ( + chunk_initial_sharded_stitching_task_mp, +) + +__all__ = ["chunk_initial_sharded_stitching_task_mp"] diff --git a/pychunkedgraph/meshing/stitch/task.py b/pychunkedgraph/meshing/stitch/task.py new file mode 100644 index 000000000..20e66c044 --- /dev/null +++ b/pychunkedgraph/meshing/stitch/task.py @@ -0,0 +1,314 @@ +# pylint: disable=invalid-name, missing-docstring, c-extension-no-member + +import os +import math +import time +import multiprocessing as mp + +import numpy as np +from tqdm import tqdm +from cloudfiles import CloudFiles +from cloudvolume import CloudVolume +from cloudvolume.datasource.precomputed.sharding import ShardingSpecification + +from pychunkedgraph import get_logger +from pychunkedgraph.graph import attributes +from pychunkedgraph.graph.chunkedgraph import ChunkedGraph +from pychunkedgraph.graph.utils.generic import filter_failed_node_ids +from pychunkedgraph.meshing import meshgen_utils +from pychunkedgraph.meshing.stitch import utils as su +from pychunkedgraph.meshing.stitch.worker import stitch_parents_batch +from pychunkedgraph.profiler import HierarchicalProfiler, cgroup_peak_bytes +from pychunkedgraph.scheduling import WorkItem, lpt_partition, n_bins_for + +logger = get_logger(__name__) + + +def _multi_child_parents(cg, chunk_id): + """Whole-chunk scan -> ``{parent_id: [immediate_child_id,...]}`` for valid + parents with >1 child. Failed/incomplete-ingest nodes are dropped via + ``filter_failed_node_ids`` (the ingest-canonical filter; local dedup on the + range-read, no extra bigtable). Descendant resolution is left to workers.""" + child_col = attributes.Hierarchy.Child + range_read = cg.range_read_chunk(chunk_id, properties=child_col) + + row_ids = [] + children = [] + max_children_ids = [] + for row_id, row_data in range_read.items(): + child_ids = row_data[0].value + row_ids.append(row_id) + children.append(child_ids) + max_children_ids.append(np.max(child_ids)) + + row_ids = np.array(row_ids, dtype=np.uint64) + segment_ids = np.array([cg.get_segment_id(r) for r in row_ids]) + valid = set( + int(x) for x in filter_failed_node_ids(row_ids, segment_ids, max_children_ids) + ) + + return { + int(row_id): [int(c) for c in child_ids] + for row_id, child_ids in zip(row_ids, children) + if int(row_id) in valid and len(child_ids) > 1 + } + + +class _Results: + """Collects per-parent worker results into the shard's label->bytes map and + reporting stats, order-independent. Gathers every batch's profiler blocks + for a cross-process rollup.""" + + def __init__(self): + self.merged_meshes = {} + self.bad_meshes = [] + self.biggest_frag = 0 + self.biggest_frag_vx_ct = 0 + self.n_processed = 0 + self.n_missing_fragments = 0 + self.blocks = [] + + def record_batch(self, results, blocks): + self.blocks.extend(blocks) + for parent_id, frag_bytes, vx_ct, is_bad, n_missing in results: + self.n_processed += 1 + self.n_missing_fragments += n_missing + if vx_ct > self.biggest_frag_vx_ct: + self.biggest_frag = parent_id + self.biggest_frag_vx_ct = vx_ct + if is_bad: + self.bad_meshes.append(parent_id) + elif frag_bytes is not None: + self.merged_meshes[parent_id] = frag_bytes + + +def _make_batches( + multi_child_nodes, cg_info, info, mip_info, boundary, layer, batch_size +): + """Yield worker batches. Each batch carries only IDs + the picklable + cg_info ({graph_id}), the graphene info dict, the MipInfo, and the shared + boundary — no live cg/meta, no tensorstore, no mesh bytes in the parent. + + Parents are LPT-partitioned (``scheduling.lpt_partition``) into bins balanced + by immediate-child count (a free proxy for encode cost), so a giant parent + seeds its own bin and runs solo on one worker while the rest pack the others + — the straggler fix. Bins come back heaviest-first, so the giant's bin is + dispatched first under the pull-based pool.""" + items = [ + WorkItem( + payload=(int(parent_id), [int(c) for c in multi_child_nodes[parent_id]]), + weight=len(multi_child_nodes[parent_id]), + ) + for parent_id in multi_child_nodes + ] + for bin_items in lpt_partition(items, n_bins_for(len(items), batch_size)): + yield { + "cg_info": cg_info, + "info": info, + "mip_info": mip_info, + "layer": layer, + "boundary": boundary, + "parents": [it.payload for it in bin_items], + } + + +def _assert_no_fragment_loss(acc, n_parents, multi_child_nodes): + assert ( + acc.n_processed == n_parents + ), f"result/parent mismatch: {acc.n_processed} results vs {n_parents} parents" + assert len(acc.merged_meshes) + len(acc.bad_meshes) <= acc.n_processed, ( + f"accounting: {len(acc.merged_meshes)} merged + {len(acc.bad_meshes)} bad " + f"> {acc.n_processed} processed" + ) + parent_keys = {int(k) for k in multi_child_nodes} + assert ( + set(acc.merged_meshes) | set(acc.bad_meshes) <= parent_keys + ), "produced a mesh for an id that is not a multi_child_nodes parent" + + +def _write_shard( + cv, sharding_spec, layer, chunk_id, merged_meshes, out_subdir, cache_string +): + t = time.time() + shard_binary = sharding_spec.synthesize_shard(merged_meshes) + synth_s = time.time() - t + shard_filename = cv.mesh.readers[layer].get_filename(chunk_id) + cf = CloudFiles( + os.path.join(cv.cloudpath, cv.mesh.meta.mesh_path, out_subdir, str(layer)) + ) + logger.note( + "synthesized shard (%.2f GB, %s labels) in %.1fs; uploading %s/%s/%s", + len(shard_binary) / 1e9, + len(merged_meshes), + synth_s, + out_subdir, + int(layer), + shard_filename, + ) + t = time.time() + cf.put( + shard_filename, + shard_binary, + content_type="application/octet-stream", + compress=False, + cache_control=cache_string, + ) + return len(shard_binary), synth_s, time.time() - t + + +def chunk_initial_sharded_stitching_task_mp( + cg_name, + chunk_id, + mip, + cg=None, + high_padding=1, + cache=True, + n_processes=None, + out_subdir="initial", + max_parents=None, +): + """Parallel sharded stitch. Main does only the serial work (one graph query, + fan-out, collect, synthesize, upload); each worker gets ``cg.meta`` + the + JSON info once and fetches/stitches its own child shards. Output is + mesh-equivalent to ``meshgen.chunk_initial_sharded_stitching_task``. + + ``out_subdir`` overrides the write location (default ``"initial"``). + ``max_parents`` caps the number of parents stitched (debug preview of the + full pipeline + report); they are sampled from the smaller half (medium down + to tiny), skipping the giant stragglers so the preview stays fast. The shard + it writes is partial — never use it as ``initial``. + """ + start_time = time.time() + if n_processes is None: + n_processes = ( + int(os.environ.get("PCG_MESH_STITCH_WORKERS", 0)) or os.cpu_count() + ) + if max_parents is not None: + # The pool must fork before any network I/O (s2n atfork), so cap workers + # to the batch count up front — workers beyond n_batches never get a + # batch. n_parents <= max_parents, so this batch count is the worst case. + capped_batch_size = su.derive_batch_size(max_parents, max(n_processes, 1)) + n_batches = max(1, math.ceil(max_parents / capped_batch_size)) + n_processes = min(n_processes, n_batches) + + # Fork the worker pool FIRST, before the parent touches the network. The + # forked children re-init their own TLS/cg; forking AFTER the parent has + # done cloud I/O trips s2n's pthread_atfork guard ("fork() detected") and + # kills the child. mp.Pool forks eagerly at construction (unlike + # ProcessPoolExecutor, which forks lazily on first submit). + pool = None if n_processes == 1 else mp.Pool(n_processes) + + try: + if cg is None: + cg = ChunkedGraph(graph_id=cg_name) + cache_string = "public" if cache else "no-cache" + + layer = cg.get_chunk_layer(chunk_id) + info = meshgen_utils.get_json_info(cg) + cv = CloudVolume( + "graphene://https://localhost/segmentation/table/dummy", info=info + ) + sharding_spec = ShardingSpecification.from_dict( + cv.mesh.meta.info["sharding"][str(layer)] + ) + + cg_info = cg.get_serialized_info() + mip_info = su.MipInfo.from_meta(cg.meta, mip) + multi_child_nodes = _multi_child_parents(cg, chunk_id) + if max_parents is not None: + multi_child_nodes = su.sample_debug(multi_child_nodes, max_parents) + n_parents = len(multi_child_nodes) + boundary = su.compute_merge_boundary(cg.meta, chunk_id, mip_info, high_padding) + batch_size = su.derive_batch_size(n_parents, max(n_processes, 1)) + logger.note( + "chunk %s layer %s: %s parents, %s workers, batch_size=%s, out=%s", + int(chunk_id), + int(layer), + n_parents, + n_processes, + batch_size, + out_subdir, + ) + + acc = _Results() + progress = tqdm(total=n_parents, desc="stitch", unit="parent") + batches = _make_batches( + multi_child_nodes, cg_info, info, mip_info, boundary, layer, batch_size + ) + + if pool is None: + for batch in batches: + results, blocks = stitch_parents_batch(batch) + acc.record_batch(results, blocks) + progress.update(len(results)) + else: + for results, blocks in pool.imap_unordered(stitch_parents_batch, batches): + acc.record_batch(results, blocks) + progress.update(len(results)) + progress.close() + join_s = 0.0 + if pool is not None: + # imap_unordered is fully consumed here, so every result is already + # in acc. A graceful close()/join() blocks for minutes while each + # worker's exit waits on grpc's non-daemon C-core threads to drain; + # terminate() SIGTERMs them immediately and loses no results. + t = time.time() + pool.terminate() + pool.join() + join_s = time.time() - t + logger.note( + "all batches collected; worker pool terminated in %.1fs", join_s + ) + finally: + if pool is not None: + pool.terminate() + + _assert_no_fragment_loss(acc, n_parents, multi_child_nodes) + if acc.n_missing_fragments: + logger.warning( + "%s expected child fragments were missing (absent in initial shards)", + acc.n_missing_fragments, + ) + + shard_bytes, synth_s, upload_s = _write_shard( + cv, + sharding_spec, + layer, + chunk_id, + acc.merged_meshes, + out_subdir, + cache_string, + ) + total_time = time.time() - start_time + # Whole-cgroup peak: parent + all forked workers + the synthesize tail (the + # parent-side high-water moment). This is what a k8s pod's memory limit + # enforces, so it sizes the pod's memory request. None when not under a + # memory cgroup (dev box / macOS). + peak_rss_bytes = cgroup_peak_bytes() + logger.note( + "%s/%s/%s (%.2f GB); peak rss %s; total %.1fs", + out_subdir, + int(layer), + cv.mesh.readers[layer].get_filename(chunk_id), + shard_bytes / 1e9, + "n/a" if peak_rss_bytes is None else f"{peak_rss_bytes / 1e9:.2f} GB", + total_time, + ) + + profiler = HierarchicalProfiler.from_blocks(acc.blocks) + + return { + "chunk_id": chunk_id, + "total_time": total_time, + "biggest_frag": acc.biggest_frag, + "biggest_frag_vx_ct": acc.biggest_frag_vx_ct, + "number_frag": acc.n_processed, + "bad meshes": acc.bad_meshes, + "missing_fragments": acc.n_missing_fragments, + "peak_rss_bytes": peak_rss_bytes, + "profiler": profiler, + "stage_blocks": acc.blocks, + "join_s": join_s, + "synth_s": synth_s, + "upload_s": upload_s, + } diff --git a/pychunkedgraph/meshing/stitch/utils.py b/pychunkedgraph/meshing/stitch/utils.py new file mode 100644 index 000000000..12d9db7d3 --- /dev/null +++ b/pychunkedgraph/meshing/stitch/utils.py @@ -0,0 +1,307 @@ +# pylint: disable=invalid-name, missing-docstring, c-extension-no-member + +import math +from dataclasses import dataclass +from typing import List, Tuple + +import numpy as np +import zmesh + +from pychunkedgraph.graph.chunks import utils as chunk_utils +from pychunkedgraph.graph.chunks import hierarchy as chunk_hierarchy +from pychunkedgraph.meshing import meshgen + + +@dataclass(frozen=True) +class MipInfo: + """The watershed-CloudVolume values the geometry math needs, snapshotted in + the parent so workers never touch ``meta.cv`` (tensorstore is fork-hostile). + Built once via ``MipInfo.from_meta`` and passed to workers (picklable).""" + + mip: int + resolution: Tuple[int, int, int] + voxel_offset: Tuple[int, int, int] + scales: List[dict] + + @classmethod + def from_meta(cls, meta, mip): + return cls( + mip=int(mip), + resolution=np.asarray(meta.cv.mip_resolution(mip)), + voxel_offset=np.asarray(meta.cv.mip_voxel_offset(mip)), + scales=meta.dataset_info["scales"], + ) + + +def mesh_block_shape_for_mip(meta, graphlayer, mip_info): + """``meta``+``mip_info`` form of ``meshgen_utils.get_mesh_block_shape_for_mip``.""" + scales = mip_info.scales + distortion = np.floor_divide( + scales[mip_info.mip]["resolution"], scales[0]["resolution"] + ) + graphlayer_chunksize = np.array( + meta.graph_config.CHUNK_SIZE + ) * meta.graph_config.FANOUT ** np.max([0, graphlayer - 2]) + return np.floor_divide( + graphlayer_chunksize, distortion, dtype=int, casting="unsafe" + ) + + +def meshing_necessities(meta, chunk_id, mip_info): + """``(layer, mesh_block_shape, chunk_offset)`` for ``chunk_id``, using + parent-precomputed ``mip_info`` instead of ``meta.cv``.""" + layer = chunk_utils.get_chunk_layer(meta, chunk_id) + cx, cy, cz = chunk_utils.get_chunk_coordinates(meta, chunk_id) + mesh_block_shape = mesh_block_shape_for_mip(meta, layer, mip_info) + chunk_offset = ( + (cx, cy, cz) * mesh_block_shape + mip_info.voxel_offset + ) * mip_info.resolution + return layer, mesh_block_shape, chunk_offset + + +def get_next_layer_draco_encoding_settings_pure( + prev_layer_encoding_settings, + mesh_block_shape, + chunk_offset, + segmentation_resolution, +): + """``cg``-free form of ``meshgen.get_next_layer_draco_encoding_settings``.""" + old_draco_bin_size = prev_layer_encoding_settings["quantization_range"] // ( + 2 ** prev_layer_encoding_settings["quantization_bits"] - 1 + ) + min_quantization_range = ( + max(mesh_block_shape * segmentation_resolution) + 2 * old_draco_bin_size + ) + max_draco_bin_size = np.floor(min(segmentation_resolution) / np.sqrt(2)) + ( + draco_quantization_bits, + draco_quantization_range, + draco_bin_size, + ) = meshgen.calculate_quantization_bits_and_range( + min_quantization_range, max_draco_bin_size + ) + draco_quantization_origin = ( + chunk_offset + - old_draco_bin_size + - ((chunk_offset - old_draco_bin_size) % draco_bin_size) + ) + return { + "quantization_bits": draco_quantization_bits, + "compression_level": 1, + "quantization_range": draco_quantization_range, + "quantization_origin": draco_quantization_origin, + "create_metadata": True, + } + + +def compute_layer_geometry(meta, node_id, layer, mip_info): + """Per-layer-step ``(mesh_block_shape, chunk_offset, seg_resolution)`` for + ``fragment_layer+1 .. layer``, from ``meta`` (chunk bit-math) + + parent-precomputed ``mip_info`` (no ``meta.cv``).""" + parent_chunk_ids = chunk_hierarchy.get_parent_chunk_ids(meta, node_id) + fragment_layer = chunk_utils.get_chunk_layer(meta, node_id) + if fragment_layer >= layer: + raise ValueError( + f"Node {node_id} somehow has greater or equal layer than target layer {layer}" + ) + assert len(parent_chunk_ids) > layer - fragment_layer + geometry = [] + for next_layer in range(fragment_layer + 1, layer + 1): + next_layer_chunk_id = parent_chunk_ids[next_layer - fragment_layer] + _, mesh_block_shape, chunk_offset = meshing_necessities( + meta, next_layer_chunk_id, mip_info + ) + geometry.append((mesh_block_shape, chunk_offset, mip_info.resolution)) + return geometry + + +def apply_draco_transform(mesh, layer_geometry, fragment_layer, layer): + """``cg``-free form of ``transform_draco_fragment_and_return_encoding_options``. + Mutates ``mesh["vertices"]`` per intermediate layer; returns final settings. + ``mesh`` carries the decoded fragment's starting quantization as + ``encoding_options_qr`` / ``encoding_options_qb``.""" + cur_encoding_settings = { + "quantization_range": mesh["encoding_options_qr"], + "quantization_bits": mesh["encoding_options_qb"], + } + for i, (mesh_block_shape, chunk_offset, segmentation_resolution) in enumerate( + layer_geometry + ): + next_layer = fragment_layer + 1 + i + next_encoding_settings = get_next_layer_draco_encoding_settings_pure( + cur_encoding_settings, + mesh_block_shape, + chunk_offset, + segmentation_resolution, + ) + if next_layer < layer: + meshgen.transform_draco_vertices(mesh, next_encoding_settings) + cur_encoding_settings = next_encoding_settings + return cur_encoding_settings + + +def get_draco_encoding_settings_for_chunk(meta, chunk_id, mip_info, high_padding): + """``meta``+``mip_info`` form of ``meshgen.get_draco_encoding_settings_for_chunk``.""" + _, mesh_block_shape, chunk_offset = meshing_necessities(meta, chunk_id, mip_info) + segmentation_resolution = mip_info.resolution + min_quantization_range = max( + (mesh_block_shape + high_padding) * segmentation_resolution + ) + max_draco_bin_size = np.floor(min(segmentation_resolution) / np.sqrt(2)) + ( + draco_quantization_bits, + draco_quantization_range, + draco_bin_size, + ) = meshgen.calculate_quantization_bits_and_range( + min_quantization_range, max_draco_bin_size + ) + draco_quantization_origin = chunk_offset - (chunk_offset % draco_bin_size) + return { + "quantization_bits": draco_quantization_bits, + "compression_level": 1, + "quantization_range": draco_quantization_range, + "quantization_origin": draco_quantization_origin, + "create_metadata": True, + } + + +def compute_merge_boundary(meta, chunk_id, mip_info, high_padding): + """Parent-chunk quantized boundary plane for ``merge_*_pure``; identical for + every parent in the task. ``meta``+``mip_info`` form of meshgen lines 720-750.""" + chunk_coords = chunk_utils.get_chunk_coordinates(meta, chunk_id) + coords_bottom_corner_child_chunk = chunk_coords * 2 + 1 + child_chunk_id = chunk_utils.get_chunk_id( + meta, + layer=chunk_utils.get_chunk_layer(meta, chunk_id) - 1, + x=coords_bottom_corner_child_chunk[0], + y=coords_bottom_corner_child_chunk[1], + z=coords_bottom_corner_child_chunk[2], + ) + _, _, child_chunk_offset = meshing_necessities(meta, child_chunk_id, mip_info) + draco_encoding_settings_smaller_chunk = get_draco_encoding_settings_for_chunk( + meta, child_chunk_id, mip_info, high_padding + ) + draco_bin_size = draco_encoding_settings_smaller_chunk["quantization_range"] / ( + 2 ** draco_encoding_settings_smaller_chunk["quantization_bits"] - 1 + ) + chunk_boundary_bin_index = np.floor( + ( + child_chunk_offset + - draco_encoding_settings_smaller_chunk["quantization_origin"] + ) + / draco_bin_size + + np.float32(0.5) + ) + return ( + draco_encoding_settings_smaller_chunk["quantization_origin"] + + chunk_boundary_bin_index * draco_bin_size + ) + + +def _remap_faces(faces, num_vertices, index_remaps): + """Relabel ``faces`` (each entry an index into ``0..num_vertices-1``) to the + deduped vertex indices. + + ``index_remaps`` is a list of ``(original_indices, new_indices)`` array pairs + whose ``original_indices`` together cover every value in ``0..num_vertices-1`` + exactly once (each original vertex is either kept or folded into a boundary + duplicate). Because that key range is contiguous, a lookup table indexed by + original vertex id gives the same per-face relabeling a dictionary would — + while avoiding a Python dictionary with one entry per vertex (which grows to + the full vertex count, millions on a large parent, and dominates merge time + and memory). The table is scattered from the pairs, then gathered by faces. + """ + lookup_table = np.empty(num_vertices, dtype=np.uint32) + for original_indices, new_indices in index_remaps: + lookup_table[original_indices] = new_indices + return lookup_table[faces] + + +def merge_draco_meshes_across_boundaries_pure( + fragments, quantized_chunk_boundary, return_zmesh_object=False +): + """``cg``-free merge: concatenate fragment vertices/faces and dedup vertices + on the (precomputed) quantized chunk boundary. meshgen lines 711-717 + 752-794.""" + vertexct = np.zeros(len(fragments) + 1, np.uint32) + vertexct[1:] = np.cumsum([x["mesh"]["num_vertices"] for x in fragments]) + vertices = np.concatenate([x["mesh"]["vertices"] for x in fragments]) + faces = np.concatenate( + [mesh["mesh"]["faces"] + vertexct[i] for i, mesh in enumerate(fragments)] + ) + del fragments + + if vertexct[-1] > 0: + # Carry each vertex's original index out of band (uint32) rather than + # hstacking it onto the float32 vertices — that hstack with an int64 + # column promotes everything to float64, doubling the vertex buffer and + # running the dedup np.unique on float64. Splitting by the boolean mask + # keeps vertices float32 throughout (the values are unchanged; float32 + # is exactly representable, so the dedup result is identical). + are_chunk_aligned = (vertices == quantized_chunk_boundary).any(axis=1) + original_index = np.arange(vertexct[-1], dtype=np.uint32) + chunk_aligned = vertices[are_chunk_aligned] + not_chunk_aligned = vertices[~are_chunk_aligned] + chunk_aligned_index = original_index[are_chunk_aligned] + not_chunk_aligned_index = original_index[~are_chunk_aligned] + del vertices + del are_chunk_aligned + index_remaps = [] + if len(not_chunk_aligned) > 0: + index_remaps.append( + ( + not_chunk_aligned_index, + np.arange(len(not_chunk_aligned), dtype=np.uint32), + ) + ) + if len(chunk_aligned) > 0: + unique_chunk_aligned, inverse_to_chunk_aligned = np.unique( + chunk_aligned, return_inverse=True, axis=0 + ) + index_remaps.append( + ( + chunk_aligned_index, + np.uint32(len(not_chunk_aligned)) + + inverse_to_chunk_aligned.reshape(-1).astype(np.uint32), + ) + ) + vertices = np.concatenate((not_chunk_aligned, unique_chunk_aligned)) + else: + vertices = not_chunk_aligned + faces = _remap_faces(faces, vertexct[-1], index_remaps) + + if return_zmesh_object: + return zmesh.Mesh(vertices, faces.reshape(-1, 3), None) + + return { + "num_vertices": np.uint32(len(vertices)), + "vertices": vertices.reshape(-1), + "faces": faces, + } + + +MIN_BATCH = 32 + + +def derive_batch_size(n_parents, n_processes, batches_per_worker=64): + """Split into ~``n_processes * batches_per_worker`` batches so work-stealing + is fine enough to balance the heavy-tailed per-parent cost. ``MIN_BATCH`` + floors the size so each batch still coalesces enough byte ranges to amortize + its minishard-index reads.""" + target_batches = max(1, n_processes * batches_per_worker) + return max(MIN_BATCH, math.ceil(n_parents / target_batches)) + + +def sample_debug(multi_child_nodes, max_parents): + """Down-sample ``{parent_id: [child_ids]}`` for a debug preview: stride + evenly across the smaller half (proxy = immediate-child count), spanning + just-above-medium down to tiny. Skips the giant stragglers whose encode + dominates wall time so the preview exercises every stage cheaply. Returns + the dict unchanged when it already has ``<= max_parents`` entries.""" + if len(multi_child_nodes) <= max_parents: + return multi_child_nodes + # ascending by size; the lower half is medium..tiny (drop the big upper half). + ranked = sorted(multi_child_nodes, key=lambda p: (len(multi_child_nodes[p]), p)) + lower = ranked[: max(max_parents, len(ranked) // 2)] + step = len(lower) / max_parents + sampled = [lower[int(i * step)] for i in range(max_parents)] + return {p: multi_child_nodes[p] for p in sampled} diff --git a/pychunkedgraph/meshing/stitch/worker.py b/pychunkedgraph/meshing/stitch/worker.py new file mode 100644 index 000000000..701bdebe7 --- /dev/null +++ b/pychunkedgraph/meshing/stitch/worker.py @@ -0,0 +1,224 @@ +# pylint: disable=invalid-name, missing-docstring, c-extension-no-member + +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import DracoPy +from cloudvolume import CloudVolume + +from pychunkedgraph.graph.chunkedgraph import ChunkedGraph +from pychunkedgraph.graph.chunks import utils as chunk_utils +from pychunkedgraph.meshing import meshgen +from pychunkedgraph.meshing import meshgen_utils +from pychunkedgraph.meshing.stitch import utils as su +from pychunkedgraph.profiler import get_profiler + +# Per-process state, built once per worker and reused across batches. The cg is +# constructed inside the worker (from the picklable graph_id) rather than passed +# from the parent — a live cg carries tensorstore/bigtable handles that don't +# survive fork; rebuilding per process is the fork-safe pattern used by ingest. +_CG = None +_CV = None + + +def _get_cg(cg_info): + global _CG + if _CG is None: + _CG = ChunkedGraph(**cg_info) + return _CG + + +def _get_cv(info): + global _CV + if _CV is None: + _CV = CloudVolume( + "graphene://https://localhost/segmentation/table/dummy", info=info + ) + return _CV + + +def _fetch_fragments(cv, meta, labels, mesh_subdir=None): + """Byte-range fetch of just ``labels``' draco bytes from a sharded mesh dir, + grouped by layer. Goes straight to the sharded reader (the inner call of + get_meshes_on_bypass) to skip its per-label probe of the nonexistent + ``dynamic`` path. ``mesh_subdir`` selects the dir under ``mesh_path`` (default + is the production sharded mesh dir); the compare harness passes its + ``test_*`` / ``initial`` prefix. Returns ``{label:int -> raw draco bytes}``; + coalesces adjacent byte ranges and amortizes minishard-index reads across + all labels in the batch.""" + labels_arr = np.fromiter(labels, dtype=np.uint64, count=len(labels)) + by_layer = defaultdict(list) + for label, layer_id in zip( + labels_arr, chunk_utils.get_chunk_layers(meta, labels_arr) + ): + by_layer[int(layer_id)].append(int(label)) + + mesh_meta = cv.mesh.meta + if mesh_subdir is None: + mesh_subdir = mesh_meta.sharded_mesh_dir + out = {} + for layer_id, layer_labels in by_layer.items(): + subdir = mesh_meta.join(mesh_meta.mesh_path, mesh_subdir, str(layer_id)) + for label, raw in ( + cv.mesh.readers[layer_id].get_data(layer_labels, path=subdir).items() + ): + if raw is not None: + out[int(label)] = raw + return out + + +def _stitch_one( + prof, meta, fragment_bytes, parent_id, descendants, boundary, layer, mip_info +): + """Stitch one parent from prefetched ``fragment_bytes`` ({label: raw draco + bytes}). Descendants are pre-resolved and their bytes batch-fetched by the + caller; this does decode -> transform -> merge -> encode. Each substage is a + ``prof.profile`` block (timing only — RSS is sampled once per batch by the + enclosing ``stitch`` block); ``profile`` is a no-op when profiling is off.""" + n_missing = sum(1 for d in descendants if int(d) not in fragment_bytes) + + old_fragments = [] + first_options = None + for child_node_id in descendants: + raw = fragment_bytes.get(int(child_node_id)) + if raw is None: + continue + with prof.profile("decode"): + mesh = meshgen.decode_draco_mesh_buffer(raw) + eo = mesh["encoding_options"] + mesh["encoding_options_qr"] = eo.quantization_range + mesh["encoding_options_qb"] = eo.quantization_bits + fragment_layer = chunk_utils.get_chunk_layer(meta, np.uint64(child_node_id)) + with prof.profile("transform"): + geom = su.compute_layer_geometry( + meta, np.uint64(child_node_id), layer, mip_info + ) + options = su.apply_draco_transform(mesh, geom, fragment_layer, layer) + if first_options is None: + first_options = options + old_fragments.append({"mesh": mesh, "node_id": np.uint64(child_node_id)}) + + if not old_fragments: + return (int(parent_id), None, 0, False, n_missing) + + with prof.profile("merge"): + new_fragment = su.merge_draco_meshes_across_boundaries_pure( + old_fragments, boundary + ) + vx_ct = len(new_fragment["vertices"]) + try: + with prof.profile("encode"): + new_fragment_b = DracoPy.encode_mesh_to_buffer( + new_fragment["vertices"], new_fragment["faces"], **first_options + ) + except Exception: + print(f"failed to merge {parent_id}") + return (int(parent_id), None, vx_ct, True, n_missing) + + return (int(parent_id), new_fragment_b, vx_ct, False, n_missing) + + +def _split_in_two(items): + """Partition ``items`` into two contiguous halves for prefetch overlap. + Returns one non-empty group when there are fewer than two items (never an + empty group, so the fetch/stitch loop has nothing to skip).""" + if len(items) < 2: + return [items] + mid = len(items) // 2 + return [items[:mid], items[mid:]] + + +def _group_labels(group): + """Flat set of descendant labels for one group of ``(parent_id, descendants)``.""" + return {int(d) for _, descs in group for d in descs} + + +def _resolve_descendants(cg, parents): + """``[(parent_id, descendant_ids), ...]`` for ``parents`` + (``[(parent_id, [immediate_child_id,...]), ...]``). + + ``get_downstream_multi_child_nodes`` maps each input node to its descendant + independently and preserves input order, so resolving all parents' children + in ONE call and slicing the result back per parent gives the identical + per-parent descendant lists as a call-per-parent — with far fewer bigtable + round-trips. Order is preserved (concatenate, resolve, slice are all + order-preserving), which matters: the stitch encodes with the first surviving + fragment's options, so a reorder would change output bytes.""" + all_children = [np.uint64(c) for _, child_ids in parents for c in child_ids] + if not all_children: + return [(parent_id, np.array([], dtype=np.uint64)) for parent_id, _ in parents] + resolved = meshgen_utils.get_downstream_multi_child_nodes( + cg, np.array(all_children, dtype=np.uint64) + ) + out = [] + offset = 0 + for parent_id, child_ids in parents: + out.append((parent_id, resolved[offset : offset + len(child_ids)])) + offset += len(child_ids) + return out + + +def stitch_parents_batch(batch): + """Stitch a batch of parents in one worker process. ``batch`` = + ``{cg_info, info, mip_info, layer, boundary, parents}`` where ``parents`` is + ``[(parent_id, [child_node_id,...]), ...]``, ``cg_info`` is + ``cg.get_serialized_info()`` ({graph_id}), ``info`` is the graphene CV info + dict, and ``mip_info`` is a ``MipInfo``. No ``meta.cv``/tensorstore is + touched in the worker (it is fork-hostile). Returns ``(results, blocks)`` + where ``blocks`` is this batch's per-stage ``BlockMetrics`` (a no-op empty + list when profiling is disabled), rolled up across batches by the parent.""" + cg = _get_cg(batch["cg_info"]) + cv = _get_cv(batch["info"]) + meta = cg.meta + boundary = batch["boundary"] + layer = batch["layer"] + mip_info = batch["mip_info"] + + prof = get_profiler() + prof.reset() + + # Resolve every parent's descendants in one batched graph read; each half's + # fragments are then byte-range fetched together so adjacent ranges coalesce + # and the minishard index is read once per half. + with prof.profile("graph"): + descendants_per_parent = _resolve_descendants(cg, batch["parents"]) + + # Split the batch into two halves and prefetch the second half's fragments + # while the first half stitches: read is IO (GIL-released) and stitch is CPU, + # so they overlap. A single fetch thread runs at a time, so get_data is never + # called concurrently and at most two halves' fragment bytes are in flight + # (lower peak than fetching the whole batch up front). The "read" block times + # only the blocking wait on each half's fetch — the read NOT hidden behind + # stitch — so it trends toward 0 when the overlap fully covers it. + groups = _split_in_two(descendants_per_parent) + + results = [] + with ThreadPoolExecutor(max_workers=1) as fetch_pool: + pending = fetch_pool.submit( + _fetch_fragments, cv, meta, _group_labels(groups[0]) + ) + for i, group in enumerate(groups): + with prof.profile("read"): + fragment_bytes = pending.result() + if i + 1 < len(groups): + pending = fetch_pool.submit( + _fetch_fragments, cv, meta, _group_labels(groups[i + 1]) + ) + # one RSS sample pair per batch wraps the substages (light); the + # substages themselves are timed individually inside _stitch_one. + with prof.profile("stitch", sampled_rss=True): + for parent_id, descendants in group: + results.append( + _stitch_one( + prof, + meta, + fragment_bytes, + parent_id, + descendants, + boundary, + layer, + mip_info, + ) + ) + return results, prof.blocks From 538eb3c7fb1aff9e33e39d23b847759459b08676 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 18:51:50 +0000 Subject: [PATCH 278/367] Add profiling + correctness harness for the parallel stitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile.py runs the parallel stitch to a test_* prefix (never production), reports per-stage timing, percentiles, and per-worker peak RSS, and compares the produced shard against production for mesh-equivalence — failing fast on the first mismatched label by default (fail_fast=False scans all and reports the total). _forkdiag.py is a one-shot fork-safety diagnostic. --- pychunkedgraph/meshing/stitch/_forkdiag.py | 106 ++++++ pychunkedgraph/meshing/stitch/profile.py | 372 +++++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 pychunkedgraph/meshing/stitch/_forkdiag.py create mode 100644 pychunkedgraph/meshing/stitch/profile.py diff --git a/pychunkedgraph/meshing/stitch/_forkdiag.py b/pychunkedgraph/meshing/stitch/_forkdiag.py new file mode 100644 index 000000000..d441b91b8 --- /dev/null +++ b/pychunkedgraph/meshing/stitch/_forkdiag.py @@ -0,0 +1,106 @@ +"""Pinpoint which parent-side op arms the s2n/grpc pthread_atfork guard that +aborts forked children doing TLS. Run in a FRESH kernel via debug.ipynb. + +After each candidate parent op, a one-shot mp.Pool(1) worker does a real GCS +TLS op (the exact failure path). The first op after which the pool goes +OK -> FAIL is the one that makes the process fork-hostile.""" + +import os +import multiprocessing as mp + +import numpy as np +from cloudfiles import CloudFiles +from cloudvolume import CloudVolume + +from pychunkedgraph.graph import attributes +from pychunkedgraph.graph.chunkedgraph import ChunkedGraph +from pychunkedgraph.meshing import meshgen_utils +from pychunkedgraph.meshing.stitch import utils as su +from pychunkedgraph.meshing.stitch import task as st +from pychunkedgraph.meshing.stitch.worker import stitch_parents_batch + +_GCS = "gs://pcg_ws/pinky_sv_size_exp/20240920152649/graphene_meshes/initial/6" + + +def _worker_gcs(_): + return CloudFiles(_GCS).exists("200-0.shard") + + +def _worker_rebuild_cg(cg_info): + """What the real stitch worker does first: rebuild cg + touch meta.cv.""" + cg = ChunkedGraph(**cg_info) + _ = meshgen_utils.get_json_info(cg) + _ = cg.meta.cv.mip_resolution(0) + return True + + +def _worker_descendants(args): + """Child does ONLY the bigtable descendant resolution (get_children).""" + cg_info, child_ids = args + cg = ChunkedGraph(**cg_info) + meshgen_utils.get_downstream_multi_child_nodes( + cg, np.array(child_ids, dtype=np.uint64) + ) + return True + + +def _worker_disassemble(args): + """Child does ONLY graphene cv build + fetch + disassemble_shard.""" + cg_info, layer = args + cg = ChunkedGraph(**cg_info) + cv = CloudVolume( + "graphene://https://localhost/segmentation/table/dummy", + info=meshgen_utils.get_json_info(cg), + ) + cf = CloudFiles(os.path.join(cv.cloudpath, cv.mesh.meta.mesh_path, "initial")) + content = cf.get(f"{layer}/200-0.shard") + if content is not None: + cv.mesh.readers[layer].disassemble_shard(content) + return True + + +def _pool_ok(fn, arg): + try: + with mp.Pool(1) as p: + p.map(fn, [arg]) + return True + except Exception: + return False + + +def run(graph_id, chunk_id): + """Localize the abort: is it the parent doing a bigtable/gRPC RPC, the child + doing GCS/TLS (s2n), or the combination? Each line forks a fresh mp.Pool(1) + GCS worker after a different amount of parent work. FAIL = the child + hard-aborted (s2n/grpc atfork). No spawn, no env changes.""" + print("(a) parent did NOTHING -> child GCS:") + print(" ", "OK" if _pool_ok(_worker_gcs, 0) else "FAIL") + + cg = ChunkedGraph(graph_id=graph_id) + print("(b) parent built cg -> child GCS:") + print(" ", "OK" if _pool_ok(_worker_gcs, 0) else "FAIL") + + cg.range_read_chunk(chunk_id, properties=attributes.Hierarchy.Child) + print("(c) parent did bigtable range_read -> child GCS:") + print(" ", "OK" if _pool_ok(_worker_gcs, 0) else "FAIL") + + cg_info = cg.get_serialized_info() + print("(d) child rebuilds cg + get_json_info + meta.cv.mip_resolution:") + print(" ", "OK" if _pool_ok(_worker_rebuild_cg, cg_info) else "FAIL") + + # (e) the REAL worker on one real batch (rebuild cg + graphene cv + + # disassemble shard + descendant resolution + stitch). + mip_info = su.MipInfo.from_meta(cg.meta, 0) + mcn = st._multi_child_parents(cg, chunk_id) + one_parent, one_children = next(iter(mcn.items())) + boundary = su.compute_merge_boundary(cg.meta, chunk_id, mip_info, 1) + batch = { + "cg_info": cg_info, + "info": meshgen_utils.get_json_info(cg), + "mip_info": mip_info, + "layer": int(cg.get_chunk_layer(chunk_id)), + "boundary": boundary, + "parents": [(one_parent, one_children)], + } + print("(e) REAL worker stitch_parents_batch on one parent:") + print(" ", "OK" if _pool_ok(stitch_parents_batch, batch) else "FAIL") diff --git a/pychunkedgraph/meshing/stitch/profile.py b/pychunkedgraph/meshing/stitch/profile.py new file mode 100644 index 000000000..7be708f73 --- /dev/null +++ b/pychunkedgraph/meshing/stitch/profile.py @@ -0,0 +1,372 @@ +"""Profile + correctness harness for the parallel sharded stitching task. + +Drives ``stitch.task.chunk_initial_sharded_stitching_task_mp`` on a real chunk, +writing to a ``test_ref`` / ``test_par`` output prefix (never production), and +compares the produced shard against production for mesh-equivalence. Each entry +point is a single call returning a report, for interactive use. +""" + +import os +import tempfile +import time +import traceback +from concurrent.futures import ProcessPoolExecutor +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path + +import numpy as np +from tqdm import tqdm +from cloudfiles import CloudFiles +from cloudvolume import CloudVolume + +from pychunkedgraph.meshing import meshgen +from pychunkedgraph.meshing import meshgen_utils +from pychunkedgraph.meshing.stitch import task as stitch_task +from pychunkedgraph.meshing.stitch import worker as stitch_worker +from pychunkedgraph.profiler import HierarchicalProfiler, get_profiler + +_METRICS_ROOT = Path(tempfile.gettempdir()) / "pcg_mesh_profile" + + +def make_cv(cg): + return CloudVolume( + "graphene://https://localhost/segmentation/table/dummy", + info=meshgen_utils.get_json_info(cg), + ) + + +def resolve_chunk_id(cg, layer, shard_id, cv=None): + """Find the layer-``layer`` chunk_id whose shard filename is + ``{shard_id}-0.shard`` (inverts ``decode_chunk_position_number``). + Returns the single matching ``chunk_id`` (raises if 0 or >1 match).""" + if cv is None: + cv = make_cv(cg) + bounds = cg.meta.layer_chunk_bounds[layer] + matches = [] + for x in range(int(bounds[0])): + for y in range(int(bounds[1])): + for z in range(int(bounds[2])): + cid = cg.get_chunk_id(layer=layer, x=x, y=y, z=z) + if int(cv.meta.decode_chunk_position_number(cid)) == int(shard_id): + matches.append(int(cid)) + if len(matches) != 1: + raise ValueError( + f"expected exactly 1 layer-{layer} chunk for shard {shard_id}, " + f"got {len(matches)}: {[hex(m) for m in matches]}" + ) + return np.uint64(matches[0]) + + +def _table(title, headers, rows): + """Render an aligned monospace table (no dict/json/wall-of-text).""" + cols = [headers] + [[str(c) for c in r] for r in rows] + widths = [max(len(col[i]) for col in cols) for i in range(len(headers))] + sep = " " + out = [ + title, + sep.join(h.ljust(widths[i]) for i, h in enumerate(headers)), + sep.join("-" * widths[i] for i in range(len(headers))), + ] + for r in rows: + out.append(sep.join(str(c).ljust(widths[i]) for i, c in enumerate(r))) + return "\n".join(out) + + +def _profiler_text(ret): + """Capture the cross-process rollup + the per-batch percentile breakdown + (the straggler view) into a string for the metrics file.""" + buf = StringIO() + with redirect_stdout(buf): + ret["profiler"].metrics_report() + HierarchicalProfiler.percentile_report(ret["stage_blocks"]) + return buf.getvalue() + + +def _run(cg, chunk_id, mip, out_subdir, n_processes, high_padding, max_parents=None): + """Run the stitch task, build the bottleneck table, write it to + ``metrics.txt`` and print it. Returns ``ret`` + ``wall`` + ``metrics_path``. + ``max_parents`` caps parents for a quick full-pipeline + report preview.""" + target = _METRICS_ROOT / cg.graph_id / out_subdir / str(int(chunk_id)) + target.mkdir(parents=True, exist_ok=True) + tb_path = target / "traceback.txt" + if tb_path.exists(): + tb_path.unlink() + + # Enable the shared profiler for this run only (default off in production). + # mp.Pool forks inside the task AFTER this, so workers inherit the enabled + # instance. with_memory off: tracemalloc is the heavy path. with_rss off: + # the default RSS path spawns a sampler thread per block, which would fire on + # every per-fragment substage; RSS is taken once per batch via the explicit + # sampled_rss "stitch" block instead, keeping the hot substages timing-only. + prof = get_profiler() + prof.enabled = True + prof.with_memory_default = False + prof.with_rss_default = False + + t = time.time() + try: + ret = stitch_task.chunk_initial_sharded_stitching_task_mp( + cg.graph_id, + chunk_id, + mip, + cg=cg, + high_padding=high_padding, + cache=False, + n_processes=n_processes, + out_subdir=out_subdir, + max_parents=max_parents, + ) + except Exception: + tb_path.write_text(traceback.format_exc()) + print(f"[stitch_profile] run FAILED; traceback saved to {tb_path}") + raise + finally: + prof.enabled = False + ret["wall"] = time.time() - t + + report = ( + _table( + "run", + [ + "wall_s", + "total_s", + "n_parents", + "bad", + "biggest_frag_vx_ct", + "join_s", + "synth_s", + "upload_s", + "peak_rss_gb", + ], + [ + [ + f"{ret['wall']:.1f}", + f"{ret['total_time']:.1f}", + ret["number_frag"], + len(ret["bad meshes"]), + ret["biggest_frag_vx_ct"], + f"{ret['join_s']:.1f}", + f"{ret['synth_s']:.1f}", + f"{ret['upload_s']:.1f}", + ( + "n/a" + if ret["peak_rss_bytes"] is None + else f"{ret['peak_rss_bytes'] / 1e9:.2f}" + ), + ] + ], + ) + + "\n\n" + + _profiler_text(ret) + + "\n" + ) + + (target / "metrics.txt").write_text(report) + ret["metrics_path"] = str(target / "metrics.txt") + print(report) + return ret + + +def load_traceback(cg, chunk_id, out_subdir="test_par"): + """Return the saved traceback for the last failed run under ``out_subdir``, + or ``None`` if the last run succeeded.""" + tb_path = ( + _METRICS_ROOT / cg.graph_id / out_subdir / str(int(chunk_id)) / "traceback.txt" + ) + return tb_path.read_text() if tb_path.exists() else None + + +def run_parallel( + cg, chunk_id, mip, out_subdir, high_padding=1, n_processes=None, max_parents=None +): + """Parallel run -> writes shard under ``out_subdir``. The caller picks the + location so a partial ``max_parents`` preview and a full run don't collide.""" + return _run( + cg, + chunk_id, + mip, + out_subdir, + n_processes, + high_padding, + max_parents=max_parents, + ) + + +def _shard_label_set(cg, chunk_id, subdir, cv=None): + """The set of labels in the shard under ``subdir``, read from the minishard + index only (no full-shard download). ``None`` if the shard is absent.""" + if cv is None: + cv = make_cv(cg) + layer = cg.get_chunk_layer(chunk_id) + reader = cv.mesh.readers[layer] + shard_filename = reader.get_filename(chunk_id) + path = os.path.join(cv.mesh.meta.mesh_path, subdir, str(layer)) + if not CloudFiles(os.path.join(cv.cloudpath, path)).exists(shard_filename): + return None + return set(int(x) for x in reader.list_labels(shard_filename, path=path)) + + +def _shard_head(cg, chunk_id, subdir, cv=None): + """``(crc32c, size)`` of the shard under ``subdir`` without downloading it, + or ``None`` if absent.""" + if cv is None: + cv = make_cv(cg) + layer = cg.get_chunk_layer(chunk_id) + shard_filename = cv.mesh.readers[layer].get_filename(chunk_id) + cf = CloudFiles( + os.path.join(cv.cloudpath, cv.mesh.meta.mesh_path, subdir, str(layer)) + ) + if not cf.exists(shard_filename): + return None + h = cf.head(shard_filename) + return h["Content-Crc32c"], h["Content-Length"] + + +def _canonical(mesh): + """Sort a decoded mesh's vertices so order-differences don't matter.""" + v = np.asarray(mesh["vertices"]).reshape(-1, 3) + order = np.lexsort((v[:, 2], v[:, 1], v[:, 0])) + return v[order], int(len(mesh["faces"])) + + +def _decode_label_pair(args): + label, bytes_a, bytes_b = args + va, fa = _canonical(meshgen.decode_draco_mesh_buffer(bytes_a)) + vb, fb = _canonical(meshgen.decode_draco_mesh_buffer(bytes_b)) + return label, (va.shape == vb.shape and fa == fb and np.array_equal(va, vb)) + + +def compare_shards( + cg, + chunk_id, + subdir_a="test_ref", + subdir_b="test_par", + cv=None, + n_processes=None, + subset=False, + fail_fast=True, +): + """Gate: mesh-equivalence + label-set parity between two shards. + + Fast path: equal crc32c -> byte-identical, returned instantly (no download). + Otherwise: read both shards' label sets from the minishard index (no + full-shard download), check label-set parity (the prior failure mode = + dropped fragments) + count, then byte-range fetch only the labels to compare + (reusing the stitch worker's fetch) and decode them in parallel, asserting + identical vertices/faces. ``fail_fast`` (default) raises on the first + mismatched label without decoding the rest; ``fail_fast=False`` scans every + label and raises once with the full mismatch count + a sample. + + ``subset=True`` verifies ``subdir_b`` is a correct SUBSET of ``subdir_a`` + (for a partial ``max_parents`` run vs full production): every ``b`` label + must exist in ``a`` and decode-match; ``a`` may have labels ``b`` doesn't. + A label in ``b`` but not ``a`` is still a failure (a stray/wrong id). The + crc32c fast path is skipped since a subset can't be byte-identical.""" + if cv is None: + cv = make_cv(cg) + + head_a = _shard_head(cg, chunk_id, subdir_a, cv) + head_b = _shard_head(cg, chunk_id, subdir_b, cv) + assert head_a is not None, f"{subdir_a} shard missing" + assert head_b is not None, f"{subdir_b} shard missing" + if not subset and head_a[0] is not None and head_a == head_b: + return { + "subdir_a": subdir_a, + "subdir_b": subdir_b, + "crc32c": head_a[0], + "size": head_a[1], + "result": "BYTE-IDENTICAL", + } + + la = _shard_label_set(cg, chunk_id, subdir_a, cv) + lb = _shard_label_set(cg, chunk_id, subdir_b, cv) + only_b = lb - la + assert not only_b, ( + f"{len(only_b)} labels in {subdir_b} not in {subdir_a} (stray/wrong id): " + f"{list(only_b)[:5]}..." + ) + if not subset: + only_a = la - lb + assert not only_a, ( + f"LABEL SET MISMATCH (dropped fragments): " + f"{len(only_a)} only in {subdir_a} ({list(only_a)[:5]}...)" + ) + assert len(la) == len(lb), f"count mismatch {len(la)} vs {len(lb)}" + + # Byte-range fetch only the labels under comparison (lb) from each shard's + # mesh dir — the same targeted fetch the worker uses, not a whole-shard + # download + disassemble. + a = stitch_worker._fetch_fragments(cv, cg.meta, lb, mesh_subdir=subdir_a) + b = stitch_worker._fetch_fragments(cv, cg.meta, lb, mesh_subdir=subdir_b) + work = [(lbl, a[lbl], b[lbl]) for lbl in lb] + mismatched = [] + with ProcessPoolExecutor(max_workers=n_processes or os.cpu_count()) as ex: + for label, equal in tqdm( + ex.map(_decode_label_pair, work, chunksize=16), + total=len(work), + desc="compare", + unit="label", + ): + if not equal: + if fail_fast: + raise AssertionError(f"label {label} differs in decoded mesh") + mismatched.append(label) + assert ( + not mismatched + ), f"{len(mismatched)} labels differ in decoded mesh: {mismatched[:10]}" + return { + "n_labels": len(b), + "subdir_a": subdir_a, + "subdir_b": subdir_b, + "result": "SUBSET-EQUIVALENT" if subset else "MESH-EQUIVALENT", + } + + +def check_parent_resolution(cg, chunk_id): + """Gate: the new parent-side filter + worker-side descendant resolution + yields the SAME ``{parent: set(descendants)}`` as the original + ``meshgen.get_multi_child_nodes``. Catches a regression in the reshape that + moved descendant resolution from the (whole-flat) parent call to per-parent + worker calls. Returns a report; raises on mismatch.""" + ref, _ = meshgen.get_multi_child_nodes(cg, chunk_id) + ref = {int(k): set(int(c) for c in v) for k, v in ref.items()} + + immediate = stitch_task._multi_child_parents(cg, chunk_id) + got = {} + for parent_id, child_ids in immediate.items(): + desc = meshgen_utils.get_downstream_multi_child_nodes( + cg, np.array(child_ids, dtype=np.uint64) + ) + got[int(parent_id)] = set(int(c) for c in desc) + + only_ref, only_got = set(ref) - set(got), set(got) - set(ref) + assert not only_ref and not only_got, ( + f"PARENT SET MISMATCH: {len(only_ref)} only in meshgen, " + f"{len(only_got)} only in new ({list(only_ref)[:5]} / {list(only_got)[:5]})" + ) + bad = [p for p in ref if ref[p] != got[p]] + assert not bad, f"{len(bad)} parents have different descendants: {bad[:5]}" + return {"n_parents": len(ref), "result": "PARENT RESOLUTION MATCHES meshgen"} + + +def compare_against_production( + cg, chunk_id, subdir, cv=None, subset=False, fail_fast=True +): + """Full correctness gate against the production ``initial`` shard on GCS: + label-set parity + per-label decoded mesh equality (crc32c fast path first). + + ``subset=True`` for a partial ``max_parents`` run: verify every produced + label exists in production and decode-matches (a subset can't be byte- or + set-identical, but each mesh it did produce must still match production). + ``fail_fast`` (default) raises on the first mismatched label; pass + ``fail_fast=False`` to scan all and report the full mismatch count.""" + return compare_shards( + cg, + chunk_id, + subdir_a="initial", + subdir_b=subdir, + cv=cv, + subset=subset, + fail_fast=fail_fast, + ) From 3f729c8c11c947940935156a2666f39c971b80a4 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 18:58:33 +0000 Subject: [PATCH 279/367] Decode draco buffers as zero-copy views; deprecate the serial stitch fn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode_draco_mesh_buffer returns np.asarray views of the DracoPy point and face buffers instead of copies — faces are read-only downstream and vertices are mutated only in a freshly-allocated buffer, so the views are safe. Mark chunk_initial_sharded_stitching_task deprecated; it stays as the reference the parallel path is gated against. --- pychunkedgraph/meshing/meshgen.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pychunkedgraph/meshing/meshgen.py b/pychunkedgraph/meshing/meshgen.py index ea6410dfd..7ccffbeaa 100644 --- a/pychunkedgraph/meshing/meshgen.py +++ b/pychunkedgraph/meshing/meshgen.py @@ -39,8 +39,13 @@ def decode_draco_mesh_buffer(fragment): try: mesh_object = DracoPy.decode_buffer_to_mesh(fragment) - vertices = np.array(mesh_object.points) - faces = np.array(mesh_object.faces) + # asarray, not array: points/faces are already ndarrays, so this is + # zero-copy and aliases mesh_object's buffers. Callers mutate + # "vertices" in place (transform_draco_vertices) but never read + # mesh_object.points again, and each decode allocates its own buffer, + # so the alias is safe. + vertices = np.asarray(mesh_object.points) + faces = np.asarray(mesh_object.faces) except ValueError as exc: raise ValueError("Not a valid draco mesh") from exc @@ -1250,6 +1255,10 @@ def chunk_stitch_remeshing_task( def chunk_initial_sharded_stitching_task( cg_name, chunk_id, mip, cg=None, high_padding=1, cache=True ): + """DEPRECATED: single-threaded sharded stitch. ``meshing.meshing_sqs.MeshTask`` + now dispatches ``meshing.stitch.chunk_initial_sharded_stitching_task_mp`` + (parallel, mesh-equivalent output) instead. Kept as the reference + implementation the parallel path is gated against.""" start_existence_check_time = time.time() if cg is None: cg = ChunkedGraph(graph_id=cg_name) From 63d43ef58fa824047bbd401ea7ff9d43767a568f Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 18:59:37 +0000 Subject: [PATCH 280/367] Dispatch the parallel stitch from the mesh task MeshTask routes layer-3+ chunks to chunk_initial_sharded_stitching_task_mp instead of the serial meshgen function. --- pychunkedgraph/meshing/meshing_sqs.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pychunkedgraph/meshing/meshing_sqs.py b/pychunkedgraph/meshing/meshing_sqs.py index b302a1744..7d4852c3c 100644 --- a/pychunkedgraph/meshing/meshing_sqs.py +++ b/pychunkedgraph/meshing/meshing_sqs.py @@ -1,5 +1,6 @@ from taskqueue import RegisteredTask from pychunkedgraph.meshing import meshgen +from pychunkedgraph.meshing.stitch import chunk_initial_sharded_stitching_task_mp import numpy as np @@ -14,16 +15,10 @@ def execute(self): layer = self.layer if layer == 2: result = meshgen.chunk_initial_mesh_task( - cg_name, - chunk_id, - None, - mip=mip, - sharded=True, - cache=self.cache + cg_name, chunk_id, None, mip=mip, sharded=True, cache=self.cache ) else: - result = meshgen.chunk_initial_sharded_stitching_task( + result = chunk_initial_sharded_stitching_task_mp( cg_name, chunk_id, mip, cache=self.cache ) print(result) - From 1a1025b93502a7dfc6e5c84506c5db9113ad8df5 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 19:03:10 +0000 Subject: [PATCH 281/367] Remove unused ocdbt edge put/get helpers --- pychunkedgraph/graph/edges/__init__.py | 1 - pychunkedgraph/graph/edges/ocdbt.py | 87 -------------------------- 2 files changed, 88 deletions(-) delete mode 100644 pychunkedgraph/graph/edges/ocdbt.py diff --git a/pychunkedgraph/graph/edges/__init__.py b/pychunkedgraph/graph/edges/__init__.py index 80bc57d4a..b8fd9d301 100644 --- a/pychunkedgraph/graph/edges/__init__.py +++ b/pychunkedgraph/graph/edges/__init__.py @@ -3,7 +3,6 @@ """ from .definitions import EDGE_TYPES, Edges -from .ocdbt import put_edges, get_edges from .stale import ( get_new_nodes, diff --git a/pychunkedgraph/graph/edges/ocdbt.py b/pychunkedgraph/graph/edges/ocdbt.py deleted file mode 100644 index 6c7fba353..000000000 --- a/pychunkedgraph/graph/edges/ocdbt.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -OCDBT storage I/O for edges. -""" - -from os import environ - -import numpy as np -import tensorstore as ts -import zstandard as zstd -from graph_tool import Graph - -from pychunkedgraph.graph import basetypes -from .definitions import ADJACENCY_DTYPE, ZSTD_EDGE_COMPRESSION, Edges - - -def put_edges(destination: str, nodes: np.ndarray, edges: Edges) -> None: - graph_ids, _edges = np.unique(edges.get_pairs(), return_inverse=True) - graph_ids_reverse = {n: i for i, n in enumerate(graph_ids)} - _edges = _edges.reshape(-1, 2) - - graph = Graph(directed=False) - graph.add_edge_list(_edges) - e_aff = graph.new_edge_property("double", vals=edges.affinities) - e_area = graph.new_edge_property("int", vals=edges.areas) - cctx = zstd.ZstdCompressor(level=ZSTD_EDGE_COMPRESSION) - ocdbt_host = environ["OCDBT_COORDINATOR_HOST"] - ocdbt_port = environ["OCDBT_COORDINATOR_PORT"] - - spec = { - "driver": "ocdbt", - "base": destination, - "coordinator": {"address": f"{ocdbt_host}:{ocdbt_port}"}, - } - dataset = ts.KvStore.open(spec).result() - with ts.Transaction() as txn: - for _node in nodes: - node = graph_ids_reverse[_node] - neighbors = graph.get_all_neighbors(node) - adjacency_list = np.zeros(neighbors.size, dtype=ADJACENCY_DTYPE) - adjacency_list["node"] = graph_ids[neighbors] - adjacency_list["aff"] = [e_aff[(node, neighbor)] for neighbor in neighbors] - adjacency_list["area"] = [ - e_area[(node, neighbor)] for neighbor in neighbors - ] - dataset.with_transaction(txn)[str(graph_ids[node])] = cctx.compress( - adjacency_list.tobytes() - ) - - -def get_edges(source: str, nodes: np.ndarray) -> Edges: - spec = {"driver": "ocdbt", "base": source} - dataset = ts.KvStore.open(spec).result() - zdc = zstd.ZstdDecompressor() - - read_futures = [dataset.read(str(n)) for n in nodes] - read_results = [rf.result() for rf in read_futures] - compressed = [rr.value for rr in read_results] - - try: - n_threads = int(environ.get("ZSTD_THREADS", 1)) - except ValueError: - n_threads = 1 - - decompressed = [] - try: - decompressed = zdc.multi_decompress_to_buffer(compressed, threads=n_threads) - except ValueError: - for content in compressed: - decompressed.append(zdc.decompressobj().decompress(content)) - - node_ids1 = [np.empty(0, dtype=basetypes.NODE_ID)] - node_ids2 = [np.empty(0, dtype=basetypes.NODE_ID)] - affinities = [np.empty(0, dtype=basetypes.EDGE_AFFINITY)] - areas = [np.empty(0, dtype=basetypes.EDGE_AREA)] - for n, content in zip(nodes, compressed): - adjacency_list = np.frombuffer(content, dtype=ADJACENCY_DTYPE) - node_ids1.append([n] * adjacency_list.size) - node_ids2.append(adjacency_list["node"]) - affinities.append(adjacency_list["aff"]) - areas.append(adjacency_list["area"]) - - return Edges( - np.concatenate(node_ids1), - np.concatenate(node_ids2), - affinities=np.concatenate(affinities), - areas=np.concatenate(areas), - ) From 6bc0152145bb072469904e5d4c2aeff409d77921 Mon Sep 17 00:00:00 2001 From: Akhilesh Halageri Date: Sun, 31 May 2026 22:13:41 +0000 Subject: [PATCH 282/367] Harden parallel stitch for heavy chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retry the byte-range fragment fetch on transient short reads (DecompressionError / IntegrityError) — a truncated sharded read decodes a layer above cloudfiles' own retry, where one failure aborted the whole worker pool. Free merge and decoded-fragment intermediates eagerly to trim the per-worker peak. NOTES.md records the remaining structural peak-memory issue, a large-chunk test handle, and the streaming/throttle levers for later. --- pychunkedgraph/meshing/stitch/NOTES.md | 43 +++++++++++++++++++++++++ pychunkedgraph/meshing/stitch/task.py | 4 ++- pychunkedgraph/meshing/stitch/utils.py | 5 +++ pychunkedgraph/meshing/stitch/worker.py | 17 +++++++++- 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 pychunkedgraph/meshing/stitch/NOTES.md diff --git a/pychunkedgraph/meshing/stitch/NOTES.md b/pychunkedgraph/meshing/stitch/NOTES.md new file mode 100644 index 000000000..217659f5b --- /dev/null +++ b/pychunkedgraph/meshing/stitch/NOTES.md @@ -0,0 +1,43 @@ +# stitch — notes (known issues, test handles, future work) + +Secondary material that doesn't belong in `README.md` (the design reference). + +## Large-chunk test handle + +`experimental_pinky_lgsv_split_v0` layer-6 chunk `451485862643892224` — ~28k +parents, ~5.5 GB shard, an ~83M-vertex giant fragment. One of the larger shards in +that dataset; a good memory/perf optimization target. Reproduce with +`debug.repro_chunk(cg, 451485862643892224)`. + +## Known issue — peak memory OOM on small nodes + +Heavy layer-6 chunks drive whole-cgroup peak RSS above a small node's limit +(~40 GB peak at 24 workers vs a 28 GB node), OOM-ing a worker mid-run. Reproduces +only under that memory pressure — the chunk above completes cleanly with more +headroom / fewer workers. + +Measured split (the chunk above, 16 workers): per-worker peak RSS p50 ≈ 0.75 GB, +max ≈ 2.2 GB (the giant's worker). The peak is dominated by the **parent synthesize +transient** — `acc.merged_meshes` accumulates every encoded mesh, then +`synthesize_shard` holds ~2-3× the ~5.5 GB shard — **plus** the broad sum of +concurrent per-worker working sets (~0.75 GB × N). No single giant worker dominates; +both terms stack to ~40 GB at 24 workers. + +Eager `del` of merge/decoded-fragment intermediates (in `worker._stitch_one` and +`utils.merge_draco_meshes_across_boundaries_pure`) trims only a few GB — not enough +to close the gap on its own. + +## Future levers (neither done; parent term is the bigger one) + +- **Stream the shard via a tmp dir.** Workers write each encoded mesh to a + `mesh_path//