Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src-js/coupling-algorithms.test.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down Expand Up @@ -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
);
Expand All @@ -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
);
Expand Down Expand Up @@ -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
);
Expand All @@ -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
);
Expand Down Expand Up @@ -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
);
Expand All @@ -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([]);
});

Expand All @@ -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
);
Expand All @@ -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
);
Expand Down
262 changes: 246 additions & 16 deletions src-js/coupling-algorithms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -479,6 +478,229 @@ function findMaximallyCoupledSets(effectivelyCoupled: Set<string>[]): Set<string
});
}

/**
* Coupling algorithm based on edges that expire together
*
* This algorithm couples edges that will always be removed from the graph at
* the same time. Formally, a set of edges expire together on a graph G iff for
* all reachable subgraphs G', G' either contains all edges in the set or none
* of them.
*
* This is the fundamental desired property: edges that expire together should
* definitely be coupled.
*/
function computeCouplingExpireTogether(nodes: Node[], edges: Edge[]): CoupledEdgeResult[] {
const graph = new HypergraphForCoupling(nodes, edges);

const allReachableGraphs = getAllReachableGraphs(graph);

const edgeIds = graph.edges.map(e => 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<string>();

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<string>[] {
const expireTogether: Set<string>[] = [];

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<string>();

for (const nodeId of graph.nodes) {
const coupledWithNode = unblockingCoupled.filter(coupled =>
coupled.sources.includes(nodeId)
);

if (coupledWithNode.length === 0) continue;

const allUnderlyingEdges = new Set<InternalEdge>();
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
Expand Down Expand Up @@ -506,28 +728,36 @@ interface CouplingAlgorithm {
/**
* Available coupling algorithms
*/
export const COUPLING_ALGORITHMS: Record<string, CouplingAlgorithm> = {
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 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
}
};
} 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);
}

Expand Down
Loading