From a41d3e30f122685fc0372db2a1ba83ccfd301de8 Mon Sep 17 00:00:00 2001 From: Zack Grannan Date: Fri, 10 Oct 2025 12:55:00 -0700 Subject: [PATCH 1/3] WIP --- src-js/coupling-algorithms.test.ts | 2 +- src-js/coupling-algorithms.ts | 250 +++++++++++++++++++++++++++-- src-js/hypergraph-renderer.ts | 17 +- src/coupling.md | 70 +++++++- 4 files changed, 321 insertions(+), 18 deletions(-) diff --git a/src-js/coupling-algorithms.test.ts b/src-js/coupling-algorithms.test.ts index 11c4b5c..41b7980 100644 --- a/src-js/coupling-algorithms.test.ts +++ b/src-js/coupling-algorithms.test.ts @@ -1,7 +1,7 @@ /** * Test suite for coupling algorithms * - * These tests verify each step of the frontier expiries algorithm + * These tests verify each step of the unblocking frontier expiries algorithm * using the example graph from "Defining Coupling Based on an Expires After Relation" * in coupling.md */ diff --git a/src-js/coupling-algorithms.ts b/src-js/coupling-algorithms.ts index 4368696..c3e186f 100644 --- a/src-js/coupling-algorithms.ts +++ b/src-js/coupling-algorithms.ts @@ -260,7 +260,7 @@ interface Unblocking { } /** - * Coupling algorithm based on frontier expiries + * Coupling algorithm based on unblocking frontier expiries * * This algorithm generates coupled edges by finding maximally coupled edges. * Edges are effectively coupled if for all reachable subgraphs in distinct @@ -276,7 +276,7 @@ interface Unblocking { * subgraphs. Maximally coupled edges are effectively coupled sets that are * not subsets of other effectively coupled sets. */ -function computeCouplingFrontierExpiries(nodes: Node[], edges: Edge[]): CoupledEdgeResult[] { +function computeCouplingUnblockingFrontierExpiries(nodes: Node[], edges: Edge[]): CoupledEdgeResult[] { const graph = new HypergraphForCoupling(nodes, edges); const allUnblockings = computeAllUnblockings(graph); @@ -323,10 +323,9 @@ function computeAllUnblockings(graph: HypergraphForCoupling): Unblocking[] { } const result: Unblocking[] = []; - const frontiers = graph.getAllFrontiers(); - const productiveFrontiers = frontiers.filter(f => graph.isProductiveExpiry(f)); + const minimalProductiveFrontiers = graph.getMinimalProductiveFrontiers(); - for (const frontier of productiveFrontiers) { + for (const frontier of minimalProductiveFrontiers) { const unblockedNodes = graph.getUnblockedNodes(frontier); if (unblockedNodes.length === 0) continue; @@ -479,6 +478,229 @@ function findMaximallyCoupledSets(effectivelyCoupled: Set[]): Set e.id); + const expireTogetherSets = findExpireTogetherSets( + allReachableGraphs, + edgeIds + ); + + const maximalSets = findMaximallyCoupledSets(expireTogetherSets); + + return maximalSets.map((edgeIdSet, idx) => { + const underlyingEdges = graph.edges.filter(e => edgeIdSet.has(e.id)); + const coupled = new CoupledEdge(underlyingEdges); + return { + type: 'coupled', + underlyingEdges: underlyingEdges, + sources: coupled.sources, + targets: coupled.targets + }; + }); +} + +/** + * Get all reachable subgraphs from a given graph + */ +function getAllReachableGraphs(graph: HypergraphForCoupling): HypergraphForCoupling[] { + const reachableGraphs = [graph]; + const toProcess = [graph]; + const visited = new Set(); + + const getGraphSignature = (g: HypergraphForCoupling): string => { + const nodeIds = Array.from(g.nodes).sort().join(','); + const edgeIds = g.edges.map(e => e.id).sort().join(','); + return `${nodeIds}|${edgeIds}`; + }; + + visited.add(getGraphSignature(graph)); + + while (toProcess.length > 0) { + const current = toProcess.pop()!; + const frontiers = current.getAllFrontiers(); + + for (const frontier of frontiers) { + const nextGraph = current.clone(); + nextGraph.removeNodes(frontier); + + const signature = getGraphSignature(nextGraph); + if (!visited.has(signature)) { + visited.add(signature); + reachableGraphs.push(nextGraph); + toProcess.push(nextGraph); + } + } + } + + return reachableGraphs; +} + +/** + * Find all sets of edges that expire together + */ +function findExpireTogetherSets( + reachableGraphs: HypergraphForCoupling[], + edgeIds: string[] +): Set[] { + const expireTogether: Set[] = []; + + const numSubsets = Math.pow(2, edgeIds.length); + for (let i = 1; i < numSubsets; i++) { + const subset: string[] = []; + for (let j = 0; j < edgeIds.length; j++) { + if (i & (1 << j)) { + subset.push(edgeIds[j]); + } + } + + if (doEdgesExpireTogether(subset, reachableGraphs)) { + expireTogether.push(new Set(subset)); + } + } + + return expireTogether; +} + +/** + * Check if a set of edges expire together + * Edges expire together if for all reachable graphs, the graph contains + * either all edges or none of them. + */ +function doEdgesExpireTogether( + edgeIds: string[], + reachableGraphs: HypergraphForCoupling[] +): boolean { + const edgeSet = new Set(edgeIds); + + for (const graph of reachableGraphs) { + const graphEdgeIds = new Set(graph.edges.map(e => e.id)); + + const containsAll = edgeIds.every(id => graphEdgeIds.has(id)); + const containsNone = edgeIds.every(id => !graphEdgeIds.has(id)); + + if (!containsAll && !containsNone) { + return false; + } + } + + return true; +} + +/** + * Merged unblocking frontier expiries coupling algorithm + * + * This algorithm takes the coupled edges from unblocking-frontier-expiries and + * performs an additional merging step. The result is that removing any coupled + * edge unblocks at least one node. + * + * Algorithm: + * 1. Compute coupled edges using unblocking-frontier-expiries + * 2. For each node N in the graph: + * a. Find all coupled edges whose sources contain N + * b. Create a new hyperedge by merging all underlying edges from these coupled edges + * 3. Filter out redundant edges: Remove any edge E if there exist distinct edges E1, E2 + * such that underlying(E1) ∪ underlying(E2) = underlying(E) + * Rationale: If E can be expressed as the union of two other coupled edges, then + * unblocking E is equivalent to unblocking E1 and E2 in sequence, making E redundant. + * + * Note: The resulting coupled edges may overlap (share underlying edges), which + * is the key difference from the original algorithm. + */ +function computeCouplingMergedUnblockingFrontierExpiries(nodes: Node[], edges: Edge[]): CoupledEdgeResult[] { + const graph = new HypergraphForCoupling(nodes, edges); + + const unblockingCoupled = computeCouplingUnblockingFrontierExpiries(nodes, edges); + + if (unblockingCoupled.length === 0) { + return []; + } + + const mergedCoupled: CoupledEdgeResult[] = []; + const processedSets = new Set(); + + for (const nodeId of graph.nodes) { + const coupledWithNode = unblockingCoupled.filter(coupled => + coupled.sources.includes(nodeId) + ); + + if (coupledWithNode.length === 0) continue; + + const allUnderlyingEdges = new Set(); + coupledWithNode.forEach(coupled => { + coupled.underlyingEdges.forEach(edge => allUnderlyingEdges.add(edge)); + }); + + const underlyingEdgesArray = Array.from(allUnderlyingEdges); + const edgeIdsSignature = underlyingEdgesArray + .map(e => e.id) + .sort() + .join(','); + + if (!processedSets.has(edgeIdsSignature)) { + processedSets.add(edgeIdsSignature); + const coupled = new CoupledEdge(underlyingEdgesArray); + mergedCoupled.push({ + type: 'merged-coupled', + underlyingEdges: underlyingEdgesArray, + sources: coupled.sources, + targets: coupled.targets + }); + } + } + + return filterRedundantCoupledEdges(mergedCoupled); +} + +/** + * Filter out redundant coupled edges. + * + * An edge E is redundant if there exist distinct edges E1 and E2 such that + * underlying(E1) ∪ underlying(E2) = underlying(E). + * + * This is because unblocking E is equivalent to unblocking E1 and E2 in sequence. + */ +function filterRedundantCoupledEdges(coupledEdges: CoupledEdgeResult[]): CoupledEdgeResult[] { + return coupledEdges.filter((edge, idx) => { + const edgeUnderlyingIds = new Set(edge.underlyingEdges.map(e => e.id)); + + for (let i = 0; i < coupledEdges.length; i++) { + if (i === idx) continue; + + for (let j = i + 1; j < coupledEdges.length; j++) { + if (j === idx) continue; + + const edge1UnderlyingIds = new Set(coupledEdges[i].underlyingEdges.map(e => e.id)); + const edge2UnderlyingIds = new Set(coupledEdges[j].underlyingEdges.map(e => e.id)); + + const unionIds = new Set([...edge1UnderlyingIds, ...edge2UnderlyingIds]); + + if (unionIds.size === edgeUnderlyingIds.size && + Array.from(unionIds).every(id => edgeUnderlyingIds.has(id)) && + Array.from(edgeUnderlyingIds).every(id => unionIds.has(id))) { + return false; + } + } + } + + return true; + }); +} + /** * Identity coupling - returns each edge as its own coupled edge * This is useful as a baseline comparison @@ -512,10 +734,20 @@ export const COUPLING_ALGORITHMS: Record = { description: 'Show original edges without coupling', compute: computeCouplingIdentity }, - 'frontier-expiries': { - name: 'Frontier Expiries', - description: 'Maximal coupling based on frontier expiries', - compute: computeCouplingFrontierExpiries + 'unblocking-frontier-expiries': { + name: 'Unblocking Frontier Expiries', + description: 'Maximal coupling based on unblocking frontier expiries', + compute: computeCouplingUnblockingFrontierExpiries + }, + // 'merged-unblocking-frontier-expiries': { + // name: 'Merged Unblocking Frontier Expiries', + // description: 'Merges coupled edges such that removing any coupled edge unblocks a node (coupled edges may overlap)', + // compute: computeCouplingMergedUnblockingFrontierExpiries + // }, + 'expire-together': { + name: 'Expire Together', + description: 'Coupling based on edges that expire together in all reachable subgraphs', + compute: computeCouplingExpireTogether } }; diff --git a/src-js/hypergraph-renderer.ts b/src-js/hypergraph-renderer.ts index 5c69169..bc10b96 100644 --- a/src-js/hypergraph-renderer.ts +++ b/src-js/hypergraph-renderer.ts @@ -208,7 +208,14 @@ const CYTOSCAPE_STYLES = [ try { const couplingAttr = container.getAttribute("data-coupling-algorithms"); if (couplingAttr) { - couplingAlgorithms = JSON.parse(couplingAttr) as string[]; + const parsed = JSON.parse(couplingAttr); + if (parsed === "all") { + couplingAlgorithms = Object.keys(COUPLING_ALGORITHMS).filter(id => id !== "none"); + } else if (Array.isArray(parsed)) { + couplingAlgorithms = parsed as string[]; + } else { + couplingAlgorithms = []; + } } } catch (e) { console.error("Failed to parse coupling algorithms:", e); @@ -339,6 +346,14 @@ const CYTOSCAPE_STYLES = [ label.appendChild(checkbox); label.appendChild(document.createTextNode(`${sourcesStr} → ${targetsStr}`)); + const underlyingEdgesStr = edge.underlyingEdges.map(e => { + const uSources = e.sources.length > 1 ? `{${e.sources.join(", ")}}` : e.sources[0]; + const uTargets = e.targets.length > 1 ? `{${e.targets.join(", ")}}` : e.targets[0]; + return `${uSources} → ${uTargets}`; + }).join(', '); + + label.appendChild(document.createTextNode(` [${underlyingEdgesStr}]`)); + edgeDiv.appendChild(label); edgesList.appendChild(edgeDiv); }); diff --git a/src/coupling.md b/src/coupling.md index 6e8f22d..a9c8565 100644 --- a/src/coupling.md +++ b/src/coupling.md @@ -98,7 +98,7 @@ none of them, i.e.: 1. $\overline{e} \cap edges(G) = \overline{e}$, or 2. $\overline{e} \cap edges(G) = \emptyset$ -If a set of edges $\overline{e}$ expire togehter on a graph $G$, then there must +If a set of edges $\overline{e}$ expire together on a graph $G$, then there must exist a set of edges $\overline{e'} \supseteq \overline{e}$ that are coupled for $G$. @@ -362,6 +362,62 @@ the base case where $U$ is empty. We then need to show that expiry edge $e_1$ that unblocks $U_1$ is either in $coupled(G)$ or is a combination of edges in $coupled(G)$. --> + + ## Test Graphs ### `m` function @@ -379,7 +435,7 @@ fn m<'a: 'c, 'b: 'e, 'c, 'd, 'e, T>( ```hypergraph { "height": "300px", - "couplingAlgorithms": ["frontier-expiries"], + "couplingAlgorithms": "all", "nodes": [ {"id": "x_a", "place": "x", "lifetime": "'a", "x": 100, "y": 100}, {"id": "y_b", "place": "y", "lifetime": "'b", "x": 300, "y": 100}, @@ -411,7 +467,7 @@ fn w<'a: 'd, 'b: 'd, 'c: 'e, 'd, 'e T>( ```hypergraph { "height": "300px", - "couplingAlgorithms": ["frontier-expiries"], + "couplingAlgorithms": "all", "nodes": [ {"id": "x_a", "place": "x", "lifetime": "'a", "x": 50, "y": 100}, {"id": "y_b", "place": "y", "lifetime": "'b", "x": 200, "y": 100}, @@ -433,7 +489,7 @@ fn w<'a: 'd, 'b: 'd, 'c: 'e, 'd, 'e T>( ```hypergraph { "height": "300px", - "couplingAlgorithms": ["frontier-expiries"], + "couplingAlgorithms": "all", "nodes": [ {"id": "a", "place": "a", "x": 0, "y": 0}, {"id": "b", "place": "b", "x": 100, "y": 0}, @@ -464,7 +520,7 @@ fn f<'a>(x: &'a mut T) -> &'a mut T { ```hypergraph { "height": "250px", - "couplingAlgorithms": ["frontier-expiries"], + "couplingAlgorithms": "all", "nodes": [ {"id": "x_a", "place": "x", "lifetime": "'a", "x": 100, "y": 100}, {"id": "result_a", "place": "result", "lifetime": "'a", "x": 100, "y": 200} @@ -486,7 +542,7 @@ fn f<'a, 'b: 'a>(x: &'a mut T, y: &'b mut T) -> &'a mut T { ```hypergraph { "height": "300px", - "couplingAlgorithms": ["frontier-expiries"], + "couplingAlgorithms": "all", "nodes": [ {"id": "x_a", "place": "x", "lifetime": "'a", "x": 100, "y": 100}, {"id": "y_b", "place": "y", "lifetime": "'b", "x": 300, "y": 100}, @@ -504,7 +560,7 @@ fn f<'a, 'b: 'a>(x: &'a mut T, y: &'b mut T) -> &'a mut T { ```hypergraph { "height": "350px", - "couplingAlgorithms": ["frontier-expiries"], + "couplingAlgorithms": "all", "nodes": [ {"id": "x1", "place": "x1", "x": 50, "y": 50}, {"id": "y1", "place": "y1", "x": 50, "y": 200}, From 00d9d41eb4af54efe7fe290b8a725654a41d02af Mon Sep 17 00:00:00 2001 From: Zack Grannan Date: Fri, 10 Oct 2025 13:10:04 -0700 Subject: [PATCH 2/3] fix tests --- src-js/coupling-algorithms.test.ts | 18 +++++++++--------- src-js/coupling-algorithms.ts | 17 ++++++++++------- src-js/hypergraph-renderer.ts | 11 ++++++----- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src-js/coupling-algorithms.test.ts b/src-js/coupling-algorithms.test.ts index 41b7980..bc61e5a 100644 --- a/src-js/coupling-algorithms.test.ts +++ b/src-js/coupling-algorithms.test.ts @@ -344,10 +344,10 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { }); }); - describe("Complete Frontier Expiries Algorithm", () => { + describe("Unblocking Frontier Expiries Algorithm", () => { test("should produce coupled edges", () => { const result = applyCouplingAlgorithm( - "frontier-expiries", + "unblocking-frontier-expiries", testNodes, testEdges ); @@ -359,7 +359,7 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { test("should document the expected coupled edges for the test graph", () => { const result = applyCouplingAlgorithm( - "frontier-expiries", + "unblocking-frontier-expiries", testNodes, testEdges ); @@ -388,7 +388,7 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { test("coupled edges should have valid structure", () => { const result = applyCouplingAlgorithm( - "frontier-expiries", + "unblocking-frontier-expiries", testNodes, testEdges ); @@ -406,7 +406,7 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { test("coupled edges should be consistent with underlying edges", () => { const result = applyCouplingAlgorithm( - "frontier-expiries", + "unblocking-frontier-expiries", testNodes, testEdges ); @@ -434,7 +434,7 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { test("should produce different results than identity coupling", () => { const frontierResult = applyCouplingAlgorithm( - "frontier-expiries", + "unblocking-frontier-expiries", testNodes, testEdges ); @@ -450,7 +450,7 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { describe("Edge Case Handling", () => { test("should handle empty graph", () => { - const emptyResult = applyCouplingAlgorithm("frontier-expiries", [], []); + const emptyResult = applyCouplingAlgorithm("unblocking-frontier-expiries", [], []); expect(emptyResult).toEqual([]); }); @@ -462,7 +462,7 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { const noEdges: Edge[] = []; const result = applyCouplingAlgorithm( - "frontier-expiries", + "unblocking-frontier-expiries", leafNodes, noEdges ); @@ -477,7 +477,7 @@ describe("Coupling Algorithms - Test Graph from coupling.md", () => { const singleEdge: Edge[] = [{ id: "e1", sources: ["a"], targets: ["b"] }]; const result = applyCouplingAlgorithm( - "frontier-expiries", + "unblocking-frontier-expiries", simpleNodes, singleEdge ); diff --git a/src-js/coupling-algorithms.ts b/src-js/coupling-algorithms.ts index c3e186f..460dc9d 100644 --- a/src-js/coupling-algorithms.ts +++ b/src-js/coupling-algorithms.ts @@ -728,12 +728,17 @@ interface CouplingAlgorithm { /** * Available coupling algorithms */ -export const COUPLING_ALGORITHMS: Record = { +export const COUPLING_ALGORITHMS = { 'none': { name: 'None (Original)', description: 'Show original edges without coupling', compute: computeCouplingIdentity }, + 'frontier-expiries': { + name: 'Frontier Expiries', + description: 'Maximal coupling based on unblocking frontier expiries', + compute: computeCouplingUnblockingFrontierExpiries + }, 'unblocking-frontier-expiries': { name: 'Unblocking Frontier Expiries', description: 'Maximal coupling based on unblocking frontier expiries', @@ -749,17 +754,15 @@ export const COUPLING_ALGORITHMS: Record = { description: 'Coupling based on edges that expire together in all reachable subgraphs', compute: computeCouplingExpireTogether } -}; +} as const; + +export type CouplingAlgorithmId = keyof typeof COUPLING_ALGORITHMS; /** * Apply coupling algorithm to a graph */ -export function applyCouplingAlgorithm(algorithmId: string, nodes: Node[], edges: Edge[]): CoupledEdgeResult[] { +export function applyCouplingAlgorithm(algorithmId: CouplingAlgorithmId, nodes: Node[], edges: Edge[]): CoupledEdgeResult[] { const algorithm = COUPLING_ALGORITHMS[algorithmId]; - if (!algorithm) { - throw new Error(`Unknown coupling algorithm: ${algorithmId}`); - } - return algorithm.compute(nodes, edges); } diff --git a/src-js/hypergraph-renderer.ts b/src-js/hypergraph-renderer.ts index bc10b96..b7297b1 100644 --- a/src-js/hypergraph-renderer.ts +++ b/src-js/hypergraph-renderer.ts @@ -10,6 +10,7 @@ import BubbleSets from "cytoscape-bubblesets"; import { COUPLING_ALGORITHMS, applyCouplingAlgorithm, + CouplingAlgorithmId, } from "./coupling-algorithms"; // Register extensions @@ -235,7 +236,7 @@ const CYTOSCAPE_STYLES = [ ${couplingAlgorithms .map((alg) => { - const algInfo = COUPLING_ALGORITHMS[alg]; + const algInfo = COUPLING_ALGORITHMS[alg as CouplingAlgorithmId]; const selected = alg === defaultAlgorithm ? ' selected' : ''; return ``; }) @@ -289,7 +290,7 @@ const CYTOSCAPE_STYLES = [ function applyCouplingToEdges( edges: GraphEdge[], nodes: GraphNode[], - couplingAlgorithmId: string + couplingAlgorithmId: CouplingAlgorithmId ): GraphEdge[] { if (!couplingAlgorithmId || couplingAlgorithmId === "none") { return edges; @@ -430,7 +431,7 @@ const CYTOSCAPE_STYLES = [ return { edgeElements, hyperedgeGroups }; } - function renderGraph(couplingAlgorithmId: string): RenderResult { + function renderGraph(couplingAlgorithmId: CouplingAlgorithmId): RenderResult { const nodeElements = processNodes(data.nodes); const edgesToRender = applyCouplingToEdges( @@ -448,7 +449,7 @@ const CYTOSCAPE_STYLES = [ }; } - const initialAlgorithm = couplingAlgorithms.length > 0 ? couplingAlgorithms[0] : "none"; + const initialAlgorithm = (couplingAlgorithms.length > 0 ? couplingAlgorithms[0] : "none") as CouplingAlgorithmId; let { elements, hyperedgeGroups } = renderGraph(initialAlgorithm); const cy = cytoscape({ @@ -573,7 +574,7 @@ const CYTOSCAPE_STYLES = [ ) as HTMLSelectElement; if (select) { select.addEventListener("change", function () { - const selectedAlgorithm = select.value; + const selectedAlgorithm = select.value as CouplingAlgorithmId; cy.elements().remove(); From 7016a083404f5cb3fd1da19144cf8f3b925f4b03 Mon Sep 17 00:00:00 2001 From: Zack Grannan Date: Fri, 10 Oct 2025 13:11:42 -0700 Subject: [PATCH 3/3] fix tests --- src-js/coupling-algorithms.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src-js/coupling-algorithms.ts b/src-js/coupling-algorithms.ts index 460dc9d..2c480a4 100644 --- a/src-js/coupling-algorithms.ts +++ b/src-js/coupling-algorithms.ts @@ -734,11 +734,6 @@ export const COUPLING_ALGORITHMS = { description: 'Show original edges without coupling', compute: computeCouplingIdentity }, - 'frontier-expiries': { - name: 'Frontier Expiries', - description: 'Maximal coupling based on unblocking frontier expiries', - compute: computeCouplingUnblockingFrontierExpiries - }, 'unblocking-frontier-expiries': { name: 'Unblocking Frontier Expiries', description: 'Maximal coupling based on unblocking frontier expiries',