Skip to content

feat(search): state each answer's assumptions and open a mixed answer with its coverage - #67

Merged
jm-rivera merged 45 commits into
devfrom
feat/search-answer-statements
Oct 23, 2026
Merged

jm-rivera merged 45 commits into
devfrom
feat/search-answer-statements

Conversation

@jm-rivera

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

Copy link
Copy Markdown
Contributor

Search answers state their assumptions as structured items and sentence clauses. Each part carries assumptions with kind, binding_ref, text, and refs. The kinds cover declared slot defaults, open flow roles, and place scope. Roles and entities requested by the caller produce no corresponding assumption. Candidate parts carry each reading's assumptions and expose their shared items at part level.

Resubmitted identities echo the assumption items. The server rebuilds their text from the resulting Spec and keeps only items that still hold. An edit that fills a defaulted slot drops that default. Missing echoes state the resulting open roles and place scope, without reconstructing a declared default from caller-supplied slots.

A mixed resolution with a definite part opens with “Answered N of M parts.” in a dedicated coverage segment. Consumers can preserve that lead while reading each part's own heading separately. The served OpenAPI also requires the answer kind discriminator.

This PR is stacked on #66 and includes its source-choice exclusion fix. DevelopmentFinanceObservatory/website#42 renders the assumptions and echoes them on edits; DevelopmentFinanceObservatory/ai#29 uses the coverage lead. Both this PR and #68 update tests/fixtures/first_answer_place_scenarios.json, so whichever merges second must re-record that shared fixture against the combined app.

Testing: the current-dev composition with #65, #66, #68, and #72 passed 200 selected tests covering contract schemas, refusal registry binding, source choices and scope, and end-to-end assumptions. The source-choice change also passed 1,218 changed-file tests on #66 before propagation.

…served at that basis

A single-variable raw-text question passes the whole query to the region, which reads "constant" as a RealValue requirement. The judge then found no measurementQualifier binding and raised query_qualifier_dropped ("The requested qualifier "constant" was not applied.") on answers served in constant 2022 euros.

The judge now takes each answer's served price basis, read from the Spec's unit request, and counts a measurementQualifier requirement as met when that basis reads as one of the rule's codes: constant prices meet RealValue and InflationAdjusted, current prices meet Nominal. A question for constant prices answered from a current-price series still warns.
`no_data.options` lists one `{mentions, label, data_query}` per reading the client can choose, on entity_ambiguous, source_ambiguous, source_code_ambiguous, role_not_resolved and unit_not_resolved. Each `data_query` is a complete parsed request that the client posts back unchanged. The list replaces `no_data.data_query`.

role_not_resolved offers each giving and receiving assignment of the named places, up to six, and drops a reading that its own role words contradict. A part whose directions were all read with none holding data offers nothing. unit_not_resolved offers each currency the words can name, so "francs" offers CHF, XAF and XOF.
…nswer leaves that role open

"aid from all donors to Ethiopia in 2022" dropped "all donors" with a `query_qualifier_dropped` warning and a `measure_not_applied` departure, though the answer totals across every provider. `role_text.every_member_axis` reads a dropped phrase such as "all donors", "every provider", "to all creditors" or "from all countries" as a request for one flow axis, and `region.result.every_member_phrases` admits it when the answer is role-bearing and binds no place on that axis. A phrase on a subject-only answer, on an axis a named place binds, or outside the role lexicon ("in all sectors") still warns.

The response cache states the named axis in its layer 2 key, so "from all countries" and "to all countries" never share an entry.
…ery-member words on every answer

A `unit_not_resolved` option names a currency whose code is also an English word ("COP") by its display name, which the unit binder now reads, so each of the 13 ambiguous codes offers words that bind back to it.

"each" and "every" ask for a breakdown, so they are met only when every answer declares a role on that axis; "all" is also met by a total. A candidate set meets an every-member phrase only when every candidate does. The `NoData.options` description lists when the list is empty. The price-basis judge reads `PRICES_BY_QUALIFIER` in place of its own copy.
…d post only unambiguous group names

`_settle_direction` refuses `no_direction_holds` only when every direction was read and each read is empty; a read the coverage index cannot answer is unknown, so the role refusal still offers its readings. A group candidate's option posts a registered name only when that name resolves to the group alone, and has no option otherwise.

The trace's query qualifiers, on the definite answer and on each candidate, are judged under the unit request the answer is served under, as the warnings are.
… meets

A dropped every-member phrase that one part's region admits cleared the departure on every part of the request, so "aid to Kenya and GDP of Kenya from all donors" stated no departure on the GDP part, whose subject-only answer reads no donor. Each region now carries the request-wide qualifier warnings its own region leaves unapplied (`RegionResult.unapplied_qualifier_warnings`), and the part's departures, including late departures, read them beside its own and the envelope's warnings. The envelope's `query_qualifier_dropped` warning keeps its rule: one region admitting the phrase clears it.
…option round trip

A role refusal's option mentions came from `unresolved_entity_names` while its readings came from `role_assignments`, so a name with no place set, or a set named by a pinned node's label, could appear in the mentions without any option settling it. `_role_options` now takes the mentions from the texts the readings map, in the request's words. `_source_options` keeps one group per wording in a dict. The choice-option round trip requires a 200 for every posted option. Conformance clauses C191 and C192 state the price-basis and every-member rules, and `src/search/README.md` states the price-basis rule.
… texts, in query order

A `role_not_resolved` refusal named each untold place set once, by a graph label when the set's only text was a pinned key, while its options rewrote every text of those sets. A client saw `entity_terms` and the message say "Kenya" where every option's `mentions` said "KE", and the mentions came grouped by place set. `untold_direction_names` now returns every request text of each untold set in `part.entities` order, and `direction_readings` takes its untold sets from the same grouping (`place_roles._untold_groups`), so the option mentions, `entity_terms` and the message read one list. `NoData.entity_terms` names `role_not_resolved` among the reasons that carry it.
…ce's texts together

A `role_not_resolved` refusal now quotes every text of each untold place set, so one place named twice read as two parties: "the union" (pinned to the EU) and "European Union" with Kenya as destination gave "Could not tell who is giving and who is receiving among "the union" and "European Union"." `RegionResult.untold_groups` carries each untold set's texts (`place_roles.untold_direction_groups`), and the no-data renderer chooses its wording by the number of places, writing a place's further texts in parentheses: "Could not tell whether "the union" ("European Union") is giving or receiving." `entity_terms` and the option mentions stay flat, in query order.
…ocument the naming and departure rules

A role option's label named one place once per text: `Kenya`, `Norway` and a pinned `KEN` gave "Kenya and KEN to Norway" where the message names the place as "Kenya" ("KEN"). `render.no_data.untold_place_name` now writes a place for both the message and the labels, and `conjoin._untold_terms` gives `entity_terms` and the option mentions one list. The README and C192 state that the every-member warning is request-wide and stays while no part meets the phrase, while each part whose own answer leaves it unmet departs; C50e states the role-refusal naming rule.
…statements

Docstrings and comments the choice options and the qualifier rule added read as present-tense sentences, and C50e says when options are offered in one sentence. No behaviour changes.
… with its coverage

Every part carries `assumptions`, the defaults the answer applied that the request did not ask for: a declared slot default such as official development assistance, and each flow role the heading states with no entity of its own, such as every provider. A candidates part carries the assumptions its readings share. The sentence ends with one clause per joined sentence ("Assumed: official development assistance; every provider."), and the sentence plan marks it with an `assumption` segment.

A dataset resubmit entry echoes `assumptions` back, so an edit that keeps a default still states it, and an edit to that slot drops it. A mixed answer opens "Answered N of M parts." The schema states that one `spec_id` means one reading.
…nd required in the schema

`Assumption` carries `refs`, the dcid a declared default binds. On `dataset_resubmit` a part's assumptions are the echoed items that still hold for the rebuilt answer, matched by kind, `binding_ref` and `refs` and worded afresh from the rebuilt Spec; the rebuilt trace keeps the posted provenance. The echoed strings and the refs list are bounded.

`kind` is required on Resolution, CatalogueAnswer and DeclinedAnswer in the served OpenAPI schema, which clients branch on first.
… out of the assumptions

The place scope reads "every country" when every member is a country, "the world as a whole" for Earth alone, else "every place". A role the scope default filled is an assumption whatever its member count, so "to Norway" read across every recipient states "every recipient". Words that asked for every member of an axis ("all donors") mark that axis asked, carried from the region result outside the identity, so no assumption names it.

`ResubmitIdentityEntry.assumptions` is optional: absent computes the assumptions as for a fresh request, and `[]` states none.
…corded specs

The golden was recorded before parts carried assumptions. A re-render now recomputes each definite and candidates part's assumptions from its Spec, with no asked axes and no echo, so 42 of the 182 panel sentences close with their assumptions clause.
…keep a folded part's assumptions

A role an operation runs over is asked, so it states no open role: "top 5 donors for standard grants in Kenya" now ends "Assumed: every recipient." The one definition of an asked flow axis, `search.models.asked_open_axes`, serves fanout and `assumptions_for` alike; it counts an operation over origin or destination, and an operation over `place` asks no flow axis.

A declared default is named by its code's full label in the code index ("official development assistance" for `CRSTypeOfFlow10`, whose short name is "ODA"), so the clause reads the same whether or not the heading states the slot.

The envelope composer groups each part with the parts folded into it. An assumption every part of a group carries is stated once and unowned; one only a single part carries is owned by that part, so a folded part never inherits the claim of an item it does not state.
`trim_spec` keeps `trace.slot_filters` and each part keeps its operations, so a re-render recomputes declared defaults and asked axes as the engine does. The panel was re-recorded in process against the dev graph: 143 of 182 envelope sentences changed, 4 only in the assumptions clause, 11 in other wording and 128 because the answer changed (MLS imputations and many CRS aid questions now answer from OECD DAC2a). The eval checks that read panel rows by id move to rows of the same shape in the new recording, and C192 cites weo-008 and weo-044 for the opener.
C191 and C192 state the price-basis and every-member rules, so the assumptions, mixed-answer opener and shared-spec clauses take the next three numbers.
A same-shape merge ranks its group's slot provenance, so a slot one leg's request left open and another's named reads `text_recognized` on the merged Spec. "aid flows and psi flows to Kenya" gave its aid part a `slot_defaulted` warning and no assumption, where "aid flows to Kenya" alone states "official development assistance". `assumptions_for` now takes the part's own pre-merge Spec (`own_spec`, passed by `conjoin._build_part`), reads which slots its request left to a declared default there, and states each value the served Spec's slot still binds, worded from the served Spec. C195 cites the assumptions clause, C193, which now states the merged-part rule.
A candidates part's `assumptions` holds only the items every reading yields alike, text included, and a client that picked a reading echoed that list, so the picked answer dropped the reading's own open role or place scope and any item two readings word differently ("every provider" against "every creditor"). `CandidatesPart.reading_assumptions` maps each shown reading's `spec_id` to the list a definite part answering that Spec would carry, read with the part's operations and asked axes; a client that picks a reading echoes that list on its `dataset_resubmit` identity entry. The part's own list still states the shared items. The first-answer scenarios fixture moves only its `capability_version`.
…, and state what every reading's list shares

`ResubmitCandidatesEntry` carried no echo, so an edited candidates answer read its readings as fresh: a year edit dropped "official development assistance", since a posted identity binds every slot. `ResubmitCandidate.assumptions` echoes the reading's `reading_assumptions` list, with the identity entry's bounds, matching rule and None-against-[] meaning, and the resubmit door reads each rebuilt reading through its own echo (`RegionResult.reading_assumption_echoes`).

A candidates part's shared `assumptions` is now the items every `reading_assumptions` list holds alike, in the first reading's order (`render.assumptions.common_assumptions`). It was read under the candidates heading's kept how properties, which could leave out a role every reading's own list states. `assumptions_for` reads open roles from the heading a definite part answering the Spec would carry. The sentence golden's re-render follows the engine and changes no sentence.
… keep a part's sentence when its clause differs

An echoed declared default was kept only when its slot bound exactly the echoed refs. A merged part posts the merged identity, whose flow slot binds both legs' values, so an unchanged repost of "aid flows and psi flows to Kenya" dropped "official development assistance". The echo path now keeps an echoed default while its slot still binds the echoed refs, and states only those.

Per-part composition dropped a part's sentence that repeated an earlier one but kept the part's own clause, so the response read "... Assumed: every provider. Assumed: official development assistance." under the first part. `_departures_stay_with_sentences` now drops a later statement only when its sentence and clause both repeat an earlier one's; a part whose clause differs keeps its own sentence.

C193 states clause token ownership as `envelope._clause` assigns it. The sentence golden's re-render keeps the recorded assumptions of a part whose Spec another part shares, since the wire carries no pre-merge Spec to recompute them from.
…of its Spec, and state the repeated-statement rule

`_fold_groups` mapped each spec_id to the last contributing part that carried it, so a part folded into the first of `[a, f, separate c]` joined no group and its own items left the clause. A fold group folds into its first member, which is never separate, so the lookup now keys on non-separate definite parts and the first one wins.

C193, `src/search/README.md` and `Resolution.rendered_sentence` now say that in a sentence written part by part, a part whose sentence and clause both repeat an earlier part's is stated once with it under no `part_index`, followed by each part's own departures, and that a part whose clause differs keeps its own sentence. `_departures_stay_with_sentences` is renamed `_statements_stated_once`.
"Answered N of M parts." went out as two unowned heading tokens, so a client that read the heading (the website's Studio hand-off, data-mcp's digest) took the opener as part of what the answer is, and could tell it from another unowned heading token only by repeating the server's rule. `SentenceSegment` gains `coverage`, which only the opener takes. C194, `src/search/README.md` and the `SentenceToken.segment` and `Resolution.sentence_plan` descriptions say so. The sentence golden's two mixed rows (weo-008, weo-044) are re-rendered and the first-answer scenarios carry the new schema hash.
… opener's folded count

The destination role shares the where slot with the subject. On a flow shape that declares no roles, an undirected place stays on that slot, and the heading stated the destination as "all recipients" while the request named Kenya, so the assumptions clause called the asked recipient "every recipient". `_role_phrases` now names the places the where slot binds when it would otherwise state the destination open, and C193 says so. A mixed answer's opener test gains a folded part, which C194's "folded ones included" cites.
…scope once, and cite each answer-statement rule's test

A dataset_resubmit candidates entry that posted one identity twice kept both readings, and its reading_assumptions held one key, so the second echo replaced the first and the clause could vanish. The set now keeps the first survivor per spec_id with its own echo, as a fresh candidate set does. A place scope on observationAbout is the where slot's one assumption, so the destination states no open role beside it. C24 states that kind is required on each served response kind, and C193 cites the tests behind its echo, merged-default and role-default rules and says that a measure family election records its forced axis as measure_elected.
…ped destination's reading, and pin the separate-part guard

A candidates resubmit whose candidates all resolve and rebuild to one spec_id now warns that they rebuild to one reading, where it said only one still resolved. The heading keeps a destination's role scope reading beside a where-bound place, so the subject swap applies only to a destination stated open. The separate-part fold test runs with the separate part first and last, each with its own item, and fails without the separate guard in `_fold_groups`. The twin-creditor resubmit test reads the collapsed definite part, and the binding_ref bound's comment names the prefixes the server emits.
…ning, and say what a resubmit with no echo states

A candidates resubmit whose resolving candidates rebuild to one reading now warns "N candidates were posted; M still resolve and rebuild to 1 reading", where a repeat beside a candidate with no data was reported as one resolving. C193, the README and `assumptions_for` say that an entry with no echo states the open roles and place scope the rebuilt answer reads and no declared default, since the posted identity binds every slot, and that a set keeping two or more readings drops a repeat silently. C195 also cites the engine test for a separate part that never folds.
…sentence clauses and the part_index description

C169 lists `assumption` and `coverage` among the segments, C129 and C131 and the README add the assumptions clause after a part's departures and the opener on a mixed answer, and `SentenceToken.part_index` gives null's fourth reading: an assumption item more than one part carries, with the opener and an unowned clause's lead among the sentence-wide tokens.
…an unowned clause among the sentence-wide tokens
Validate ambiguous source candidates against the resolved include and
exclude scope before offering ready requests. Preserve parent-source
exclusions and reject conflicting choices for repeated mixed-polarity
mentions.
`_option` in src/search/engine/conjoin.py compiles each option's
sources against the bound catalogue's dictionary and drops an option
the parsed door would refuse, so a choice-asking refusal offers only
requests the server accepts. A source wording whose every reading
empties the scope is refused with request_invalid at the excluded
mention (`_refuse_every_reading_empty`), at one compile per candidate.
The raw-text door still answers no_data. `blocked_choices` and
`_source_choices` are removed.
Keep dev's measure-qualifier warnings, `_admitted_phrases` and
`MeasureRule`, and the branch's unapplied-qualifier warnings,
every-member phrases and price-basis check. A `MeasureRule` is also met
by the served unit's price basis. The branch's conformance rows become
C194 and C195, since dev took C191 to C193.
Read the every-member phrases once in `_run_phases` and pass them to
`_admitted_phrases`, which now takes them, since the answer statements
also need their axes for `every_member_axes`.
@jm-rivera
jm-rivera marked this pull request as ready for review October 23, 2026 10:48
@jm-rivera
jm-rivera changed the base branch from fix/search-choice-options to dev October 23, 2026 11:16
# Conflicts:
#	src/search/engine/region/pipeline.py
#	src/search/engine/region/result.py
#	tests/engine/test_role_scope_pipeline_e2e.py
#	tests/fixtures/first_answer_place_scenarios.json
# Conflicts:
#	tests/eval/checks/test_checks.py
@jm-rivera
jm-rivera merged commit 1f62c09 into dev Oct 23, 2026
14 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