Skip to content

feat(search): state the provider scope of a DAC1 total ODA part - #166

Merged
jm-rivera merged 15 commits into
devfrom
feat/search-dac1-provider-scope
Oct 28, 2026
Merged

jm-rivera merged 15 commits into
devfrom
feat/search-dac1-provider-scope

Conversation

@jm-rivera

@jm-rivera jm-rivera commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Merge and deploy this only after DevelopmentFinanceObservatory/ai#90, the harness change that reads provider:contributions, and DevelopmentFinanceObservatory/ai#91, which keeps a ratio's accepted DAC1 total from moving when a comparison aligns it, are live on dev. A harness without it reads the new option as unknown and words the contributions_counted reason as a conflict.

A DAC1 total ODA part carried no provider record, because provider_rule in src/search/scope/provider.py served only the CRS bilateral table. A CRS bilateral health figure was divided by DAC1 total ODA, which also counts core contributions to multilaterals. PUBLISHED_PROVIDER_SCOPES in code_index/scope_conventions.py now declares a provider scope for two DAC1 aid types. Total ODA (1010) holds provider:contributions and bilateral ODA (1015) holds provider:bilateral. Each offers the other as a switch through a slot binding. provider:inclusive is unavailable on the total with reason contributions_counted, since the total already counts core contributions. A stated "bilateral" in a fresh search keeps the total and reports the entry not_served, and a switch reaches bilateral ODA.

The sibling option is judged per donor and per year. A year is covered when every donor that holds the part's own total that year also holds the sibling. When the sibling covers only part of the years the answer holds it is partial, its label states the covered run ("bilateral flows only, 2016 to 2017"), and its binding carries that run as SlotBinding.window, a new optional field the harness ignores as unknown. On a latest window the sibling pins the year its donors share: available when that is the total's year, partial with that year otherwise, and unavailable when the donors share none.

A part gets the record only when its provider counts as a donor: a country, the EU institutions (DONOR_ORGANISATIONS), or a declared group made only of countries. Multilateral organisations and totals that include them get none, and so does a pool of the EU institutions with a state that was a member during the part's window (_pools_funder), which would count the member's EU contribution twice.

A record names a convention's rule only when the request matched that convention in this call and the part holds its scope. Otherwise it names the served option's scope rule from PROVIDER_SCOPE_RULES (bilateral_flows, bilateral_plus_imputed). A resubmit matches no convention, so several CRS expectations in tests/scope/test_provider.py move from named_donor_total. No dfo-ai code reads rule. A record never holds an option it marks unavailable.

A resubmit that switches several axes at once judges each requested option on the identity the other requested edits produce (_requested_tables in src/search/scope/build.py), so switching measure basis and provider together compiles as the two switches one after the other. Before, the DAC1 sibling was judged on the old basis and refused as option_not_covered. An option that conflicts with another requested option makes no edit, so the conflict rule still decides that pair.

Spec_ids do not change. The scope rule tables are hashed into the declarations, so the capability version moves to cap_9594f5ce0957914a, and 82 capability_version pins in tests/fixtures/first_answer_place_scenarios.json move with it. Nothing else in those snapshots changed.

Testing: tests/scope/test_provider.py (127), test_compile.py, test_parsed_door.py, tests/engine/test_dataset_resubmit.py and test_first_answer_place_scenarios.py pass, as do the rest of tests/scope and the graph_release format and publish tests. test_a_dac1_record_never_holds_an_option_it_marks_unavailable checks every table. ruff, ty and pre-commit are clean.

A DAC1 total counts core contributions to multilaterals as the donor's ODA, so it now carries a provider choice whose one available option is provider:contributions ("bilateral plus core contributions to multilaterals"). Bilateral and inclusive are unavailable with reason counts_core_contributions, and DAC1's own bilateral line is never offered as bilateral. Spec_ids do not change; the scope rule tables' hash moves the capability version.
…her dataset codes

The provider convention reads DAC1_TOTAL_ODA from declarations.py, so no election module names a declared dataset's codes.
…ateral donors

PUBLISHED_PROVIDER_SCOPES maps DAC1 total ODA to contributions and DAC1 bilateral ODA to bilateral, for donors outside the declared multilateral providers. A stated inclusive provider on a DAC1 total drops with contributions_counted, since the total already counts core contributions. ProviderScope is declared once, contributions is an explicit no-op edit, and the settled record names the rule that set its option. Search's whole-request test and README paragraph live here.
A convention whose scope the part does not hold no longer names the record; it reads bilateral_flows, so a fresh search and a resubmit agree.
…ep each record consistent

A DAC1 total or bilateral ODA part gets a provider record only for a country, the EU institutions, or a declared group of countries, and never for a pool of an EU member with the EU institutions. On a DAC1 total, bilateral binds DAC1 bilateral ODA and is available to a switch; inclusive is unavailable with contributions_counted. Each option names its own rule, so a fresh search and a resubmit record the same rule, and the record never holds an option it marks unavailable. The provider axis has one option builder, and the published table is read once per identity.
…es a request matched

A DAC1 total's bilateral option and bilateral ODA's contributions option are available only where a series covers the part's donor, basis and window. A provider record falls back to an available option on every table. A record names a convention's rule only when the request matched it in this call; otherwise each option names its own scope rule from PROVIDER_SCOPE_RULES, bilateral_flows or bilateral_plus_imputed. Availability objects are per option, the convention lookup is memoised, and AxisTable checks its held rule.
…lot options to published tables

A sibling DAC1 option serves the years every donor in the part holds, read per basis segment on the headline, and is partial or unavailable otherwise. When a convention matched, a record falls back to the held option before bilateral. Slot-bound provider options are allowed only on published tables, published tables skip the CRS convention lookup, and the pass and the build share one composite predicate.
…erves

A recorded bilateral on a base holding the inclusive composite now
strips the composite, and a fallback names the served option's rule. A
partial sibling total binds its covered run of years as the window
(SlotBinding.window), a latest window counts a donor holding any year,
and sibling and held years come from one coverage scan per family. A
pool loses its record only when it pairs the EU institutions with an
EU member, read from declared membership. AxisTable.rule derives from
rules.
… holds

A pool's sibling option compared its coverage with the years every
donor held, so one donor holding none made the option fully available
and a switch understated the pool. It now compares with the union of
the donors' years, and a partial sibling states its covered run in its
label.
…year

A year counts as covered when every donor holding the part's own total
that year holds the sibling too, so a donor with no own total places
no demand. On a latest window the sibling elects the year its pool
shares and pins it: available when it matches the total's year,
partial with that year otherwise, unavailable when the donors share
none. donor_years reads only the window's years.
A latest sibling whose elected year no donor demands is unavailable
rather than raising, and a part whose own total holds no year judges
the sibling per donor. The EU-institutions double-count guard reads
membership over the part's window (policy_members_between), so a pool
with the United Kingdom before 2020 gets no record. The
SlotBinding.window description covers the latest case.
…quested edits produce

A resubmit switching measure basis and provider together judged the DAC1
sibling on the old basis and refused it as option_not_covered, though the
same switches one after the other compiled. Each explicit entry is now
judged on the axis table read with the other explicit entries' edits
applied; options that conflict with another requested option make no edit,
so the conflict rule still decides that pair.
@jm-rivera
jm-rivera merged commit 21e7f54 into dev Oct 28, 2026
28 of 30 checks passed
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