• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Quang00 / dqsweep / 13928964920

18 Mar 2025 04:53PM UTC coverage: 83.226% (-2.5%) from 85.714%
13928964920

push

github

Quang00
parallelize sweep param function with process pool

7 of 17 new or added lines in 2 files covered. (41.18%)

6 existing lines in 1 file now uncovered.

516 of 620 relevant lines covered (83.23%)

1.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.59
/experiments/utils.py
1
import copy
2✔
2
import itertools
2✔
3
import os
2✔
4
from typing import Callable, Generator, List, Union
2✔
5

6
import matplotlib.pyplot as plt
2✔
7
import numpy as np
2✔
8
import pandas as pd
2✔
9
from netqasm.sdk import Qubit
2✔
10
from netsquid.qubits.dmutil import dm_fidelity
2✔
11

12
from squidasm.run.stack.config import StackNetworkConfig
2✔
13
from squidasm.run.stack.run import run
2✔
14
from squidasm.sim.stack.program import ProgramContext
2✔
15
from squidasm.util import get_qubit_state
2✔
16
from squidasm.util.routines import teleport_recv, teleport_send
2✔
17

18
# =============================================================================
19
# Constants
20
# =============================================================================
21
LOG_SCALE_PARAMS = {
2✔
22
    "length",
23
    "T1",
24
    "T2",
25
    "single_qubit_gate_time",
26
    "two_qubit_gate_time",
27
}
28

29

30
# =============================================================================
31
# Helper Functions
32
# =============================================================================
33
def truncate_param(name: str, char: str = "_", n: int = 4) -> str:
2✔
34
    """Truncates a parameter name to improve readability in plots.
35

36
    Args:
37
        name (str): Parameter name to truncate.
38
        char (str, optional): Character to split the name. Defaults to "_".
39
        n (int, optional): Number of tokens to keep. Defaults to 4.
40

41
    Returns:
42
        str: Truncated parameter name.
43
    """
44
    return " ".join(name.split(char)[:n]).capitalize()
2✔
45

46

47
def create_subdir(
2✔
48
    directory: str, experiment: str, sweep_params: Union[list, str]
49
) -> str:
50
    """Creates a structured subdirectory for storing experiment results.
51

52
    Args:
53
        directory (str): The main results directory (e.g., "results").
54
        experiment (str): The experiment name (e.g., "pingpong").
55
        sweep_params (list | str): List of swept parameters (or a string).
56

57
    Returns:
58
        str: Path to the created experiment-specific directory.
59
    """
60
    if not os.path.exists(directory):
2✔
61
        os.makedirs(directory)
2✔
62

63
    if isinstance(sweep_params, str):
2✔
64
        sweep_params = sweep_params.split(",")
2✔
65

66
    sweep_params = [param.strip() for param in sweep_params]
2✔
67
    param_names = (
2✔
68
        "_".join(truncate_param(param, n=1) for param in sweep_params)
69
        if sweep_params
70
        else "default"
71
    )
72
    experiment_dir = os.path.join(directory, f"{experiment}_{param_names}")
2✔
73
    cpt = 1
2✔
74
    new_subdir = experiment_dir
2✔
75

76
    while os.path.exists(new_subdir):
2✔
77
        new_subdir = f"{experiment_dir}_{cpt}"
2✔
78
        cpt += 1
2✔
79

80
    os.makedirs(new_subdir)
2✔
81

82
    return new_subdir
2✔
83

84

85
def parse_range(range_str: str, param_name: str) -> np.ndarray:
2✔
86
    """Parses a range string and returns a numpy array of values.
87

88
    Uses logarithmic scaling if the parameter is in LOG_SCALE_PARAMS.
89

90
    Args:
91
        range_str (str): Range in format "start,end,points".
92
        param_name (str): The name of the parameter being parsed.
93

94
    Returns:
95
        np.ndarray: Array of evenly spaced values.
96
    """
97
    try:
2✔
98
        start, end, points = map(float, range_str.split(","))
2✔
99
        if param_name in LOG_SCALE_PARAMS:
2✔
100
            return np.logspace(start, end, int(points))
2✔
101
        return np.linspace(start, end, int(points))
2✔
102
    except ValueError:
×
103
        raise ValueError("Invalid format. Use 'start,end,points'.") from None
×
104

105

106
def compute_fidelity(
2✔
107
    qubit: Qubit, owner: str, dm_state: np.ndarray, full_state: bool = True
108
) -> float:
109
    """Computes the fidelity between the density matrix of a given qubit and
110
    the density matrix constructed from a reference state vector.
111

112
    Args:
113
        qubit (Qubit): The qubit whose state is to be evaluated.
114
        owner (str): The owner of the qubit.
115
        dm_state (np.ndarray): The reference state vector.
116
        full_state (bool): Flag to retrieve the full entangled state.
117

118
    Returns:
119
        float: The fidelity between the two density matrices.
120
    """
121
    dm = get_qubit_state(qubit, owner, full_state=full_state)
2✔
122
    dm_ref = np.outer(dm_state, np.conjugate(dm_state))
2✔
123
    fidelity = dm_fidelity(dm, dm_ref, dm_check=False)
2✔
124

125
    return fidelity
2✔
126

127

128
def metric_correlation(
2✔
129
    df: pd.DataFrame,
130
    sweep_params: list,
131
    metric_cols: list,
132
    output_dir: str,
133
    experiment: str,
134
):
135
    """Computes and generate a txt file for parameter-performance correlation.
136

137
    Args:
138
        df (pd.DataFrame): Dataframe containing parameter values and metrics.
139
        sweep_params (list): List of parameters being swept.
140
        metric_cols (list): List of performance metrics.
141
        output_dir (str): Directory to save the plot.
142
        experiment (str): Experiment name.
143
    """
144
    # Calculate the correlation for the selected columns
145
    corr = df[sweep_params + metric_cols].corr().loc[sweep_params, metric_cols]
×
146
    filename = os.path.join(output_dir, f"{experiment}_corr.txt")
×
147

148
    with open(filename, "w") as f:
×
149
        f.write("Parameter\t" + "\t".join(metric_cols) + "\n")
×
150
        for param in sweep_params:
×
151
            row = [f"{corr.loc[param, metric]:.3f}" for metric in metric_cols]
×
152
            f.write(f"{param}\t" + "\t".join(row) + "\n")
×
153

154
    print(f"Saved correlation values to {filename}")
×
155

156

157
def run_simulation(
2✔
158
    config: str,
159
    epr_rounds: int = 10,
160
    num_times: int = 10,
161
    classes: dict = None,
162
):
163
    """Runs a simulation with the given configuration and program classes.
164

165
    Args:
166
        config (str): Path to the network configuration YAML file.
167
        epr_rounds (int): Number of EPR rounds to execute in the simulation.
168
        num_times (int): Number of simulation repetitions.
169
        classes (dict): A dictionary mapping program names to their classes.
170

171
    Returns:
172
        dict: A dictionary containing simulation results for each node.
173
    """
174
    if classes is None or not classes:
2✔
175
        raise ValueError("At least one class must be provided.")
×
176

177
    # Load the network configuration.
178
    cfg = StackNetworkConfig.from_file(config)
2✔
179

180
    # Instantiate all program classes.
181
    programs = {
2✔
182
        name: cls(num_epr_rounds=epr_rounds) for name, cls in classes.items()
183
    }
184

185
    # Run the simulation with the provided configuration.
186
    results = run(
2✔
187
        config=cfg,
188
        programs=programs,
189
        num_times=num_times,
190
    )
191

192
    return results
2✔
193

194

195
def check_sweep_params_input(params: str, cfg: StackNetworkConfig) -> bool:
2✔
196
    """Check that all sweep parameters are found in the configuration.
197

198
    Args:
199
        params (str): Parameters to sweep.
200
        cfg (StackNetworkConfig): Network configuration.
201

202
    Returns:
203
        bool: True if all sweep parameters are found.
204

205
    Raises:
206
        ValueError: If one or more sweep parameters are missing in the config.
207
    """
208
    sweep_set = {param.strip() for param in params.split(",")}
×
209
    cfg_set = {token.strip("',:") for token in str(cfg).split()}
×
210

211
    missing = sweep_set - cfg_set
×
212
    if missing:
×
213
        raise ValueError("Please provide parameters that exist in the config.")
×
214
    return True
×
215

216

217
def update_cfg(cfg: StackNetworkConfig, map_param: dict) -> None:
2✔
218
    """
219
    Update the configuration for every link and stack with the
220
    given parameter values.
221

222
    Args:
223
        cfg (StackNetworkConfig): Network configuration.
224
        map_param (dict): Map parameter names to their values.
225
    """
UNCOV
226
    for param, value in map_param.items():
×
UNCOV
227
        for link in cfg.links:
×
UNCOV
228
            if getattr(link, "link_cfg", None) is not None:
×
229
                link.cfg[param] = value
×
UNCOV
230
        for stack in cfg.stacks:
×
UNCOV
231
            if getattr(stack, "qdevice_cfg", None) is not None:
×
UNCOV
232
                stack.qdevice_cfg[param] = value
×
233

234

235
def parallelize_comb(comb, cfg, sweep_params, num_experiments, programs):
2✔
236
    """Parallelize a single combination of parameters
237

238
    Args:
239
        comb (tuple): One combination of parameters value.
240
        cfg (StackNetworkConfig): Network configuration.
241
        sweep_params (list): Parameters to sweep.
242
        num_experiments (int): Number of experiments per configuration.
243
        programs (dict): Progam that map a name to class.
244

245
    Returns:
246
        dict: A dictionary with the parameter mapping and computed results.
247
    """
248
    # Map parameter name to value.
NEW
249
    map_param = dict(zip(sweep_params, comb))
×
250

NEW
251
    local_cfg = copy.deepcopy(cfg)
×
NEW
252
    update_cfg(local_cfg, map_param)
×
253

NEW
254
    res = run(config=local_cfg, programs=programs, num_times=num_experiments)
×
NEW
255
    last_program_results = res[len(programs) - 1]
×
256

NEW
257
    all_fid_results = [r[0] for r in last_program_results]
×
NEW
258
    all_time_results = [r[1] for r in last_program_results]
×
259

NEW
260
    avg_fid = np.mean(all_fid_results) * 100
×
NEW
261
    avg_time = np.mean(all_time_results)
×
262

NEW
263
    return {
×
264
        **map_param,
265
        "Fidelity Results": all_fid_results,
266
        "Simulation Time Results": all_time_results,
267
        "Average Fidelity (%)": avg_fid,
268
        "Average Simulation Time (ms)": avg_time,
269
    }
270

271

272
# =============================================================================
273
# Plotting Functions
274
# =============================================================================
275
def plot_heatmap(
2✔
276
    ax,
277
    df: pd.DataFrame,
278
    p: str,
279
    q: str,
280
    metric: dict,
281
    params: dict,
282
    exp: str,
283
    rounds: int,
284
):
285
    """Plot an individual heatmap on a given axes.
286

287
    Args:
288
        ax: Matplotlib Axes object.
289
        df (pd.DataFrame): Dataframe containing experiment results.
290
        p (str): Name of the parameter for the y-axis.
291
        q (str): Name of the parameter for the x-axis.
292
        metric (dict): Dictionary with keys 'name', 'cmap', and 'file_label'.
293
        params (dict): Dictionary mapping parameters to their ranges.
294
        exp (str): Name of the experiment.
295
        rounds (int): Number of epr rounds.
296
    """
297
    pivot = df.pivot_table(index=p, columns=q, values=metric["name"])
×
298
    metric_matrix = pivot.values
×
299

300
    im = ax.imshow(
×
301
        metric_matrix,
302
        extent=[
303
            params[q][0],
304
            params[q][-1],  # X-axis
305
            params[p][0],
306
            params[p][-1],  # Y-axis
307
        ],
308
        origin="lower",
309
        aspect="auto",
310
        cmap=metric["cmap"],
311
    )
312

313
    cbar = ax.figure.colorbar(im, ax=ax)
×
314
    cbar.set_label(metric["name"], fontsize=14)
×
315

316
    var1 = truncate_param(q)
×
317
    var2 = truncate_param(p)
×
318

319
    ax.set_xlabel(var1, fontsize=14)
×
320
    ax.set_ylabel(var2, fontsize=14)
×
321

322
    title_suffix = f" with {rounds} hops" if exp == "pingpong" else ""
×
323
    ax.set_title(
×
324
        f"{exp.capitalize()}: {var2} vs {var1}{title_suffix}",
325
        fontsize=16,
326
        fontweight="bold",
327
    )
328

329

330
def save_single_heatmap(
2✔
331
    metric: dict,
332
    df: pd.DataFrame,
333
    pairs: list,
334
    params: dict,
335
    exp: str,
336
    rounds: int,
337
    dir: str,
338
) -> None:
339
    """Generate and save a heatmap for a single metric.
340

341
    Args:
342
        metric (dict): Metric details (keys: 'name', 'cmap', 'file_label').
343
        df (pd.DataFrame): DataFrame containing experiment results.
344
        pairs (list): List of parameter pairs generated from sweep_params.
345
        params (dict): Dictionary mapping parameters to their ranges .
346
        exp (str): Name of the experiment.
347
        rounds (int): Number of EPR rounds.
348
        dir (str): Directory to save the generated figures.
349
    """
350
    figsize = (12 * len(pairs), 8)
×
351
    fig, axes = plt.subplots(1, len(pairs), figsize=figsize)
×
352

353
    # Ensure axes is iterable.
354
    if len(pairs) == 1:
×
355
        axes = [axes]
×
356

357
    for ax, (p, q) in zip(axes, pairs):
×
358
        plot_heatmap(ax, df, p, q, metric, params, exp, rounds)
×
359

360
    plt.tight_layout()
×
361
    prefix = f"{exp}_heat_{metric['file_label']}"
×
362
    suffix = f"{(rounds + 1) // 2}" if exp == "pingpong" else ""
×
363
    filename = os.path.join(dir, f"{prefix}_{suffix}.png")
×
364
    plt.savefig(filename, dpi=300)
×
365
    plt.close(fig)
×
366
    print(f"Saved {metric['name']} heatmap to {filename}")
×
367

368

369
def save_combined_heatmaps(
2✔
370
    metrics: list,
371
    df: pd.DataFrame,
372
    pairs: list,
373
    params: dict,
374
    exp: str,
375
    rounds: int,
376
    dir: str,
377
) -> None:
378
    """Generate and save a combined figure with heatmaps for all metrics.
379

380
    Args:
381
           metrics (list): Metric details (keys: 'name', 'cmap', 'file_label').
382
           df (pd.DataFrame): DataFrame containing experiment results.
383
           pairs (list): List of parameter pairs generated from sweep_params.
384
           params (dict): Dictionary mapping parameters to their ranges.
385
           exp (str): Name of the experiment.
386
           rounds (int): Number of EPR rounds.
387
           dir (str): Directory to save the generated figures.
388
    """
389
    figsize = (12 * len(pairs), 8 * len(metrics))
×
390
    fig, axes = plt.subplots(len(metrics), len(pairs), figsize=figsize)
×
391

392
    # Ensure axes is 2D even when there is only one pair.
393
    if len(pairs) == 1:
×
394
        axes = np.array([axes]).reshape(len(metrics), 1)
×
395

396
    for i, metric in enumerate(metrics):
×
397
        for j, (p, q) in enumerate(pairs):
×
398
            plot_heatmap(axes[i, j], df, p, q, metric, params, exp, rounds)
×
399

400
    plt.tight_layout()
×
401
    filename = os.path.join(dir, f"{exp}_heatmaps.png")
×
402
    plt.savefig(filename, dpi=300)
×
403
    plt.close(fig)
×
404
    print(f"Saved combined heatmaps to {filename}")
×
405

406

407
def plot_combined_heatmaps(
2✔
408
    df: pd.DataFrame,
409
    sweep_params: list,
410
    params: dict,
411
    dir: str,
412
    exp: str,
413
    rounds: int,
414
    separate_files: bool = False,
415
):
416
    """Generates heatmaps from experiment results.
417

418
    If `separate_files` is True, separate figures will be created for each
419
    metric. Otherwise, a single combined figure is generated.
420

421
    Args:
422
        df (pd.DataFrame): DataFrame containing experiment results.
423
        sweep_params (list): List of swept parameters.
424
        params (dict): Dictionary mapping parameters to their ranges.
425
        dir (str): Directory to save the generated figures.
426
        exp (str): Name of the experiment.
427
        rounds (int): Number of EPR rounds.
428
        separate_files (bool, optional): Whether to generate separate files.
429
    """
430
    pairs = list(itertools.combinations(sweep_params, 2))
×
431
    metrics = [
×
432
        {
433
            "name": "Average Fidelity (%)",
434
            "cmap": "magma",
435
            "file_label": "fidelity"},
436
        {
437
            "name": "Average Simulation Time (ms)",
438
            "cmap": "viridis",
439
            "file_label": "sim_times",
440
        },
441
    ]
442

443
    if separate_files:
×
444
        for metric in metrics:
×
445
            save_single_heatmap(metric, df, pairs, params, exp, rounds, dir)
×
446
    else:
447
        save_combined_heatmaps(metrics, df, pairs, params, exp, rounds, dir)
×
448

449

450
# =============================================================================
451
# Gates
452
# =============================================================================
453
def toffoli(control1: Qubit, control2: Qubit, target: Qubit) -> None:
2✔
454
    """Performs a Toffoli gate with `control1` and `control2` as control qubits
455
    and `target` as target, using CNOTS, Ts and Hadamard gates.
456

457
    See https://en.wikipedia.org/wiki/Toffoli_gate
458

459
    Args:
460
        control1 (Qubit): First control qubit.
461
        control2 (Qubit): Second control qubit.
462
        target (Qubit): Target qubit.
463
    """
464
    target.H()
2✔
465
    control2.cnot(target)
2✔
466
    target.rot_Z(angle=-np.pi / 4)
2✔
467
    control1.cnot(target)
2✔
468
    target.rot_Z(angle=np.pi / 4)
2✔
469
    control2.cnot(target)
2✔
470
    target.rot_Z(angle=-np.pi / 4)
2✔
471
    control1.cnot(target)
2✔
472
    control2.rot_Z(angle=np.pi / 4)
2✔
473
    target.rot_Z(angle=np.pi / 4)
2✔
474
    target.H()
2✔
475
    control1.cnot(control2)
2✔
476
    control1.rot_Z(angle=np.pi / 4)
2✔
477
    control2.rot_Z(angle=-np.pi / 4)
2✔
478
    control1.cnot(control2)
2✔
479

480

481
def ccz(control1: Qubit, control2: Qubit, target: Qubit) -> None:
2✔
482
    """Performs a CCZ gate with `control1` and `control2` as control qubits
483
    and `target` as target, using Toffoli and Hadamard gates.
484

485
    Args:
486
        control1 (Qubit): First control qubit.
487
        control2 (Qubit): Second control qubit.
488
        target (Qubit): Target qubit.
489
    """
490
    target.H()
×
491
    toffoli(control1, control2, target)
×
492
    target.H()
×
493

494

495
# =============================================================================
496
# Routines
497
# =============================================================================
498
def pingpong_initiator(
2✔
499
    qubit: Qubit, context: ProgramContext, peer_name: str, num_rounds: int = 3
500
):
501
    """Executes the ping‐pong teleportation protocol for the initiator.
502

503
    In even rounds, the provided qubit is sent to the peer.
504
    In odd rounds, the initiator receives the qubit.
505
    The formal return is a generator and requires use of `yield from`
506
    in usage in order to function as intended.
507

508
    Args:
509
        qubit (Qubit): The qubit to be teleported.
510
        context (ProgramContext): Connection, csockets, and epr_sockets.
511
        peer_name (str): Name of the peer.
512
        num_rounds (int): Number of ping‐pong rounds.
513
    """
514
    if num_rounds % 2 == 0:
2✔
515
        raise ValueError("It must be odd for a complete ping-pong exchange.")
2✔
516

517
    for round_num in range(num_rounds):
2✔
518
        if round_num % 2 == 0:
2✔
519
            # Even round: send the qubit.
520
            yield from teleport_send(qubit, context, peer_name)
2✔
521
        else:
522
            # Odd round: receive a new qubit from the peer.
523
            qubit = yield from teleport_recv(context, peer_name)
2✔
524

525
    yield from context.connection.flush()
2✔
526

527

528
def pingpong_responder(
2✔
529
    context: ProgramContext, peer_name: str, num_rounds: int = 3
530
) -> Generator[None, None, Qubit]:
531
    """Executes the complementary ping‐pong teleportation protocol
532
    for the responder.
533

534
    The responder starts without a qubit and in the first (even) round
535
    receives one. In odd rounds he sends the qubit. After completing
536
    the rounds, Bob returns the final qubit he holds.
537
    The formal return is a generator and requires use of `yield from`
538
    in usage in order to function as intended.
539

540
    Args:
541
        context (ProgramContext): Connection, csockets, and epr_sockets.
542
        peer_name (str): Name of the peer.
543
        num_rounds (int): Number of ping‐pong rounds.
544

545
    Returns:
546
        Generator[None, None, Qubit]: The final teleported qubit.
547
    """
548
    if num_rounds % 2 == 0:
2✔
549
        raise ValueError("It must be odd for a complete ping-pong exchange.")
×
550

551
    qubit = None
2✔
552

553
    for round_num in range(num_rounds):
2✔
554
        if round_num % 2 == 0:
2✔
555
            # Even round: receive a qubit from the peer.
556
            qubit = yield from teleport_recv(context, peer_name)
2✔
557
        else:
558
            # Odd round: send the qubit to the peer.
559
            yield from teleport_send(qubit, context, peer_name)
2✔
560

561
    yield from context.connection.flush()
2✔
562

563
    return qubit
2✔
564

565

566
def distributed_n_qubit_controlled_u_control(
2✔
567
    context: ProgramContext, peer_name: str, ctrl_qubit: Qubit
568
) -> Generator[None, None, None]:
569
    """Performs the n-qubit controlled-U gate, but with one control qubit
570
    located on this node, the target on a remote node. The formal return is a
571
    generator and requires use of `yield from` in usage in order to function
572
    as intended.
573

574
    Args:
575
        context (ProgramContext): Context of the current program.
576
        peer_name (str): Name of the peer.
577
        ctrl_qubit (Qubit): The control qubit.
578
    """
579
    csocket = context.csockets[peer_name]
2✔
580
    epr_socket = context.epr_sockets[peer_name]
2✔
581
    connection = context.connection
2✔
582

583
    epr = epr_socket.create_keep()[0]
2✔
584
    ctrl_qubit.cnot(epr)
2✔
585
    epr_meas = epr.measure()
2✔
586
    yield from connection.flush()
2✔
587

588
    csocket.send(str(epr_meas))
2✔
589
    target_meas = yield from csocket.recv()
2✔
590
    if target_meas == "1":
2✔
591
        ctrl_qubit.Z()
2✔
592

593
    yield from connection.flush()
2✔
594

595

596
def distributed_n_qubit_controlled_u_target(
2✔
597
    context: ProgramContext,
598
    peer_names: List[str],
599
    target_qubit: Qubit,
600
    controlled_u: Callable[..., None],
601
):
602
    """Performs the n-qubit controlled-U gate, but with the target qubit
603
    located on this node, the controls on remote nodes. The formal return is
604
    a generator and requires use of `yield from` in usage in order to function
605
    as intended.
606

607
    Args:
608
        context (ProgramContext): Context of the current program.
609
        peer_names (List[str]): Name of the peer engaging.
610
        target_qubit (Qubit): The target qubit.
611
        controlled_u (Callable[..., None]): The n-qubits gate U with this
612
        signature `U(control_qubit_1, control_qubit_2, ..., target_qubit)`.
613
    """
614
    connection = context.connection
2✔
615

616
    epr_dict = {}
2✔
617
    for peer_name in peer_names:
2✔
618
        epr_dict[peer_name] = context.epr_sockets[peer_name].recv_keep()[0]
2✔
619
    yield from connection.flush()
2✔
620

621
    for peer_name, epr in epr_dict.items():
2✔
622
        m = yield from context.csockets[peer_name].recv()
2✔
623
        if m == "1":
2✔
624
            epr.X()
2✔
625

626
    epr_list = [epr_dict[peer_name] for peer_name in peer_names]
2✔
627
    controlled_u(*epr_list, target_qubit)
2✔
628

629
    epr_meas = {}
2✔
630
    for peer_name, epr in epr_dict.items():
2✔
631
        epr.H()
2✔
632
        epr_meas[peer_name] = epr.measure()
2✔
633
    yield from connection.flush()
2✔
634

635
    for peer_name in peer_names:
2✔
636
        context.csockets[peer_name].send(str(epr_meas[peer_name]))
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc