Drugi projekt praktyczny nie powtarza analizy zamówień. Budujemy system, który wykrywa skoordynowane składanie wniosków o świadczenia i rekompensaty przez sieć gospodarstw, pełnomocników, rachunków, telefonów oraz urządzeń.
Celem jest znalezienie spraw wymagających kontroli: wielu formalnie niezależnych osób korzystających z jednego rzadkiego urządzenia, rachunki krążące między gospodarstwami, seryjne pełnomocnictwa i wnioski wypełniane tym samym wzorcem. System musi jednocześnie rozpoznać legalnych pośredników, wspólne terminale urzędowe, rodziny i domy opieki.
Używamy TuGraph Community 4.5.2, OpenCypher, offline importu oraz kontrolowanej procedury. Artykuł o TuGraph wyjaśnia LMDB i granice edycji; tutaj powstaje działający projekt.
Dlaczego ten problem pasuje do TuGraph
Typowe zapytanie zaczyna się od jednego Application albo Person i przechodzi po lokalnym sąsiedztwie do czterech kroków. Graf syntetycznego województwa mieści się na jednym dużym serwerze, więc lokalny storage i page cache mogą dać niską latency bez partycjonowania.
Cypher dobrze opisuje motywy, a procedura C++ może policzyć bardziej złożony, ograniczony subgraph bez wielu round-trips. WCC/Louvain pomagają znaleźć grupy kandydatów na snapshot, ale nie podejmują decyzji.
Scenariusz: województwo Warta
Generator tworzy:
- 1,15 mln syntetycznych osób;
- 510 tys. gospodarstw w czasie;
- 2,4 mln wniosków z czterech programów;
- 680 tys. rachunków;
- 1,9 mln telefonów i urządzeń;
- 44 tys. pełnomocników, pracowników pomocy i opiekunów;
- 160 mln zdarzeń logowania/składania zagregowanych do relacji;
- 60 tys. wyników płatności;
- 240 zasianych sieci nadużyć;
- 20 tys. legalnych struktur podobnych topologicznie.
Surowych logowań nie wkładamy bez końca do OLTP graph. Pipeline agreguje je do czasowych edges per application/device/session window, a pełny log pozostaje w lakehouse.
Cztery programy, cztery reguły
Wniosek mieszkaniowy, dopłata energetyczna, wsparcie opiekuńcze i rekompensata kryzysowa mają różne definicje gospodarstwa i dochodu. Nie tworzymy uniwersalnego eligible.
Graf znajduje relacje wspólne, ale ocena sprawy używa program_code, rule_version i daty. Ta sama osoba może legalnie być pełnomocnikiem w jednym programie i wykluczonym beneficjentem w innym.
Co jest „koordynacją”
Nie każdy wspólny element jest podejrzany. Motyw ma kilka niezależnych sygnałów w krótkim oknie:
8+ gospodarstw
├─ wnioski w ciągu 90 minut
├─ ten sam rzadki device fingerprint
├─ 3 rachunki o wspólnym właścicielu/transferach
├─ jeden niezgłoszony pełnomocnik
└─ podobny zestaw błędów lub załączników
Publiczny kiosk również wygeneruje wspólne urządzenie i czas, lecz ma Device.kind=PUBLIC_TERMINAL, lokalizację urzędu oraz zgłoszone sesje assistance. Legalne biuro pomocy ma formalnego pełnomocnika. Te cechy działają jako kontrdowody.
Zasiane przypadki
Sieć urządzeń
Operator kontroluje 40 syntetycznych tożsamości. Wnioski trafiają z trzech urządzeń, ale telefon i adresy różnią się. Sam fuzzy matching osób nie wystarczy.
Rotujące rachunki
Świadczenia trafiają na kilka kont, a następnie przelewy zbierają środki w jednym rachunku. Część rodzin legalnie używa rachunku opiekuna — jest hard negative.
Fałszywe gospodarstwa
Te same osoby występują w różnych gospodarstwach zależnie od programu i daty. Graf musi stosować temporal membership.
Masowy pełnomocnik
Pośrednik składa setki wniosków. Jeśli jest zarejestrowanym pracownikiem socjalnym, to legalne. Jeśli występuje tylko przez device i rachunek, wymaga sprawdzenia.
Wspólny budynek
Dom opieki i akademik tworzą wielkie komponenty przez adres. Adres budynku nie może sam tworzyć gospodarstwa.
Model źródłowy i ground truth
Generator najpierw tworzy ukrytą prawdę:
- osoby;
- gospodarstwa z okresami;
- legalnych przedstawicieli;
- własność urządzeń i rachunków;
- zasiane role operatorów;
- publiczne terminale;
- sprawy, które spełniają lub nie spełniają reguł programu.
Następnie produkuje źródła z błędami:
- rejestr mieszkańców;
- system wniosków;
- bankowe potwierdzenia rachunków;
- log sesji;
- pełnomocnictwa;
- płatności;
- korekty i odwołania.
truth_network_id nigdy nie trafia do grafu analitycznego. Jest wyłącznie w zestawie testowym.
Model vertices
Person(person_token, birth_year, status)
SourceRecord(source_key, source_system, payload_hash)
Household(household_id, program_code)
Application(application_id, program_code, submitted_at, status)
Device(device_token, kind, trust_class)
Phone(phone_token, kind)
Account(account_token, bank_class)
Address(address_token, address_kind, unit_known)
Representative(rep_token, registration_status)
Payment(payment_id, amount_minor, paid_at)
Document(document_id, hash, document_type)
Representative może być rolą Person zamiast osobnego vertex. W projekcie jest osobny, gdy identyfikacja pośrednika pochodzi z innego rejestru i może wskazać osobę lub organizację. Edge RESOLVES_TO wiąże go z zatwierdzoną encją.
Model edges
IDENTIFIES SourceRecord → Person
MEMBER_OF Person → Household
SUBMITTED Person → Application
FOR_HOUSEHOLD Application → Household
USED_DEVICE Application → Device
USED_PHONE Application → Phone
PAYS_TO Application → Account
REPRESENTED_BY Application → Representative
REGISTERED_AT Person|Household → Address
RESULTED_IN Application → Payment
PAID_TO Payment → Account
TRANSFERRED_TO Account → Account
ATTACHED Application → Document
RESOLVES_TO Representative → Person
SUPPORTED_BY edge/fact → SourceRecord|Document
W TuGraph edge nie może wskazywać edge. Provenance zapisujemy jako properties source_key, document_id, a jeżeli dowód ma własne relacje, reifikujemy fakt jako vertex Assertion.
Czas
MEMBER_OF, REGISTERED_AT, PAYS_TO i REPRESENTED_BY mają valid_from, valid_to, recorded_at. USED_DEVICE ma observed_at i session_id.
Query gospodarstwa na dzień wniosku:
MATCH (a:Application {application_id:$id})-[:FOR_HOUSEHOLD]->(h:Household)
MATCH (p:Person)-[m:MEMBER_OF]->(h)
WHERE m.valid_from <= a.submitted_at
AND (m.valid_to IS NULL OR a.submitted_at < m.valid_to)
RETURN p.person_token, m.source_key
```
Stan dzisiejszy nie może zastąpić stanu w dniu decyzji.
## Entity resolution przed grafem
Warstwa [entity resolution](entity-resolution-tozsamosc-czas-i-pochodzenie-danych.html) wydaje `person_token`. Nie scalamy ludzi po wspólnym telefonie ani rachunku; te zasoby są relacjami.
Candidate merge z confidence 0,83 trafia jako osobny SourceRecord/assertion i nie może automatycznie połączyć dwóch gospodarstw. Analiza może pokazać ścieżkę zależną od inference, wyraźnie ją oznaczając.
## Schema TuGraph
Uproszczona sekcja konfiguracji `lgraph_import`:
```json
{
"schema": [
{
"label": "Person",
"type": "VERTEX",
"primary": "person_token",
"properties": [
{"name":"person_token","type":"STRING","index":true,"unique":true},
{"name":"birth_year","type":"INT16","optional":true},
{"name":"status","type":"STRING"}
]
},
{
"label": "Application",
"type": "VERTEX",
"primary": "application_id",
"properties": [
{"name":"application_id","type":"STRING","index":true,"unique":true},
{"name":"program_code","type":"STRING","index":true},
{"name":"submitted_at","type":"DATETIME"},
{"name":"status","type":"STRING"}
]
},
{
"label": "SUBMITTED",
"type": "EDGE",
"constraints": [{"source":"Person","destination":"Application"}],
"properties": [{"name":"source_key","type":"STRING"}]
}
]
}
```
Dokładny schema jest walidowany przez `lgraph_import` 4.5.2. Przykład dokumentuje intencję; CI uruchamia import małej próbki i porównuje wynik `dbms.graph.getGraphSchema()`.[^1]
## Pliki importu
`applications.csv`:
```csv
application_id,program_code,submitted_at,status
A000001,ENERGY,2027-01-12 09:15:22,PAID
```
`used_devices.csv`:
```csv
application_id,device_token,observed_at,session_id,source_key
A000001,D991,2027-01-12 09:14:51,S887,PORTAL:LOG:991827
```
Device token jest HMAC lub losowym identyfikatorem z kontrolowanego systemu, nie surowym fingerprintem publikowanym operatorowi.
## Offline import
Kolejność:
1. Persons, Households i Representatives;
2. Devices, Phones, Accounts, Addresses, Documents;
3. Applications i Payments;
4. identity/household edges;
5. application edges;
6. transfer edges zagregowane do dozwolonego okna;
7. indexes;
8. counts i invariants.
Import działa do nowego katalogu `warta-v1`. Nie nadpisujemy aktywnej bazy. Po testach zmieniamy symlink/config/router w kontrolowanym deploy.
## Invariants po imporcie
```cypher
MATCH (a:Application)
WHERE NOT (a)<-[:SUBMITTED]-(:Person)
RETURN count(a)
```
Oczekujemy zera, poza jawnie oznaczonymi wnioskami organizacyjnymi.
Inne testy:
- każdy Payment ma jeden RESULTED_IN origin;
- każdy Device ma `kind`;
- brak MEMBER_OF z `valid_to <= valid_from`;
- Account bez właściciela jest oznaczony, nie domyślnie scalony;
- publiczne terminale mają wpis rejestru;
- źródła użyte w edges istnieją w immutable archive.
## Q01: rzadkie urządzenie, wiele gospodarstw
```cypher
MATCH (h:Household)<-[:FOR_HOUSEHOLD]-(a:Application)
-[u:USED_DEVICE]->(d:Device)
WHERE a.submitted_at >=$from
AND a.submitted_at < $to
AND d.kind <> 'PUBLIC_TERMINAL'
WITH d, collect(DISTINCT h.household_id) AS households,
collect(DISTINCT a.application_id) AS applications,
min(a.submitted_at) AS first_at,
max(a.submitted_at) AS last_at
WHERE size(households) >= 6
RETURN d.device_token, households, applications, first_at, last_at
LIMIT 200
```
Query nie dowodzi koordynacji. `Device.trust_class`, liczba wszystkich użytkowników i geolokalizacja klasowa są potrzebne do interpretacji.
## Q02: pełnomocnik i wspólny rachunek
```cypher
MATCH (a:Application)-[:REPRESENTED_BY]->(r:Representative),
(a)-[:PAYS_TO]->(acc:Account),
(a)-[:FOR_HOUSEHOLD]->(h:Household)
WHERE a.submitted_at >=$from
WITH r, acc, collect(DISTINCT h.household_id) AS hs,
collect(DISTINCT a.application_id) AS apps
WHERE size(hs) >= 4
RETURN r.rep_token, r.registration_status,
acc.account_token, hs, apps
Zarejestrowany opiekun może legalnie odbierać środki kilku osób. Wynik musi pokazać rodzaj programu, dokument pełnomocnictwa i status rachunku.
Q03: powrót środków do operatora
MATCH (a:Application)-[:RESULTED_IN]->(p:Payment)-[:PAID_TO]->(start:Account)
MATCH path=(start)-[t:TRANSFERRED_TO*1..3]->(end:Account)
WHERE p.paid_at >= $from
AND ALL(x IN relationships(path)
WHERE x.transferred_at >= p.paid_at
AND x.transferred_at < p.paid_at + duration('P14D'))
WITH end, collect(DISTINCT a.application_id) AS apps,
collect(path) AS paths
WHERE size(apps) >= 5
RETURN end.account_token, apps, paths
LIMIT 100
```
Wersja i zakres temporal arithmetic w TuGraph trzeba przetestować; jeśli konkretna funkcja duration nie jest wspierana, API wylicza granice i podaje `$until`. Nie piszemy zapytania zależnego od funkcji innego Cypher bez compatibility test.
## Q04: zbyt wiele gospodarstw jednej osoby
```cypher
MATCH (p:Person)-[m:MEMBER_OF]->(h:Household)
WHERE m.valid_from <= $at
AND (m.valid_to IS NULL OR$at < m.valid_to)
WITH p, collect(DISTINCT h) AS hs
WHERE size(hs) > 1
RETURN p.person_token, [x IN hs | x.household_id]
To nie zawsze błąd. Różne program_code mogą mieć odmienne definicje. Reguła musi grupować tylko wzajemnie wykluczające się membership types.
Query motywu z kontrdowodem
Końcowy endpoint nie sumuje samych dodatnich cech. Buduje strukturę:
{
"signals": [
{"type":"SHARED_RARE_DEVICE","count":11},
{"type":"COMMON_ACCOUNT","count":7},
{"type":"BURST_90_MIN","count":9}
],
"counterevidence": [
{"type":"REGISTERED_REPRESENTATIVE","status":"EXPIRED"},
{"type":"PUBLIC_TERMINAL","matched":false}
],
"paths": [],
"source_snapshot":"warta-2027-03-01"
}
Analityk widzi, dlaczego kontrdowód nie wyłączył sprawy: rejestracja pełnomocnika wygasła przed wnioskami.
Procedura case_network_v1
Wydajna procedura może przyjąć application_id, zakres czasu, maksymalną głębokość i limity. Pseudokod C++:
Result CaseNetwork(GraphDB& db, Params p) {
auto txn = db.CreateReadTxn();
auto start = FindApplication(txn, p.application_id);
Budget budget{.max_vertices=5000, .max_edges=20000};
Expand(start, {"USED_DEVICE", "PAYS_TO", "REPRESENTED_BY"}, budget);
ExpandMatchedApplications(p.from, p.to, budget);
FilterPublicTerminals();
AttachSourceKeys();
return BuildEvidenceBundle();
}
Procedura nie zapisuje score i nie wykonuje dowolnego traversal. Zwraca błąd BUDGET_EXCEEDED z liczbą odwiedzonych elements. Source jest reviewowany i podpisany.
Cypher pozostaje referencyjną implementacją na małym grafie. Test porównuje zestaw paths procedury z Cypher. Optymalizacja nie może zmienić semantyki.
WCC: candidate components
Na snapshot tworzymy graf roboczy tylko z wybranych edges:
- Person–Household aktywne;
- Application–Device niepubliczne;
- Application–Account;
- Representative bez aktywnej rejestracji;
- transfery w oknie 14 dni.
WCC znajduje komponenty. Nie używamy Address edges, bo budynki zlepiają region. Komponent powyżej progu trafia do kolejnego etapu.
WCC odpowiada „co jest połączone”, nie „czy popełniono nadużycie”. Wielki komponent może ujawnić pośrednika legalnego albo błąd tokenizacji.
Louvain i community detection
W dużym komponencie Louvain może znaleźć gęstsze grupy. Edge weights:
same rare device within 1h 5
same payout account 5
same unregistered rep 3
transfer within 14d 2
same exact unit address 1
same building 0.1
public terminal 0
Wagi są hipotezą, wersjonowaną i testowaną. Community ID nie jest dowodem ani stabilnym identyfikatorem. Wynik joba zachowuje snapshot_id, parametry i seed/determinism info.
Subgraph extraction zamiast algorytmu na całym kraju
Pełny graf zawiera 160 mln zdarzeń. Do analizy sprawy ekstraktor wybiera:
- okno 90 dni;
- program lub zgodne programy;
- 4 kroki;
- tylko istotne edge labels;
- degree cap dla adresów i telefonów publicznych;
- wszystkie source keys użytych edges.
Mały subgraph trafia do UI i może zostać zachowany w aktach. Nie eksportujemy miliona sąsiadów banku.
Priority score sprawy
priority = 4 × shared_rare_device
+ 4 × common_payout_account
+ 3 × transfer_convergence
+ 2 × unregistered_representative
+ 2 × time_burst
- 5 × verified_public_terminal
- 4 × valid_assistance_case
- 2 × institutional_address
Score porządkuje sprawy, nie obywateli. Próg może zależeć od capacity zespołu kontrolnego. Automatyczne wstrzymanie wypłaty wymaga oddzielnej formalnej reguły opartej na konkretnym konflikcie, nie tylko priority.
Precision i recall
240 zasianych sieci dzielimy przed strojeniem:
- train/dev do projektowania;
- validation do wyboru progów;
- sealed test, którego generator seed nie jest znany autorom reguł.
Mierzymy:
- network-level recall: ile zasianych sieci wykryto;
- case precision: jaki udział alertów dotyczy zasianej sieci;
- person collateral rate: ilu legalnych ludzi wciągnięto do podgrafu;
- median/95p component size;
- review minutes per confirmed case;
- false positives per 10 tys. wniosków;
- wynik per typ legalnego pośrednika.
Pairwise accuracy byłaby myląca, bo prawie wszystkie możliwe pary są negatywne.
Testy trudnych negatywów
Generator celowo tworzy:
- urząd z terminalem publicznym;
- dom opieki z jednym rachunkiem opiekuna;
- rodzinę wielopokoleniową;
- księgową składającą legalne wnioski firm;
- wieś ze współdzielonym NAT;
- literówkę łączącą dwa telefony;
- wspólny adres bez lokalu;
- opóźnione unieważnienie pełnomocnictwa.
Reguła, która wykrywa tylko pozytywy bez tych przypadków, nie nadaje się nawet do shadow mode.
Odwołanie i korekta
Osoba może zakwestionować:
- membership gospodarstwa;
- własność rachunku;
- przypisanie device;
- pełnomocnictwo;
- entity merge;
- datę ważności.
Korekta źródła publikuje event. Graph writer zamyka starą relację, tworzy nową i oznacza dotknięte evidence bundles. System ponownie liczy sprawy i zachowuje różnicę.
Analityk nie „usuwa krawędzi” bezpośrednio w Browser. Składa correction assertion ze źródłem.
Incremental writer
TuGraph adapter otrzymuje canonical events i wykonuje idempotentne upserts. Pair-unique edge index można użyć tylko dla relacji, których semantyka jest naprawdę unikalna.
Event order per Application jest kontrolowany przez partition key. Late application.corrected ma source version. Co noc writer zapisuje offset manifest, a reconciliation porównuje losowe sprawy.
Zimny start i SLO
Po restarcie LMDB page cache jest zimny. Runbook:
- start bazy bez traffic;
- sprawdzenie schema;
lgraph_warmup/wspierany mechanizm;- kanoniczne lookups;
- kanoniczne 4-hop queries;
- dopiero readiness;
- stopniowy ruch.
SLO oddziela cold recovery od steady state. RTO obejmuje warmup, nie tylko uruchomienie procesu.
Backup i rebuild
Codzienny lgraph_backup daje szybsze odtworzenie, ale prawdą pozostają źródła i event archive. Raz w miesiącu budujemy graph od zera:
- export canonical snapshot;
lgraph_importdo nowego katalogu;- replay od watermark;
- invariants;
- golden Cypher;
- porównanie alertów;
- canary API;
- switch.
To testuje zarówno disaster recovery, jak i brak ukrytej zależności od starego LMDB.
Community availability model
W Community awaria hosta przerywa usługę. Możemy utrzymywać:
- backup na oddzielnym storage;
- ciepły host z binariami i konfiguracją;
- snapshot/event archive;
- DNS/router failover po restore;
- read-only ostatni bezpieczny wynik w aplikacji.
Nie udajemy synchronous HA. Jeżeli RTO jest zbyt długie, porównujemy Enterprise Raft albo HugeGraph HStore.
Monitoring
Dashboard zawiera:
- p95/p99 per query template;
- procedure visited vertices/edges;
- active transactions;
- page faults i disk latency;
- import/event lag;
- application/device/account degree percentiles;
- wielkość WCC;
- alerts per 10 tys. applications;
- public-terminal suppressions;
- backup age i restore test age;
- correction-driven case changes.
Skok degree jednego Device do 100 tys. częściej oznacza błąd tokenizacji niż genialne odkrycie.
Bezpieczeństwo
Role:
ingest_writer— mutacja danych, bez procedures admin;case_api— read i zatwierdzone procedure;analyst— aplikacja sprawowa, bez dowolnego Cypher;graph_engineer— konsola w bastionie;procedure_deployer— dwie osoby i podpis;auditor— evidence i log bez mutacji;backup_operator— narzędzia bez normalnego query.
Device/account tokens są pseudonimami. Reidentyfikacja wymaga osobnego serwisu i podstawy. Eksport podgrafu ma watermark oraz case ID.
Porównanie z projektem HugeGraph
Projekt HugeGraph analizuje wpływ wokół zamówień i preferuje Gremlin oraz ścieżki kontroli. Ten projekt:
- ma większy, lecz lokalny graph;
- preferuje Cypher patterns;
- używa Community single-node;
- pokazuje procedurę C++;
- używa WCC/Louvain do candidate networks;
- mierzy collateral rate wokół gospodarstw;
- skupia się na czasowych urządzeniach i rachunkach.
To dwa różne benchmarki. Nie ogłaszamy zwycięzcy po porównaniu latency różnych pytań.
Droga do polskiej produkcji
Projekt może dziś działać w naszym zapleczu eksperckim w całości na danych syntetycznych. Generator tworzy osoby, gospodarstwa i błędy, a sealed truth umożliwia uczciwe testy.
Po zdobyciu mandatu politycznego i neutralizacji ograniczeń AI Act/RODO można dołączać realne programy po jednym. Pierwsze miesiące to shadow mode. System znajduje sieci, lecz nie zmienia wypłat. Eksperci mierzą różnicę między syntetycznym a rzeczywistym rozkładem.
Następnie technologia może wspierać manual review, a konkretne, dobrze zweryfikowane reguły — automatyczne działania. Na szczęście silna kontrola społeczna nie wymaga nieprzejrzystej czarnej skrzynki. Graf może pokazać każdą ścieżkę i kontrdowód.
Wnioski
TuGraph dobrze pasuje do lokalnego, intensywnie traversowanego grafu wniosków, urządzeń, gospodarstw i rachunków. Cypher czytelnie opisuje motywy, C++ procedure ogranicza round-trips, a WCC/Louvain tworzą kandydatów na snapshot.
Projekt jest realistyczny dopiero dzięki legalnym look-alikes: terminalom publicznym, opiekunom, domom zbiorowym i wspólnym NAT. Bez nich model nauczyłby się, że każda społeczność jest nadużyciem.
Ostateczny produkt nie jest listą podejrzanych osób. Jest kolejką spraw z wersjonowanym evidence bundle, kontrdowodami, źródłami, możliwością korekty i mierzalnym false-positive cost. To właśnie pozwala wykorzystać graf do skutecznej kontroli bez technicznego chaosu.