Skip to main content

whyempty

Find why your filters returned nothing—and which changes would recover matches.

A typed Python toolkit to trace actual eliminations, reuse filtering rules, audit every failed condition, and explore alternatives from recorded decisions. Python 3.10+, MIT licensed, zero required runtime dependencies.

Install

python -m pip install whyempty==0.2.0

For a source checkout:

git clone https://github.com/giovannyTorres/whyempty.git
cd whyempty
python -m pip install -e .
python examples/basic_usage.py
python examples/audit_usage.py

Trace actual eliminations

The original 0.1 API works unchanged:

from whyempty import trace

records = [
    {"id": 1, "active": True, "country": "MX", "score": 40},
    {"id": 2, "active": False, "country": "MX", "score": 90},
    {"id": 3, "active": True, "country": "US", "score": 95},
]
result = (
    trace(records)
    .keep("Active", lambda row: row["active"])
    .keep("Mexico", lambda row: row["country"] == "MX")
    .keep("Score >= 80", lambda row: row["score"] >= 80)
)
print(result.explain())
assert result.items == ()

Example output; timings vary:

whyempty | Filter diagnostic
Initial records: 3
Filter             Before    Removed  Remaining   Retained   Time (ms)
----------------------------------------------------------------------
1. Active               3          1          2      66.7%       0.006
2. Mexico               2          1          1      50.0%       0.004
3. Score >= 80          1          1          0       0.0%       0.002
Result: EMPTY
All records were eliminated.
Final elimination: 3. Score >= 80
Significant reductions (at least 50% removed): 2. Mexico

Only survivors reach the next stage. Later zero-input stages are marked skipped, never blamed for an earlier elimination. Initially empty input is identified separately. Significant reduction means at least 50% removed from a nonempty stage; it is a descriptive threshold, not statistical significance.

Reuse rules and audit every failure

from whyempty import Pipeline

rules = (
    Pipeline()
    .keep("Active", lambda row: row["active"])
    .keep("Mexico", lambda row: row["country"] == "MX")
    .keep("Score >= 80", lambda row: row["score"] >= 80)
)
traced = rules.run(records)  # Sequential Trace, as above.
audited = rules.audit(records)  # Every rule on every occurrence.
print(audited.explain())

Use the same pipeline with different datasets. In typed applications use Pipeline[Record]() with your record type, such as a TypedDict; see the executable audit example.

Independent counts for the example:

Filter Evaluated Passed Failed Only blocker
1. Active 3 2 1 1
2. Mexico 3 2 1 1
3. Score >= 80 3 2 1 1

Only blocker counts occurrences failing that filter alone. Mexico passes two records independently, although only one reached it in the sequential trace. Inspect all failures of an occurrence using its zero-based original position:

assert [rule.name for rule in audited.failed_filters(0)] == ["Score >= 80"]
assert audited.filters[1].exclusive_failures == 1

audit() evaluates in input order and, within each occurrence, rule-definition order. Every predicate and its truth conversion execute once. It does not short-circuit: each predicate must be defined on every record. A first rule checking "score" in row does not protect a later row["score"] access during an audit. Use row.get("score", 0) if a default is your intended rule. Sequential run() continues to support guards protecting later filters.

Explore changes without rerunning predicates

change = audited.relax(3)  # Disable one-based filter index 3.
assert change.filter_names == ("Score >= 80",)
assert change.remaining == 1
assert change.rescued == 1
assert audited.matching_indices(without=(3,)) == (0,)

# Multiple identities are normalized to sorted unique indices.
assert audited.relax(3, 2, 3).filter_indices == (2, 3)
assert audited.relax(2, 3).remaining == 2
assert audited.is_empty  # Queries do not change the result.

remaining includes current matches; rescued counts additional matches. matching_indices() returns original positions; retain your input if you need the corresponding records. Names may repeat, so rule identities are one-based indices. Boolean/noninteger indices raise TypeError; out-of-range indices raise IndexError. Negative original positions are also rejected.

Projections are exact for the recorded decisions. Repeating them in another execution requires stable predicates and unchanged records. Prefer pure rules; queries never execute predicates or read record fields.

Find the smallest change that restores matches

suggestions = audited.suggest_relaxations(limit=3)
assert [entry.filter_indices for entry in suggestions] == [(1,), (2,), (3,)]
assert all(entry.rescued == 1 for entry in suggestions)

For an empty result with nonempty input, suggestions disable the fewest filters needed to recover at least one recorded match. Among equally small sets, more rescued records rank first, then filter indices lexicographically. The default limit is five and must be a positive integer.

This is minimum cardinality, not every set minimal by inclusion. If {A} recovers one record and {B, C} recovers ten, only {A} is a fewest-filter suggestion. Inspect other combinations explicitly with relax(). Empty input or an already nonempty result needs no suggestions.

Suggestions describe the supplied dataset, not logical incompatibility for all possible inputs. They do not automatically change your rules. The implementation groups observed failure sets into integer bitmasks instead of searching all 2^K subsets. There is no 64-filter limit.

API

API Behavior
trace(iterable) / Trace(iterable) Consume input once into an eager mutable trace.
Trace.keep(name, predicate) Keep truthy matches, record statistics, return the same trace.
Trace.items, .initial_count, .count, .is_empty Survivor tuple and counts.
Trace.steps, .first_empty_step Frozen Step snapshot and first emptying stage or None.
Trace.rejected() First-rejection metadata, in original order.
Pipeline[T]().keep(name, predicate) Configure reusable rules and return the same pipeline.
Pipeline.filter_names Tuple snapshot of configured names.
Pipeline.run(iterable) / .audit(iterable) Sequential Trace[T] or independent AuditResult[T].
AuditResult.items, .initial_count, .count, .is_empty References passing every rule and counts.
AuditResult.filters, .elapsed_seconds Frozen FilterStats and total audit time.
AuditResult.failed_filters(original_index) All failed filter statistics for one occurrence.
AuditResult.relax(*filter_indices) Frozen Relaxation with projected counts.
AuditResult.matching_indices(without=()) Original positions matching the selected combination.
AuditResult.suggest_relaxations(limit=5) Fewest-filter suggestions for an empty result.
Results: .explain() / .to_dict() Recorded diagnostics as text / JSON-compatible metadata.

Step: index, name, before, removed, remaining, retention_pct, elapsed_seconds, emptied, skipped, significant_reduction. Stage time includes partitioning and provenance, excluding formatting and Step creation.

FilterStats: index, name, tested, passed, failed, exclusive_failures, retention_pct. Audit time includes input iteration, evaluation, and outcome recording; it is not per-filter timing. Retention is None for zero input, serialized as null and displayed as N/A in sequential traces.

Rejection: original_index, step_index, filter_name. Relaxation: filter_indices, filter_names, remaining, rescued. All four models are frozen dataclasses with .to_dict().

Both kinds of keep() mutate and return their object. Tuples already retrieved remain snapshots. Each pipeline execution snapshots its rules before consuming input; configuration changes affect future runs. Survivor references are shared, not deep-copied. Labels must be nonblank printable strings. Repeated values and labels are supported.

Errors, rejected records, and privacy

from whyempty import FilterError, trace

pending = trace([{"score": 90}, {}])
try:
    pending.keep("Score >= 80", lambda row: row["score"] >= 80)
except FilterError as error:
    assert error.step_index == 1 and error.original_index == 1
    assert isinstance(error.__cause__, KeyError)
    assert pending.count == 2 and pending.steps == ()

Predicate and truth-conversion errors preserve their original cause. Failed trace stages commit no state; failed audits return no partial result. Side effects cannot be rolled back, and retrying an execution runs predicates again. BaseException and input-iteration failures propagate directly. Exceptions are never silently converted into rejections.

import json

payload = json.dumps(audited.to_dict(), allow_nan=False)
for rejection in result.rejected():
    print(rejection.original_index, rejection.step_index, rejection.filter_name)
    original_record = records[rejection.original_index]

Summaries omit original records, record representations, predicates, and per-occurrence decisions. Audit summaries include at most five suggestions. Diagnostics never call record repr() or print anything themselves. Labels are visible, so choose names suitable for logs. Tracebacks may retain and display original exception data.

Results retain survivor references and metadata, not predicates or rejected records. An object may remain referenced by another surviving occurrence, the caller, or a traceback. A Pipeline intentionally retains its configured callables. Retain your input if you need rejected values later.

Performance and storage

Measured with CPython 3.12.14 on Linux x86_64, using 12 rotating rounds of 20 operations per scenario. Times are medians in milliseconds:

Records Python sequential Trace Trace + explain Python all rules Audit Audit + queries
1,000 0.103 0.197 0.224 0.264 0.645 0.694
10,000 0.990 1.850 1.925 2.554 6.259 6.973
100,000 10.072 21.810 21.740 24.864 62.197 67.562

At 100,000 records, tracing costs 2.17× its sequential Python baseline. Auditing costs 2.50× the full-evaluation baseline, which also runs every rule for every record. Audit plus diagnostic queries takes 67.562 ms. Its peak allocation is 1,693.95 KiB versus 65.83 KiB for the full-evaluation baseline; tracing peaks at 6,763.54 KiB versus 776.84 KiB for sequential Python.

Auditing is more expensive because it evaluates all conditions and records positional failure masks and aggregate statistics. The smaller audit peak than trace in this three-rule workload reflects compact masks and one-pass recording; it is not a general memory guarantee. Full evaluation without observability stores only its final survivors, so its memory baseline is low.

Audit + queries builds a new audit, formats its explanation, calls relax(2), obtains matching_indices(without=(2,)), and asks for suggestions. This benchmark has surviving records, so suggestions return an empty tuple; it does not time ranking large collections of minimal failure patterns. Small differences such as trace plus explanation being slightly faster than trace alone reflect host noise. These synthetic results are not performance guarantees for other predicates, filter counts, or selectivity.

Reproduce the six-scenario benchmark from an installed checkout:

python benchmarks/benchmark.py --repeat 12 --number 20 \
    --json-output benchmarks/results.json

Equivalent sequential and full-evaluation Python baselines are measured. Timing runs without instrumentation; incremental tracemalloc peaks are measured separately with one live result. Peaks are not RSS or retained memory. Input construction and pipeline configuration are excluded. See the full methodology and samples. Historical 0.1.0 measurements are preserved separately.

Sequential filters evaluate stage survivors; audits make exactly N×K predicate calls. Audit metadata stores masks and their frequencies. relax() scans U observed patterns; matching_indices() scans N positions. Suggestions rank observed patterns with a bounded heap, without a 2^K search. Bitmask operations scale with the number of filter bits. See design and invariants.

Limitations and development

Finite inputs only. Traces materialize input; audits consume it in one pass but retain positional metadata. Pipelines and traces are mutable and not thread safe; reentrant Trace.keep() is rejected. Wide Unicode names may affect visual column alignment. There is no automatic comprehension capture, native Pandas/Polars integration, CLI, or distributed execution.

python -m pip install -e '.[dev]'
python -m pytest --cov=whyempty --cov-report=term-missing
python -m ruff check .
python -m ruff format --check .
python -m mypy
python -m build
python -m twine check dist/*

CI tests CPython 3.10–3.15 and requires at least 95% branch-inclusive coverage. It also checks distributions and isolated wheel installation. Publication uses a maintainer-triggered workflow; see CONTRIBUTING.md and the changelog.

Coffea cutflows describe cumulative array-based selections. more-itertools supplies iterable utilities, and IceCream inspects expressions. whyempty combines general predicates, positional provenance, independent audits, and cached filter-relaxation analysis. This is not a claim of novelty.

Metadata

Release files for whyempty 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for whyempty 0.2.0
File Size Uploaded Provenance
whyempty-0.2.0.tar.gz 48.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for whyempty 0.2.0
File Interpreter ABI Platform
whyempty-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 67.3 kB

Release files / whyempty-0.2.0.tar.gz

Download URL whyempty-0.2.0.tar.gz
Size 48.9 kB
Tags Source
SHA-256 checksum
How to use checksums
9102c3a627f11aa94e48351c25fbf768288666f62b67dff63c36d020b887484b
BLAKE2b-256 checksum
How to use checksums
a8289e081f93cbd2720779772aa0780912c2c6678eab53dd22a566c46d0558ba
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15

Release files / whyempty-0.2.0-py3-none-any.whl

Download URL whyempty-0.2.0-py3-none-any.whl
Size 18.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dd03af2ae9f181c4f5d0cad1dd857140f03b1dcb5b4b83f4f137503381f1e96c
BLAKE2b-256 checksum
How to use checksums
ffccddb65df5290016b45575b4de854785ada03f0ba50e568871e6907de8f3a6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15

Security for whyempty 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page