Skip to content

Return an empty array from chained vector lookups that select no rows - #589

Open
MaxGhenis wants to merge 3 commits into
masterfrom
fix-empty-vector-parameter-lookup
Open

MaxGhenis wants to merge 3 commits into
masterfrom
fix-empty-vector-parameter-lookup

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes

A chained vector parameter lookup that selects no rows raises once it reaches numeric leaves. The audit's witness is a tree with one leaf a.b.c = 0 and three empty key arrays:

node = ParameterNode(data={"a": {"b": {"c": {"2020-01-01": 0}}}})("2020-01-01")
none = np.asarray([], dtype=str)
node[none][none][none]
# IndexError: index 0 is out of bounds for size 0  (vectorial_parameter_node_at_instant.py:416)

After the empty selection every numeric child holds zero values. The "scalar per child" path, meant for 0-d and length-1 children, read the first value of each. That path now takes only 0-d and length-1 children. Length-0 children go to the existing N-element path, which stacks the children into a (K+1, N) matrix and returns a length-0 float array.

The independent review found two more cases in the same code, now fixed:

  • Unknown keys after an empty selection. A one-element key array broadcasts over zero rows. With the leaf fix alone (the first head, f8426179), a one-element unknown key after an empty selection returned an empty array, because the unknown-key check fired only when the result held NaN. It now also fires when any key index is the missing-child sentinel. A one-element unknown key after zero rows raises ParameterNotFoundError at a node or a leaf; master raised IndexError there.
  • Nodes (structured levels) after an empty selection. Master sent zero rows down the one-row path. A one-element key that named the alphabetically first child returned one row of NaN, and any other key raised IndexError. A key of two or more elements returned NaN rows only if every element named the first child. Zero rows now give zero rows at nodes as at leaves: a valid one-element key gives zero rows, and a key of two or more elements raises a shape-mismatch ValueError at a node (IndexError, with a shape-mismatch message, at a leaf).

Every change follows an earlier selection that left zero rows:

After a selection of zero rows Master This PR
empty key at a leaf (the audit's bug) IndexError empty float64 array
one-element valid key at a node one NaN row if the key names the first child; IndexError otherwise zero rows
one-element unknown key (node or leaf) IndexError ParameterNotFoundError
key of 2+ elements at a node NaN rows if every element names the first child; IndexError otherwise ValueError (shape mismatch)
key of 2+ elements at a leaf IndexError IndexError (shape-mismatch message)

Invariants

For any parameter tree of depth 1 to 3 whose siblings share a schema (the generator draws one width, 1 to 3, per depth), with numeric leaves, and any 0 to 20 paths through it:

  • Looking the paths up one level at a time with key arrays gives an array of shape (number of paths,). Each element equals the scalar lookup of its path. Zero paths give an empty array.
  • A key that names no child raises ParameterNotFoundError at any level, among valid keys for the selected rows, or alone when no row is selected.

Tests

  • test_fancy_indexing.py::test_empty_fancy_indexing_at_every_level (leaf, node and triple lookups on the existing fixture tree) and ::test_empty_fancy_indexing_on_a_single_path (the audit witness).
  • test_fancy_indexing.py::test_one_key_after_an_empty_selection: one-element keys after an empty selection, at a node and at a leaf.
  • New test_fancy_indexing_property.py: the two properties above, 300 examples each.

Before the fix, on master's policyengine_core/parameters:

test_fancy_indexing.py:            2 failed, 8 passed   (IndexError: index 0 is out of bounds for size 0)
test_fancy_indexing_property.py:   1 failed             (minimal: depth 2, one leaf, no paths)

Round 2 (after the independent review), the new tests on the first head f8426179:

test_fancy_indexing.py:            1 failed, 10 passed   (node[none][["b"]] had 1 row: assert 1 == 0)
test_fancy_indexing_property.py:   1 failed, 1 passed    (DID NOT RAISE ParameterNotFoundError)

After:

test_fancy_indexing.py:                   11 passed
test_fancy_indexing_property.py:           2 passed
test_parameters.py:                       14 passed
test_numpy2_structured_arrays.py:          4 passed
test_vectorial_parameter_node_copy.py:    64 passed

Ruff format and ruff check pass. I ran these files one at a time and left the full suite to CI, because the shared host is saturated.

Impact

Lookups that select at least one row are unchanged. The hub's independent review compared master and this head on 30,000 random trees of depth 1 to 4, including trees whose siblings have different schemas, broadcast keys, mismatched key lengths and unknown keys. It found 0 differences in the 26,303 cases that selected rows at every level. All 1,188 differences followed an earlier zero-row selection, and in each of them master raised IndexError. A caller that reduced master's NaN row at a node now reduces an empty array.

The review found two PE-US formulas whose masked key arrays can be empty (in_county_tax.py, co_ccap_fpg_eligible.py). Both are single-level lookups from a one-row root, a path this PR doesn't change. Core computes each formula over the whole population and masks afterwards, so a formula never looks up parameters for a zero-row subset.

A hub full-sample A/B run on PE-US's default dataset is waiting on a host disk reservation (the uk-data batch-3 build). The hub runs it after the lift and records the result here.

axiom: n/a: engine fix in vector parameter lookup, no policy change

Other audit findings, handled elsewhere

The 2026-10-06 invariants audit of master 757147c7 also confirmed defects that this PR leaves alone, because other work owns them:

Branch staleness after set_input and raw-file restore are documented behaviour, not defects.

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits October 6, 2026 12:57
A chained vector parameter lookup such as node[keys][keys][keys] with empty
key arrays raised IndexError once it reached numeric leaves: after the empty
selection each leaf held zero values, and the scalar-per-child path read the
first one. Leaves with one value still take that path; leaves with zero
values now take the N-element path, which yields an array of length zero.

Adds regressions at every level of the fixture tree and on the audit's
single-path witness, plus a Hypothesis property: for trees of depth 1 to 3
and 0 to 20 paths, the vector lookup returns one value per path, each equal
to the scalar lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the independent review of f842617 (one should-fix finding),
and a second defect the extended property found.

- After a selection of no rows, a one-element unknown key broadcast over
  those rows and returned an empty array, so the NaN check that raises
  ParameterNotFoundError never fired. The check now also fires when any
  key index is the missing-child sentinel, whatever the result holds.
- At a node (structured level), zero rows took the one-row path: a valid
  one-element key returned one row of NaN, and node[none][["b"]][["c"]]
  read NaN where it should give no rows (on master too). Zero rows now
  give zero rows at nodes as at leaves, broadcasting the key the same way
  (a key of two or more over zero rows raises a shape mismatch).

Adds a regression for both, and a Hypothesis property: a key naming no
child raises ParameterNotFoundError at any level, among valid keys for the
selected rows or alone after selecting none. Both fail on f842617.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the branch up to date with master (e34d329) so CI tests the current
combination. Clean merge; master changed none of this PR's files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant