From 758fc8a3a4c650e171f5cf9d4972f30e79f0d743 Mon Sep 17 00:00:00 2001 From: Saurav Bhattacharya Date: Sat, 14 Mar 2026 01:07:50 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20GraphNeighborhoodAnalyzer=20=E2=80=94?= =?UTF-8?q?=20k-hop=20neighborhood=20analysis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New analyzer for local structural analysis of graph neighborhoods: - k-hop neighborhood extraction (BFS layers from any vertex) - Growth profile — cumulative reachable set size at each depth - Expansion rates — boundary/interior ratio per hop - Local density — edge density of induced k-hop subgraph - Neighborhood overlap — Jaccard and overlap coefficient between vertex pairs, measuring structural proximity - Boundary vertices — frontier at exact distance k - Aggregate statistics — mean/min/max sizes across all vertices - Vertex ranking by neighborhood size (widest local reach) - Text report with per-vertex and aggregate summaries 47 unit tests covering paths, cycles, stars, complete graphs, disconnected components, boundary conditions, and invariant checks (monotonicity, symmetry, density bounds, self-containment). Usage: new GraphNeighborhoodAnalyzer(graph).getKHopNeighborhood(v, k) --- .../gvisual/GraphNeighborhoodAnalyzer.java | 435 ++++++++++++++ .../GraphNeighborhoodAnalyzerTest.java | 566 ++++++++++++++++++ 2 files changed, 1001 insertions(+) create mode 100644 Gvisual/src/gvisual/GraphNeighborhoodAnalyzer.java create mode 100644 Gvisual/test/gvisual/GraphNeighborhoodAnalyzerTest.java diff --git a/Gvisual/src/gvisual/GraphNeighborhoodAnalyzer.java b/Gvisual/src/gvisual/GraphNeighborhoodAnalyzer.java new file mode 100644 index 0000000..9d313e6 --- /dev/null +++ b/Gvisual/src/gvisual/GraphNeighborhoodAnalyzer.java @@ -0,0 +1,435 @@ +package gvisual; + +import edu.uci.ics.jung.graph.Graph; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Analyses k-hop neighborhoods for vertices in a graph, providing local + * structural insights that complement global metrics. + * + *

Capabilities

+ * + * + *

All computations use BFS from the source vertex. For directed graphs + * the successor (out-edge) direction is followed. The source vertex itself + * is always at depth 0.

+ * + * @author zalenix + */ +public class GraphNeighborhoodAnalyzer { + + private final Graph graph; + + /** + * Constructs an analyser for the given graph. + * + * @param graph the graph to analyse (must not be null) + * @throws IllegalArgumentException if graph is null + */ + public GraphNeighborhoodAnalyzer(Graph graph) { + if (graph == null) { + throw new IllegalArgumentException("Graph must not be null"); + } + this.graph = graph; + } + + // ------------------------------------------------------------------ + // Core: BFS layer computation + // ------------------------------------------------------------------ + + /** + * Computes BFS layers from a source vertex up to depth {@code maxK}. + * Layer 0 contains only the source. Layer i contains vertices at + * exactly distance i from the source. + * + * @param source the starting vertex + * @param maxK maximum depth (must be ≥ 0) + * @return list of layers, where index i is the set of vertices at depth i + * @throws IllegalArgumentException if source is not in the graph or maxK < 0 + */ + public List> computeLayers(String source, int maxK) { + validateVertex(source); + if (maxK < 0) { + throw new IllegalArgumentException("maxK must be >= 0, got " + maxK); + } + + List> layers = new ArrayList<>(); + Set visited = new HashSet<>(); + visited.add(source); + layers.add(Collections.singleton(source)); + + for (int depth = 1; depth <= maxK; depth++) { + Set frontier = new LinkedHashSet<>(); + for (String v : layers.get(depth - 1)) { + for (String neighbor : graph.getNeighbors(v)) { + if (!visited.contains(neighbor)) { + frontier.add(neighbor); + visited.add(neighbor); + } + } + } + if (frontier.isEmpty()) { + break; // no more vertices to reach + } + layers.add(Collections.unmodifiableSet(frontier)); + } + return layers; + } + + // ------------------------------------------------------------------ + // k-hop neighborhood + // ------------------------------------------------------------------ + + /** + * Returns the set of vertices within k hops of the source (inclusive). + * + * @param source starting vertex + * @param k hop distance (≥ 0) + * @return unmodifiable set of vertices within k hops + */ + public Set getKHopNeighborhood(String source, int k) { + List> layers = computeLayers(source, k); + Set result = new LinkedHashSet<>(); + for (Set layer : layers) { + result.addAll(layer); + } + return Collections.unmodifiableSet(result); + } + + /** + * Returns boundary vertices — those at exactly distance k from source. + * If k exceeds the eccentricity, an empty set is returned. + * + * @param source starting vertex + * @param k exact hop distance + * @return unmodifiable set of boundary vertices + */ + public Set getBoundary(String source, int k) { + List> layers = computeLayers(source, k); + if (k < layers.size()) { + return layers.get(k); + } + return Collections.emptySet(); + } + + // ------------------------------------------------------------------ + // Growth profile + // ------------------------------------------------------------------ + + /** + * Computes the cumulative neighborhood size at each hop depth. + * Index 0 is always 1 (the source). The list has at most maxK+1 entries, + * fewer if the graph is exhausted before maxK. + * + * @param source starting vertex + * @param maxK maximum depth + * @return list of cumulative sizes at each depth + */ + public List getGrowthProfile(String source, int maxK) { + List> layers = computeLayers(source, maxK); + List profile = new ArrayList<>(); + int cumulative = 0; + for (Set layer : layers) { + cumulative += layer.size(); + profile.add(cumulative); + } + return profile; + } + + /** + * Computes the expansion rate at each hop depth. + * Expansion at depth d = |layer d| / |cumulative up to d-1|. + * Depth 0 has no meaningful expansion (returned as 0.0). + * + * @param source starting vertex + * @param maxK maximum depth + * @return list of expansion rates (length = number of layers computed) + */ + public List getExpansionRates(String source, int maxK) { + List> layers = computeLayers(source, maxK); + List rates = new ArrayList<>(); + int cumulative = 0; + for (int i = 0; i < layers.size(); i++) { + if (i == 0) { + rates.add(0.0); + cumulative += layers.get(i).size(); + } else { + int boundary = layers.get(i).size(); + double rate = cumulative > 0 ? (double) boundary / cumulative : 0.0; + rates.add(rate); + cumulative += boundary; + } + } + return rates; + } + + // ------------------------------------------------------------------ + // Local density + // ------------------------------------------------------------------ + + /** + * Computes the edge density of the induced subgraph on the k-hop + * neighborhood of the source vertex. + * + *

Density = 2 * |edges in subgraph| / (n * (n-1)) for n > 1, + * where n is the neighborhood size. Returns 1.0 for a single vertex + * and 0.0 for an empty graph.

+ * + * @param source starting vertex + * @param k hop distance + * @return edge density in [0.0, 1.0] + */ + public double getLocalDensity(String source, int k) { + Set neighborhood = getKHopNeighborhood(source, k); + int n = neighborhood.size(); + if (n <= 1) { + return n == 1 ? 1.0 : 0.0; + } + + int edgeCount = 0; + for (edge e : graph.getEdges()) { + String src = graph.getEndpoints(e).getFirst(); + String dst = graph.getEndpoints(e).getSecond(); + if (neighborhood.contains(src) && neighborhood.contains(dst)) { + edgeCount++; + } + } + + double maxEdges = (double) n * (n - 1) / 2.0; + return edgeCount / maxEdges; + } + + // ------------------------------------------------------------------ + // Overlap / Similarity + // ------------------------------------------------------------------ + + /** + * Computes the Jaccard similarity of the k-hop neighborhoods of two + * vertices: |A ∩ B| / |A ∪ B|. + * + * @param v1 first vertex + * @param v2 second vertex + * @param k hop distance + * @return Jaccard similarity in [0.0, 1.0] + */ + public double getNeighborhoodOverlap(String v1, String v2, int k) { + Set n1 = getKHopNeighborhood(v1, k); + Set n2 = getKHopNeighborhood(v2, k); + + Set intersection = new HashSet<>(n1); + intersection.retainAll(n2); + + Set union = new HashSet<>(n1); + union.addAll(n2); + + if (union.isEmpty()) { + return 1.0; // both empty → identical + } + return (double) intersection.size() / union.size(); + } + + /** + * Computes the overlap coefficient of the k-hop neighborhoods: + * |A ∩ B| / min(|A|, |B|). More resilient to size differences + * than Jaccard. + * + * @param v1 first vertex + * @param v2 second vertex + * @param k hop distance + * @return overlap coefficient in [0.0, 1.0] + */ + public double getOverlapCoefficient(String v1, String v2, int k) { + Set n1 = getKHopNeighborhood(v1, k); + Set n2 = getKHopNeighborhood(v2, k); + + Set intersection = new HashSet<>(n1); + intersection.retainAll(n2); + + int minSize = Math.min(n1.size(), n2.size()); + if (minSize == 0) { + return n1.isEmpty() && n2.isEmpty() ? 1.0 : 0.0; + } + return (double) intersection.size() / minSize; + } + + // ------------------------------------------------------------------ + // Aggregate statistics + // ------------------------------------------------------------------ + + /** + * Result of aggregate neighborhood statistics across all vertices. + */ + public static class AggregateStats { + private final int k; + private final double meanSize; + private final int minSize; + private final int maxSize; + private final double meanDensity; + private final String minVertex; + private final String maxVertex; + + public AggregateStats(int k, double meanSize, int minSize, int maxSize, + double meanDensity, String minVertex, String maxVertex) { + this.k = k; + this.meanSize = meanSize; + this.minSize = minSize; + this.maxSize = maxSize; + this.meanDensity = meanDensity; + this.minVertex = minVertex; + this.maxVertex = maxVertex; + } + + public int getK() { return k; } + public double getMeanSize() { return meanSize; } + public int getMinSize() { return minSize; } + public int getMaxSize() { return maxSize; } + public double getMeanDensity() { return meanDensity; } + public String getMinVertex() { return minVertex; } + public String getMaxVertex() { return maxVertex; } + } + + /** + * Computes aggregate k-hop neighborhood statistics across all vertices. + * + * @param k hop distance + * @return aggregate statistics + * @throws IllegalArgumentException if k < 0 or graph is empty + */ + public AggregateStats getAggregateStats(int k) { + if (k < 0) { + throw new IllegalArgumentException("k must be >= 0, got " + k); + } + Collection vertices = graph.getVertices(); + if (vertices.isEmpty()) { + throw new IllegalArgumentException("Graph has no vertices"); + } + + int minSize = Integer.MAX_VALUE; + int maxSize = Integer.MIN_VALUE; + double totalSize = 0; + double totalDensity = 0; + String minVertex = null; + String maxVertex = null; + + for (String v : vertices) { + Set hood = getKHopNeighborhood(v, k); + int size = hood.size(); + double density = getLocalDensity(v, k); + + totalSize += size; + totalDensity += density; + + if (size < minSize) { + minSize = size; + minVertex = v; + } + if (size > maxSize) { + maxSize = size; + maxVertex = v; + } + } + + int n = vertices.size(); + return new AggregateStats(k, + totalSize / n, minSize, maxSize, + totalDensity / n, minVertex, maxVertex); + } + + // ------------------------------------------------------------------ + // Vertex ranking + // ------------------------------------------------------------------ + + /** + * Ranks all vertices by their k-hop neighborhood size (descending). + * Useful for identifying vertices with the widest local reach. + * + * @param k hop distance + * @return list of (vertex, size) pairs sorted by size descending + */ + public List> rankByNeighborhoodSize(int k) { + if (k < 0) { + throw new IllegalArgumentException("k must be >= 0, got " + k); + } + Map sizes = new LinkedHashMap<>(); + for (String v : graph.getVertices()) { + sizes.put(v, getKHopNeighborhood(v, k).size()); + } + return sizes.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .collect(Collectors.toList()); + } + + // ------------------------------------------------------------------ + // Report + // ------------------------------------------------------------------ + + /** + * Generates a human-readable text report of neighborhood analysis. + * + * @param source vertex to profile (use null for aggregate-only report) + * @param maxK maximum hop depth to report + * @return formatted text report + */ + public String getTextReport(String source, int maxK) { + StringBuilder sb = new StringBuilder(); + sb.append("=== Neighborhood Analysis Report ===\n"); + sb.append(String.format("Graph: %d vertices, %d edges\n", + graph.getVertexCount(), graph.getEdgeCount())); + sb.append(String.format("Max depth: %d\n\n", maxK)); + + if (source != null && graph.containsVertex(source)) { + sb.append(String.format("--- Vertex '%s' ---\n", source)); + List> layers = computeLayers(source, maxK); + List expansion = getExpansionRates(source, maxK); + int cumulative = 0; + for (int d = 0; d < layers.size(); d++) { + cumulative += layers.get(d).size(); + String expStr = d == 0 ? "n/a" : String.format("%.3f", expansion.get(d)); + sb.append(String.format(" Depth %d: layer=%d cumulative=%d expansion=%s\n", + d, layers.get(d).size(), cumulative, expStr)); + } + sb.append(String.format(" Local density (k=%d): %.4f\n", maxK, + getLocalDensity(source, maxK))); + sb.append("\n"); + } + + sb.append("--- Aggregate Statistics ---\n"); + for (int k = 1; k <= Math.min(maxK, 3); k++) { + if (graph.getVertexCount() == 0) break; + AggregateStats stats = getAggregateStats(k); + sb.append(String.format(" k=%d: mean=%.1f min=%d (%s) max=%d (%s) density=%.4f\n", + k, stats.getMeanSize(), stats.getMinSize(), stats.getMinVertex(), + stats.getMaxSize(), stats.getMaxVertex(), stats.getMeanDensity())); + } + + return sb.toString(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private void validateVertex(String vertex) { + if (vertex == null || !graph.containsVertex(vertex)) { + throw new IllegalArgumentException( + "Vertex not found in graph: " + vertex); + } + } +} diff --git a/Gvisual/test/gvisual/GraphNeighborhoodAnalyzerTest.java b/Gvisual/test/gvisual/GraphNeighborhoodAnalyzerTest.java new file mode 100644 index 0000000..2bc66d2 --- /dev/null +++ b/Gvisual/test/gvisual/GraphNeighborhoodAnalyzerTest.java @@ -0,0 +1,566 @@ +package gvisual; + +import edu.uci.ics.jung.graph.Graph; +import edu.uci.ics.jung.graph.UndirectedSparseGraph; +import org.junit.Before; +import org.junit.Test; + +import java.util.*; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link GraphNeighborhoodAnalyzer}. + */ +public class GraphNeighborhoodAnalyzerTest { + + private Graph graph; + + @Before + public void setUp() { + graph = new UndirectedSparseGraph<>(); + } + + private edge addEdge(String v1, String v2) { + edge e = new edge("f", v1, v2); + e.setWeight(1.0f); + if (!graph.containsVertex(v1)) graph.addVertex(v1); + if (!graph.containsVertex(v2)) graph.addVertex(v2); + graph.addEdge(e, v1, v2); + return e; + } + + private void buildPath(int n) { + for (int i = 0; i < n; i++) graph.addVertex(String.valueOf(i)); + for (int i = 0; i < n - 1; i++) + addEdge(String.valueOf(i), String.valueOf(i + 1)); + } + + private void buildComplete(int n) { + for (int i = 0; i < n; i++) graph.addVertex(String.valueOf(i)); + for (int i = 0; i < n; i++) + for (int j = i + 1; j < n; j++) + addEdge(String.valueOf(i), String.valueOf(j)); + } + + private void buildStar(int n) { + graph.addVertex("c"); + for (int i = 0; i < n; i++) { + graph.addVertex(String.valueOf(i)); + addEdge("c", String.valueOf(i)); + } + } + + private void buildCycle(int n) { + for (int i = 0; i < n; i++) graph.addVertex(String.valueOf(i)); + for (int i = 0; i < n; i++) + addEdge(String.valueOf(i), String.valueOf((i + 1) % n)); + } + + // ------------------------------------------------------------------ + // Constructor validation + // ------------------------------------------------------------------ + + @Test(expected = IllegalArgumentException.class) + public void testNullGraph() { + new GraphNeighborhoodAnalyzer(null); + } + + // ------------------------------------------------------------------ + // computeLayers + // ------------------------------------------------------------------ + + @Test + public void testLayersSingleVertex() { + graph.addVertex("A"); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> layers = analyzer.computeLayers("A", 3); + assertEquals(1, layers.size()); + assertTrue(layers.get(0).contains("A")); + } + + @Test + public void testLayersPath5() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> layers = analyzer.computeLayers("0", 10); + assertEquals(5, layers.size()); + assertEquals(Collections.singleton("0"), layers.get(0)); + assertEquals(Collections.singleton("1"), layers.get(1)); + assertEquals(Collections.singleton("2"), layers.get(2)); + assertEquals(Collections.singleton("3"), layers.get(3)); + assertEquals(Collections.singleton("4"), layers.get(4)); + } + + @Test + public void testLayersPath5FromMiddle() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> layers = analyzer.computeLayers("2", 10); + assertEquals(3, layers.size()); // depth 0, 1, 2 + assertEquals(Collections.singleton("2"), layers.get(0)); + assertTrue(layers.get(1).contains("1")); + assertTrue(layers.get(1).contains("3")); + assertEquals(2, layers.get(1).size()); + assertTrue(layers.get(2).contains("0")); + assertTrue(layers.get(2).contains("4")); + } + + @Test + public void testLayersCompleteGraph() { + buildComplete(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> layers = analyzer.computeLayers("0", 5); + assertEquals(2, layers.size()); // depth 0 and 1 + assertEquals(1, layers.get(0).size()); + assertEquals(4, layers.get(1).size()); + } + + @Test + public void testLayersMaxKZero() { + buildPath(3); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> layers = analyzer.computeLayers("0", 0); + assertEquals(1, layers.size()); + assertEquals(Collections.singleton("0"), layers.get(0)); + } + + @Test + public void testLayersMaxKLimitsDepth() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> layers = analyzer.computeLayers("0", 2); + assertEquals(3, layers.size()); // 0, 1, 2 only + } + + @Test(expected = IllegalArgumentException.class) + public void testLayersInvalidVertex() { + buildPath(3); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + analyzer.computeLayers("Z", 2); + } + + @Test(expected = IllegalArgumentException.class) + public void testLayersNegativeK() { + buildPath(3); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + analyzer.computeLayers("0", -1); + } + + // ------------------------------------------------------------------ + // getKHopNeighborhood + // ------------------------------------------------------------------ + + @Test + public void testKHopPath() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + Set hood = analyzer.getKHopNeighborhood("0", 2); + assertEquals(3, hood.size()); + assertTrue(hood.containsAll(Arrays.asList("0", "1", "2"))); + } + + @Test + public void testKHopComplete() { + buildComplete(4); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + Set hood = analyzer.getKHopNeighborhood("0", 1); + assertEquals(4, hood.size()); // all vertices within 1 hop + } + + @Test + public void testKHopDisconnected() { + graph.addVertex("A"); + graph.addVertex("B"); + graph.addVertex("C"); + addEdge("A", "B"); + // C is disconnected + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + Set hood = analyzer.getKHopNeighborhood("A", 5); + assertEquals(2, hood.size()); + assertTrue(hood.contains("A")); + assertTrue(hood.contains("B")); + assertFalse(hood.contains("C")); + } + + @Test + public void testKHopZero() { + buildPath(3); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + Set hood = analyzer.getKHopNeighborhood("1", 0); + assertEquals(1, hood.size()); + assertTrue(hood.contains("1")); + } + + // ------------------------------------------------------------------ + // getBoundary + // ------------------------------------------------------------------ + + @Test + public void testBoundaryPath() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + Set boundary = analyzer.getBoundary("0", 3); + assertEquals(1, boundary.size()); + assertTrue(boundary.contains("3")); + } + + @Test + public void testBoundaryStar() { + buildStar(4); // c-0, c-1, c-2, c-3 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + Set boundary = analyzer.getBoundary("c", 1); + assertEquals(4, boundary.size()); + } + + @Test + public void testBoundaryBeyondEccentricity() { + buildPath(3); // 0-1-2 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + Set boundary = analyzer.getBoundary("0", 10); + assertTrue(boundary.isEmpty()); + } + + // ------------------------------------------------------------------ + // getGrowthProfile + // ------------------------------------------------------------------ + + @Test + public void testGrowthProfilePath() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List profile = analyzer.getGrowthProfile("0", 10); + // Each hop adds 1 vertex: [1, 2, 3, 4, 5] + assertEquals(Arrays.asList(1, 2, 3, 4, 5), profile); + } + + @Test + public void testGrowthProfileStar() { + buildStar(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List profile = analyzer.getGrowthProfile("c", 3); + // Depth 0: {c} = 1, Depth 1: {0..4} = 6 + assertEquals(Arrays.asList(1, 6), profile); + } + + @Test + public void testGrowthProfileComplete() { + buildComplete(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List profile = analyzer.getGrowthProfile("0", 5); + assertEquals(Arrays.asList(1, 5), profile); + } + + // ------------------------------------------------------------------ + // getExpansionRates + // ------------------------------------------------------------------ + + @Test + public void testExpansionRatesPath() { + buildPath(4); // 0-1-2-3 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List rates = analyzer.getExpansionRates("0", 10); + assertEquals(4, rates.size()); + assertEquals(0.0, rates.get(0), 0.001); // depth 0: no expansion + assertEquals(1.0, rates.get(1), 0.001); // depth 1: 1 new / 1 prior + assertEquals(0.5, rates.get(2), 0.001); // depth 2: 1 new / 2 prior + assertEquals(1.0 / 3, rates.get(3), 0.001); // depth 3: 1 new / 3 prior + } + + @Test + public void testExpansionRatesStar() { + buildStar(4); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List rates = analyzer.getExpansionRates("c", 3); + assertEquals(2, rates.size()); + assertEquals(0.0, rates.get(0), 0.001); + assertEquals(4.0, rates.get(1), 0.001); // 4 new / 1 prior + } + + // ------------------------------------------------------------------ + // getLocalDensity + // ------------------------------------------------------------------ + + @Test + public void testLocalDensityComplete() { + buildComplete(4); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + double density = analyzer.getLocalDensity("0", 1); + assertEquals(1.0, density, 0.001); // K4 is fully connected + } + + @Test + public void testLocalDensityPath() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + // k=1 from vertex 2: neighborhood = {1,2,3}, edges = 1-2, 2-3 = 2 edges + // density = 2*2 / (3*2) = 0.667 + double density = analyzer.getLocalDensity("2", 1); + assertEquals(2.0 / 3, density, 0.001); + } + + @Test + public void testLocalDensitySingleVertex() { + graph.addVertex("A"); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + assertEquals(1.0, analyzer.getLocalDensity("A", 0), 0.001); + } + + @Test + public void testLocalDensityStar() { + buildStar(3); // c connected to 0, 1, 2 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + // k=1 from c: neighborhood = {c,0,1,2}, 3 edges, max = 4*3/2 = 6 + double density = analyzer.getLocalDensity("c", 1); + assertEquals(3.0 / 6, density, 0.001); + } + + // ------------------------------------------------------------------ + // getNeighborhoodOverlap + // ------------------------------------------------------------------ + + @Test + public void testOverlapSameVertex() { + buildPath(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + assertEquals(1.0, analyzer.getNeighborhoodOverlap("0", "0", 2), 0.001); + } + + @Test + public void testOverlapAdjacentPath() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + // k=1: N(0)={0,1}, N(1)={0,1,2} + // intersection = {0,1}, union = {0,1,2} + double overlap = analyzer.getNeighborhoodOverlap("0", "1", 1); + assertEquals(2.0 / 3, overlap, 0.001); + } + + @Test + public void testOverlapDisconnected() { + graph.addVertex("A"); + graph.addVertex("B"); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + // N(A)={A}, N(B)={B}, intersection empty, union = {A,B} + assertEquals(0.0, analyzer.getNeighborhoodOverlap("A", "B", 1), 0.001); + } + + // ------------------------------------------------------------------ + // getOverlapCoefficient + // ------------------------------------------------------------------ + + @Test + public void testOverlapCoefficientSubset() { + // A-B-C, k=2 from A = {A,B,C}, k=1 from B = {A,B,C} + addEdge("A", "B"); + addEdge("B", "C"); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + // N2(A)={A,B,C}, N1(B)={A,B,C}, overlap coeff = 3/3 = 1.0 + assertEquals(1.0, analyzer.getOverlapCoefficient("A", "B", 2), 0.001); + } + + @Test + public void testOverlapCoefficientDisjoint() { + graph.addVertex("A"); + graph.addVertex("B"); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + assertEquals(0.0, analyzer.getOverlapCoefficient("A", "B", 1), 0.001); + } + + // ------------------------------------------------------------------ + // getAggregateStats + // ------------------------------------------------------------------ + + @Test + public void testAggregateStatsComplete() { + buildComplete(4); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + GraphNeighborhoodAnalyzer.AggregateStats stats = analyzer.getAggregateStats(1); + assertEquals(1, stats.getK()); + assertEquals(4.0, stats.getMeanSize(), 0.001); // all see all + assertEquals(4, stats.getMinSize()); + assertEquals(4, stats.getMaxSize()); + assertEquals(1.0, stats.getMeanDensity(), 0.001); + } + + @Test + public void testAggregateStatsPath() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + GraphNeighborhoodAnalyzer.AggregateStats stats = analyzer.getAggregateStats(1); + // k=1 sizes: 0→2, 1→3, 2→3, 3→3, 4→2 → mean = 13/5 = 2.6 + assertEquals(2.6, stats.getMeanSize(), 0.001); + assertEquals(2, stats.getMinSize()); + assertEquals(3, stats.getMaxSize()); + } + + @Test(expected = IllegalArgumentException.class) + public void testAggregateStatsNegativeK() { + buildPath(3); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + analyzer.getAggregateStats(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testAggregateStatsEmptyGraph() { + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + analyzer.getAggregateStats(1); + } + + // ------------------------------------------------------------------ + // rankByNeighborhoodSize + // ------------------------------------------------------------------ + + @Test + public void testRankPath() { + buildPath(5); // 0-1-2-3-4 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> ranked = analyzer.rankByNeighborhoodSize(1); + assertEquals(5, ranked.size()); + // Top entries should have size 3 (vertices 1, 2, or 3) + assertEquals(3, (int) ranked.get(0).getValue()); + // Last entries should have size 2 (vertices 0 or 4) + assertEquals(2, (int) ranked.get(4).getValue()); + } + + @Test + public void testRankStar() { + buildStar(4); // c with leaves 0,1,2,3 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List> ranked = analyzer.rankByNeighborhoodSize(1); + // c has size 5 (c + 4 leaves), leaves have size 2 (self + c) + assertEquals("c", ranked.get(0).getKey()); + assertEquals(5, (int) ranked.get(0).getValue()); + } + + @Test(expected = IllegalArgumentException.class) + public void testRankNegativeK() { + buildPath(3); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + analyzer.rankByNeighborhoodSize(-1); + } + + // ------------------------------------------------------------------ + // getTextReport + // ------------------------------------------------------------------ + + @Test + public void testTextReportNotEmpty() { + buildPath(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + String report = analyzer.getTextReport("2", 3); + assertNotNull(report); + assertTrue(report.contains("Neighborhood Analysis Report")); + assertTrue(report.contains("Vertex '2'")); + assertTrue(report.contains("Depth 0")); + assertTrue(report.contains("Aggregate Statistics")); + } + + @Test + public void testTextReportNullSource() { + buildComplete(3); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + String report = analyzer.getTextReport(null, 2); + assertNotNull(report); + assertTrue(report.contains("Aggregate Statistics")); + assertFalse(report.contains("Vertex")); + } + + @Test + public void testTextReportContainsExpansion() { + buildPath(4); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + String report = analyzer.getTextReport("0", 3); + assertTrue(report.contains("expansion=")); + } + + // ------------------------------------------------------------------ + // Edge case: cycle graph + // ------------------------------------------------------------------ + + @Test + public void testCycleNeighborhood() { + buildCycle(6); // 0-1-2-3-4-5-0 + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + // k=1 from 0: {0, 1, 5} + Set hood = analyzer.getKHopNeighborhood("0", 1); + assertEquals(3, hood.size()); + assertTrue(hood.containsAll(Arrays.asList("0", "1", "5"))); + + // k=3 from 0: should reach all 6 vertices + Set fullHood = analyzer.getKHopNeighborhood("0", 3); + assertEquals(6, fullHood.size()); + } + + @Test + public void testCycleGrowthProfile() { + buildCycle(6); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List profile = analyzer.getGrowthProfile("0", 5); + // [1, 3, 5, 6] + assertEquals(4, profile.size()); + assertEquals(1, (int) profile.get(0)); + assertEquals(3, (int) profile.get(1)); + assertEquals(5, (int) profile.get(2)); + assertEquals(6, (int) profile.get(3)); + } + + // ------------------------------------------------------------------ + // Edge case: single edge + // ------------------------------------------------------------------ + + @Test + public void testSingleEdge() { + addEdge("A", "B"); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + assertEquals(2, analyzer.getKHopNeighborhood("A", 1).size()); + assertEquals(1.0, analyzer.getLocalDensity("A", 1), 0.001); + assertEquals(1.0, analyzer.getNeighborhoodOverlap("A", "B", 1), 0.001); + } + + // ------------------------------------------------------------------ + // Consistency checks + // ------------------------------------------------------------------ + + @Test + public void testGrowthProfileMonotonicallyIncreasing() { + buildCycle(8); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + List profile = analyzer.getGrowthProfile("0", 10); + for (int i = 1; i < profile.size(); i++) { + assertTrue("Growth must be monotonically increasing", + profile.get(i) >= profile.get(i - 1)); + } + } + + @Test + public void testKHopContainsSelf() { + buildPath(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + for (String v : graph.getVertices()) { + Set hood = analyzer.getKHopNeighborhood(v, 2); + assertTrue("k-hop neighborhood must contain the source vertex", + hood.contains(v)); + } + } + + @Test + public void testOverlapSymmetric() { + buildPath(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + double ab = analyzer.getNeighborhoodOverlap("0", "4", 2); + double ba = analyzer.getNeighborhoodOverlap("4", "0", 2); + assertEquals("Overlap must be symmetric", ab, ba, 0.0001); + } + + @Test + public void testLocalDensityBounded() { + buildPath(5); + GraphNeighborhoodAnalyzer analyzer = new GraphNeighborhoodAnalyzer(graph); + for (String v : graph.getVertices()) { + double d = analyzer.getLocalDensity(v, 2); + assertTrue("Density must be >= 0", d >= 0.0); + assertTrue("Density must be <= 1", d <= 1.0 + 0.0001); + } + } +}