Skip to content

fix: keep filters when pushing projections into imports (#792) - #793

Merged
mmarx merged 3 commits into
knowsys:mainfrom
larsw:fix/792-filter-import-projection
Jul 31, 2026
Merged

fix: keep filters when pushing projections into imports (#792)#793
mmarx merged 3 commits into
knowsys:mainfrom
larsw:fix/792-filter-import-projection

Conversation

@larsw

@larsw larsw commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #792.

Summary

Both symptoms in #792 — a filter silently doing nothing, and an index-out-of-bounds panic — come from one place: push_projections_and_filters in nemo/src/rule_model/pipeline/transformations/filter_imports.rs, which pushes a
rule's projection and its filters into the import it reads from. Two separate mistakes there could separate a filter from the column it needs.

My report guessed the fault was in StackProgram::evaluate / the reference map. That was wrong — those are downstream victims, and nemo-physical is not touched by this PR.

1. The projection did not count filter-only variables

Columns to read were chosen from the head variables plus the variables of operations that bind something (variable_assignment()). A variable used only by a boolean filter matches neither, so its column was marked SKIP — while the filter that reads it was pushed into that same import. The filter then referred to a column that had never been read:

p(?s) :- triple(?s, <http://example.org/label>, ?v), STRSTARTS(?v, "al") .
thread 'main' panicked at nemo-physical/src/function/evaluation.rs:220:36:
index out of bounds: the len is 2 but the index is 2

Index 2 was the skipped column. Every variable an operation mentions now keeps its
column alive.

2. Rules carrying filters were removed as pure projections

A rule whose head and body atoms are all variables was treated as a projection the import can perform itself, and dropped. The check never asked whether the rule carried any operations, so its filters were discarded with it and the rule matched everything:

p(?v) :- triple(?s, ?q, ?v), STRSTARTS(?v, "al") .   % also returned "beta"

A rule with body operations is no longer considered obsolete. This is also why the filter never appeared in the execution plan: by planning time the rule was gone.

3. Adjacent: obsolete_rules was drained in the wrong order

obsolete_rules is collected in ascending index order and was drained with rules.remove(idx) in that same order, so every removal after the first took the wrong element. Now drained in reverse. Unnoticed because the surrounding conditions rarely make two rules obsolete at once; found while reading, not by a failing test.

Behaviour

Filter STRSTARTS(?v, "al") over two imported triples, expecting only "alpha":

body atom filtered var in head before after
triple(?s, <const>, ?v) yes correct correct
triple(?s, <const>, ?v) no panic correct
triple(?s, ?q, ?v) yes "alpha", "beta" correct
triple(?s, ?q, ?v) no both rows correct

An unsatisfiable filter (STRSTARTS(?v, "zz")) returned every row before and returns nothing now.

Tests

resources/testcases/regression/filter_import_projection/ — five cases, verified to fail on v0.10.0 and on main before this change (three wrong answers, one panic). kept is a control that passed before and still passes.

One rule per case, deliberately: several rules with different head predicates trip
same_head_predicate and disable the import pushdown altogether, which hides the
bug. My first attempt at these tests passed on an unpatched build for exactly that reason.

cargo test --workspace: 458 passed, 0 failed. cargo fmt --check and cargo clippy -p nemo clean.

Questions

This fix is conservative: a filtered column is now read even though it is not projected, so the pushdown optimises slightly less in precisely those cases. I took correctness over the optimisation on the grounds that a filter which cannot see its input is not an optimisation.

The alternative is to keep the skip and remap the filter's references onto the post-skip layout — with_skips already does this renaming, so the machinery exists; it would need the projection and the filter to agree on which columns
survive. That is a larger change and a design call that is yours, so I did not attempt it.

Happy to rework this if you would prefer that direction.

@github-project-automation github-project-automation Bot moved this to Todo in nemo Jul 28, 2026
@mmarx
mmarx self-requested a review July 28, 2026 10:57
`push_projections_and_filters` pushes a rule's projection *and* its filters
into the import it reads from. Two things there could separate a filter from
the column it needs.

First, the projection decided which columns to read by looking at the head
variables plus the variables of operations that bind a variable
(`variable_assignment()`). A variable used only by a boolean filter matched
neither, so its column was marked SKIP while the filter that reads it was
pushed into the same import. The filter then referenced a column that had
never been read:

    p(?s) :- triple(?s, <http://example.org/label>, ?v), STRSTARTS(?v, "al") .

    thread 'main' panicked at nemo-physical/src/function/evaluation.rs:220:36:
    index out of bounds: the len is 2 but the index is 2

Now every variable an operation mentions keeps its column alive. This is
conservative — a filtered column is read even though it is not projected —
but a filter that cannot see its input is not an optimisation.

Second, a rule whose head and body atoms are all variables was treated as a
pure projection and removed, on the grounds that the import can do the
projection itself. The check never asked whether the rule carried any
operations, so the rule's filters were discarded with it and the rule matched
everything:

    p(?v) :- triple(?s, ?q, ?v), STRSTARTS(?v, "al") .   % returned "beta" too

A rule with body operations is no longer considered obsolete.

Also fixes an adjacent defect in the same block: `obsolete_rules` is collected
in ascending order and was drained with `rules.remove(idx)` in that same order,
so every removal after the first took the wrong element. It is now drained in
reverse. No test covered this because the surrounding conditions rarely make
two rules obsolete at once.

Adds regression tests under
resources/testcases/regression/filter_import_projection/ — one rule per case,
deliberately: several rules with different head predicates disable the import
pushdown entirely and hide the bug. Four of the five fail before this change
(two by returning unfiltered rows, one by returning everything for an
unsatisfiable filter, one by panicking); `kept` is a control that passed
before and still passes.
@mmarx
mmarx force-pushed the fix/792-filter-import-projection branch from 7a0acf9 to 2149d1c Compare July 31, 2026 19:59
@mmarx
mmarx enabled auto-merge July 31, 2026 21:01
@github-project-automation github-project-automation Bot moved this from Todo to In Progress in nemo Jul 31, 2026
@mmarx
mmarx merged commit 0da9a40 into knowsys:main Jul 31, 2026
8 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in nemo Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Built-in filters in rule bodies are silently dropped, or panic, depending on atom shape

2 participants