Rumor vs Counterwave

Competing rumor and fact-check messages racing across a social network.

Level:Intermediate

competing-contagionsocial-networkrumorfact-check

  • Stocks:rumor, correction
  • Flows:transmissions
  • Probes:rumor, correction
FAQ
When is the fact-check counterwave seeded?
At the configured counter_delay time the simulation introduces counter_seed nodes spreading the correction.
How does rumor propagation stop?
Each day rumor holders may forget due to rumor_decay or switch to the correction when contacted.
Which metrics summarize spread?
The result object reports peak_rumor, correction_coverage and basic reproduction numbers for both waves.
simulation.py

Viral misinformation vs fact-check counterwave

Two memes race across a social graph. A rumor starts first, hopping from account to account. After a delay, a fact-check counterwave begins spreading through the same network. Each has its own forwarding probability and a chance to fade from memory each day. When the correction reaches a user they drop the rumor if they were spreading it.

Probes track how widely each message propagates. At the end we report:

  • Peak rumor reach
  • Correction coverage percentage
  • Time gap between rumor peak and when half the network holds the correction
  • The basic reproduction number R0 for both rumor and correction

from tys import probe, progress


def simulate(cfg: dict):
    """Simulate competing contagions on a random social graph."""

    import random
    import simpy

    env = simpy.Environment()
    rng = random.Random(cfg.get("seed", 12345))

Parameters

    n = cfg["num_nodes"]               # number of users in the network
    degree = cfg["avg_degree"]         # average connections per user
    rumor_p = cfg["rumor_p"]           # chance a rumor holder forwards to each neighbour
    rumor_decay = cfg["rumor_decay"]   # daily chance a rumor fades
    correction_p = cfg["correction_p"] # chance a correction holder forwards
    correction_decay = cfg["correction_decay"]
    counter_delay = cfg["counter_delay"]   # days before fact-check seeding
    rumor_seed = cfg["rumor_seed"]         # initial rumor carriers
    counter_seed = cfg["counter_seed"]     # initial correction carriers
    sim_time = cfg["sim_time"]

Build an undirected random graph with roughly the desired degree.

    neighbors = {i: set() for i in range(n)}
    for i in range(n):
        while len(neighbors[i]) < degree:
            j = rng.randrange(n)
            if j != i and j not in neighbors[i]:
                neighbors[i].add(j)
                neighbors[j].add(i)

    rumor = set(rng.sample(range(n), rumor_seed))
    correction = set()

    initial_rumor = len(rumor)
    initial_correction = counter_seed
    rumor_spread = 0
    correction_spread = 0

    rumor_peak = len(rumor)
    rumor_peak_time = 0
    correction_half_time = None

    done = env.event()

    def dynamics():
        nonlocal rumor, correction, rumor_peak, rumor_peak_time, correction_half_time
        nonlocal rumor_spread, correction_spread

        for t in range(sim_time):
            new_rumor = set()
            new_correction = set()

Launch the counterwave after the configured delay

            if t == counter_delay:
                seeds = rng.sample([x for x in range(n) if x not in correction], counter_seed)
                new_correction.update(seeds)

Spread rumor

            for node in list(rumor):
                for neigh in neighbors[node]:
                    if neigh not in rumor and neigh not in correction:
                        if rng.random() < rumor_p:
                            new_rumor.add(neigh)
                            rumor_spread += 1
                if rng.random() < rumor_decay:
                    rumor.remove(node)

Spread correction

            for node in list(correction):
                for neigh in neighbors[node]:
                    if neigh not in correction:
                        if neigh in rumor:
                            rumor.discard(neigh)
                        if rng.random() < correction_p:
                            new_correction.add(neigh)
                            correction_spread += 1
                if rng.random() < correction_decay:
                    correction.remove(node)

            rumor.update(new_rumor - correction)
            correction.update(new_correction)

            probe("rumor", env.now, len(rumor))
            probe("correction", env.now, len(correction))

            if len(rumor) > rumor_peak:
                rumor_peak = len(rumor)
                rumor_peak_time = t
            if correction_half_time is None and len(correction) >= n / 2:
                correction_half_time = t

            progress(int(100 * t / sim_time))
            yield env.timeout(1)

        progress(100)
        final_coverage = len(correction) / n * 100
        time_gap = None
        if correction_half_time is not None:
            time_gap = correction_half_time - rumor_peak_time

        done.succeed({
            "peak_rumor": rumor_peak,
            "correction_coverage": final_coverage,
            "time_gap": time_gap,
            "R0_rumor": rumor_spread / initial_rumor if initial_rumor else 0,
            "R0_correction": correction_spread / initial_correction if initial_correction else 0,
        })

    env.process(dynamics())
    env.run(until=done)
    return done.value


def requirements():
    return {
        "builtin": ["micropip", "pyyaml"],
        "external": ["simpy==4.1.1"],
    }
Default.yaml
num_nodes: 200
avg_degree: 6
rumor_seed: 5
counter_seed: 5
rumor_p: 0.3
rumor_decay: 0.05
correction_p: 0.25
correction_decay: 0.05
counter_delay: 5
sim_time: 50
seed: 12345
Charts (Default)

rumor

rumor chartCSV
Samples50 @ 0.00–49.00
Valuesmin 7.00, mean 41.18, median 21.00, max 196.00, σ 47.00

correction

correction chartCSV
Samples50 @ 0.00–49.00
Valuesmin 0.00, mean 160.80, median 194.50, max 199.00, σ 69.97
Final Results (Default)
MetricValue
peak_rumor196.00
correction_coverage98.00
time_gap4.00
R0_rumor121.00
R0_correction182.80