--out sim/datacenter/topologies/dragonfly/p${p}a${a}h${h}
+```
+
+Example for 4 hosts (`p2a1h1`):
+
+```bash
+python sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py -p 2 -a 1 -H 1 --out sim/datacenter/topologies/dragonfly/p2a1h1
+```
+
+The Dragonfly generator script is adapted from the SPCL topology-generation tooling and outputs files directly in the layout expected by `htsim_uec_df`.
+
### SlimFly
SlimFly topologies use a two-partition structure based on optimized graph constructions. Pre-generated topology assets are provided in `topologies/slimfly/` (e.g., `p4q5` for p=4 hosts/switch, q=5 graph parameter).
@@ -124,6 +138,28 @@ Supported routing strategies: `MINIMAL`, `VALIANT`, `UGAL_L`, `SOURCE`
./htsim_uec_sf -topo topologies/slimfly/p4q5 -tm traffic.tm -routing MINIMAL -q 88
```
+To generate custom SlimFly topology assets (`slimfly.topo`, `slimfly.adjlist`, `fib/`, `host_table/`) for any `(p,q)`, use:
+
+```bash
+python sim/datacenter/topologies/slimfly/generate_slimfly_assets.py -p -q --out sim/datacenter/topologies/slimfly/p${p}q${q}
+```
+
+Example:
+
+```bash
+python sim/datacenter/topologies/slimfly/generate_slimfly_assets.py -p 4 -q 5 --out sim/datacenter/topologies/slimfly/p4q5
+```
+
+The SlimFly generator script is adapted from the SPCL topology-generation tooling and emits all files required by `htsim_uec_sf` in this repository.
+
+Note: topology generation scripts require Python packages `networkx` and `sympy`.
+
+Quick generator validation (small checks + end-to-end SOURCE runs for Dragonfly and SlimFly):
+
+```bash
+bash tests/run_generator_tests.sh
+```
+
### Traffic Matrix Format
All topologies use the same traffic matrix format:
diff --git a/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py b/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py
new file mode 100644
index 0000000..7b319c9
--- /dev/null
+++ b/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+
+import argparse
+from pathlib import Path
+from typing import Dict, List, Tuple
+
+
+def get_group_switch(src_group: int, dst_group: int, a: int, h: int, no_groups: int) -> int:
+ if src_group < dst_group:
+ right_steps = dst_group - src_group
+ else:
+ right_steps = (no_groups + dst_group) - src_group
+ return (src_group * a) + (right_steps - 1) // h
+
+
+def get_target_switch(src_switch: int, global_link: int, a: int, h: int, no_groups: int) -> int:
+ src_group = src_switch // a
+ src_switch_index = src_switch - (src_group * a)
+ right_steps = (src_switch_index * h) + global_link + 1
+ dst_group = (src_group + right_steps) % no_groups
+ return (dst_group * a) + (a - 1) - src_switch_index
+
+
+def get_src_pairs(src_switch: int,
+ dst_switch: int,
+ a: int,
+ h: int,
+ no_groups: int) -> List[Tuple[int, int]]:
+ src_group = src_switch // a
+ dst_group = dst_switch // a
+
+ group_start = src_group * a
+ group_switches = list(range(group_start, group_start + a))
+
+ pairs: List[Tuple[int, int]] = []
+
+ if src_group == dst_group:
+ for hop_one in group_switches:
+ if hop_one != src_switch:
+ pairs.append((hop_one, 0))
+ return pairs
+
+ for link in range(h):
+ hop_one = get_target_switch(src_switch, link, a, h, no_groups)
+ pairs.append((hop_one, 0))
+
+ for hop_one in group_switches:
+ if hop_one == src_switch:
+ continue
+ for link in range(h):
+ hop_two = get_target_switch(hop_one, link, a, h, no_groups)
+ pairs.append((hop_one, hop_two))
+
+ deduped: List[Tuple[int, int]] = []
+ seen = set()
+ for pair in pairs:
+ if pair not in seen:
+ seen.add(pair)
+ deduped.append(pair)
+ return deduped
+
+
+def write_dragonfly_topo(path: Path,
+ p: int,
+ a: int,
+ h: int,
+ switch_latency_ns: int,
+ link_speed_gbps: int,
+ link_latency_global_ns: int,
+ link_latency_local_ns: int,
+ link_latency_host_ns: int) -> None:
+ content = (
+ "# Dragonfly\n"
+ f"p {p}\n"
+ f"a {a}\n"
+ f"h {h}\n\n"
+ f"Switch_latency_ns {switch_latency_ns}\n\n"
+ "# Default - link speed\n"
+ f"Link_speed_global_Gbps {link_speed_gbps}\n"
+ f"Link_speed_local_Gbps {link_speed_gbps}\n"
+ f"Link_speed_host_Gbps {link_speed_gbps}\n\n"
+ "# Default - link latency\n"
+ f"Link_latency_global_ns {link_latency_global_ns}\n"
+ f"Link_latency_local_ns {link_latency_local_ns}\n"
+ f"Link_latency_host_ns {link_latency_host_ns}\n"
+ )
+ (path / "dragonfly.topo").write_text(content)
+
+
+def write_dragonfly_adjlist(path: Path, a: int, h: int, no_groups: int, no_switches: int) -> None:
+ lines: List[str] = []
+ for src in range(no_switches):
+ src_group = src // a
+ group_start = src_group * a
+ locals_ = [sw for sw in range(group_start, group_start + a) if sw != src]
+ globals_ = [get_target_switch(src, link, a, h, no_groups) for link in range(h)]
+ neighbors = locals_ + globals_
+ lines.append(" ".join([str(src)] + [str(x) for x in neighbors]))
+ (path / "dragonfly.adjlist").write_text("\n".join(lines) + "\n")
+
+
+def write_host_tables(path: Path, p: int, a: int, h: int, no_groups: int, no_switches: int) -> None:
+ host_table_dir = path / "host_table"
+ host_table_dir.mkdir(parents=True, exist_ok=True)
+
+ for src in range(no_switches):
+ out_lines: List[str] = []
+ for dst in range(no_switches):
+ if src == dst:
+ continue
+
+ out_lines.append(str(dst))
+ pairs = get_src_pairs(src, dst, a, h, no_groups)
+
+ direct = [(h1, h2) for (h1, h2) in pairs if h2 == 0]
+ two_hop = [(h1, h2) for (h1, h2) in pairs if h2 != 0]
+
+ if direct:
+ payload = " ".join(f"{h1} {h2}" for (h1, h2) in direct)
+ out_lines.append(f"1 {len(direct)} {payload}")
+ if two_hop:
+ payload = " ".join(f"{h1} {h2}" for (h1, h2) in two_hop)
+ out_lines.append(f"2 {len(two_hop)} {payload}")
+
+ (host_table_dir / f"{src}.lt").write_text("\n".join(out_lines) + "\n")
+
+
+def parse_pairs_from_lt(path: Path) -> Dict[int, List[Tuple[int, int]]]:
+ entries: Dict[int, List[Tuple[int, int]]] = {}
+ current_dst = None
+ for raw in path.read_text().splitlines():
+ line = raw.strip()
+ if not line:
+ continue
+ tokens = line.split()
+ if len(tokens) == 1:
+ current_dst = int(tokens[0])
+ entries[current_dst] = []
+ continue
+ if current_dst is None:
+ continue
+ for idx in range(2, len(tokens) - 1, 2):
+ entries[current_dst].append((int(tokens[idx]), int(tokens[idx + 1])))
+ return entries
+
+
+def compare_with_reference(generated_dir: Path, reference_dir: Path) -> None:
+ ref_host = reference_dir / "host_table"
+ gen_host = generated_dir / "host_table"
+ if not ref_host.exists() or not gen_host.exists():
+ print("[compare] skipped: host_table missing in one of the directories")
+ return
+
+ strict_subset_ok = True
+ coverage_stats = []
+
+ for gen_file in sorted(gen_host.glob("*.lt")):
+ ref_file = ref_host / gen_file.name
+ if not ref_file.exists():
+ strict_subset_ok = False
+ continue
+
+ gen_entries = parse_pairs_from_lt(gen_file)
+ ref_entries = parse_pairs_from_lt(ref_file)
+
+ for dst, gen_pairs in gen_entries.items():
+ ref_pairs = set(ref_entries.get(dst, []))
+ gen_set = set(gen_pairs)
+
+ if not gen_set.issubset(ref_pairs):
+ strict_subset_ok = False
+
+ cov = 1.0 if not gen_set else len(gen_set & ref_pairs) / len(gen_set)
+ coverage_stats.append(cov)
+
+ avg_cov = sum(coverage_stats) / len(coverage_stats) if coverage_stats else 0.0
+ print(f"[compare] generated pairs subset of reference: {'yes' if strict_subset_ok else 'no'}")
+ print(f"[compare] average generated-pair coverage in reference: {avg_cov:.3f}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Generate Dragonfly topology assets")
+ parser.add_argument("-p", type=int, required=True, help="Hosts per switch")
+ parser.add_argument("-a", type=int, required=True, help="Switches per group")
+ parser.add_argument("-H", "--h", dest="h", type=int, required=True,
+ help="Global links per switch")
+ parser.add_argument("--out", type=Path, required=True, help="Output topology directory")
+
+ parser.add_argument("--switch-latency-ns", type=int, default=500)
+ parser.add_argument("--link-speed-gbps", type=int, default=400)
+ parser.add_argument("--link-latency-global-ns", type=int, default=500)
+ parser.add_argument("--link-latency-local-ns", type=int, default=25)
+ parser.add_argument("--link-latency-host-ns", type=int, default=25)
+
+ parser.add_argument("--compare-ref", type=Path,
+ help="Optional reference topology dir (with host_table) to compare coverage")
+
+ args = parser.parse_args()
+
+ if args.p <= 0 or args.a <= 0 or args.h <= 0:
+ raise ValueError("p, a, h must all be > 0")
+
+ out = args.out
+ out.mkdir(parents=True, exist_ok=True)
+
+ no_groups = args.a * args.h + 1
+ no_switches = args.a * no_groups
+ no_hosts = args.p * no_switches
+
+ write_dragonfly_topo(out,
+ args.p,
+ args.a,
+ args.h,
+ args.switch_latency_ns,
+ args.link_speed_gbps,
+ args.link_latency_global_ns,
+ args.link_latency_local_ns,
+ args.link_latency_host_ns)
+
+ write_dragonfly_adjlist(out, args.a, args.h, no_groups, no_switches)
+ write_host_tables(out, args.p, args.a, args.h, no_groups, no_switches)
+
+ print(f"Generated Dragonfly assets in: {out}")
+ print(f"p={args.p} a={args.a} h={args.h}")
+ print(f"switches={no_switches} hosts={no_hosts}")
+
+ if args.compare_ref:
+ compare_with_reference(out, args.compare_ref)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py b/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py
new file mode 100644
index 0000000..ac1d064
--- /dev/null
+++ b/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py
@@ -0,0 +1,386 @@
+#!/usr/bin/env python3
+
+import argparse
+import itertools
+import math
+import multiprocessing as mp
+from collections import defaultdict
+from pathlib import Path
+from typing import Dict, List, Tuple
+
+import networkx as nx
+from sympy.ntheory.primetest import isprime
+from sympy.polys.domains import ZZ
+from sympy.polys.galoistools import gf_add, gf_gcdex, gf_irreducible_p, gf_mul, gf_rem, gf_sub
+
+
+def is_prime(x: int) -> bool:
+ return len([d for d in range(1, x + 1) if x % d == 0]) == 2
+
+
+def get_power_of_prime(q: int) -> Tuple[int, int]:
+ prime_divisors = [d for d in range(2, q + 1) if q % d == 0 and is_prime(d)]
+ if len(prime_divisors) != 1:
+ return 0, 0
+
+ prime = prime_divisors[0]
+ m = 0
+ v = q
+ while v != 1:
+ if v % prime == 0:
+ v = v // prime
+ m += 1
+ else:
+ return 0, 0
+ return prime, m
+
+
+class GF:
+ def __init__(self, p: int, n: int = 1):
+ p, n = int(p), int(n)
+ if not isprime(p):
+ raise ValueError(f"p must be prime, got {p}")
+ if n <= 0:
+ raise ValueError(f"n must be > 0, got {n}")
+
+ self.p = p
+ self.n = n
+
+ if n == 1:
+ self.reducing = [1, 0]
+ else:
+ for c in itertools.product(range(p), repeat=n):
+ poly = (1, *c)
+ if gf_irreducible_p(poly, p, ZZ):
+ self.reducing = poly
+ break
+
+ self.elems = None
+
+ def get_elems(self) -> List[List[int]]:
+ if self.elems is None:
+ self.elems = [[]]
+ for c in range(1, self.p):
+ self.elems.append([c])
+ for deg in range(1, self.n):
+ for c in itertools.product(range(self.p), repeat=deg):
+ for first in range(1, self.p):
+ self.elems.append(list((first, *c)))
+ return self.elems
+
+ def get_primitive_elem(self) -> List[int]:
+ for primitive in self.get_elems():
+ if self.is_primitive_elem(primitive):
+ return primitive
+ raise RuntimeError("No primitive element found")
+
+ def is_primitive_elem(self, primitive: List[int]) -> bool:
+ elems = [[]]
+ tmp = [1]
+ for _ in range(1, self.p**self.n):
+ tmp = self.mul(tmp, primitive)
+ elems.append(tmp)
+
+ return len(elems) == self.p**self.n and all(elem in elems for elem in self.get_elems())
+
+ def add(self, x: List[int], y: List[int]) -> List[int]:
+ return gf_add(x, y, self.p, ZZ)
+
+ def sub(self, x: List[int], y: List[int]) -> List[int]:
+ return gf_sub(x, y, self.p, ZZ)
+
+ def mul(self, x: List[int], y: List[int]) -> List[int]:
+ product = gf_mul(x, y, self.p, ZZ)
+ return gf_rem(product, self.reducing, self.p, ZZ)
+
+ def inv(self, x: List[int]) -> List[int]:
+ s, _, _ = gf_gcdex(x, self.reducing, self.p, ZZ)
+ return s
+
+
+def get_slimfly(q: int) -> nx.Graph:
+ assert (q - 1) % 4 == 0 or (q + 1) % 4 == 0 or q % 4 == 0
+
+ network = nx.Graph()
+ prime, prime_power = get_power_of_prime(q)
+ if prime == 0 or prime_power == 0 or prime**prime_power != q:
+ raise ValueError(f"q={q} is not a prime power")
+
+ if q % 4 == 3:
+ delta = -1
+ elif q % 4 == 2:
+ raise ValueError("q is not of form 4w + delta")
+ else:
+ delta = q % 4
+
+ if (q - delta) % 4 != 0:
+ raise ValueError("invalid q, cannot derive w")
+ w = int((q - delta) / 4)
+ if w < 1:
+ raise ValueError("invalid w")
+
+ gf = GF(prime, prime_power)
+ pe = gf.get_primitive_elem()
+
+ pe_powers = [[1]]
+ for i in range(1, q):
+ pe_powers.append(gf.mul(pe_powers[i - 1], pe))
+
+ if delta == 0:
+ X = [pe_powers[i] for i in range(0, q - 1) if i % 2 == 0]
+ Xp = [pe_powers[i] for i in range(1, q) if i % 2 == 1]
+ elif delta == 1:
+ X = [pe_powers[i] for i in range(0, q - 2) if i % 2 == 0]
+ Xp = [pe_powers[i] for i in range(1, q - 1) if i % 2 == 1]
+ else:
+ X = [pe_powers[i] for i in range(0, 2 * w - 1) if i % 2 == 0]
+ X.extend([pe_powers[i] for i in range(2 * w - 1, 4 * w - 2) if i % 2 == 1])
+ Xp = [pe_powers[i] for i in range(1, 2 * w) if i % 2 == 1]
+ Xp.extend([pe_powers[i] for i in range(2 * w, 4 * w - 1) if i % 2 == 0])
+
+ labels = [(v, x, y) for v in [1, 0] for x in gf.get_elems() for y in gf.get_elems()]
+ maps = list(zip(labels, [i for i in range(2 * q**2)]))
+
+ for s in maps:
+ for t in maps:
+ if (
+ s[0][0] == 0
+ and t[0][0] == 0
+ and s[0][1] == t[0][1]
+ and gf.sub(s[0][2], t[0][2]) in X
+ ):
+ network.add_edge(s[1], t[1])
+
+ if (
+ s[0][0] == 1
+ and t[0][0] == 1
+ and s[0][1] == t[0][1]
+ and gf.sub(s[0][2], t[0][2]) in Xp
+ ):
+ network.add_edge(s[1], t[1])
+
+ if (
+ s[0][0] == 0
+ and t[0][0] == 1
+ and s[0][2] == gf.add(gf.mul(t[0][1], s[0][1]), t[0][2])
+ ):
+ network.add_edge(s[1], t[1])
+
+ return network
+
+
+_network = None
+
+
+def init_worker(network_edges: List[Tuple[int, int]]) -> None:
+ global _network
+ _network = nx.Graph()
+ _network.add_edges_from(network_edges)
+
+
+def latency_sort_key(key: Tuple[int, int]) -> int:
+ return key[0] * 25 + key[1] * 500
+
+
+def generate_host_table_for_switch(out_dir: Path, p: int, q: int, src_switch: int) -> None:
+ global _network
+ no_switches = 2 * q**2
+
+ output_lines: List[str] = []
+ for dst_switch in range(no_switches):
+ if src_switch == dst_switch:
+ continue
+
+ paths = []
+ paths_final = []
+
+ for n in _network.neighbors(src_switch):
+ for dst in _network.neighbors(n):
+ if src_switch != dst:
+ paths.append([src_switch, n, dst])
+
+ for path in paths:
+ n = path[-1]
+ shortest_path = nx.shortest_path(_network, source=n, target=dst_switch)
+ full = path + shortest_path[1:]
+ if len(full) == len(set(full)):
+ paths_final.append(full)
+
+ group = defaultdict(list)
+ for path in paths_final:
+ local_hops = 0
+ global_hops = 0
+ for u, v in zip(path, path[1:]):
+ if u // q == v // q:
+ local_hops += 1
+ else:
+ global_hops += 1
+ group[(local_hops, global_hops)].append((path[1], path[2]))
+
+ output_lines.append(f"{dst_switch}")
+ for key in sorted(group.keys(), key=latency_sort_key):
+ line = f"{key[0]} {key[1]}"
+ for x, y in group[key]:
+ line += f" {x} {y}"
+ output_lines.append(line)
+
+ (out_dir / "host_table" / f"{src_switch}.lt").write_text("\n".join(output_lines) + "\n")
+
+
+def generate_fib_for_switch(out_dir: Path, q: int, src_switch: int) -> None:
+ global _network
+ no_switches = 2 * q**2
+
+ output_lines: List[str] = []
+ for dst_switch in range(no_switches):
+ if src_switch == dst_switch:
+ continue
+ shortest_path = nx.shortest_path(_network, source=src_switch, target=dst_switch)
+ output_lines.append(f"{dst_switch} {shortest_path[1]}")
+
+ (out_dir / "fib" / f"{src_switch}.fib").write_text("\n".join(output_lines) + "\n")
+
+
+def process_switch_slice(args: Tuple[Path, int, int, int, int]) -> None:
+ out_dir, p, q, start, end = args
+ for src_switch in range(start, end):
+ generate_host_table_for_switch(out_dir, p, q, src_switch)
+ generate_fib_for_switch(out_dir, q, src_switch)
+
+
+def write_slimfly_topo(path: Path,
+ p: int,
+ q: int,
+ switch_latency_ns: int,
+ link_speed_gbps: int,
+ link_latency_global_ns: int,
+ link_latency_local_ns: int,
+ link_latency_host_ns: int) -> None:
+ content = (
+ "# Slim Fly\n"
+ f"p {p}\n"
+ f"q {q}\n\n"
+ f"Switch_latency_ns {switch_latency_ns}\n\n"
+ "# Default - link speed\n"
+ f"Link_speed_global_Gbps {link_speed_gbps}\n"
+ f"Link_speed_local_Gbps {link_speed_gbps}\n"
+ f"Link_speed_host_Gbps {link_speed_gbps}\n\n"
+ "# Default - link latency\n"
+ f"Link_latency_global_ns {link_latency_global_ns}\n"
+ f"Link_latency_local_ns {link_latency_local_ns}\n"
+ f"Link_latency_host_ns {link_latency_host_ns}\n"
+ )
+ (path / "slimfly.topo").write_text(content)
+
+
+def parse_pairs_from_lt(path: Path) -> Dict[int, List[Tuple[int, int]]]:
+ entries: Dict[int, List[Tuple[int, int]]] = {}
+ current_dst = None
+ for raw in path.read_text().splitlines():
+ line = raw.strip()
+ if not line:
+ continue
+ tokens = line.split()
+ if len(tokens) == 1:
+ current_dst = int(tokens[0])
+ entries[current_dst] = []
+ continue
+ if current_dst is None:
+ continue
+ for idx in range(2, len(tokens) - 1, 2):
+ entries[current_dst].append((int(tokens[idx]), int(tokens[idx + 1])))
+ return entries
+
+
+def compare_with_reference(generated_dir: Path, reference_dir: Path) -> None:
+ ref_host = reference_dir / "host_table"
+ gen_host = generated_dir / "host_table"
+ if not ref_host.exists() or not gen_host.exists():
+ print("[compare] skipped: host_table missing in one of the directories")
+ return
+
+ strict_subset_ok = True
+ coverage_stats = []
+
+ for gen_file in sorted(gen_host.glob("*.lt")):
+ ref_file = ref_host / gen_file.name
+ if not ref_file.exists():
+ strict_subset_ok = False
+ continue
+
+ gen_entries = parse_pairs_from_lt(gen_file)
+ ref_entries = parse_pairs_from_lt(ref_file)
+
+ for dst, gen_pairs in gen_entries.items():
+ ref_pairs = set(ref_entries.get(dst, []))
+ gen_set = set(gen_pairs)
+
+ if not gen_set.issubset(ref_pairs):
+ strict_subset_ok = False
+
+ cov = 1.0 if not gen_set else len(gen_set & ref_pairs) / len(gen_set)
+ coverage_stats.append(cov)
+
+ avg_cov = sum(coverage_stats) / len(coverage_stats) if coverage_stats else 0.0
+ print(f"[compare] generated pairs subset of reference: {'yes' if strict_subset_ok else 'no'}")
+ print(f"[compare] average generated-pair coverage in reference: {avg_cov:.3f}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Generate SlimFly topology assets")
+ parser.add_argument("-p", type=int, required=True, help="Hosts per switch")
+ parser.add_argument("-q", type=int, required=True, help="SlimFly q parameter")
+ parser.add_argument("--out", type=Path, required=True, help="Output topology directory")
+ parser.add_argument("-n", "--num-workers", type=int, default=mp.cpu_count())
+
+ parser.add_argument("--switch-latency-ns", type=int, default=500)
+ parser.add_argument("--link-speed-gbps", type=int, default=400)
+ parser.add_argument("--link-latency-global-ns", type=int, default=500)
+ parser.add_argument("--link-latency-local-ns", type=int, default=25)
+ parser.add_argument("--link-latency-host-ns", type=int, default=25)
+
+ parser.add_argument("--compare-ref", type=Path,
+ help="Optional reference topology dir (with host_table) to compare coverage")
+
+ args = parser.parse_args()
+ if args.p <= 0 or args.q <= 0:
+ raise ValueError("p and q must be > 0")
+
+ out = args.out
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "host_table").mkdir(parents=True, exist_ok=True)
+ (out / "fib").mkdir(parents=True, exist_ok=True)
+
+ network = get_slimfly(args.q)
+ nx.write_adjlist(network, out / "slimfly.adjlist")
+ edges = list(network.edges())
+
+ write_slimfly_topo(out,
+ args.p,
+ args.q,
+ args.switch_latency_ns,
+ args.link_speed_gbps,
+ args.link_latency_global_ns,
+ args.link_latency_local_ns,
+ args.link_latency_host_ns)
+
+ no_switches = 2 * args.q**2
+ num_workers = max(1, min(args.num_workers, no_switches))
+ slice_size = math.ceil(no_switches / num_workers)
+ work_items = []
+ for start in range(0, no_switches, slice_size):
+ end = min(start + slice_size, no_switches)
+ work_items.append((out, args.p, args.q, start, end))
+
+ with mp.Pool(processes=num_workers, initializer=init_worker, initargs=(edges,)) as pool:
+ pool.map(process_switch_slice, work_items)
+
+ print(f"Generated SlimFly assets in: {out}")
+ print(f"p={args.p} q={args.q} switches={no_switches} hosts={args.p * no_switches}")
+
+ if args.compare_ref:
+ compare_with_reference(out, args.compare_ref)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/run_generator_tests.sh b/tests/run_generator_tests.sh
new file mode 100755
index 0000000..1b749d7
--- /dev/null
+++ b/tests/run_generator_tests.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DF_GEN="$ROOT_DIR/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py"
+SF_GEN="$ROOT_DIR/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py"
+DF_BIN="$ROOT_DIR/htsim/sim/datacenter/htsim_uec_df"
+SF_BIN="$ROOT_DIR/htsim/sim/datacenter/htsim_uec_sf"
+
+REF_DF="$ROOT_DIR/htsim/sim/datacenter/topologies/dragonfly/p2a4h2"
+
+PASS=0
+FAIL=0
+
+fail() {
+ echo "FAIL: $1"
+ FAIL=$((FAIL + 1))
+}
+
+pass() {
+ echo "PASS: $1"
+ PASS=$((PASS + 1))
+}
+
+require_file() {
+ local path="$1"
+ local msg="$2"
+ if [[ -f "$path" ]]; then
+ pass "$msg"
+ else
+ fail "$msg (missing: $path)"
+ fi
+}
+
+require_dir() {
+ local path="$1"
+ local msg="$2"
+ if [[ -d "$path" ]]; then
+ pass "$msg"
+ else
+ fail "$msg (missing: $path)"
+ fi
+}
+
+echo "=== Generator tests (small + end-to-end) ==="
+
+if ! python3 -c "import networkx, sympy" >/dev/null 2>&1; then
+ echo "ERROR: missing Python dependencies. Install with: pip install networkx sympy"
+ exit 1
+fi
+
+if [[ ! -x "$DF_BIN" || ! -x "$SF_BIN" ]]; then
+ echo "ERROR: missing simulator binaries. Build HTSIM first (cmake --build ...)."
+ exit 1
+fi
+
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+echo "Using temp directory: $TMP_DIR"
+
+DF_OUT="$TMP_DIR/dragonfly_p2a1h1"
+DF_LOG="$TMP_DIR/dragonfly_compare.log"
+python3 "$DF_GEN" -p 2 -a 1 -H 1 --out "$DF_OUT" >/dev/null
+
+require_file "$DF_OUT/dragonfly.topo" "Dragonfly generator writes dragonfly.topo"
+require_file "$DF_OUT/dragonfly.adjlist" "Dragonfly generator writes dragonfly.adjlist"
+require_dir "$DF_OUT/host_table" "Dragonfly generator writes host_table directory"
+require_file "$DF_OUT/host_table/0.lt" "Dragonfly host table includes switch 0"
+require_file "$DF_OUT/host_table/1.lt" "Dragonfly host table includes switch 1"
+
+if [[ -d "$REF_DF/host_table" ]]; then
+ python3 "$DF_GEN" -p 2 -a 4 -H 2 --out "$TMP_DIR/dragonfly_p2a4h2" --compare-ref "$REF_DF" >"$DF_LOG"
+fi
+
+if [[ ! -d "$REF_DF/host_table" ]]; then
+ pass "Dragonfly compare-ref skipped (no host_table in $REF_DF)"
+elif grep -q "generated pairs subset of reference: yes" "$DF_LOG" && grep -q "average generated-pair coverage in reference: 1.000" "$DF_LOG"; then
+ pass "Dragonfly generator matches reference host-table coverage"
+else
+ fail "Dragonfly compare-ref coverage check"
+fi
+
+cat >"$TMP_DIR/df_1flow.tm" <<'EOF'
+Nodes 4
+Connections 1
+0->3 start 0 size 4096
+EOF
+
+"$DF_BIN" -basepath "$DF_OUT" -routing SOURCE -tm "$TMP_DIR/df_1flow.tm" >"$TMP_DIR/df_run.out" 2>&1 || true
+if grep -q "Done" "$TMP_DIR/df_run.out" && [[ "$(grep -c 'finished at' "$TMP_DIR/df_run.out")" -eq 1 ]]; then
+ pass "Dragonfly SOURCE end-to-end run succeeds with generated assets"
+else
+ fail "Dragonfly SOURCE end-to-end run"
+fi
+
+SF_OUT="$TMP_DIR/slimfly_p2q3"
+python3 "$SF_GEN" -p 2 -q 3 --out "$SF_OUT" -n 1 >/dev/null
+
+require_file "$SF_OUT/slimfly.topo" "SlimFly generator writes slimfly.topo"
+require_file "$SF_OUT/slimfly.adjlist" "SlimFly generator writes slimfly.adjlist"
+require_dir "$SF_OUT/fib" "SlimFly generator writes fib directory"
+require_dir "$SF_OUT/host_table" "SlimFly generator writes host_table directory"
+
+FIB_COUNT="$(ls "$SF_OUT"/fib/*.fib 2>/dev/null | wc -l | tr -d ' ')"
+HT_COUNT="$(ls "$SF_OUT"/host_table/*.lt 2>/dev/null | wc -l | tr -d ' ')"
+if [[ "$FIB_COUNT" -eq 18 && "$HT_COUNT" -eq 18 ]]; then
+ pass "SlimFly generator writes expected fib/host_table file counts for q=3"
+else
+ fail "SlimFly output counts (fib=$FIB_COUNT host_table=$HT_COUNT, expected 18/18)"
+fi
+
+cat >"$TMP_DIR/sf_1flow.tm" <<'EOF'
+Nodes 36
+Connections 1
+0->35 start 0 size 4096
+EOF
+
+"$SF_BIN" -topo "$SF_OUT" -routing SOURCE -tm "$TMP_DIR/sf_1flow.tm" >"$TMP_DIR/sf_run.out" 2>&1 || true
+if grep -q "Done" "$TMP_DIR/sf_run.out" && [[ "$(grep -c 'finished at' "$TMP_DIR/sf_run.out")" -eq 1 ]]; then
+ pass "SlimFly SOURCE end-to-end run succeeds with generated assets"
+else
+ fail "SlimFly SOURCE end-to-end run"
+fi
+
+echo "========================================="
+echo "Generator tests: PASS=$PASS FAIL=$FAIL"
+echo "========================================="
+
+if [[ "$FAIL" -gt 0 ]]; then
+ exit 1
+fi