Jak działa nasz silnik symulacji?

Nasz silnik symulacji jest oparty na technice Discrete Event Simulation. Nie iteruje po sekundach — przeskakuje do momentów gdy dzieje się coś istotnego.


1. Czym jest DES?

Discrete Event Simulation to technika modelowania systemów, w których stan zmienia się w dyskretnych skokach (zdarzeniach), a nie w sposób ciągły.

Alternatywa: symulacja krokowa (time-step)

Naiwne podejście — iteruj co sekundę przez każdą osobę:

dla każdej sekundy T od 00:00 do 24:00:
    dla każdej osoby P spośród 120 000:
        jeśli P.next_event_time == T:
            obsłuż zdarzenie

Koszt: 86 400 sek × 120 000 osób = ~10 miliardów iteracji dziennie. Ogromna większość z nich to puste "nic się nie zmieniło".

DES: skacz od zdarzenia do zdarzenia

dopóki scheduler nie jest pusty:
    pobierz zdarzenie (T, P, A) z najmniejszym T
    obsłuż: oblicz duration, next_activity, zapisz log
    wstaw do schedulera: (T + duration, P, next_activity)

Koszt: tylko tyle iteracji ile jest zdarzeń. Przy ~120 000 osobach i ~20 zdarzeniach dziennie = ~2 400 000 zdarzeń/dzień symulacji. Przy speed=60 (1 min realna = 1 h symulacyjna) demon przetwarza dobę w ~24 sekundy.


2. Główna pętla

                   ┌──────────────────────────────────────────────────┐
                   │              SCHEDULER (min-heap)                │
                   │                                                  │
                   │  (T=06:31, id=4821, EVT_WAKEUP)  ← najmniejszy   │
                   │  (T=06:33, id=9104, EVT_WAKEUP)                  │
                   │  (T=06:34, id=  17, EVT_WAKEUP)                  │
                   │  (T=06:35, id=7722, EVT_SLEEP )                  │
                   │  ...                                             │
                   └───────────────┬──────────────────────────────────┘
                                   │ scheduler_pop()
                                   ▼
                   ┌──────────────────────────────────────────────────┐
                   │           sim_process_event(item)                │
                   │                                                  │
                   │  1. activity_unit(akt)    → lokal                │
                   │  2. activity_duration(akt)→ czas_trwania         │
                   │     czas_konca = czas_startu + czas_trwania      │
                   │  3. next_activity(akt)    → nastepna             │
                   │  4. activity_unit(nastepna)→ nastepny_lokal      │
                   │  5. elog_write(...)       → plik logu            │
                   │  6. aktualizuj stan osoby                        │
                   │  7. plan_daily_meeting / try_breakup / try_birth │
                   │  8. try_work_encounter / try_shop_flirt          │
                   └───────────────┬──────────────────────────────────┘
                                   │ scheduler_push(czas_konca, id, nastepna)
                                   ▼
                   ┌──────────────────────────────────────────────────┐
                   │              SCHEDULER (min-heap)                │
                   │  (T=06:33, id=9104, EVT_WAKEUP)  ← nowy min      │
                   │  (T=06:34, id=  17, EVT_WAKEUP)                  │
                   │  (T=06:35, id=7722, EVT_SLEEP )                  │
                   │  (T=06:46, id=4821, EVT_BREAKFAST) ← właśnie     │
                   │  ...                               dodane        │
                   └──────────────────────────────────────────────────┘

Łańcuch jest samo-zasilający się: każde zdarzenie natychmiast planuje następne. Żadna osoba nigdy nie "wypadnie" z harmonogramu — dopóki demon działa, każda ma dokładnie 1 oczekujące zdarzenie.


3. Scheduler: min-heap z generacjami

Min-heap

Scheduler to binarna kolejka priorytetowa (min-heap) sortowana po fire_time.

Wstawienie (push):   O(log N)
Pobranie min (pop):  O(log N)
Peek min:            O(1)

Przy N = 120 000 zdarzeń:
  log2(120 000) ≈ 17 porównań na operację

Nie ma potrzeby usuwania zdarzeń ze środka kopca — stale event detection (patrz niżej) sprawia, że nieaktualne zdarzenia są odrzucane przy pobraniu.

Problem: unieważnianie zdarzeń

Gdy force_cohabit_sex() lub force_morning_sex() musi natychmiast zmienić harmonogram osoby B, jej stare zdarzenie nadal siedzi gdzieś w kopcu. Usunięcie ze środka kopca to O(N) — za wolne.

Rozwiązanie: licznik generacji (sched_gen)

Każda osoba ma uint8_t sched_gen. Każde zdarzenie nosi gen z chwili wstawienia.

Unieważnienie harmonogramu osoby B:
  B.sched_gen++                          ← inkrementuj
  scheduler_push(nowy_czas, B.id, nowy_akt, B.sched_gen)

Gdy stare zdarzenie B zostanie pobrane z kopca:
  if (item.gen != osoba->sched_gen) return;   ← odrzuć
         Kopiec przed unieważnieniem           Kopiec po
         ─────────────────────────             ──────────────────────────
  min →  (T=19:45, B, WATCH_TV,  gen=3)        (T=19:45, B, WATCH_TV,  gen=3)  ← zostanie pominięte
         (T=20:00, A, SEX_HOME,  gen=7)        (T=19:45, B, SEX_HOME,  gen=4)  ← nowe, wygra
         (T=20:00, B, SEX_HOME,  gen=4)        (T=20:00, A, SEX_HOME,  gen=7)
         ...                                   ...

Koszt: O(log N) na wstawienie nowego + O(1) odrzucenie starego przy pobraniu. sched_gen to uint8_t — zawija się po 256. Nie stanowi problemu, bo między zawarciem a sprawdzeniem nie może dojść do 256 zmian dla tej samej osoby w jednym ticku.


4. Deterministyczna pseudolosowość

prand(person_id, day_epoch, salt) → uint32_t

// XOR-Shift Hash z mnożnikami Knutha:
uint32_t h = person_id ^ (day_epoch * 2654435761u) ^ ((uint32_t)salt * 40503u);
h ^= h >> 16;
h *= 0x45d9f3b;
h ^= h >> 16;
return h;

Trzy właściwości:

┌──────────────┬──────────────────────────────────────────────────────┐
│ Właściwość   │ Znaczenie                                            │
├──────────────┼──────────────────────────────────────────────────────┤
│ Bezstanowość │ Brak globalnego stanu → bezpieczne wielowątkowo,     │
│              │ wyniki nie zależą od kolejności wywołań              │
├──────────────┼──────────────────────────────────────────────────────┤
│ Powtarzalność│ Ta sama (person_id, day_epoch, salt) → zawsze ten    │
│              │ sam wynik → symulacja jest w pełni deterministyczna  │
│              │ dla danego snapshotu startowego                      │
├──────────────┼──────────────────────────────────────────────────────┤
│ Niezależność │ Każda odrębna decyzja ma unikatowy salt (0–95+) →    │
│              │ brak korelacji między np. godziną wstania (salt=0)   │
│              │ a decyzją o małżeństwie (salt=89)                    │
└──────────────┴──────────────────────────────────────────────────────┘

Wspólne decyzje pary: shared_id = min(A.id, B.id)

Gdy para A i B musi podjąć wspólną decyzję (np. o której godzinie się spotkać), oboje wywołują prand(shared_id, day, salt) niezależnie i dostają identyczny wynik:

Osoba A (id=1200) budzi się → plan_daily_meeting():
  shared_id = min(1200, 3847) = 1200
  meet_time = day + H(19) + prand(1200, day, 44) % H(3)
  meet_time = day + 19:00 + 37 min  → 19:37

Osoba B (id=3847) budzi się (może minutę później) → plan_daily_meeting():
  shared_id = min(1200, 3847) = 1200
  meet_time = day + H(19) + prand(1200, day, 44) % H(3)
  meet_time = day + 19:00 + 37 min  → 19:37  ← identyczny wynik!

Brak komunikacji między osobami. Brak synchronizacji. Każda oblicza to samo.

variance(person_id, day, salt, range) → int32_t

Zwraca losową wariację z przedziału [-range, +range]. Używana wszędzie tam, gdzie czas bazowy powinien być "ludzki" (nie do co sekundy):

wakeup_time = H(6) + M(30) + variance(id, day, 0, M(30))
           ↑ bazowo 06:30  ↑ ±30 min — wynik: 06:00–07:00

5. Maszyna stanów — harmonogram dnia

Każda osoba podąża ścieżką zdarzeń zależną od swojego statusu.

Pracownik (STATUS_EMPLOYED)

  06:30                08:00            12:00      12:45       16:30     18:00    22:30
    │                    │                │           │           │         │        │
  SLEEP ──► WAKEUP ──► BREAKFAST ──► COMMUTE ──► WORK ──► BREAK ──► WORK ──► COMMUTE ──► HOME_REST
    ▲                                  TO_WORK  (4h±20m)  (45m±10m) (4h±20m) FROM_WORK       │
    │                                                                                  ┌─────┤
    │                                                                              DINNER  GROCERY
    │                                                                                  │
    │                                                                              WATCH_TV / HOME_REST
    │                                                                                  │
    └──────────────────────────────────────────────────────────── FALLING_ASLEEP ◄─────┘

Uczeń (STATUS_STUDENT / STATUS_CHILD)

  07:00        07:15       08:00         10:30     10:45      13:00       15:00
    │            │           │             │          │          │           │
  WAKEUP ──► BREAKFAST ──► COMMUTE ──► SCHOOL ──► BREAK ──► SCHOOL ──► COMMUTE ──► HOME_LEARNING ──► HOME_REST
                           TO_SCHOOL   (2.5h)    (15 min)  (2.5h)    FROM_SCHOOL     (2h±30m)

Emeryt / bezrobotny (STATUS_RETIRED / UNEMPLOYED)

  08:00      08:15       08:45                  18:00      19:00    22:30
    │          │           │                      │          │         │
  WAKEUP ──► BREAKFAST ──► HOME_REST ──────────► DINNER ──► WATCH_TV ──► FALLING_ASLEEP ──► SLEEP
                                     (cały dzień)

Weekendy — dodatkowe aktywności

  Sobota:
    ┌── 9:00–13:00: GROCERY_SHOPPING (kobiety >18 lat, 70%)
    ├── 13:00–15:00: LUNCH (pary kohabitujące)
    └── 16:00–23:00: SEX_HOME (pary COHABIT, 90%/70% z/bez dzieci)
                     lub CLUB_NIGHT (single 18–40 lat, 8%)

  Niedziela:
    ├── 10:00–12:00: HOME_LEARNING (uczniowie, 70%)
    ├── 13:00–15:00: LUNCH (pary kohabitujące)
    └── 16:00–23:00: SEX_HOME (pary COHABIT, 80% — tylko jeśli brak sobotniego seksu)

Wieczorne wyjścia po kolacji (weekend, salt=75)

  rd = prand(id, day, 75) % 100

   0–19 │██████████████████████│ WALK          (20%, wiek ≥ 7)
  20–32 │█████████████│         MEET_FRIENDS   (13%, wiek ≥ 18)
  33–40 │████████│              VISIT_FAMILY   ( 8%, wiek ≥ 7)
  41–45 │█████│                 RESTAURANT     ( 5%, wiek ≥ 18)
  46–49 │████│                  CINEMA_THEATRE ( 4%, wiek ≥ 12)
  50–52 │███│  (tylko sob)      HOME_PARTY     ( 3%, wiek ≥ 18)
  53–99 │                       zostajemy w domu

6. Synchronizacja par

Dwa modele seksu wymagają koordynacji między osobami:

Model HOST/GUEST (para z osobnych domów)

OSOBA A (HOST, id=1200)          OSOBA B (GUEST, id=3847)
────────────────────────         ────────────────────────
18:00  HOME_REST                 18:00  HOME_REST
       (plan_cap skraca          17:23  HOME_REST kończy się
       do 19:37)                        (cap = meet_time - travel_time)
19:37  WAIT_FOR_PARTNER  ◄───────19:23  TRAVEL_TO_PARTNER (14 min)
19:37  SEX_HOST          ◄───────19:37  SEX_GUEST
21:10  HOME_REST                 21:10  STAY_OVERNIGHT / MORNING_RETURN

Oboje obliczają meet_time = 19:37 niezależnie (shared_id = 1200). Gość wychodzi 14 minut wcześniej (trigger = meet_time - travel_time).

Model COHABIT (para we wspólnym domu)

Inicjator A wchodzi w EVT_SEX_HOME. Partner B może być jeszcze w WATCH_TV:

OSOBA A (PLAN_COHABIT)           OSOBA B (nie dobił do meet_time)
────────────────────────         ────────────────────────
19:37  SEX_HOME ─────────────────► force_cohabit_sex() wywołane
       (sim_process_event)              B.sched_gen++
                                        elog_write(SEX_HOME, B)
                                        B.current_activity = SEX_HOME
                                        scheduler_push(21:10, B, HOME_REST, gen=4)
21:10  HOME_REST                 21:10  HOME_REST

Stare zdarzenie B (WATCH_TV, gen=3) zostanie odrzucone gdy osiągnie czoło kolejki.

Poranny seks (MORNING_SEX)

~06:45  next_activity(SLEEP, A) = MORNING_SEX   ← decyzja A (15%, salt=77)
        force_morning_sex(sched, B, czas_konca)
              B.sched_gen++
              scheduler_push(06:45, B, MORNING_SEX, gen=new)

06:45  A: EVT_MORNING_SEX       06:45  B: EVT_MORNING_SEX
       (A normalnie procesuje)         (B procesuje swoje zdarzenie)
06:50  A: EVT_WAKEUP            06:50  B: EVT_WAKEUP

B nie wie, że A zdecydowało. Scheduler robi całą robotę.


7. Wydajność

Złożoność obliczeniowa

┌─────────────────────────────┬──────────────┬───────────────────────────┐
│ Operacja                    │ Złożoność    │ Uwaga                     │
├─────────────────────────────┼──────────────┼───────────────────────────┤
│ scheduler_push / pop        │ O(log N)     │ N = liczba osób ≈ 120 000 │
│ city_person(id)             │ O(1)         │ tablica lookup pers_by_id │
│ city_unit(id)               │ O(1)         │ tablica lookup unit_by_id │
│ city_org(id)                │ O(1)         │ tablica lookup org_by_id  │
│ prand()                     │ O(1)         │ ~6 operacji CPU           │
│ travel_time(from, to)       │ O(1)         │ lookup + arytmetyka       │
│ QUERY_STATS (IPC)           │ O(1)         │ act_count pre-wyliczone   │
│ LIST_ACTIVITY (IPC)         │ O(N)         │ skan wszystkich osób      │
└─────────────────────────────┴──────────────┴───────────────────────────┘

Zużycie pamięci

person_record_t = 84 bajty × 120 000 osób   ≈  10 MB
daily_plan_t    = ~32 bajty × 120 000       ≈   4 MB
sat_had_sex[]   =   1 bajt  × 120 000       ≈ 120 KB
act_count[][]   = 1024 × 2 × 4 bajty        ≈   8 KB
scheduler heap  = ~16 bajty × 120 000       ≈   2 MB
lookup arrays   =  4 bajty  × max_id × 4    ≈   2 MB
─────────────────────────────────────────────────────
Razem (struktury danych)                    ≈  18 MB

Bez alokacji pamięci w gorącej ścieżce (sim_process_event): żadnego malloc, calloc ani free w pętli głównej.

Przepustowość

120 000 osób × 20 zdarzeń/osobę/dobę = 2 400 000 zdarzeń/dobę

Przy speed=60 (1 min realna = 1 h symulacyjna):
  1 doba symulacyjna = 24 minuty realne
  2 400 000 ÷ (24 × 60) ≈ 1 667 zdarzeń/sekundę realną

Przy speed=3600 (catchup, pełna prędkość CPU):
  ~2–5 milionów zdarzeń/sekundę (zależy od sprzętu)
  1 doba symulacyjna w ciągu ~1 sekundy

Brak alokacji w hot path

// sim_process_event() — zero malloc, zero I/O (oprócz buforowanego logu):
void sim_process_event(city_t *miasto, scheduler_t *sched,
                       event_log_t *elog, const sched_item_t *item,
                       FILE *console)
{
    person_record_t *osoba = city_person(miasto, item->person_id);  // O(1) lookup
    // ... wszystkie zmienne na stosie, żadnych heap allocations
    scheduler_push(sched, czas_konca, osoba->id, nastepna, osoba->sched_gen);
}

Log zdarzeń

Bufor stdio: 256 KB (unikamy syscalli write() co zdarzenie)
Format: pipe-separated, ~80 bajtów/linia
Przy 2.4M zdarzeń/dobę: ~192 MB/dobę logu
Logrotate: codzienna rotacja z SIGHUP (reopen bez restartu demona)

8. Skalowanie: miasto 2M i państwo 40M mieszkańców

Jak zmienia się obciążenie

┌──────────────────────┬─────────────┬─────────────┬──────────────┐
│ Metryka              │  120 000    │   2 000 000 │  40 000 000  │
│                      │ (Gorzów)    │  (Warszawa) │   (Polska)   │
├──────────────────────┼─────────────┼─────────────┼──────────────┤
│ Zdarzeń/dobę         │    2.4 M    │    40 M     │    800 M     │
│ log2(N) scheduler    │      17     │      21     │      25      │
│ RAM — persons        │    10 MB    │   168 MB    │    3.4 GB    │
│ RAM — razem          │    18 MB    │   300 MB    │    6.0 GB    │
│ Czas doby (catchup)  │    ~1 s     │   ~10 s     │   ~3 min     │
│ Log/dobę             │   190 MB    │    3.2 GB   │    64 GB     │
└──────────────────────┴─────────────┴─────────────┴──────────────┘

Kluczowa obserwacja: złożoność rośnie liniowo z N, nie kwadratowo. Każda osoba generuje stałą liczbę zdarzeń (~20/dobę) niezależnie od rozmiarów populacji. Operacje na schedulerze rosną tylko logarytmicznie (log2(40M) ≈ 25, log2(120K) ≈ 17 — różnica zaledwie 8 porównań na operację).

Ilość zdarzeń będzie oczywiście rosnąć w przypadku implementacji dodatkowych aktywności, np. symulacji przestępczości. Nadal jednak architektura rozwiązania pozwala na symulację nawet całej Polski na dość podstawowym serwerze dedykowanym.

Wyzwanie 1: presja pamięci i cache miss

Przy 40M osobach tablica persons[] zajmuje ~3.4 GB — nie mieści się w L3 cache (typowe serwery: 16–64 MB L3). Każdy losowy dostęp city_person(id) to potencjalny cache miss (~100 ns kara zamiast ~1 ns z cache).

Przy 120K:   persons[] = 10 MB  → mieści się w L3 (ciepły cache)
Przy 2M:     persons[] = 168 MB → część w RAM, cache miss ~5–20% dostępów
Przy 40M:    persons[] = 3.4 GB → niemal każdy dostęp to RAM hit, ~100 ns kara

Skutek dla 40M:
  800M zdarzeń/dobę × 2 dostępy × 100 ns = ~160 sekund samych cache miss
  → catchup ~3 minuty zamiast ~1 sekundy dla 120K

Remedium bez zmiany architektury: partycjonowanie geograficzne. Osoby z tej samej dzielnicy mają zbliżone id → lokalność odwołań rośnie naturalnie jeśli generator przydziela id sekwencyjnie per dzielnica.

Wyzwanie 2: rozmiar logu

40M × 20 zdarzeń × 80 bajtów = 64 GB/dobę

Opcje:
  a) Selektywny log: logować tylko zdarzenia relacyjne (EVT_FIRST_MEETING,
     EVT_WEDDING, EVT_BIRTH...) → redukuje log 50× do ~1.3 GB/dobę
  b) Format binarny zamiast pipe-separated → redukuje 3× do ~20 GB/dobę
  c) Próbkowanie: logować 1% osób pełnie, resztę tylko zdarzenia kluczowe
  d) Strumieniowanie przez socket zamiast zapisu na dysk (konsument przetwarza
     zdarzenia na bieżąco bez trwałego składowania)

Wyzwanie 3: interakcje społeczne

Funkcje try_work_encounter() i try_shop_flirt() szukają kandydata spośród pracowników tej samej organizacji (do 10 prób). Przy 40M osobach organizacje mogą liczyć tysiące pracowników:

Obecna implementacja: do 10 prób → O(1) per zdarzenie, O(N) łącznie
Przy 40M: nadal O(1) per zdarzenie — żadna skrzynka nie rośnie

Jednak gęstość interakcji rośnie: w dużej org (np. 5000 pracowników) losowy kandydat z 10 prób prawie zawsze jest w WORK_BREAK → wskaźnik rozmów wzrośnie nieproporcjonalnie. Remedium: limit org (max ~200 pracowników dla jednej lokalizacji) lub przeskalowanie prawdopodobieństwa 30% przez 1/sqrt(org_size).

Wyzwanie 4: typy danych — uint32_t

Obecne person_id, unit_id, org_id to uint32_t (max ~4.3 miliarda). Dla 40M nie wymaga żadnych zmian. Jedyna potencjalna granica to max_pers_id używany do alokacji tablic lookup — przy 40M oznacza to ~160 MB samych wskaźników.

Dlaczego architektura jest wystarczająca

Trzy właściwości projektowe sprawiają, że obecna architektura skaluje się bez przepisywania od zera:

1. Brak globalnych blokad i współdzielonego stanu między osobami.

Każda osoba jest niezależnym agentem. Jedyny współdzielony stan to:

  • scheduler (jeden, ale wielowątkowy min-heap jest trywialny do implementacji)
  • act_count[][] (kilka atomowych inkrementów/dekrementów)
  • tablice plans[], sat_had_sex[] (person-indexed, brak konfliktów)
Obecna architektura → wielowątkowość przez partycjonowanie populacji:

  Wątek 1: osoby id 1 – 10 000 000
  Wątek 2: osoby id 10 000 001 – 20 000 000
  Wątek 3: osoby id 20 000 001 – 30 000 000
  Wątek 4: osoby id 30 000 001 – 40 000 000

  Synchronizacja potrzebna tylko gdy:
    - force_cohabit_sex(partner) → partner w innej partycji (rzadkie, ~1% par)
    - try_work_encounter(target) → target w innej partycji (rzadkie)
  → trylock + queue per wątek eliminuje contention

2. Algorytm jest lokalny — osoba decyduje tylko na podstawie swoich danych.

prand() nie wymaga komunikacji z innymi osobami. plan_daily_meeting() oblicza identyczny wynik niezależnie po obu stronach pary. Brak "głosowania", brak barier synchronizacji.

3. Złożoność czasowa jest O(N log N) per dobę, nie O(N²).

Najczęstsze operacje (push/pop schedulera) to O(log N). Interakcje społeczne są O(1) per osoba. Brak globalnego skanu populacji w hot path (poza LIST_ACTIVITY które jest operacją diagnostyczną, nie symulacyjną).

Dlaczego symulacja będzie wiarygodna przy większej skali

Agent-based modeling — każda osoba jest niezależnym agentem z własnym stanem — jest uznaną metodą w demografii i socjologii obliczeniowej. Kluczowe właściwości zapewniające wiarygodność:

Emergentna statystyka z deterministycznych reguł. Żadna reguła w kodzie nie mówi "20% małżeństw rozpada się do 10 lat". To wynika z indywidualnych reguł (staz ≥ 7 lat → 0.05%/dzień → po 7 latach prawdopodobieństwo ~13% rocznie), które przy milionach par dają rozkłady zgodne z danymi GUS — bez kalibracji ad-hoc.

Heterogeniczność populacji. Każda z 40 milionów osób ma inny wiek, status, wykształcenie, historię związków. Agregowane modele (np. równania różniczkowe) nie uchwycą ogona rozkładu — nastolatka ze statusem CHILD i emeryt ze statusem RETIRED zachowują się kompletnie inaczej, co jest odzwierciedlone w kodzie dosłownie.

Wariancja per-osoba, nie per-populacja. variance(id, day, salt, range) sprawia, że dwie identyczne statystycznie osoby jednak różnią się w konkretnych decyzjach każdego dnia. Populacja 40M generuje naturalne rozkłady Gaussa tam gdzie są oczekiwane (godziny wstania) i rozkłady potęgowe tam gdzie modele socjologiczne ich przewidują (czas trwania związków).

Powtarzalność = falsyfikowalność. Ta sama populacja startowa (snapshot) zawsze daje identyczną historię. To pozwala na naukowe testowanie hipotez: "co by się stało, gdyby szansa na romans wzrosła z 5% do 10%?" — zmień jeden parametr, uruchom dwie instancje z tym samym seedem, porównaj logi.

Snapshot A (seed=42) + reguła oryginalna  → wyniki_A.log
Snapshot A (seed=42) + zmodyfikowana reguła → wyniki_B.log

diff wyniki_A.log wyniki_B.log
→ dokładnie te osoby i te zdarzenia które zmieniła nowa reguła

Topologia przestrzenna. Czas dojazdu travel_time() oparty na rzeczywistej geometrii miasta sprawia, że osoby z odległych dzielnic rzadziej się spotykają — co jest zgodne z badaniami socjologicznymi nad geografią sieci społecznych. Przy skalowaniu do 40M (cały kraj) topologia deterministycznie wymusza regionalne klastrowanie relacji.