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_token przez primary strategy;
  • org_token;
  • procurement_id, bid_id, contract_id;
  • range po submitted_at dla 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

  1. Documents i SourceRecords;
  2. Person/Organization/Address/Device;
  3. Procurement/Bid/Contract/Meeting;
  4. identity assertions;
  5. własność, role i adresy;
  6. proces zakupowy;
  7. observations;
  8. SUPPORT_BY/provenance;
  9. 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:

  1. odtwarzamy ostatni sprawdzony backup;
  2. odczytujemy manifest watermark;
  3. replayujemy events;
  4. wykonujemy invariants;
  5. porównujemy golden queries;
  6. 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.