fintech
artykuł··5 min czytania

Zero to nie brak danych: stany „zmierzono”, „nie dostarczono” i „nie odczytano”

#php #data-quality #null-handling #credit-scoring #testing

W pipeline scoringowym wyjątek z parsera to awaria widoczna: job pada, ktoś dostaje alert. Parser, który zwraca 0, bo nie umiał odczytać danych, to awaria niewidoczna. Wartość ma właściwy typ, przechodzi walidację, a reguły dalej traktują ją jak fakt. W ocenie kredytowej ten fakt bywa korzystny dla wnioskodawcy: zero wpisów w rejestrze dłużników, zero opóźnień, zero zapytań.

Przykład: zewnętrzny silnik zwraca podsumowanie, że wnioskodawca nie ma wpisów w rejestrze dłużników, a surowa odpowiedź rejestru dołączona do tego samego wywołania zawiera aktywny wpis. Podsumowanie jest błędne, bo parser nie trafił w strukturę odpowiedzi i użył wartości domyślnych. W logach nic nie wskazuje na problem.

Skąd się bierze zero

Najczęściej z kodu, który linijka po linijce jest rozsądny. PHP ułatwia zamianę „braku wartości” na zero:

$debts  = $response['summary']['count'] ?? 0;
$amount = (int) $row['amount'];   // null → 0, "" → 0, "abc" → 0
$total  = array_sum($items);      // [] → 0

Do tego konstruktory z wartościami domyślnymi:

final class RegistryResult
{
    public function __construct(
        public int $negativeCount = 0,
        public float $negativeAmount = 0.0,
    ) {}
}

Po złożeniu tych elementów parser, który nie trafi w strukturę odpowiedzi (nowa wersja schematu, inny namespace XML, zmieniona nazwa klucza), nie zgłasza błędu. Nic nie znajduje, a „nic” dostaje wartość domyślną. Testy oparte na pustych albo minimalnych odpowiedziach przechodzą, bo dla takiego parsera każda odpowiedź wygląda na pustą.

Ten nawyk bierze się z traktowania null jako ryzyka wywrotki. Tony Hoare nazwał referencję null swoim „błędem za miliard dolarów” i sporo kodu defensywnego od tamtej pory ma po prostu sprawić, żeby null zniknął. Efekt bywa gorszy: głośną awarię zastępuje cicha, błędna odpowiedź.

Trzy znaczenia słowa „brak”

W danych opisujących osobę albo firmę pusty wynik może znaczyć trzy różne rzeczy:

  1. Zmierzono, wynik to zero. Rejestr odpowiedział i wpisów nie ma. To jest informacja.
  2. Nie dostarczono. O źródło nie zapytano albo nie przyszło. To jest luka.
  3. Nie odczytano. Źródło przyszło, ale parser go nie zinterpretował. To jest awaria.

Tylko pierwszy przypadek może stać się 0. Dwa pozostałe muszą dojść do warstwy decyzyjnej jako osobne stany, a reguła, która od nich zależy, powinna zwrócić „nie da się ocenić” zamiast wyniku.

enum Presence
{
    case Measured;
    case NotProvided;
    case Unreadable;
}

final readonly class Feature
{
    private function __construct(
        public Presence $presence,
        public ?float $value,
    ) {}

    public static function measured(float $value): self
    {
        return new self(Presence::Measured, $value);
    }

    public static function notProvided(): self
    {
        return new self(Presence::NotProvided, null);
    }

    public static function unreadable(): self
    {
        return new self(Presence::Unreadable, null);
    }
}

Parser rozstrzyga, który stan zachodzi, i nie zgaduje:

final class RegistryParser
{
    public function negativeCount(?array $response): Feature
    {
        if ($response === null) {
            return Feature::notProvided();
        }

        $count = $response['summary']['count'] ?? null;

        if (!is_int($count) || $count < 0) {
            return Feature::unreadable();
        }

        return Feature::measured($count);
    }
}

Reguła obsługuje każdy stan jawnie. match po enumie przy pominiętym przypadku rzuci w trakcie działania UnhandledMatchError, zamiast przejść do wartości domyślnej:

enum Decision
{
    case Pass;
    case Reject;
    case CannotAssess;
}

function debtorRegistryRule(Feature $negatives): Decision
{
    return match ($negatives->presence) {
        Presence::Measured => $negatives->value > 0 ? Decision::Reject : Decision::Pass,
        Presence::NotProvided, Presence::Unreadable => Decision::CannotAssess,
    };
}

To samo rozróżnienie powinno trafić do bazy. Sama kolumna nullable nie odróżni „nie dostarczono” od „nie odczytano”. Zapisuj stan obok wartości (np. negative_count integer null i negative_count_status text not null), żeby raporty i późniejsze analizy mogły po nim filtrować.

Koszty: każdy konsument musi teraz obsłużyć trzy stany, a produkt potrzebuje ustalonej ścieżki dla „nie da się ocenić” (ręczna weryfikacja, prośba o dokumenty, ponowienie zapytania). To dodatkowa praca i o to chodzi: decyzja o brakujących danych przestaje być przypadkową wartością domyślną i staje się jawną regułą biznesową. Dla pól, w których brak naprawdę oznacza zero, np. opcjonalnego rabatu, ta konstrukcja jest zbędna.

Testy, które wyłapią parser zwracający zera

Test na pustej odpowiedzi niczego nie dowodzi, bo zepsuty parser go przejdzie. Przydatny fixture to prawdziwa, zanonimizowana odpowiedź z pozytywnym wynikiem i asercja, że ten wynik został znaleziony:

public function test_detects_entry_in_registry_response(): void
{
    $json = file_get_contents(__DIR__ . '/fixtures/registry_with_active_entry.json');
    $response = json_decode($json, true, flags: JSON_THROW_ON_ERROR);

    $feature = (new RegistryParser())->negativeCount($response);

    $this->assertSame(Presence::Measured, $feature->presence);
    $this->assertGreaterThan(0, $feature->value);
}

public function test_unknown_structure_is_unreadable_not_zero(): void
{
    $feature = (new RegistryParser())->negativeCount(['v2' => ['items' => []]]);

    $this->assertSame(Presence::Unreadable, $feature->presence);
}

Przy każdej zmianie schematu u dostawcy dodaj nowy fixture. Jedna niepusta próbka na format wyłapie więcej niż wiele testów happy path na syntetycznych danych.

Sprawdzanie pól na wielu przypadkach

Drugi sposób nie wymaga znajomości parsera. Porównaj to samo pole w kilku raportach o różnych osobach. Pole, które w każdym raporcie ma tę samą wartość, to najpewniej wypełniacz albo zepsute mapowanie, a nie pomiar. Typowi kandydaci: flaga ryzyka zawsze ustawiona, znacznik „prowadzi działalność gospodarczą” zawsze na false, kategoria obecna w każdym raporcie niezależnie od danych wejściowych.

/**
 * @param list<array<string, mixed>> $reports
 * @return list<string> pola z jedną wartością we wszystkich raportach
 */
function constantFields(array $reports): array
{
    if (count($reports) < 2) {
        return [];
    }

    $fields = array_keys($reports[0]);

    return array_values(array_filter(
        $fields,
        static fn (string $field): bool => count(array_unique(array_map(
            static fn (array $report): string => json_encode($report[$field] ?? null),
            $reports,
        ))) === 1,
    ));
}

Uruchom to na pięciu do dziesięciu prawdziwych przypadkach, zanim zaufasz nowemu źródłu danych. Rozkład wartości pola mówi o jego jakości więcej niż jego nazwa.

To samo dotyczy cudzych ekstrakcji w ogóle. Uporządkowany JSON z czytelnymi nazwami pól to czyjaś interpretacja źródła, razem z cudzymi wartościami domyślnymi i błędami mapowania. Zanim zbudujesz na nim wnioski (np. „wszystkie zobowiązania są pozabankowe”), porównaj kilka przypadków z surowym dokumentem źródłowym.

Lista kontrolna

  • Wyszukaj ?? 0, rzutowania (int) i liczbowe wartości domyślne w konstruktorach parserów i DTO.
  • Modeluj „zmierzono”, „nie dostarczono” i „nie odczytano” jako osobne stany, w kodzie i w bazie.
  • Reguły dla dwóch ostatnich stanów mają zwracać „nie da się ocenić”, a proces musi określać, co dalej.
  • Trzymaj co najmniej jeden niepusty, zanonimizowany fixture na każdy format źródła.
  • Sprawdź rozkład wartości na prawdziwych przypadkach, zanim oprzesz się na polu.
koniec artykułu