fintech
article··updated ·6 min read

Zero is not missing data: modelling "measured", "not provided" and "unreadable"

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

In a scoring pipeline, an exception from a parser is a visible failure. The job fails and, if monitoring is set up, someone gets an alert. A parser that returns 0 because it couldn't read the input fails invisibly. The value has the right type and passes validation, so downstream rules treat it as a fact, and in credit scoring that fact can be favorable to the applicant: zero entries in a debtor registry, zero arrears, zero inquiries.

I ran into this while working on scoring. An external engine returned a summary saying the applicant had no entries in a debtor registry, while the raw registry response attached to the same call contained an active entry. The summary was wrong because the parser hadn't matched the response structure and fell back to defaults. Nothing in the logs pointed to a problem.

How the zero gets in

Mostly through code that's reasonable line by line. PHP makes it easy to replace "no value" with zero:

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

And through constructors with defaults:

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

Combine them, and a parser that misses the structure of a response (a new schema version, a different XML namespace, a renamed key) doesn't fail. It finds nothing. "Nothing" becomes the default value. Tests built on empty or minimal responses still pass, because for this parser every response looks empty.

The habit comes from treating null as a crash risk. Tony Hoare called the null reference his "billion-dollar mistake", and a lot of defensive code since then aims to make nulls disappear. Often you get a wrong number instead of an exception.

What an empty result can mean

For data describing a person or a company, "nothing" covers different situations:

  1. Measured, result is zero. The registry answered and has no entries for this person. You can score that.
  2. Not provided. The source was never requested or never arrived, so there's nothing to score yet.
  3. Unreadable. The source arrived and the parser couldn't interpret it. Something in the code or in the provider's format has changed, or the data arrived corrupted.

Only the first may become 0. The other two must reach the decision layer as distinct states, and a rule that depends on them should return "cannot assess" instead of a score.

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);
    }
}

The parser decides which state applies. It doesn't guess:

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);
    }
}

The rule handles every state explicitly. With match over an enum, a forgotten case raises UnhandledMatchError at runtime instead of falling through to a default:

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,
    };
}

The same distinction belongs in storage. A nullable column alone does not separate "not provided" from "unreadable"; store the state next to the value (for example negative_count integer null plus negative_count_status text not null), so reports and later analyses can filter on it.

This has a cost. Every consumer now has to handle three states, and the product needs a defined path for "cannot assess" (manual review, a request for documents, a retry). What you get for it is a business rule about missing data that someone actually wrote down. For fields where a missing value really is equivalent to zero, such as an optional discount, this machinery isn't needed.

Tests that catch a parser returning zeros

A test with an empty response proves nothing, since a broken parser passes it too. Use a real, anonymized response that contains a positive result, with an assertion that the result is found:

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);
}

Add a new fixture whenever the provider changes its schema. One non-empty sample per format is the kind of test that fails when a parser starts returning zeros. Happy-path tests on synthetic data usually don't, however many of them there are.

Checking fields across many cases

Another check needs no knowledge of the parser at all. Compare the same field across several reports about different subjects. If a field has the same value in every report, it's probably padding or a broken mapping. Typical candidates: a risk flag that is always set, a "has a registered business" marker that is always false, a category that appears in every report regardless of input.

/**
 * @param list<array<string, mixed>> $reports
 * @return list<string> fields with a single value across all reports (a missing field counts as null)
 */
function constantFields(array $reports): array
{
    if (count($reports) < 2) {
        return [];
    }

    $fields = array_keys(array_replace(...$reports));

    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, JSON_THROW_ON_ERROR),
            $reports,
        ))) === 1,
    ));
}

Run it on five to ten real cases before trusting a new data source.

Third-party extractions in general deserve the same treatment. Structured JSON with readable field names is someone's interpretation of the source, with their defaults and their mapping errors included. Before building conclusions on it (for example, "all obligations are non-bank"), compare a few cases with the raw source document.

end of node