Skip to content

feat(search): report the scope choices each answer settled, and count by the OECD's conventions - #163

Merged
jm-rivera merged 37 commits into
devfrom
feat/scope-choices
Oct 27, 2026
Merged

jm-rivera merged 37 commits into
devfrom
feat/scope-choices

Conversation

@jm-rivera

Copy link
Copy Markdown
Contributor

Search now reports, for each part it answers, the scope choices it settled on the sector, region and measure-basis axes, with every option the part could have been counted on, each option's availability and reason, and the rule or user words that chose the active one. The record is ResolvedSpec.scope_choices (src/search/models/scope.py), built after dataset election by pipeline._with_scope_choices from the src/search/scope/ package: per-axis rules in sector.py, region.py and basis.py, a pure compile that derives defaults after the explicit and carried entries, and declared tables in code_index/scope_conventions.py registered in the graph release declarations.

The defaults change what several questions return. "Health" binds CRS 120 and 130 together. A part summing over a recipient region binds the region's members plus the regional and unspecified buckets the served dataset publishes, and a graph region with a DAC regional total (sub-Saharan Africa, Asia, Europe, the Middle East, the African subregions, South America) reads as the OECD counts it on DAC data; rankings, listings and other datasets keep the graph place. The measure basis follows one convention table: a donor total from 2018 in grant equivalents, before 2018 in net flows, a recipient total in net flows, and a sector, instrument or modality part in gross disbursements. These figures match the OECD's published cells to within 0.02% on the rows checked, and the gross-or-net question the harness used to ask drops from 62.6% to 3.3% of definite aid answers.

A client switches an option by posting the stored base identity with scope_entries through dataset_resubmit; one entry per axis, a switch replaces the entry on its axis, and switching back returns the original spec_id. Only a resubmit refuses, with the two codes the spec-kit PR registers. A fresh search never refuses on scope: a stated option nothing serves is reported under dropped_entries. The parsed request accepts user_message and carried_entries, and the parsed cache keys on the scope they state.

Reviewers should challenge the convention table in scope_conventions.py, the region reading in phase_intake.oecd_reading and place_roles._with_region_residuals, and the entity-word rule in phase_rank_bind._resolved_name_words.

Merge order: the spec-kit registry PR first, then re-pin dfo-spec-kit here to its merge commit; this PR and the dfo-ai scope-choices PR merge together, since the harness sends scope_entries and reads the new record.

Testing: targeted suites (tests/scope, stated scope, place settling, qualifier guard, departures, first-answer scenarios, render, test_corpus_offline) pass locally; the full suite runs on CI. Live against the dev graph on a local server: OECD comparisons for US 2019, sub-Saharan Africa and Asia, switch round trips, eight Gates-to-Senegal trials, and cold latency over 25 aid probes at +26 ms p50 and +124 ms p90.

Search can now report how each part was counted. ResolvedSpec gains an
optional scope_choices record: the part's base identity, the explicit and
carried entries, one choice per settled axis with its options and their
availability, and the compiled identity. A pure compile derives every
default after the stated entries, never moves an explicit or carried
entry, and refuses a selection it cannot serve.

The dataset_resubmit door accepts scope_entries on an identity entry,
compiles them over the posted identity and returns the compiled spec,
refusing with requested_option_unavailable or
conflicting_requested_options (422). The sector-grouping and
measure-basis declaration tables join the declarations hash. No axis is
live yet, so every response is unchanged. The spec-kit pins move to the
commit that registers the two codes.
Parsed requests may carry the user's own message and the conversation's
carried scope entries. A deterministic, negation-aware scanner turns
stated provider and measure-basis words into explicit entries and notes
"total" intent; a single-part request reads the message, a multi-part
request reads each part's own text. Entries reach every part before the
binders run and apply only to an axis the part settled (a sector entry
only to its concept). The raw-text cache key includes the stated scope,
so "bilateral" and plain queries never share an entry.
A part that sums over a recipient region binds the region's members
plus the regional and unspecified buckets the served dataset publishes,
and reports a region scope choice (members and residuals, or members
only). Listings, rankings and "African countries" bind members only.
Residuals are left out of every member count, ranking and unavailable
entity by party kind, and the read-as-members departure names what the
data allocates to no single country. A switch through dataset_resubmit
round-trips to the first spec_id.
# Conflicts:
#	src/search/README.md
#	src/search/engine/region/pipeline.py
…oice

A declared sector grouping turns its concept words into the grouping's
code set with declared_grouping provenance: "health" binds CRS 120 +
130 and "humanitarian aid" binds 720, 730 and 740. A code the text
names keeps its binding and states an explicit choice. The sector rule
reads the base identity alone, so search and dataset_resubmit offer the
same options: the grouping and each member code, with option_not_covered
marking an option no covered member declares.
# Conflicts:
#	src/search/README.md
#	src/search/scope/build.py
#	src/search/scope/rules.py
#	tests/scope/test_build.py
…esh search's scope

Both live axes register in rules.AXIS_RULES. build_part_scope applies the
entry a binder read (AxisRecord.entry) by one rule: it joins the posted
entries when the part's table on its axis holds the option the record
settled, which covers an axis the answer does not reach and a base that
no longer holds the binder's option. ScopeFacts keeps one shape, with
the part's operations, groups and datasets in PartFacts.

Carried entries reach read_sector and region_selection through
ScopeState.entries_on; parsed-door tests pin a carried sector:120 on a
"health aid" part and a carried region:members_only on a summed region.

On the parsed and raw_text doors build_part_scope runs with refuse
false: an entry compile refuses is dropped into dropped_entries
(carried_choice_not_applied for a carried entry, the new
stated_choice_not_applied for a stated one, with the refusal's cause),
and the defaults derive without it. The 422s stay on dataset_resubmit
scope_entries, so the request pointers ScopeIntake carried for them are
gone.

Option labels are standalone noun phrases: "health incl. population and
reproductive health (CRS 120 + 130)", "health, CRS 120 alone", "Africa
incl. regional and unspecified amounts", "Africa's countries only". The
templates sit in code_index.scope_conventions.OPTION_LABELS and feed the
rule version; the region is named from the part's group, else its total.

The capability pins in the first-answer snapshot and the render
fixtures are re-recorded.
The basis follows the question's shape and window: grant equivalents
for a donor-only total from 2018 (the OECD headline, flows 10 and 60),
net flows before 2018 and for a window crossing it, net disbursements
for a recipient's all-sector total (DAC2a receipts), and gross
disbursements once a sector, instrument or modality is bound. A stated
or carried basis wins and drives dataset election, so a stated grant
equivalent for a recipient reads the CRS. Each part reports a basis
scope choice, and alternative_measures no longer lists siblings of
another basis, so a settled basis raises no count question.
… the parsed cache on stated scope

A fresh search compiles only the entries its binders applied and
reports the rest as not applied, so the record never claims an option
the served answer does not hold; a basis served off the convention
records its own rule. Same-shape merges state the merged scope. A
switch replaces the entry on its axis, and a posted entry without a
concept takes its axis's concept. Grant equivalents read flows 10 and
60 however the basis was elected, so switches round-trip. The parsed
cache keys on the stated scope, not the raw message. "commitment" in
the pledge sense states no basis, region labels come from a declared
name table, and the axis rules scan each family once per call.
…components

A declared composite provenance names how its components derive from
the composite identity: bilateral CRS plus imputed multilateral stacked
over the years both cover, or the OECD headline spliced from DAC1 net
before 2018 and grant equivalents from 2018. The mint materialises each
component through the single-provenance path under its own pins, keeps
only the composite window, writes a zero row where the imputed table
leaves a covered cell empty, and stamps the composite on every row.
Citations and a re-render from the version record name the component
tables. The admission estimate counts the zero rows, a frame mixing raw
provenances is still refused, and every reader of facets.provenance
either expands the composite or refuses it.
…ed stated words and label prices as served

Recall that finds only a dataset's amount family now carries the
sibling families that measure a basis the conventions count it on, so a
donor-only "ODA" from 2018 is counted in grant equivalents like "aid". A
stated word on an axis no rule serves, such as "bilateral", is reported
as not applied. Basis labels state the prices and currency the part is
served in. A word naming a resolved entity is read as that entity and
not rated as a qualifier, and judges read a declared grouping by its
label.
A declared table maps graph regions to the DAC regions the OECD
publishes totals for: sub-Saharan Africa, Asia, Europe, the Middle East,
the African subregions and South America. On a part summing over such a
region on DAC data, Search binds the DAC members and the residual
amounts the served dataset publishes, so figures match the OECD; the
region choice states the composition and offers the graph's member
countries as the other option. Rankings, listings and every other
dataset keep the graph's place.
…ds it

A word from a resolved entity's name, such as "Gates", is read as the
entity only when no qualifier or measure vocabulary reads it, so
"women" beside UN Women or "malaria" beside the Global Fund is still
rated against the series. Merged parts and resubmits report the basis
rule the search records, and region totals skip the reading match for a
single member.
A CRS part gains a provider choice: bilateral flows, or bilateral plus
imputed multilateral over the years the imputed table covers, offered
shortened with its window when it covers only part of the request. A
total from a named donor defaults to the inclusive reading when it
covers the request. A donor-only DAC1 window crossing 2018 is counted
as the OECD headline, net to 2017 and grant equivalents from 2018. Both
answer as one composite spec with its components, switch and switch
back to the same spec_id, and read through the component tables
wherever Search reads a provenance.
# Conflicts:
#	packages/dfo-data-tools/src/code_index/scope_conventions.py
#	packages/dfo-data-tools/src/graph_release/declarations.py
#	src/search/scope/basis.py
…d provider the part lacks

A composite answer states its own headline year and series, and counts
the StatVars of every component. A typed provider word on a part with
no provider axis, such as a DAC1 donor total, is reported as not
applied.
# Conflicts:
#	src/search/README.md
#	src/search/engine/region/pipeline.py
#	src/search/models/scope.py
#	src/search/scope/basis.py
#	src/search/scope/compile.py
…p its latest year out of the base

A headline or inclusive reading becomes the default only when every
component is available; otherwise the served shape stands with the rule
it was served under. An inclusive latest window keeps the base on
latest and elects the latest year both tables cover after compile, so a
switch to bilateral answers bilateral's own latest year. The worker
estimates the composed frame, and a composite is labelled from its
declaration.
# Conflicts:
#	packages/dfo-data-tools/pyproject.toml
#	pyproject.toml
#	uv.lock
# Conflicts:
#	packages/dfo-data-tools/pyproject.toml
#	pyproject.toml
#	src/search/engine/departures.py
#	tests/fixtures/first_answer_place_scenarios.json
#	uv.lock
The restated composite takes its date range, its when slot and the data
years its departures read from the years the components serve, so a
latest-year answer names the elected year.
…ite both of its tables

A composite mint passes each component's unserved members through to its
coverage warning and writes no zero row for a member its facet group
left out. A composite answer cites each component table and names its
measure from the declaration.
… declarations

The scope conventions read the dataset provenances and the CRS codes
their groupings and basis tables name from code_index.declarations, the
one module where dataset literals live.
…, over a year both tables hold

The inclusive provider scope is offered for a part whose named providers
the imputed table carries, and for a pool only when none of them is a
multilateral whose own outflows the table attributes to its funders
(REDISTRIBUTED_PROVIDERS), so a pool of the US and IDA, or a part naming
no provider, keeps the bilateral count. A latest read elects its year
among the years both tables hold. A composite zero row states the unit
its cell states, so a member absent from one component takes its own
currency.
…egion names, and build scope off the loop

A carried sector entry applies to a part when the grouping offers it and
it was made for the concept, or names codes the part's words matched. A
part keeps alternative measures on other bases unless its measure-basis
choice offers them. A region switch restates the recipient group for the
members it reads, so the group name and departure survive. Every text
origin indexes the part's normalised scope text, scope choices build in
a worker thread, one elector holds the convention-dataset gate, and the
declarations hash covers every scope table.
# Conflicts:
#	packages/dfo-data-tools/src/graph_release/declarations.py
#	src/search/engine/assemble.py
#	src/search/engine/entry/dataset_resubmit.py
#	src/search/engine/region/phase_election.py
#	src/search/scope/state.py
…es the part

A convention row is a dataset and a basis: it applies when the elected
shape reads that dataset, and otherwise the part takes the row of the
dataset it reads, so a CRS-only recipient part binds gross. A convention
basis with no series at the places falls back to the shape's default
and records the departure. On grant equivalents ODA binds flows 10 and
60 and its label names both, and an echoed assumption holds only when
it names the declared default the slot states.
…d compose each frame once

A stacked composite serves the years every component table covers, so a
year the imputed table covers keeps its bilateral figure with imputed
zeros. Every path that restates a composite elects its latest window,
explicit entries attach only where their concept holds, a composite
provenance names its component datasets to unit conversion and skills,
and the ceiling counts only the rows a composite keeps. The mint builds
the composed frame once and folds the digest, statistics and output
profile in that pass, cutting a 43,680-row composite mint from 7.4 s to
3.8 s with byte-identical output.
# Conflicts:
#	src/search/engine/region/phase_election.py
@jm-rivera
jm-rivera marked this pull request as ready for review October 27, 2026 10:46
@jm-rivera
jm-rivera marked this pull request as draft October 27, 2026 10:50
@jm-rivera
jm-rivera marked this pull request as ready for review October 27, 2026 10:50
…r's composite check

The worker asks the stored definition whether it binds a composite
before estimating, so the live over-ceiling tests stand in a definition
whose identity binds none, and their estimate stand-in takes the
composite it is given.
… and pin spec-kit 455e958

A place named without a direction has no part shape until its
direction settles, so basis election leaves it to the house policy.
Test fixtures model the net members DAC2a publishes, the pass-end test
holds only its listing on the held pool, model registries list the new
fields, and every dfo-spec-kit pin moves to 455e958 on spec-kit dev.
@jm-rivera
jm-rivera merged commit ac70845 into dev Oct 27, 2026
18 checks passed
jm-rivera added a commit that referenced this pull request Oct 29, 2026
…e answers by component (#186)

#163 made Search answer by the OECD's conventions: a recipient's total in net flows, a donor's total from 2018 in grant equivalents, a sector, instrument or modality part in gross disbursements, and "health" as CRS 120 and 130. The corpus still expected the earlier answers, so 67 cases regressed against main. Each now expects the convention's answer, with a `why` that names the rule, and `dac1.disbursement_net` joins the DAC families.

The materialisation check counted no observations for an answer served by the OECD headline composite, because no graph series carries a composite's provenance. It now derives the components with `component_identities` and sums each component's count under its own table's provenance and inside its own years.
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