Zbudujemy od zera graf, który odpowiada na praktyczne pytanie: czy osoby uczestniczące w decyzjach miejskich są pośrednio powiązane z organizacjami otrzymującymi zamówienia, granty lub preferencyjne decyzje?
Nie tworzymy fikcyjnego „algorytmu korupcji”. System znajduje ścieżki i motywy wymagające sprawdzenia: wspólnego pełnomocnika, historycznego pracodawcę, darowiznę, spotkanie, rodzinę, adres lub urządzenie użyte przy złożeniu dokumentu. Każda krawędź ma pochodzenie i czas.
Projekt używa Apache HugeGraph 1.7, Gremlin, HugeGraph-Loader i danych syntetycznych. Artykuł architektoniczny opisuje komponenty; tutaj podejmujemy konkretne decyzje implementacyjne.
Scenariusz: Nowe Miasto
Generator tworzy fikcyjne „Nowe Miasto”:
- 240 tys. mieszkańców;
- 31 tys. przedsiębiorstw i organizacji;
- 420 urzędników oraz 65 osób pełniących funkcje polityczne;
- 18 tys. postępowań zakupowych z pięciu lat;
- 92 tys. ofert;
- 21 tys. umów, aneksów i grantów;
- 1,8 mln wersjonowanych relacji;
- 14 kontrolowanych scenariuszy nieprawidłowości;
- tysiące legalnych podobnych układów jako hard negatives.
Liczby nie mają udawać konkretnego miasta. Dają rozkład, na którym można zmierzyć supernodes, ścieżki, false positives i import.
Co jest wynikiem projektu
Wynikiem nie jest tylko baza. Repozytorium wdrożeniowe powinno zawierać:
generator/
seed.yaml
truth.jsonl
source-errors.yaml
schema/
hugegraph-schema.groovy
loader/
mapping.json
queries/
Q01-direct-interest.gremlin
Q02-hidden-control.gremlin
Q03-shared-intermediary.gremlin
Q04-meeting-before-tender.gremlin
api/
tests/
manifests/
dashboards/
runbooks/
truth.jsonl opisuje zasiane motywy oraz legalne wyjaśnienia. Test może ocenić wynik, a nie tylko brak wyjątku.
Granica domeny
Graf zawiera dane potrzebne do analizy publicznego procesu:
- formalne role i własność;
- dokumenty zamówienia;
- deklaracje i jawne darowizny w zakresie scenariusza;
- służbowe kalendarze spotkań;
- reprezentację i pełnomocnictwa;
- adresy organizacji;
- techniczne metadane złożenia oferty.
Nie dodajemy poglądów, prywatnej korespondencji ani dowolnych znajomości. Relacja społeczna musi mieć obserwowalną definicję. ATTENDED oznacza obecność w udokumentowanym spotkaniu, nie „zna”.
Źródła syntetyczne
Generator bazowy, np. gorzow.co.pl, może dostarczyć realistyczne osoby, nazwy ulic i firmy. Dodatkowy generator domenowy tworzy:
- historię zatrudnienia;
- udziały i control chain;
- komitety oraz fundacje;
- postępowania, lots i bids;
- komisje oceniające;
- umowy, kwoty i daty;
- spotkania służbowe;
- pełnomocników;
- urządzenia i adresy IP użyte przy składaniu dokumentów;
- dokumenty będące źródłem facts.
Nie generujemy edges niezależnie. Najpierw powstaje ukryty model świata, potem źródła z opóźnieniami i błędami. Rejestr przedsiębiorstw może znać zmianę zarządu po dwóch dniach, a system zamówień używać starej nazwy.
Zasiane motywy
Motyw A: ukryta kontrola
Firma wygrywająca przetarg jest w 60% własnością spółki pośredniej, ta fundacji, a fundacja kontrolowana przez byłego wspólnika członka komisji.
Motyw B: pośrednik wielofunkcyjny
Ten sam pełnomocnik reprezentuje kilka formalnie konkurujących firm, a oferty wysłano z jednego urządzenia w krótkim czasie.
Motyw C: korzyść po decyzji
Osoba zatwierdza aneks, a kilka tygodni później powiązana fundacja otrzymuje darowiznę od beneficjenta.
Motyw D: legalna wspólność
Dziesiątki firm używają adresu biura rachunkowego i tego samego pełnomocnika. To hard negative; system ma obniżyć znaczenie popularnego węzła.
Motyw E: przypadkowe spotkanie
Urzędnik i oferent uczestniczą w otwartej konferencji z 800 osobami. Krawędź istnieje, ale jej waga informacyjna jest niska.
Bez D i E projekt byłby sztuczny: każda wspólna cecha wskazywałaby nieprawidłowość.
Model vertices
Person
person_token, birth_year, public_role
Organization
org_token, legal_form, sector, status
Procurement
procurement_id, authority_id, opened_at, procedure_type
Bid
bid_id, amount_minor, submitted_at, status
Contract
contract_id, amount_minor, signed_at
Meeting
meeting_id, starts_at, visibility, participant_count
Device
device_token, device_class
Address
address_token, address_kind, popularity_band
Document
document_id, source_system, hash, issued_at
SourceRecord
source_system, source_record_id, payload_hash
Kwoty są integerem w groszach. Person nie zawiera PESEL; person_token pochodzi z warstwy entity resolution.
Model edges
OWNS Person|Organization → Organization
CONTROLS Person|Organization → Organization
EMPLOYED_BY Person → Organization
MEMBER_OF Person → Organization
REPRESENTS Person|Organization → Organization
REGISTERED_AT Organization → Address
HAS_BID Procurement → Bid
SUBMITTED_BY Bid → Organization
EVALUATED_BY Procurement → Person
RESULTED_IN Procurement → Contract
AWARDED_TO Contract → Organization
ATTENDED Person → Meeting
USED_DEVICE Bid → Device
SUPPORTED_BY dowolny fakt → Document
IDENTIFIES SourceRecord → Person|Organization
Każda relacja domenowa ma valid_from, valid_to, recorded_at, source_record_id, assertion_type i confidence tylko dla inference.
Dlaczego Bid jest vertex
Oferta ma kwotę, czas, dokumenty, urządzenie, organizację i status. Zrobienie z niej edge utrudniłoby podłączanie wielu dowodów. Vertex Bid zachowuje zdarzenie biznesowe.
Analogicznie Meeting jest vertex, bo ma wielu uczestników i własny kontekst. Nie tworzymy pary MET między każdą dwójką z konferencji; przy 800 osobach powstałoby prawie 320 tys. par i fałszywa gęsta społeczność.
Popularność jako własność kontekstu
Adres lub pełnomocnik może być hubem. Codzienny job wylicza:
active_org_degree
active_person_degree
domain_percentile
computed_at
Query może odrzucić wirtualne biuro powyżej 99. percentyla albo tylko obniżyć wagę. Nie usuwamy węzła, bo może być istotny w innym motywie.
Uruchomienie środowiska
Development pinuje release/digest HugeGraph 1.7.0 i Java 11 zgodnie z dokumentacją. latest jest zabronione. Server nasłuchuje wyłącznie w sieci projektu; authentication jest włączone od początku.
Minimalne środowisko:
hugegraph-server 8 vCPU / 32 GB RAM / NVMe
loader worker 4 vCPU / 16 GB RAM
Kafka synthetic event replay
MinIO/object raw snapshots i documents
API tylko zatwierdzone queries
Prometheus metrics
To nie sizing produkcji. Ma umożliwić powtarzalny eksperyment.
Tworzenie schema
Fragment skryptu HugeGraph:
schema = graph.schema()
schema.propertyKey('person_token').asText().ifNotExist().create()
schema.propertyKey('org_token').asText().ifNotExist().create()
schema.propertyKey('valid_from').asDate().ifNotExist().create()
schema.propertyKey('valid_to').asDate().ifNotExist().create()
schema.propertyKey('source_record_id').asText().ifNotExist().create()
schema.propertyKey('amount_minor').asLong().ifNotExist().create()
schema.propertyKey('submitted_at').asDate().ifNotExist().create()
schema.vertexLabel('Person')
.properties('person_token')
.primaryKeys('person_token')
.ifNotExist().create()
schema.vertexLabel('Organization')
.properties('org_token')
.primaryKeys('org_token')
.ifNotExist().create()
schema.edgeLabel('OWNS')
.link('Person', 'Organization')
.properties('valid_from', 'valid_to', 'source_record_id')
.nullableKeys('valid_to')
.ifNotExist().create()
W prawdziwym schema OWNS może mieć dwa warianty source label: Person i Organization. Jeżeli wersja API nie pozwala jednemu EdgeLabel na oba zestawy w oczekiwany sposób, tworzymy PERSON_OWNS i ORG_OWNS lub wspólny label Actor. Decyzję potwierdza test na 1.7, nie założenie.
Indeksy
Minimalne indeksy:
- unique/secondary po
person_tokenprzez primary strategy; org_token;procurement_id,bid_id,contract_id;- range po
submitted_atdla Bid, jeżeli plan go wykorzystuje; - secondary po status/procedure tylko jeśli query zaczyna od selektywnej kombinacji.
Nie zaczynamy traversal od wszystkich Bid WHERE year=2026. Najczęściej startujemy od postępowania, organizacji lub osoby z indeksowanego ID.
Pliki wejściowe
persons.csv:
person_token,birth_year,public_role
P000001,1981,COMMITTEE_MEMBER
P000002,1974,NONE
owns.csv:
from_person,to_org,share_bp,valid_from,valid_to,source_record_id
P000002,O000771,6000,2025-01-01,,KRS:88412:v7
share_bp=6000 oznacza 60% w basis points. Float nie powinien decydować o progu kontroli.
Mapping Loadera
Konfiguracja Loader wskazuje źródło, header, label, ID i mapping. Uproszczony fragment:
{
"vertices": [{
"label": "Person",
"input": {"type": "file", "path": "persons.csv", "format": "CSV", "header": ["person_token", "birth_year", "public_role"]},
"id": ["person_token"],
"mapping": {"person_token": "person_token", "birth_year": "birth_year", "public_role": "public_role"}
}],
"edges": [{
"label": "OWNS",
"input": {"type": "file", "path": "owns.csv", "format": "CSV", "header": ["from_person", "to_org", "share_bp", "valid_from", "valid_to", "source_record_id"]},
"source": ["from_person"],
"target": ["to_org"],
"mapping": {"share_bp": "share_bp", "valid_from": "valid_from", "valid_to": "valid_to", "source_record_id": "source_record_id"}
}]
}
Dokładne nazwy pól zależą od formatu konfiguracji wydania; przykład jest częścią repozytorium i musi przechodzić test Loader 1.7. Dokumentacja produktu jest źródłem prawdy.1
Kolejność importu
- Documents i SourceRecords;
- Person/Organization/Address/Device;
- Procurement/Bid/Contract/Meeting;
- identity assertions;
- własność, role i adresy;
- proces zakupowy;
- observations;
- SUPPORT_BY/provenance;
- indeksy kosztowne do zbudowania po load, jeśli test wykazuje korzyść.
Po każdym etapie zapisujemy counts, rejects i checksum logiczny.
Test zerowy: czy fakt ma dowód
g.E().hasLabel('OWNS','CONTROLS','EVALUATED_BY','AWARDED_TO')
.not(has('source_record_id'))
.count()
Oczekiwany wynik to zero. Inne invariants:
- Bid ma dokładnie jednego SUBMITTED_BY;
- Contract ma jednego aktywnego AWARDED_TO;
- EVALUATED_BY nie wskazuje organizacji;
valid_to > valid_from;- brak SourceRecord bez source system.
Q01: bezpośredni konflikt interesów
Pytanie: członek komisji bezpośrednio posiada lub kontroluje oferenta w dniu zamknięcia.
g.V().has('Procurement','procurement_id', procurementId).as('p')
.out('EVALUATED_BY').as('person')
.outE('OWNS','CONTROLS')
.has('valid_from', lte(closedAt))
.filter(or(hasNot('valid_to'), has('valid_to', gt(closedAt))))
.inV().as('org')
.where(__.in('SUBMITTED_BY').in('HAS_BID').is(select('p')))
.select('person','org')
To precyzyjny faktowy motyw. Wynik zawiera ownership edge i dokument. Reguła może automatycznie zażądać ujawnienia konfliktu, ale nie rozstrzyga intencji.
Q02: ukryty łańcuch kontroli
g.V().has('Person','person_token', memberId)
.repeat(
outE('OWNS','CONTROLS')
.has('valid_from', lte(closedAt))
.filter(or(hasNot('valid_to'), has('valid_to', gt(closedAt))))
.inV().simplePath()
)
.emit().times(4)
.where(in('SUBMITTED_BY').has('Bid','procurement_id', procurementId))
.path().limit(50)
W realnym modelu Bid nie ma procurement_id property lub ma ją jako denormalizację; właściwa końcówka przechodzi in('SUBMITTED_BY').in('HAS_BID'). Golden test pilnuje semantyki.
Limit czterech kroków wynika z reguły projektu i kosztu. Nie twierdzimy, że piąty krok jest moralnie nieistotny; po prostu nie generujemy nieograniczonego skojarzenia.
Q03: wspólny pośrednik konkurentów
g.V().has('Procurement','procurement_id', procurementId)
.out('HAS_BID').as('b')
.out('SUBMITTED_BY').as('org')
.in('REPRESENTS').as('rep')
.group()
.by(select('rep'))
.by(select('org').dedup().fold())
.unfold()
.filter(select(values).count(local).is(gte(2)))
Do wyniku dołączamy stopień pełnomocnika poza postępowaniem. Jeżeli reprezentuje 900 firm jako duża kancelaria, sygnał jest słabszy. Jeśli dwie firmy użyły także jednego urządzenia w ciągu 12 minut i złożyły niemal identyczne kwoty, motyw się wzmacnia.
Q04: spotkanie przed zmianą specyfikacji
Szukamy spotkań zamkniętych między członkiem procesu a przedstawicielem zwycięzcy w oknie 30 dni przed istotną zmianą dokumentu.
g.V().has('Procurement','procurement_id', procurementId).as('proc')
.out('EVALUATED_BY').as('official')
.out('ATTENDED').has('Meeting','visibility','CLOSED')
.has('starts_at', between(changeAt.minusDays(30), changeAt)).as('meeting')
.in('ATTENDED').as('other')
.where(out('EMPLOYED_BY','REPRESENTS').
where(__.in('AWARDED_TO').in('RESULTED_IN').is(select('proc'))))
.path().limit(100)
Wynik jest wskazówką do sprawdzenia kalendarza i dokumentów. Legalna konsultacja rynkowa może wyglądać identycznie topologicznie. Meeting.visibility, agenda i lista wszystkich uczestników są kontrdowodem.
Motif score bez „oceny człowieka”
Można uszeregować sprawy, nie osoby:
case_priority =
5 × undisclosed_direct_interest
3 × hidden_control_path
2 × shared_submission_device_rare
2 × closed_meeting_in_window
1 × post_decision_transfer
-2 × registered_public_consultation
-2 × high_popularity_intermediary
Każdy składnik jest booleanem lub jawnie zdefiniowaną miarą. Wynik służy kolejce kontroli. Nie przenosimy go na osobę i nie publikujemy jako reputacji.
W przyszłym systemie silniejszej kontroli społecznej można rozszerzyć konsekwencje, ale automatyczny skutek powinien wynikać z konkretnego faktu formalnego, np. nieujawnionego udziału, a nie sumy luźnych skojarzeń.
Evidence bundle
API zwraca:
{
"case_id": "C-2027-00192",
"rule": "HIDDEN_CONTROL_PATH_V3",
"evaluated_at": "2027-02-14T12:00:00Z",
"graph_snapshot": "nm-2027-02-14-06",
"paths": [{
"vertices": ["P17", "O81", "O93", "O122"],
"edges": ["E991", "E1201", "E1207"],
"sources": ["KRS:...", "DOC:..."],
"valid_at": "2027-01-31"
}],
"counterevidence": ["DISCLOSURE:D-18"],
"limits": ["one edge inferred with confidence 0.82"]
}
Frontend może narysować graf, ale JSON jest trwałym wynikiem. Każdy edge można odtworzyć.
Warstwa API
Nie udostępniamy Gremlin użytkownikom aplikacji. Endpointy:
GET /procurements/{id}/direct-interests;GET /procurements/{id}/control-paths?depth=4;GET /procurements/{id}/shared-intermediaries;GET /cases/{id}/evidence;POST /cases/{id}/disposition.
API narzuca role, zakres sprawy, timeout, limit i audit. Query template jest wersjonowany wraz z kodem.
Aktualizacja strumieniowa
source outbox → Kafka
procurement.changed
bid.submitted
ownership.changed
meeting.recorded
│
▼
normalizer → identity service → HugeGraph writer
Writer zachowuje event ID i source version. Korekta ownership zamyka starą edge oraz tworzy nową. Late event nie nadpisuje stanu bez kontroli bitemporalnej.
Co noc reconciliation porównuje counts i losową próbkę źródła z grafem. Co tydzień można odbudować mały referencyjny graph od zera i porównać queries.
Algorytmy całego grafu
Na snapshot wyeksportowany do HugeGraph-Computer można uruchomić:
- weakly connected components po istotnych edge labels;
- community detection dla sieci organizacji i osób;
- personalized PageRank startujący od potwierdzonych przypadków;
- triangle/motif counts;
- degree percentiles do supernode classification.
Nie mieszamy ATTENDED masowej konferencji z OWNS w jednym PageRank. Każdy job ma edge whitelist i wagi.
Wynik community_run_2027_03 trafia do oddzielnego label/property i nigdy nie staje się faktem źródłowym.
Testy poprawności
Golden graph
Mały graf 50 vertices ma ręcznie oczekiwane ścieżki. Testuje kierunek, czas, cykle i supernode.
Mutation tests
- cofnięcie ownership usuwa wynik na późniejszą datę;
- dodanie wirtualnego biura nie podnosi wszystkich spraw;
- zmiana kolejności importu nie zmienia IDs;
- duplikat eventu nie tworzy edge;
- split osoby przebudowuje zależne paths.
Precision/recall
14 zasianych przypadków nie wystarczy do statystyki, więc generator tworzy setki wariantów. Osobno mierzymy direct conflict, hidden control, intermediary i meeting motif.
Performance
- p95 Q01 < ustalonego SLO;
- p99 Q02 przy supernode;
- visited edges budget;
- import records/s;
- catch-up lag;
- restore time.
Human review
Analityk widzi path oraz dokumenty, a także:
- popularność każdego wspólnego zasobu;
- fakt/inference;
- relacje wygasłe;
- uczestników spoza ścieżki;
- regułę i jej wersję;
- legalne wyjaśnienia z generatora.
Decyzje: confirmed_issue, explained, data_error, needs_documents, not_relevant. Wynik wraca do oceny reguły, nie jest automatycznie treningową etykietą osoby.
Bezpieczeństwo
Graf wpływu może ujawnić wrażliwe relacje. Stosujemy:
- osobne graphspace dev/test/prod;
- produkcyjne dane bez dostępu Hubble dla zwykłych analityków;
- service account tylko dla graph writera;
- mTLS i secret manager;
- blokadę arbitrary Gremlin;
- audit każdego evidence bundle;
- watermark eksportu;
- limity paths;
- object-store documents przez osobny authorization check.
Source document może mieć wyższą klauzulę niż sama edge. API pokazuje, że dowód istnieje, ale pobiera go dopiero po osobnej decyzji.
Failure i rebuild
Graf nie jest systemem ewidencyjnym. Po utracie RocksDB:
- odtwarzamy ostatni sprawdzony backup;
- odczytujemy manifest watermark;
- replayujemy events;
- wykonujemy invariants;
- porównujemy golden queries;
- dopiero włączamy ruch.
Przy zmianie schema budujemy nm-v2 równolegle. Shadow API wykonuje część zapytań na v1 i v2, porównując evidence sets.
Przejście do HStore
Standalone nie powinien być bezmyślnie zastępowany klastrem. Najpierw mierzymy:
- czy storage/working set rzeczywiście przekracza host;
- jaki udział paths przekracza partycje;
- koszt PD/Store;
- rebalance po dodaniu node;
- failure jednego Store;
- różnicę p99;
- backup i RTO.
Generator tworzy relacje między dzielnicami, aby benchmark nie partycjonował zbyt łatwo po regionie.
Co uznajemy za sukces
Projekt jest gotowy, gdy:
- wszystkie zasiane motywy mają oczekiwane evidence;
- legalne hubs nie zalewają kolejki;
- każdy wynik da się odtworzyć ze źródeł;
- entity split jest obsłużony;
- nowy schema powstaje blue–green;
- utrata bazy nie oznacza utraty prawdy;
- API blokuje kosztowne zapytania;
- zespół potrafi wyjaśnić false positive;
- HugeGraph można porównać z TuGraph na neutralnych wynikach.
Droga do produkcji w Polsce
Możemy utrzymywać generator, schema i query library bez realnych danych. Zespół szkoli się na analizie przypadków, mierzy błędy i uczy obsługi HugeGraph.
Po zmianie prawa i neutralizacji AI Act/RODO w pożądanym zakresie źródła syntetyczne są zastępowane adapterami rejestrów. Najpierw działa shadow mode bez skutków. Entity resolution oraz rarity statistics są kalibrowane na realnej populacji.
Na szczęście polityczna decyzja nie musi poprzedzać testu infrastruktury, Loader, Gremlin, HStore, restore i evidence API. Nie wolno tylko udawać, że progi z syntetycznego Nowego Miasta są gotowe dla realnych ludzi.
Wnioski
Naturalny projekt HugeGraph wykorzystuje jego mocne strony: jawny schema, deterministyczne IDs, Gremlin do traversal, Loader do initial load, event writer do zmian, Computer do snapshot OLAP i Hubble wyłącznie do kontrolowanej eksploracji.
Analiza wpływu nie jest oceną przez skojarzenie, jeśli wynik pozostaje konkretną ścieżką z czasem i dowodem, popularne hubs są jawnie osłabiane, a legalne wyjaśnienia wchodzą do modelu. Taki system może znacząco zwiększyć zdolność państwa do kontroli zamówień, jednocześnie dając lepszą rozliczalność niż ręczne przeglądanie tabel.