# Ingest — jak zbieramy eventy z Nostr

Dokument opisuje logikę pipeline'u ingestu: skąd biorą się eventy, jak
decydujemy z których relayów i dla kogo nasłuchujemy, oraz jak działa
batchowanie REQ-ów i znacznik pokrycia (`live_coverage`). To źródło prawdy o
zachowaniu; schemat tabel jest w `run_sql.md`.

Konwencje czasu: **wszystkie timestampy w sekundach unix** (patrz `run_sql.md`,
reguła #1).

---

## 1. Fundament

- **DuckDB jest single-writer.** Daemon trzyma jedno połączenie zapisowe;
  każdy zapis idzie przez `_sql_lock`. Agent czyta przez `run_sql` (osobne
  połączenie, whitelist tylko-SELECT).
- **RelayBus** (`relay_bus.py`) — jedna pętla async, jedno WS na URL relaya,
  multipleksuje dowolnie wiele subskrypcji (`sub_id`) po tym samym WS.
  Reconnect z backoffem i ponowne wysłanie REQ (z przesuniętym `since`) jest po
  stronie busa. Dwa tryby subskrypcji:
  - **`req`** — jednorazowe; zbiera eventy do sygnału „koniec historii" (EOSE),
    potem zamyka.
  - **`subscribe`** — long-lived; EOSE znaczy „jesteś na bieżąco", strumień
    zostaje otwarty i dostajemy kolejne eventy na żywo. EOSE jest wystawiany do
    konsumenta przez `on_eose` (do stemplowania pokrycia).

## 2. Dwa tryby odpytywania relaya

Te dwa tryby trzeba trzymać osobno — mają przeciwny kształt i inny cel:

### A. Śledzenie (live + dociąganie historii)
Dla kont, które **już śledzimy**, na ich `productive_relays`. Cel: kompletność w
czasie. Kształt batcha: **wiele kont → jeden REQ na relay** (fan-in).
- **live** — `live_subscription.py` → `bus.subscribe_sync`. Długie subskrypcje;
  EOSE → stempel znacznika pokrycia.
- **dociąganie historii** — `update_graph_with_events_authored_by_each` →
  `_backfill_one`/`req_sync`. Jednorazowe REQ-y nadrabiające zaległości.

### B. Test przydatności relaya (discovery / probe)
`track_pubkey.run`. Cel: zmierzyć, czy dany relay w ogóle nadaje się dla danego
konta. Kształt odwrotny: **jedno konto × wiele relayów** (fan-out).
- PHASE 1 — `_probe` każdego aktywnego relaya (przez `_backfill_one`), liczy
  ile eventów relay daje dla tego konta.
- PHASE 2 — greedy weighted max-coverage: iteracyjnie wybiera relaye dające
  najwięcej nowego pokrycia, aż do `PRODUCTIVE_RELAYS_TARGET` (≤3).

**Probe NIE batchuje kont i NIE stempluje znacznika pokrycia** — większość
kandydatów odpada, nie zobowiązujemy się ich śledzić. Watermark stemplujemy
dopiero dla **zwycięskich** relayów (patrz §5).

## 3. Role śledzonych kont (`tracked_pubkeys.role`)

- **primary** — zasubskrybowany jawnie (`subscribe_to_pubkey`). Jego `p`-tagi
  rozszerzają frontier (każdy oznaczony pubkey trafia jako secondary).
- **secondary** — wykryty z `p`-taga jakiegoś primary. Pobierany w pełni jak
  primary, ale jego `p`-tagi **nie** generują kolejnych wpisów (1-hop frontier).
- **ignored** — tombstone; live-suby zerwane, auto-tracker pomija.

Pętle w tle:
- **auto-tracker** — odpala discovery na wierszach `last_scan_at IS NULL`.
- **mention-sweep** — okresowy zbiorczy `{"#p":[primaries]}` po wszystkich
  relayach, łapie wzmianki, które wpadły poza `productive_relays`.

## 4. Filtry live per kierunek

Każde śledzone konto ma na relayu osobne filtry per **kierunek**:
- **`out`** — wpisy konta: `{"authors":[hex]}`.
- **`in`** — wzmianki o koncie: `{"#p":[hex]}` (tylko primary).

Secondary ma tylko `out`. Kierunki mają osobne EOSE i osobne pokrycie, bo niosą
różne dane — „mam wszystkie wpisy" nie znaczy „mam wszystkie wzmianki".

## 5. Znacznik pokrycia (`live_coverage`)

### Problem
`since` (od kiedy dopytać relay o nowości) nie może bazować na dacie **ostatniego
eventu** — konto mogło ucichnąć rok temu, a my i tak jesteśmy na bieżąco. Z samej
daty ostatniego eventu nie da się odróżnić „cisza, ale słuchamy" od „nas nie było".

### Rozwiązanie
Tabela `live_coverage(pubkey, relay_url, direction, covered_through)` — dokąd (w
sekundach) **wiemy**, że jesteśmy zsynchronizowani, **niezależnie od tego czy
przyszły jakieś eventy**.

Znacznik podbijany w trzech momentach:
1. **Gdy relay staje się produktywny** — po PHASE 2, zanim podepniemy live-sub,
   stemplujemy `covered_through = now` dla wybranych `(konto, relay, kierunek)`.
   Właśnie skończyliśmy solo dociąganie, więc jesteśmy na bieżąco — **nawet jeśli
   konto miało zero eventów**. To gwarantuje, że nowy członek dołącza do paczki z
   sensownym `since` (patrz §6).
2. **Na EOSE live-suba** — relay potwierdza „masz komplet do teraz".
3. **Cyklicznie + przy wyłączeniu** — flush co `COVERAGE_FLUSH_INTERVAL_S`
   (na teraz 10 min, docelowo ~1h) + finalny przy `stop()`.

### Pamięć vs baza
- W pamięci: flaga „skompletowany" per (relay, kierunek), zapalana na EOSE.
- Do bazy: **rzadko** (cykliczny flush + wyłączenie), jednym zbiorczym zapisem,
  tylko dla skompletowanych subów. DuckDB źle znosi tysiące drobnych zapisów, a
  baza i tak służy tylko do przeżycia restartu.
- Każdy zapis do bazy wypisuje na stdout znacznik
  `---------- ZAPIS DO BAZY, DO KIEDY JESTEŚMY ZSYNCHRONIZOWANI: <czas UTC>`.
- Po twardym crashu tracimy najwyżej jeden interwał flushu — nadrobimy przy
  starcie, powtórki odpadają po `id` eventu.

## 6. Wyliczanie `since` i 3-miesięczna podłoga

Przy zakładaniu (lub odnawianiu) nasłuchu `since` dla `(konto, relay, kierunek)`
liczy `_coverage_since`:

```
kandydat = covered_through − MARGINES        # gdy jest watermark
         | ostatni_event + 1                 # fallback przed pierwszym pokryciem
since    = max(kandydat, now − MAX_LOOKBACK)  # 3-miesięczna podłoga; nigdy None
```

- **Margines** (`COVERAGE_SINCE_MARGIN_S`, ~10 min) cofany **przy odczycie**, nie
  zapisywany — bufor na rozjazd zegarów i eventy spóźnione/poza kolejnością.
  Restart po restarcie nie kumuluje cofania. Powtórki odpadają po `id`.
- **Podłoga** (`MAX_LOOKBACK_S`, 90 dni): nigdy nie sięgamy wstecz dalej niż 3
  miesiące dla **kogokolwiek**. Dzięki temu `since` jest zawsze ograniczony i
  nigdy nie jest „brak" — co eliminuje potrzebę `limit` w filtrze (okno czasowe
  jest jedynym ogranicznikiem).

> Strefy czasowe nie istnieją w tej warstwie — czas to sekundy unix (jeden
> absolutny moment). Realne ryzyko to rozjazd zegarów, łatany marginesem.

## 7. Batchowanie REQ-ów

Zamiast jednego REQ na konto wysyłamy **jeden zagregowany REQ na (relay,
kierunek)** nad zbiorem wszystkich śledzonych kont. Trzy primary na jednym relayu
→ jeden `{"authors":[a,b,c]}` i jeden `{"#p":[a,b,c]}`, nie trzy osobne pary.

### `since` paczki
Jeden wspólny `since` na paczkę = **najwcześniejszy** z członków, z podłogą:
```
since_paczki = max( min(since członków), now − MAX_LOOKBACK )
```
Dzięki znacznikowi pokrycia „cisi-ale-pokryci" mają `since ≈ teraz`, więc nie
cofają okna całej paczce; a podłoga gwarantuje ograniczenie nawet w skrajnym
przypadku.

### Onboarding: solo zanim do paczki
Nowy członek **najpierw** dostaje dociągnięte staroci osobno (discovery PHASE 1),
a **dopiero potem** dołącza do paczki — gdy już wiemy, że mamy jego historię i
mamy dla niego znacznik (§5 pkt 1). Bez tego jeden świeży członek bez znacznika
zatruwałby `since` całej paczki. Kolejność jest naturalnie zachowana: discovery
dociąga w PHASE 1, podpina live-suby na końcu.

### EOSE na agregacie
„Komplet" dla zagregowanego REQ-a dotyczy **wszystkich** kont z paczki, więc
stempel pokrycia rozkłada się (fan-out) na każdego członka osobno.

### Czego NIE batchujemy
- **Probe (tryb B)** — testuje relaye dla jednego konta, kształt fan-out.
- Watermark dalej płynie tylko z trybu A (probe leci przez `req`/`_backfill_one`,
  nie przez `subscribe_sync`).

## 8. Weryfikacja i ślad pochodzenia

- Każdy event przechodzi `_verify_event` (zgodność `id` = sha256 serializacji,
  podpis schnorr). Odrzuty trafiają do `fake_events` (append-only forensics) i
  nabijają `relays.invalid_events_count` (1 = auto-disable relaya).
- `event_seen_on(event_id, relay_url)` — zbiór par „ten event przyszedł z tego
  relaya". To zbiór, nie audit log (bez timestampu).
- Eventy zastępowalne (kind 0 profil, 3 follows, 10002 relay list NIP-65) —
  wygrywa najnowszy `created_at`; starszy duplikat jest pomijany.
- NIP-65 (`r`-tagi z kind-10002) zasila `pubkey_relays(read, write)` — outbox/
  inbox autora; pełne zastąpienie listy przy każdym nowszym kind-10002.

## 9. Status implementacji

- ✅ Znacznik pokrycia (`live_coverage`), `_coverage_since`, EOSE→stempel,
  cykliczny flush + shutdown, druk na stdout.
- ✅ Filtry live per kierunek (`out`/`in`), rozdział probe vs śledzenie.
- ⏳ **Batchowanie** (§7) — w trakcie: agregacja per (relay, kierunek),
  `since=min` paczki, stempel-przy-produktywności, batch dociągania historii.
- ⏳ **3-miesięczna podłoga** + usunięcie `limit` z filtrów — w trakcie.
- 🔒 `limit`/`limit_per_relay` tymczasowo zostaje jako parametr/kolumna (probe
  go jeszcze używa jako próbki); pełne wycięcie to osobny krok sprzątający.
