From baa8700cab775b524d1cc03b759e6ae004e10796 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 17:51:13 +0200 Subject: [PATCH 01/14] fix(search): count a price-basis qualifier as met when the answer is 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. --- .../engine/region/candidate_collapse.py | 20 +++-- src/search/engine/region/phase_answer.py | 8 +- src/search/engine/region/requirements.py | 62 +++++++++++---- src/search/engine/unit/conversion.py | 18 ++++- tests/engine/test_pinned_code_refusal.py | 77 +++++++++++++++++++ 5 files changed, 158 insertions(+), 27 deletions(-) diff --git a/src/search/engine/region/candidate_collapse.py b/src/search/engine/region/candidate_collapse.py index 929f4e8e..699a2f7c 100644 --- a/src/search/engine/region/candidate_collapse.py +++ b/src/search/engine/region/candidate_collapse.py @@ -90,6 +90,7 @@ from search.engine.retrieval.read_plan import ReadPlan, plan_coverage, serves_nothing from search.engine.retrieval.retrieve import Materialised, NoDataDraft from search.engine.unit.bind import with_unit_lexicon_version +from search.engine.unit.conversion import served_prices from search.models import ( CandidateBasis, IdentityUnitRequest, @@ -458,10 +459,11 @@ async def _assemble_definite( or registry-twin substitution. A survivor from an alternate answers over its own place context, so its place warnings become the answer's own (`install_place_warnings`). The survivor is judged on every - requirement on its own shape and bindings (`requirements.settle_answer`), since a - stand-in or an alternate binds its values from its own representative and can hold a - code the elected shape dropped, bind another code on the same property, or, as a - sibling every member of which holds Female, apply "female". Its refusal names the terms + requirement on its own shape, bindings and served unit request + (`requirements.settle_answer`), since a stand-in or an alternate binds its values from + its own representative and can hold a code the elected shape dropped, bind another code + on the same property, or, as a sibling every member of which holds Female, apply + "female". Its refusal names the terms its flagged match's trace records (`requirements.answer_terms`): the elected reading's unbound query terms, which no answer is shown to apply, and each code and value requirement its own answer leaves unmet. @@ -473,7 +475,9 @@ async def _assemble_definite( grounded.cand_places[unique_specs[0].spec_id], departure_facts=state.departure_facts, ) - _survivor_unbound_qualifiers = settle_answer(state, _survivor.shape, _survivor.bindings) + _survivor_unbound_qualifiers = settle_answer( + state, _survivor.shape, _survivor.bindings, served=served_prices(unique_specs[0]) + ) if _survivor_unbound_qualifiers: return state.no_data( "qualifier_not_bound", @@ -632,8 +636,9 @@ async def _assemble_candidate_set( _offer_place_warnings(state, grounded, unique_specs) if not state.elected_has_data: state.warnings.append(_elected_no_data_warning(state, unique_specs)) - # Each survivor is judged on its own shape and bindings, and a requirement departs - # unless every survivor meets it (`requirements.settle_candidate_set`). + # Each survivor is judged on its own shape and bindings and the unit request its Spec is + # served under, and a requirement departs unless every survivor meets it + # (`requirements.settle_candidate_set`). settle_candidate_set( state, [ @@ -643,6 +648,7 @@ async def _assemble_candidate_set( ) for spec in unique_specs ], + served=[served_prices(spec) for spec in unique_specs], ) # State each candidate's unit lexicon version. Its identity already carries the binding, # and the outcome's warnings were merged earlier. diff --git a/src/search/engine/region/phase_answer.py b/src/search/engine/region/phase_answer.py index e73d5956..74472c27 100644 --- a/src/search/engine/region/phase_answer.py +++ b/src/search/engine/region/phase_answer.py @@ -138,6 +138,7 @@ ConversionPeriod, counted_conversion, pins_facets, + served_prices, with_unit_conversion, ) from search.engine.unit.conversion_catalogue import current_conversion_catalogue @@ -1032,8 +1033,11 @@ async def _assemble_carve_outs( """ # Judge the final answer on every requirement (`requirements.settle_answer`). One it # leaves unmet refuses before any broader answer can pass as definite, naming the terms - # its trace records. The candidates path judges its answers the same way. - unbound_qualifiers = settle_answer(state, state.shape_draft, state.bindings) + # its trace records. The candidates path judges its answers the same way. The answer is + # judged under the unit request its Spec is served under, which meets a price basis. + unbound_qualifiers = settle_answer( + state, state.shape_draft, state.bindings, served=served_prices(spec) + ) if unbound_qualifiers: return state.no_data( "qualifier_not_bound", diff --git a/src/search/engine/region/requirements.py b/src/search/engine/region/requirements.py index 10f8ba5f..ab949f25 100644 --- a/src/search/engine/region/requirements.py +++ b/src/search/engine/region/requirements.py @@ -22,6 +22,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal +from code_index.labels import PRICE_BASIS_AXIS +from code_index.shared import get_measure_index from search.engine.bind import SlotBindingDraft from search.engine.departures import ( WarningKey, @@ -45,6 +47,7 @@ ) from search.engine.retrieval.bound_values import applies_value from search.engine.shape import ShapeDraft +from search.engine.unit.conversion import PRICE_BASIS_MEASURES, PriceBasis from search.models import GraphRef, ResolvedQualifier if TYPE_CHECKING: @@ -234,12 +237,17 @@ def judge( answers: Iterable[Answer], *, state: ResolveState | None = None, + served: Sequence[PriceBasis | None] = (), ) -> Judgement: """Judge each of `answers` on its own shape and bindings against every requirement. + `served[i]` is the price basis of the unit request answer `i` is served under, None + when it is served under none; an empty `served` serves every answer under none. A `CodeRule` is met when the answer's bindings bind one of its codes or a descendant on its property, unmet when the answer's shape holds the property and binds none of them, - and outside an answer whose shape lacks it. A `ValueRule` is met or unmet as + and outside an answer whose shape lacks it. A price-basis `CodeRule` is also met by an + answer served under a unit request whose price basis reads as one of its codes + (`_serves_price_basis`). A `ValueRule` is met or unmet as `phase_rank_bind.judge_value_requirements` decides, and an answer leaving it unmet names it by its slot when its shape has one (`_value_term`). A value of a drafted set (origin ``set_value``) is met when the answer's bindings or some selected member hold @@ -248,10 +256,12 @@ def judge( is unmet on every answer, and a term no answer is judged on (`None`) is outside every answer.""" requirements = tuple(requirements) + answers = tuple(answers) + served = tuple(served) or (None,) * len(answers) values = [r for r in requirements if isinstance(r.rule, ValueRule)] outcomes: list[tuple[Outcome, ...]] = [] slot_terms: list[dict[int, ResolvedQualifier]] = [] - for shape, bindings in answers: + for (shape, bindings), prices in zip(answers, served, strict=True): held = shape_vocabulary(shape) bound: dict[str, set[str]] = {} for binding in bindings: @@ -269,7 +279,8 @@ def judge( for j, requirement in enumerate(requirements): rule = requirement.rule if isinstance(rule, CodeRule): - answer.append(_code_outcome(rule, held, bound)) + met_by_unit = prices is not None and _serves_price_basis(rule, prices) + answer.append("met" if met_by_unit else _code_outcome(rule, held, bound)) elif isinstance(rule, ValueRule) and requirement.origin == "set_value": # A value of a drafted set: met when the answer applies it or leaves the # whole set unmet, which the refusal names; else disclosed, never refused. @@ -318,6 +329,17 @@ def _value_term( ) +def _serves_price_basis(rule: CodeRule, prices: PriceBasis) -> bool: + """Whether an answer served under a unit request at `prices` meets the price-basis + `rule`: the measure value those prices serve (`PRICE_BASIS_MEASURES`) spells the reading + of one of its codes. Constant prices meet `RealValue` and `InflationAdjusted`, current + prices meet `Nominal`, and no unit request meets `PurchasingPowerParity`.""" + if rule.property_dcid != PRICE_BASIS_AXIS: + return False + index = get_measure_index() + return any(PRICE_BASIS_MEASURES[prices] in index.reading_set(code) for code in rule.codes) + + def _code_outcome(rule: CodeRule, held: frozenset[str], bound: Mapping[str, set[str]]) -> Outcome: if rule.property_dcid not in held: return "outside" @@ -370,13 +392,18 @@ def _unbound(terms: Iterable[ResolvedQualifier]) -> tuple[ResolvedQualifier, ... def settle_answer( - state: ResolveState, shape: ShapeDraft, bindings: Sequence[SlotBindingDraft] + state: ResolveState, + shape: ShapeDraft, + bindings: Sequence[SlotBindingDraft], + *, + served: PriceBasis | None = None, ) -> tuple[ResolvedQualifier, ...]: - """Judge the part's one final answer, of `shape` read under `bindings`, and keep the - judgement for the part's departures (`ResolveState.judgement`). Return the terms its - refusal names (`refusal_terms`), empty when the answer meets every requirement it can - be judged on. A refused term departs nothing beside the refusal.""" - judgement = judge(state.requirements, [(shape, bindings)], state=state) + """Judge the part's one final answer, of `shape` read under `bindings` and served under + a unit request at `served` prices (None for none, as a refusal serving no Spec), and + keep the judgement for the part's departures (`ResolveState.judgement`). Return the + terms its refusal names (`refusal_terms`), empty when the answer meets every requirement + it can be judged on. A refused term departs nothing beside the refusal.""" + judgement = judge(state.requirements, [(shape, bindings)], state=state, served=(served,)) refused = _unbound(_in_reading_order(_reading(state, bindings), judgement.terms(0))) state.judgement = dataclasses.replace(judgement, refused=refused) return refused @@ -415,12 +442,17 @@ def settle_unanswered(state: ResolveState) -> tuple[ResolvedQualifier, ...]: return refused -def settle_candidate_set(state: ResolveState, answers: Sequence[Answer]) -> None: - """Judge a candidate set, each surviving answer on its own shape and bindings, and keep - the judgement for the part's departures. The client chooses among the answers and can - weigh an omission, so the set refuses nothing, and a requirement departs unless every - answer meets it.""" - state.judgement = judge(state.requirements, answers, state=state) +def settle_candidate_set( + state: ResolveState, + answers: Sequence[Answer], + *, + served: Sequence[PriceBasis | None] = (), +) -> None: + """Judge a candidate set, each surviving answer on its own shape and bindings and served + under a unit request at its `served` prices, and keep the judgement for the part's + departures. The client chooses among the answers and can weigh an omission, so the set + refuses nothing, and a requirement departs unless every answer meets it.""" + state.judgement = judge(state.requirements, answers, state=state, served=served) def settle_posted(state: _PromoteState) -> tuple[ResolvedQualifier, ...]: diff --git a/src/search/engine/unit/conversion.py b/src/search/engine/unit/conversion.py index 9283067d..e9a47ae5 100644 --- a/src/search/engine/unit/conversion.py +++ b/src/search/engine/unit/conversion.py @@ -95,6 +95,13 @@ _NOMINAL = "Nominal" +PRICE_BASIS_MEASURES: Final[Mapping[PriceBasis, str]] = { + "current": _NOMINAL, + "constant": "RealValue", +} +"""The measure value whose reading a unit request's `prices` serves: a series converted to +constant prices reads as `RealValue`, one served at current prices as `Nominal`.""" + @dataclass(frozen=True) class ConvertedYears: @@ -516,12 +523,17 @@ def conversion_period(*, served: YearRun, converted: ConvertedYears) -> Conversi return ConversionPeriod(served=served, years=run, limited_by=limited_by) +def served_prices(spec: Spec) -> PriceBasis | None: + """The price basis of the unit request the answer's identity binds its unit through, + served natively or converted. None when no unit request binds it.""" + unit = (spec.definition.identity.facets or {}).get("unit") + return unit.request.prices if isinstance(unit, IdentityFacetValueRequest) else None + + def _unit_requested(spec: Spec) -> bool: """Whether the answer's identity binds its unit through a unit request, served natively or converted.""" - return isinstance( - (spec.definition.identity.facets or {}).get("unit"), IdentityFacetValueRequest - ) + return served_prices(spec) is not None def _states_currency(spec: Spec) -> bool: diff --git a/tests/engine/test_pinned_code_refusal.py b/tests/engine/test_pinned_code_refusal.py index 63571b49..2a16dbe7 100644 --- a/tests/engine/test_pinned_code_refusal.py +++ b/tests/engine/test_pinned_code_refusal.py @@ -28,6 +28,7 @@ from search.engine.qualifier_match import QualifierMatch from search.engine.region.phase_answer import _assemble_carve_outs from search.engine.region.requirements import ( + judge, record_reading, refusal_terms, settle_answer, @@ -36,9 +37,11 @@ ) from search.engine.region.result import RegionResult from search.engine.region.state import ( + Requirement, ResolveState, code_requirement, qualifier_dropped_warning, + unapplied_departure, with_requirement, ) from search.engine.region.unrealisable import unbind_unrealisable @@ -449,3 +452,77 @@ def test_a_stand_in_is_judged_on_the_code_the_request_named(bound, unbound): assert [(t.slot, t.text) for t in refused] == unbound assert _departed(state) == [] + + +# A price basis is met by the unit request an answer is served under: a Nominal series +# converted to constant prices reads as RealValue. + +_CONSTANT = "constant" + + +def _price_basis_requirement(code: str) -> Requirement: + return code_requirement( + property_dcid="measurementQualifier", + codes=(code,), + asked=_CONSTANT, + named=[GraphRef(ref=code, label=code)], + departure=unapplied_departure( + _CONSTANT, asked=_CONSTANT, property_dcid="measurementQualifier" + ), + origin="bind", + ) + + +def _nominal_flow() -> ShapeDraft: + return replace(_education_share(), shape_id="flow_nominal", meas_qual_dcid="Nominal") + + +@pytest.mark.parametrize( + ("served", "code", "departs"), + [ + ("constant", "RealValue", False), + ("constant", "InflationAdjusted", False), + ("current", "RealValue", True), + (None, "RealValue", True), + ("constant", "PurchasingPowerParity", True), + ("current", "Nominal", False), + ], +) +def test_a_price_basis_is_met_by_the_served_unit_request(served, code, departs): + judgement = judge([_price_basis_requirement(code)], [(_nominal_flow(), [])], served=(served,)) + + assert bool(judgement.departures()) is departs + + +def _unit_spec(prices: str | None) -> Spec: + spec = minimal_spec() + if prices is not None: + request = {"currency": "EUR", "prices": prices} + if prices == "constant": + request["base_year"] = 2022 + spec["definition"]["identity"]["facets"] = {"unit": {"state": "value", "request": request}} + return Spec.model_validate(spec) + + +@pytest.mark.parametrize(("prices", "departs"), [("constant", False), ("current", True)]) +def test_the_definite_answer_is_judged_under_its_spec_unit_request(prices, departs): + """Germany's bilateral ODA to Kenya in constant 2022 euros: the bind leaves "constant" + unapplied on the Nominal flow, and the definite Spec converts it to constant euros, which + meets the price basis. Served at current prices it departs.""" + state = _state() + state.shape_draft = _nominal_flow() + state.measure_query_matches = ( + QualifierMatch( + code_system="measurementQualifier", + code_dcid="RealValue", + label="Real value", + source="synonym", + matched_text=_CONSTANT, + ), + ) + state.require_unapplied( + _CONSTANT, asked=_CONSTANT, property_dcid="measurementQualifier", origin="bind" + ) + + assert _carve_out(state, _unit_spec(prices)) is None + assert bool(_departed(state)) is departs From d083a693efc592df36c8e3faca6c831a2284a8e6 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 18:17:34 +0200 Subject: [PATCH 02/14] feat(search): offer each choice refusal's options as ready requests `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. --- docs/conformance-search-api-v1.md | 7 +- .../src/entity_resolution/resolver.py | 40 ++- src/search/README.md | 4 +- src/search/engine/assemble.py | 10 +- src/search/engine/conjoin.py | 194 ++++++++++-- src/search/engine/entry/parsed.py | 5 +- src/search/engine/fanout.py | 2 +- src/search/engine/region/phase_intake.py | 24 +- .../engine/region/phase_place_materialise.py | 16 +- src/search/engine/region/phase_shared.py | 13 +- src/search/engine/region/place_roles.py | 59 ++++ src/search/engine/region/result.py | 16 +- src/search/engine/region/state.py | 2 + src/search/engine/role_text.py | 20 +- src/search/engine/unit/bind.py | 39 +++ src/search/models/__init__.py | 2 + src/search/models/grammar.py | 2 +- src/search/models/outcomes.py | 76 +++-- tests/engine/test_choice_options.py | 294 ++++++++++++++++++ .../test_conjoin_assemble_and_enrichment.py | 104 +++++++ tests/engine/test_operations_e2e.py | 90 +++--- tests/engine/test_place_bindings.py | 20 ++ tests/engine/test_place_set_examples.py | 30 +- tests/engine/test_source_choice.py | 27 +- tests/engine/test_source_code.py | 59 ++++ tests/engine/test_unit_bind.py | 24 ++ tests/entity_resolution/test_resolver.py | 7 +- .../first_answer_place_scenarios.json | 164 +++++----- tests/models/test_answer_kinds.py | 22 +- 29 files changed, 1116 insertions(+), 256 deletions(-) create mode 100644 tests/engine/test_choice_options.py diff --git a/docs/conformance-search-api-v1.md b/docs/conformance-search-api-v1.md index 74b7cf21..a1efaecb 100644 --- a/docs/conformance-search-api-v1.md +++ b/docs/conformance-search-api-v1.md @@ -108,8 +108,9 @@ Gap rows state what is needed to close the clause and name the open issue that t | C50 | A no_data part's no_data.reason is one of 16 registry codes; category is unresolved/absent derived from the code; message is the reason's plain-language sentence with the query terms it names | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_three_variable_query_middle_fails`) and `tests/render/test_render_no_data_phrase.py`, which asserts that the 15 reasons carrying a phrase map to distinct phrases. `rate_coverage_gap` carries none and renders the generic sentence, so the test cannot cover it. | | C50a | Per-reason meanings for each of the 16 no_data codes | implemented | | | C50b | For entity_not_resolved, entity_ambiguous, and entity_role_not_admitted, no_data.entity_terms carries every entity wording responsible for that outcome in query order. It is an empty list for every other no_data reason, so clients need not parse rendered_sentence to identify the entity. | implemented | Additive response field; it does not alter any frozen enum or registry code. | -| C50c | For entity_ambiguous only, no_data.ambiguous_entities carries one group per ambiguous wording (empty for every other reason), each with the graph candidates it could name (ref, label, kind), `ref` holding the candidate's dcid. Every group's text equals some restatement.entities[i].text on the same part. A subject-role entity_ambiguous part (e.g. "GDP of Congo", no role_text posted) carries a real restatement instead of null. The part also carries no_data.data_query, a one-part parsed input built from the part's parsed inputs (variable text, entities with role_text, time, unit and source text, operations) with every entity that resolved to one node pinned to its dcid and each ambiguous wording unpinned; a named group, and a set read as its members, stay unpinned so each resolves to the same members. Posting that part back with the user's pick pinned, through the parsed door or as a dataset_resubmit parsed entry, resolves it. data_query is set whether or not the part carries a restatement; an entity whose direction the question leaves unnamed carries no role_text. It is null for every other reason and when the part's inputs exceed the ParsedPart bounds. The part's sentence plan marks each ambiguous wording as a slot token carrying the role the request gave it, no entity_refs and no binding_ref, so the composed sentence stamps it with the part's index and a client hangs the candidate menu on it; the rendered text is unchanged. | implemented | Additive response field; it does not alter any frozen enum or registry code or `rendered_sentence`. Pinned by `tests/engine/test_render_e2e.py` (`test_no_data_sentence_names_ambiguous_entity_candidates`, `test_no_data_restatement_for_directional_ambiguous_entity`) and `tests/engine/test_operations_e2e.py` (data_query round trip, data_query without a restatement, slot token, operations through the resubmit door's bind). | +| C50c | For entity_ambiguous only, no_data.ambiguous_entities carries one group per ambiguous wording (empty for every other reason), each with the graph candidates it could name (ref, label, kind), `ref` holding the candidate's dcid. Every group's text equals some restatement.entities[i].text on the same part. A subject-role entity_ambiguous part (e.g. "GDP of Congo", no role_text posted) carries a real restatement instead of null. The part also carries no_data.options, one per (ambiguous wording, candidate) in query order, each with the wording, the candidate's label, and a one-part parsed input built from the part's parsed inputs (variable text, entities with role_text, time, unit and sources, operations) pinning that candidate by text and every entity that resolved to one node; named groups, sets read as members and other ambiguous wordings stay unpinned. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid (C50d). Posting an option's data_query unchanged resolves that wording. options is built whether or not the part carries a restatement; an entity whose direction the question leaves unnamed carries no role_text. options is empty past the ParsedPart bounds. The part's sentence plan marks each ambiguous wording as a slot token carrying the role the request gave it, no entity_refs and no binding_ref, so the composed sentence stamps it with the part's index and a client hangs the candidate menu on it; the rendered text is unchanged. | implemented | Additive response field; it does not alter any frozen enum or registry code or `rendered_sentence`. Pinned by `tests/engine/test_render_e2e.py` (`test_no_data_sentence_names_ambiguous_entity_candidates`, `test_no_data_restatement_for_directional_ambiguous_entity`) and `tests/engine/test_operations_e2e.py` (option round trip, options without a restatement, slot token, operations through the resubmit door's bind). | | C50d | ParsedEntity.dcid pins a parsed-door entity to a specific graph node: the pin seeds entry/parsed.py's pre_resolved map directly, skipping this door's own name resolution for that entity, once the dcid confirms against the graph. An unconfirmable dcid (absent, or present but type-less) refuses. `members: true` on a pinned entity binds the countries the membership policy holds for the node when the node is a continent or subregion that the policy holds or omits (`MembershipPolicy.holds_region`), whatever its graph type, and has no effect on a pinned single place. A pinned dcid that is a resolver group's own node (`EntityResolver.group_node_dcids`, for example `EuropeanUnion`) is one place: without the flag the Spec binds that node, and with `members: true` the request refuses request_invalid at `{part}/entities/{j}`, since a group's members come from resolving its name, which a pin skips. | implemented | Reuses invalid_role_ref (422) for the unconfirmable case -- the same code region/promote_places.py's `_phase_where_places` and options/resolve.py's `_typed_anchors` already raise for an identical posted-ref-not-in-graph fact; no new registry code minted. Pinned by `tests/engine/test_parsed_pipeline.py` (`TestPinnedDcidC2`). The `members` rules are pinned by `tests/engine/test_place_set_invariants.py`. | +| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". They are offered while that gives at most six readings, none past it; none when the single untold place was read in each direction and no reading holds data; none on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code or a curated alias, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Additive response field replacing `NoData.data_query`; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | | C51 | Within a part, when several reasons apply, the first in resolution order wins (entity, variable, role, unit, slice, denominator, observations, facet) | partial | The resolution order is documented (`NO_DATA_RESOLUTION_ORDER`), and every site that picks one reason from several elects through `elect_no_data_reason`: the entity check (`phase_intake._phase_check_entities`), the refusal of a part with no admissible interpretation (`InterpretationSet.refusal_reason`), and the materialise and candidate refusals (`codes._elect_no_data_reason`). The entity check refuses with the reason elected over the ones its unresolved names mint and names every such name: an ambiguous name with its candidates, an oversized group as too large, and the rest as unresolved, so a part naming an oversized group and an ambiguous name refuses entity_ambiguous. Pinned by `tests/models/test_no_data_category.py`, which also finds every reason the code mints and checks each has a rank, and `tests/engine/test_group_resolution.py` (`test_mixed_entity_refusals_elect_one_reason_and_name_every_entity`). No structural enforcement catches a phase check reordered against the order. Live verification of the role/unit/facet positions is blocked until role-declaring StatVars are loaded on the live graph. Issues: #11/#12 (live-graph half); none (enforcement) | | C51a | Parts sharing one five-tuple shape collapse onto one spec: every member answers with the same spec_id, and every member but the first carries a part-scoped binding_folded warning naming the fold. A parsed part posted with `separate: true`, or a raw_text variable its query compares with another (C42d), is compared with the others and does not fold. It answers its own spec in query order with no binding_folded, its response part and restatement state the mark, and the other parts of its shape fold among themselves (`same_shape.fold_key`, which `collapse_same_shape` and the combiner both group by). A fold whose members bind one slot to different values merges them into a set, then drops each value that descends from another merged value, since the fold reads its parts as a union (HIV/AIDS 13040 folded with reproductive health 130 binds 130); one value left binds a value. The merged trace keeps each member's qualifier record of a text that differs in offsets, operation or selected codes. The merged spec states a set binding's values in the order its identity holds the refs, and counts an open role over the union of the members' own populations, so it reads as the resubmit of its spec_id does | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_two_variables_collapse_onto_one_merged_spec_through_fanout`), `tests/engine/test_conjoin_five_tuple_and_slots.py` (`test_collapse_same_shape_states_its_set_in_identity_order`, `test_collapse_same_shape_drops_a_merged_value_nested_under_another`, `test_collapse_same_shape_keeps_every_union_record_of_one_text`), `tests/engine/test_conjunction.py` (`test_a_separate_part_never_folds_and_the_others_fold_as_before`), `tests/engine/test_parsed_pipeline.py` (`test_a_part_marked_separate_never_folds_into_a_same_shape_part`) and `tests/engine/test_same_shape.py` (`test_a_merged_answer_unions_each_members_own_known_origin_population`). | | C51b | A candidates part carries observations_omitted when the request set include_observations, since a candidates part has not committed to one spec to fetch observations against; max_candidates applies per part; the warning ships on the buffered and SSE transports alike, since it states the request and not a transport choice | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_candidates_part_carries_observations_omitted_alongside_definite_with_observations`). | @@ -124,7 +125,7 @@ Gap rows state what is needed to close the clause and name the open issue that t | C53 | flagged_match, when present, carries the elected variable's own Spec with a gate code from NoData.gate. A role_incompatible refusal before or during materialise, a qualifier_not_bound refusal and a denominator_not_available refusal are the three exceptions: each carries the graph-confirmed elected identity without a gate, role_incompatible because role incompatibility prevents an availability verdict, qualifier_not_bound because the qualifier guard refuses before an availability verdict exists, and denominator_not_available because the quantity gate refuses the series for its form or denominator, not for its data. On gate only_alternate_has_data flagged_match is absent and alternate_match carries a different, available alternate the request did not elect. NoData.elected_count counts the StatVars the request elected on every gate and on that role_incompatible exception, and is null when no graph-confirmed identity exists. On the two sibling gates (no_member_has_data_at_place, no_member_asserts_bound_properties) flagged_match and elected_count describe the same variable | implemented | | | C188 | Before bind, every shape is held to every form the part's quantity requirement names (`quantity.serves_every_form`): per person, a numbered per-head base, per area, a rate or a share, read from the variable and its qualifier phrases; a shape that fails one is not electable, and the bind menu lists it as such (`admissibility.judge_pinned_parts`). A part none of whose readings is admissible refuses before bind with the reason the no-data order elects among their refusals, and flags nothing. The materialise gate holds the elected shape to the same forms, and to the form the bind model states when no phrase names one (`ResolutionTrace.stated_form`). When the question names a denominator, picked by the bind model from the StatVar denominators the menu's shapes divide by or "none of these", a shape with a resolved denominator arc must divide by it, and a shape whose arc does not resolve, or with no arc that classes as other, passes (`quantity.check_denominator`); a definite answer on such a series carries the `denominator_unverified` warning naming the denominator asked, judged on the series the answer serves. The alternate guard holds every alternate to the same rule (`quantity.serves_quantity`), a registry twin included, except an alternate of the elected shape's class beside an elected shape that fails it. When the elected shape fails and an alternate the fan-out admits passes, the fan-out runs as it would for any elected shape, and the part offers only the surviving readings that pass: one answers definite, as an only_alternate_has_data alternate, or as a substituted measure, and two or more are its candidates, so no candidate is a reading the gate refuses; it refuses when no surviving reading passes. A refusal is `denominator_not_available`, unless an earlier reason wins (C51), and carries as flagged_match the elected Spec, or the not-electable reading the bind model names as nearest when it declines every admissible one (`phase_place_materialise._declined_refusal`), its level reading as nearest_real (the recalled shape with the same population type, measured property, stat type and constraint properties and no denominator, under the same qualifier, or under the one qualifier its levels hold when the flagged match states none), none when the flagged shape has no such reading, as a level does, and `NoData.denominator` {asked, found}: the denominator the question named, null for none or one no shape divides by, and the one the flagged match divides by, null for none. A request that names no form reads the denominators for the bind list alone, in one batch, so a failed read of any of them leaves the list with no denominator, only "none of these"; when the bind model then states a form or names any denominator entry, "none of these" included, the read's infrastructure error fails the part | implemented | `tests/engine/test_per_capita_guard_unresolved_denominator.py` (`test_a_mismatched_denominator_refuses_with_both_denominators`, `test_none_of_these_refuses_a_shape_with_a_denominator_arc`, `test_a_series_stating_its_base_only_in_its_name_answers_unverified`, `test_an_unverified_series_without_data_raises_no_unverified_warning`, `test_a_stated_form_joins_only_when_no_phrase_names_one`, `test_a_failed_denominator_read_fails_a_request_bind_reads_a_quantity_in`, `test_an_alternate_dividing_by_another_base_is_not_offered`, `test_an_elected_share_answers_nothing_when_the_alternate_that_serves_holds_no_data`, `test_a_failed_denominator_read_leaves_a_formless_request_answering`, `test_a_failed_denominator_read_fails_a_request_naming_a_form`, `test_a_level_refused_on_its_form_is_flagged_when_bind_declines_and_names_it`, `test_a_density_no_reading_serves_refuses_before_bind`, `test_ghana_debt_to_gdp_flags_the_share_of_gni_with_the_debt_stock_nearest`), `tests/engine/test_alternate_guard.py` (`test_check_denominator_holds_the_shape_to_the_named_denominator`, `test_serves_every_form_holds_every_named_form`, `test_per_area_alternate_refused_when_the_phrase_also_names_a_per_person_form`), `tests/render/test_render_no_data_sentence_plan.py` (`test_a_denominator_refusal_names_the_denominators_and_offers_the_flagged_match`), `tests/engine/test_admissibility_table.py` (`test_each_rule_admits_or_refuses_a_reading`) | | C189 | A code the request names, or a resubmit posts, on a property the answering shape's slots hold is pinned: a definite read that does not apply it refuses `qualifier_not_bound` on every door (raw text, parsed, resubmit), with the read as flagged_match and the code named once as an unbound term, by its dcid when it has no name, and no departure beside it. A single surviving alternate is judged on its own shape, bindings and label, its slot-value words included, so one that binds the code, or a descendant of it, answers. A word of the variable that names, by its graph label, a value on a property the answering shape's slots hold ("female" for `gender` Female), as that shape or another on the bind menu offers it, is such a code whether or not the bind model lists it as unplaced, unless the answering shape's own label or population type names it; the shape answers only when its final bindings bind it or a value below it. A value word on a property the answering shape lacks is left to the bind model's judgement of the measure: it refuses when the model lists it as unplaced, and otherwise the answer departs `measure_not_applied` (`query_qualifier_dropped`) on it, unless the property is a code system the index declares, whose words the code reading judges. A value the bind model drafts on a property the elected shape has no slot for is required of every reading, judged by values alone (a word in a member's name does not meet it): a reading meets it when its bindings bind it, or every member it reads holds it or a value below it. The values drafted on one property are a set, met by membership as a set bound on a slot reads: every member the reading reads holds a value of the set or one below it. An alternate binds its slot on that property to the drafted values its members hold, a set when they hold several, so "male and female" on a shape with no sex slot offers each reading whose members hold either sex, a by-sex family serving both. A reading with a member holding a value outside the set, or none on the property, does not meet it. A reading that does not meet it is not offered, the elected one included (C45), and when none meets it the part refuses `qualifier_not_bound` naming the drafted values as one term, with the elected read as flagged_match and no departure beside it. Overlapping value labels are read longest first. The price-basis form of `measure_not_applied` (`binding_ref` `unit:`), a qualifier no one slot clears, a `measure_not_applied` code on a property the shape lacks, and `closest_match` stay departures | implemented | `tests/engine/test_pinned_code_refusal.py` (`test_education_share_refuses_the_health_code_it_cannot_bind`, `test_departure_the_shape_cannot_hold_stays_definite`, `test_unrealisable_term_refuses_once_and_candidate_sets_depart_it_once`, `test_an_alternate_survivor_is_judged_on_its_own_shape_and_bindings`, `test_an_alternate_survivor_is_judged_on_its_own_slot_value_records`), `tests/engine/test_standard_resolve.py` (`TestUnrealisableValuesUnbound`), `tests/engine/test_resubmit_measure_elected_provenance.py` (`test_a_posted_value_no_elected_member_carries_refuses`), `tests/engine/test_qualifier_guard.py` (`test_a_slot_value_the_phrase_names_is_recorded`, `test_a_value_word_on_a_property_the_elected_shape_lacks_departs`), `tests/engine/test_qualifier_grounding_fallback.py` (`test_a_reading_holding_no_member_of_the_named_purpose_refuses_before_bind`) | -| C190 | A part's `source_code` (`ParsedPart.source_code`, stripped and upper-cased) is a publisher's series code. It names the StatVars the packaged code index records for it under each provenance holding it (`CodeIndex.source_code_members`, read from the `sourceIndicatorCode` arcs of its listed rows), and it compiles at the door after the part's sources, against the provenances they allow (`source_code.compile_part_code`, in `entry.parsed.interpret_parts` and `entry.raw_text._prepare_extraction`). A code no allowed provenance holds refuses source_code_not_held, its message naming the cause: the index holds no such code, naming every source whose codes it holds, or the part's sources leave out every provenance holding it, naming those; a code two allowed provenances hold refuses source_code_ambiguous, with the code and those provenances in `NoData.ambiguous_sources` and the part in `data_query`, and the part answers once its `sources` names one; a code whose StatVar its provenance gives another code too (`CodeIndex.shared_codes`: IDS gives one net-flow StatVar DT.NFL.IMFC.CD and DT.NFL.MIDA.CD) refuses source_code_shares_variable, its message naming those codes. Each of these door refusals carries the code in `NoData.source_terms` and runs no step. Otherwise the code's StatVars are the part's recall in place of the embedding resolve (`interpret.recall`), admitted by the request's catalogue as recalled hits are, and shape derivation, the twin election and bind run on them as on a recalled pool, so `open_roles: ["creditor"]` elects DT.TDS.DEGG.CD's by-creditor variable and no open role its aggregate. Bind binds every constraint value the code's StatVars all share as a sole option (`bound_values.bind_member_values`), so the minted identity elects those StatVars and no broader family, and a variable text that is only the code reaches the bind model as its StatVars' graph name. A code part refuses source_code_not_held, carrying the code in `NoData.source_terms` and the part's warnings, model usage and source scope, wherever it would refuse variable_not_resolved (`ResolveState.no_data`): its message names the cause, that the catalogue or graph serves none of the code's StatVars when no shape derives from them, and that the code's series cannot answer the part as asked otherwise, a bind that asks among them. So does a part one of whose Specs mints an identity the Datasets mint elects StatVars outside the code's for (`pipeline._within_source_code`). Each entry of a Spec's `resolved.stat_vars.source_codes` states in `exact` whether the Spec is the series its provenance files under that code and no other (`source_code.stands_for_code`, called from `spec_build._resolved_stat_vars`): the Spec reads its observations from that provenance alone (`resolved.facets.provenance.ref`), the provenance gives none of the code's StatVars another code, every StatVar the Spec serves is the code's, and the family the Datasets mint elects for its identity is known and lies within the code's StatVars (`bound_values.elects_within`). A Spec found by the measure's words gets the flag by the same computation as one a code part answers with, and a false `exact` is left off the wire. A code part gets no UN pass (C51e). The variable text leaves out the code's words (`source_code.without_code_words`), the restatement echoes the code, and posting it mints the same `spec_id`. On the raw_text door the extraction names no code: a query token naming a held code fills it (`source_code.scan_source_codes`), in any case when the token holds `.`, `_` or a digit and only in capitals otherwise, for the variable whose words hold the token, else the only variable; the raw-text response cache keys on each variable's code | implemented | Pinned by `tests/engine/test_source_code.py`, `tests/render/test_render_no_data_phrase.py::test_a_source_code_phrase_states_its_refusal`, `tests/engine/test_source_scan.py::test_the_raw_text_door_reads_a_series_code_the_query_writes`, `tests/engine/test_response_cache_keys.py` and `tests/engine/test_operations_echo.py`. | +| C190 | A part's `source_code` (`ParsedPart.source_code`, stripped and upper-cased) is a publisher's series code. It names the StatVars the packaged code index records for it under each provenance holding it (`CodeIndex.source_code_members`, read from the `sourceIndicatorCode` arcs of its listed rows), and it compiles at the door after the part's sources, against the provenances they allow (`source_code.compile_part_code`, in `entry.parsed.interpret_parts` and `entry.raw_text._prepare_extraction`). A code no allowed provenance holds refuses source_code_not_held, its message naming the cause: the index holds no such code, naming every source whose codes it holds, or the part's sources leave out every provenance holding it, naming those; a code two allowed provenances hold refuses source_code_ambiguous, with the code and those provenances in `NoData.ambiguous_sources` and one option per provenance in `NoData.options` (C50e), and the part answers once its `sources` names one; a code whose StatVar its provenance gives another code too (`CodeIndex.shared_codes`: IDS gives one net-flow StatVar DT.NFL.IMFC.CD and DT.NFL.MIDA.CD) refuses source_code_shares_variable, its message naming those codes. Each of these door refusals carries the code in `NoData.source_terms` and runs no step. Otherwise the code's StatVars are the part's recall in place of the embedding resolve (`interpret.recall`), admitted by the request's catalogue as recalled hits are, and shape derivation, the twin election and bind run on them as on a recalled pool, so `open_roles: ["creditor"]` elects DT.TDS.DEGG.CD's by-creditor variable and no open role its aggregate. Bind binds every constraint value the code's StatVars all share as a sole option (`bound_values.bind_member_values`), so the minted identity elects those StatVars and no broader family, and a variable text that is only the code reaches the bind model as its StatVars' graph name. A code part refuses source_code_not_held, carrying the code in `NoData.source_terms` and the part's warnings, model usage and source scope, wherever it would refuse variable_not_resolved (`ResolveState.no_data`): its message names the cause, that the catalogue or graph serves none of the code's StatVars when no shape derives from them, and that the code's series cannot answer the part as asked otherwise, a bind that asks among them. So does a part one of whose Specs mints an identity the Datasets mint elects StatVars outside the code's for (`pipeline._within_source_code`). Each entry of a Spec's `resolved.stat_vars.source_codes` states in `exact` whether the Spec is the series its provenance files under that code and no other (`source_code.stands_for_code`, called from `spec_build._resolved_stat_vars`): the Spec reads its observations from that provenance alone (`resolved.facets.provenance.ref`), the provenance gives none of the code's StatVars another code, every StatVar the Spec serves is the code's, and the family the Datasets mint elects for its identity is known and lies within the code's StatVars (`bound_values.elects_within`). A Spec found by the measure's words gets the flag by the same computation as one a code part answers with, and a false `exact` is left off the wire. A code part gets no UN pass (C51e). The variable text leaves out the code's words (`source_code.without_code_words`), the restatement echoes the code, and posting it mints the same `spec_id`. On the raw_text door the extraction names no code: a query token naming a held code fills it (`source_code.scan_source_codes`), in any case when the token holds `.`, `_` or a digit and only in capitals otherwise, for the variable whose words hold the token, else the only variable; the raw-text response cache keys on each variable's code | implemented | Pinned by `tests/engine/test_source_code.py`, `tests/render/test_render_no_data_phrase.py::test_a_source_code_phrase_states_its_refusal`, `tests/engine/test_source_scan.py::test_the_raw_text_door_reads_a_series_code_the_query_writes`, `tests/engine/test_response_cache_keys.py` and `tests/engine/test_operations_echo.py`. | | C53a | A 2+ survivor `candidates` part discloses an unbound query qualifier via the same `query_qualifier_dropped` warning `entry/raw_text.py`'s own dropped-qualifier notices carry, one per unbound phrase, since a `candidates` response already signals non-confidence and hands the choice to the client. C53's `qualifier_not_bound` refusal applies only to a lone-survivor collapse to `definite`. A value the bind model drafted on a property the elected shape lacks is held by every candidate (C189) and is not disclosed this way. Of a set drafted on one such property, each value that neither a candidate's bindings nor any member it reads holds is disclosed once with the same warning and departure, on a candidates part as on a definite one. | implemented | | | C54 | nearest_real, flagged_match, and alternate_match are full spec objects, shaped by response_options and confidence-banded | implemented | | | C55 | unsupported fires when the request is valid and understood but unsupported; carries code/pointer/detail/definition without spec_id | implemented | | @@ -173,7 +174,7 @@ Gap rows state what is needed to close the clause and name the open issue that t | C77a | resolved.scope: the members the answer's read plan serves on an axis the request left unnamed and the engine defaulted, a subset of the axis's members under a bulk election or a facet filter, each member {ref, label, entity_type, role, confidence} | implemented | | | C78 | resolved.facets: the facet actually selected per axis, each {ref, label, basis} | implemented | | | C78a | Every provenance ref carries source {ref, label}, the organisation behind it, and data_commons {id, label}, the Data Commons its data comes from, both read from the origin catalogue's provenance table with its label: resolved.facets.provenance (alongside ref only), each series' facet.provenance, each series' source_refs entry and each trace.resolved_sources entry ({ref, label, source, data_commons}). A UN System Data Commons provenance is its own source. A provenance the catalogue does not list carries neither member and takes its graph label, read in one batch over those misses only; a source_refs or resolved_sources entry the graph cannot label is omitted, and its facet.provenance carries no label. Admission drops every provenance without a row, so production reads none | implemented | Pinned by `tests/engine/test_provenance.py` and `tests/engine/test_origin_wire.py`. | -| C78b | A part names or excludes sources through its source scope (`ResolveState.scope`): the provenances its mentions allow, compiled before any other input resolves (`source_scope.compile_sources`), and whether it includes any. A parsed part, or the part of a dataset_resubmit parsed entry, posts its mentions in `ParsedPart.sources`, each by its words or by an admitted provenance, Dataset or Source reference. An included mention allows its provenances, a Dataset or Source less its opt-in products unless it publishes nothing else; a part with no include allows every admitted provenance; each excluded mention removes its provenances. An excluded mention whose provenances publish none of the part's recalled shapes removes nothing, and the part warns query_qualifier_dropped quoting its words (`source_choice.idle_exclusion_warnings`) and names it in no outcome as a source that limited the answer, one mark deciding both (`source_choice.with_idle_exclusions`). A shape's publishers are its family's datasets, else its listed rows' provenances, else those the catalogue records for its members, else any provenance but a declared dataset. An included mention none of whose provenances the answer's identities bind as their source (`source_choice.answer_provenances`) served nothing of it, and the answer warns query_qualifier_dropped quoting its words (`source_choice.idle_inclusion_warnings`), marked by the same idle mark (`source_choice.with_idle_inclusions`), so a part naming the CRS and the imputed table that the CRS alone answers warns about the imputed table. Excluded words matching no admitted source stand for no provenance, so they remove nothing and warn the same way, and the restatement keeps the mention with its words and no reference. Included words matching no admitted source refuse source_not_resolved and words naming two unrelated sources, included or excluded, refuse source_ambiguous, each a no_data part naming the words (`NoData.source_terms`, `NoData.ambiguous_sources`, and `data_query` with the resolved mentions pinned for source_ambiguous). The deprecated `ParsedPart.provenance` and `source_text` fold into one included mention each. The served request schema lists the declared datasets as `provenance`'s values, in rank order, each a `const` with its declared label as `title`, its description and an `x-opt-in` flag. A part's restatement writes `sources` alone, holding the part's mentions, each with the reference it resolved to, so posting it compiles the same scope. A part with no mention restates, as one included mention, the declared dataset its identity binds as its source, where electing an opt-in shape named it, or a no-data part's settled dataset, and restates none when neither names one. Posting the restatement mints the same `spec_id`. A dataset declared opt-in (`DatasetDeclaration.opt_in`) serves a part that names no dataset only on a family (`CodeIndex.family_of`) no other dataset publishes. A named dataset binds the source slot, which hashes as facets.provenance. A part with any mention binds facets.provenance to the provenances its read plan serves, a value for one and a set for several (`spec_build.with_served_provenance`), so a mint or resubmit of the spec_id reads the sources that answered, and `resolved.facets.provenance` reports basis `bound`; the provisional sentence event and an alternate left unread carry no such pin. Shape candidacy is per part and reads the datasets that publish each shape's own family, in rank order (`shape_datasets`): the family the shape's members are recorded in (`shape_family`), which the shape's slot keys do not select again. A part with no scope keeps every shape. Under a scope, a shape of a family the index declares stays when an eligible dataset publishes it (`part_datasets`, through `code_index.members.eligible_among`): of its publishers the scope allows, those not opt-in, else all of them when the scope names a source or no publisher of the family is not opt-in, else none, so excluding a table never opens an opt-in table beside another. A shape whose five-tuple the index lists without a declared dataset (`CodeIndex.listed_statvars`) stays when a listed row's provenance is allowed; any other shape, a table the index does not declare, stays when the scope allows a provenance the index declares no dataset for, whether the part names or only excludes sources. The scoped read serves none of an excluded source's data, so a reading whose only data where the question asks is an excluded source has none there under the status rule (C45, `region.availability.has_data_where_asked`): "fertility rate in Kenya excluding the World Development Indicators" answers from the Kenya census. Every read of the part admits only allowed provenances, before DFO precedence (`origin_catalogue.Catalogue.scoped`, bound around the part's resolve), and recall adds a row of each family an allowed dataset publishes of a recalled five-tuple none of whose recalled families an allowed dataset publishes (`clients.graph.with_rival_families`), so a part whose scope allows a family recall found, as a part naming the DAC tables that recalls DAC2a, or whose exclusion removes no recalled family, recalls what the unscoped question recalls. Candidacy stamps those datasets on the shape (`ShapeDraft.datasets`), and the part reads only their rows. A role-bearing shape the index carries declares a role axis the query names when one of its stamped datasets holds that role on every row it publishes of the family (`place_roles.lacks_query_axis`, `code_index.members.family_declares_roles`), so the shape of a part naming a donor-only dataset lacks the recipient though another dataset of the family holds it; a family no dataset publishes declares the axes its rows hold. A shape whose one eligible dataset is opt-in names that dataset (`ShapeDraft.named_dataset`), so a part electing it binds the source slot and the dataset's invariants as a part naming the dataset does. A part that names a source takes candidacy before the score floor, the prunes and the ranking, since naming a table may elect a family the unscoped ranking puts lower, and refuses qualifier_not_bound before any bind call when its scope leaves none of its recalled shapes. A part that only excludes sources chooses among the readings the unscoped question reaches: the score floor, the measure-class check, the prunes, the five-tuple family election and the ranking run unscoped, and candidacy then filters the ranked menu before the top-K cap (`phase_rank_bind._phase_prune_and_rank`), unless every exclusion removes nothing; when the filter removes a reading the menu would have offered without the exclusion and what it leaves seats nothing, no shape or only the leaves of roots larger than the menu, the part refuses qualifier_not_bound before any bind call. A part the prunes or a declining bind call leave with no reading it accepts refuses qualifier_not_bound naming its sources only when its scope removed a reading (`ResolveState.scope_removed_readings`, `phase_rank_bind._declined_terminal`), and refuses variable_not_resolved otherwise. A part that names a source has a reading removed at candidacy. A part that only excludes has one removed when the menu would have offered a reading without the exclusion and does not offer it with it (`seating.seat_menu` over the unexcluded list and over what the filter leaves): a reading the filter took, or one a root displaced that seats only once the filter took some of its leaves. A leaf of a root larger than the menu, or a shape past the last seat, is offered neither way and is no loss to the part, and an excluding part whose unscoped prunes leave no reading has had none removed by its scope. Either refusal names in unbound_qualifiers the sources it named, else those it excluded, then each opt-in dataset in scope that publishes a recalled shape, with no flagged_match. A word every declared label of the datasets the part answers from holds (the settled dataset's, else each one the elected shape answers from) names the table and restricts nothing. An unplaced phrase of such words records nothing, and the qualifier coverage rule (C93) reads such a word as covered. A word those datasets state of the rows the part reads (`DatasetDeclaration.stated_words`, `phase_rank_bind._dataset_stated_words`) restricts nothing on them and is read the same way, by the coverage rule, in an unplaced phrase, in the code terms of a part naming a source, and by a value requirement: a dataset states a word of the rows with one measured property, and where it names a kind of party, of the rows whose party on that flow axis is of the kind. Every CRS row states "bilateral"; a DAC2a or DAC2b `amount` row states it where the part binds its recipient to countries, and states nothing where the recipient is an organisation or the part leaves the recipient open. So "bilateral ODA to Kenya" mints the `spec_id` "ODA to Kenya" mints, and "bilateral ODA grants to Kenya" on a part naming the CRS refuses nothing. The part states a word where its variable or a typed qualifier phrase holds it as a modifier of the measure, with no function word, connective or separator before it (`bind_repair.word_stated`); a word an occurrence negates or excludes is not stated. A recipient holds the kind only where every reading it can settle to has it (`place_roles.place_set_kinds`): a pinned recipient has the kind of the entity its dcid resolved to, a group the kind of its members, and a group the recipient role admits at its own node the node's kind too, so the EU, whose node is an organisation, holds none. In the five-tuple family election a family reads a word it states beside a rival the code index carries that reads the word in no phrase, and a stated word adds nothing beside a rival that codes it or one the index does not carry (`bound_values.elect_five_tuple_family`). Recall searches a text holding a stated word a second time without it and adds the series of the datasets that state it (`interpret._detect`). A dataset also declares a word that names one of its parties (`DatasetDeclaration.party_words`): "multilateral" names `DACMultilaterals`, the DAC's total of the multilateral organisations, as the provider of a DAC2a or DAC2b `amount` row to a country. A part states the word as it states a stated word, on a flow axis no name of the part is directed to, no operation runs across and the part does not ask every member of, while a name is directed to each axis of the word's `where` (`place_roles.stated_party_words`). Such a part is read with the word first (`pipeline.resolve_variable`): recall searches the text without the word and adds the series of the datasets that declare it, and a role-bearing shape whose every dataset declares the word for its measured property binds the party on that axis, beside the places the query names, where they fill the word's `where` and the shape reads each word next to the word (`place_roles.held_party_words`, `_beside_words_read`, `bind_place_roles`). The party's set passes role admission as a named set does (`phase_intake._prune_shapes_by_admission`). The reading stands where a shape binding the party reads every phrase any other pooled shape reads, the binding shapes then being the pool (`phase_rank_bind.with_party_reading`), and the answer holds the party alone on the word's axis in every Spec (`pipeline.binds_each_party`). So "multilateral ODA to Kenya" mints the `spec_id` "ODA from multilaterals to Kenya" mints. Otherwise the reading is withdrawn (`decisions.PartyReadingWithdrawn`) and the part is resolved again without party words: its reply equals the reply of an index declaring no party word, and no event of the first reading is sent. "multilateral aid for education to Kenya", "multilateral development bank ODA to Kenya" and "multilateral loans to Kenya" reply so, as does a part naming the provider itself, which states no party word. A declared party the graph gives no type has no set, and the read is logged and noted as degraded (`phase_intake._party_word_sets`). The raw-text response cache keys on each stated or party word the query negates (`bind_repair.stated_words_negated`). A part naming a source refuses qualifier_not_bound before any bind call on a variable code term no row of its candidate shapes holds (`phase_rank_bind._undeclared_qualifiers`). Every no_data outcome a scoped part reaches past compile, at candidacy, election or the read, names the sources that limited it (`RegionResult.source_scope`, set in `pipeline.resolve_variable`): a qualifier_not_bound outcome lists in unbound_qualifiers its category terms, then the sources the part named, else those it excluded, then any other source it could not bind, each once (a part that names sources, none of whose tables holds its bindings on one row, names those sources alone, `phase_election._unserved_sources`), as an unbound entry under its label whose `named` holds its references; the message and rendered_sentence close on "The search read only ." or "The search left out ." (`render.no_data.scope_sentence`), and a qualifier_not_bound outcome naming no term reads "No series for this query comes from ." or "... from a source other than .". The bind menu offers no source, and no bind-model draft binds the source. One rule names a dataset on a shape that names none (`bound_values.part_source`), applied to the elected shape at election vetting and to each alternate when it is walked: the shape names the dataset the part is served from among its stamped datasets within its scope, whenever its unnamed identity would resolve to another dataset at the Datasets mint, which selects a family by the identity's slot keys. A scope excluding the dataset that would serve so names the one the election serves among the eligible others, and a shape whose slot keys select another family than its own names its own dataset. A shape the index does not carry names none. The rule reads the role keys the identity binds its places under only where two or more datasets could serve. Under a scope leaving a shape one dataset every candidate names it. Each alternate is named by the same rule under its own representative's bindings and role keys, as the elected shape is under the model's, so an alternate's identity is the one a question electing its shape under the same scope mints. A code the bindings bind, the model's or one the query text forces, that a publisher of the family outside the scope holds and no row of the part's datasets holds refuses qualifier_not_bound naming the code (`phase_rank_bind._unheld_bound_codes`). A code term of the variable, read in every code system, that no row of the part's named dataset holds and that the elected shape's own codes do not read word for word refuses qualifier_not_bound once the bindings are final, in either coverage mode. The term is an unbound record with evidence `unavailable` naming its codes, and the elected identity is the flagged_match (`phase_rank_bind._unavailable_qualifiers`). "multilateral ODA", DAC1 aid type 2000, on a part naming the CRS refuses with CRS ODA as the near match. A part naming a source whose bind call elects no shape refuses qualifier_not_bound on the code terms no offered shape holds, with no flagged_match (`phase_rank_bind._unelected_qualifiers`): "education aid" naming IDS names CRS purpose 110 and IDS. A refusal on an exclusion settles the answer of a part that only excludes sources (`ResolveState.scope_settled`, set in `phase_rank_bind.exclusion_records` alone), so its UN pass does not run (C51e): our catalogue holds the measure and the part left its publishers out. Three refusals settle: the filter leaving no reading that seats, a declining bind call after the filter removed a reading, and the scoped read finding the elected data excluded where the question asks (`pipeline._excluded_from_the_place`). A refusal of the election for codes or rows the part's datasets lack settles nothing, whatever source it names, and nor does any refusal of a part that names a source; such a part reaches the UN pass when its scope allows a UN System Data Commons provenance. A part whose scope allows no UN System Data Commons provenance holds no UN shape and never waits for the UN resolve (C51e, `phase_intake._un_pool`); a part whose exclusions leave a UN provenance allowed keeps its UN shapes on the same terms as the undeclared tables of ours. On the raw_text door the extraction names no source; the engine scans the query against the source dictionary (`source_scan.scan_sources`): at each word the longest key that runs over whole words, ignoring case, except that a one-word key of at most five characters, an abbreviation such as IDS, CRS, WHO or UN, matches only where the query writes it in capitals. A hit inside a longer entity name is that entity ("Catholic Relief Services (CRS)"); a hit whose words are a whole variable is the measure ("adolescent birth rate"); a hit inside a longer code label the query writes is that code's words ("UN" in "UN peacekeeping"); a hit naming an organisation, a Source or a collection such as the DAC, that is an entity or lies in a variable's words is a party to the flow ("debt service to the World Bank", "World Bank core contributions"), unless a data word follows it ("OECD data") or "according to" precedes it; a hit naming a provenance or Dataset is a source wherever it stands ("education aid to Kenya from the CRS"). A source is excluded when a negation cue ("excluding", "except", "not", "without", "other than", "apart from", "not counting", "leaving out") stands just before it, with only articles or "from" between, and included otherwise. Each source qualifies the variables whose words hold its words, else the variable it directly precedes, else the nearest variable before it, else the nearest after it; one variable takes every source, and a source no rule places qualifies every variable, so "WEO inflation and population" scopes inflation to the WEO alone. A dropped qualifier that only names a scanned source ("excluding the CRS", "selon le SNPC") is applied by its mention and raises no warning (`source_scan.source_phrases`). An extracted entity whose words a scanned source covers is that source and no entity of the part, so "OECD data on education aid to Kenya" has Kenya as its only entity (`source_scan.SourceScan.source_entities`). Each part compiles its variable's mentions as a parsed part's are (`entry.raw_text._prepare_extraction`). On every door a part's variable text leaves out the words of each mention its scope resolved to a source, compared as the dictionary reads them, unless nothing would remain (`source_scope.without_source_words`, applied in `entry.parsed.interpret_parts` and `fanout._stream_one_variable`): "DAC2a ODA" naming DAC2a recalls, binds, restates and mints as "ODA" naming DAC2a. Words naming no admitted source are no mention, so raw text does not refuse source_not_resolved, and a key naming two unrelated sources refuses source_ambiguous. Words that leave a part no source answer that part qualifier_not_bound before any bind call, naming the sources it named, else those it excluded, while the other parts answer (`region.result.empty_scope_result`); parsed mentions that leave a part no source refuse request_invalid at the mention that empties the scope. The raw-text response cache keys on each variable's mentions. A mention posted with a ref reads one way. The bind model sees the codes of every dataset the part may answer from, less the slots they all pin to one value. The family's dataset is elected from the bindings and the role keys the identity binds its places under (`code_index.members.elect_dataset`): the named dataset, else among the part's datasets one whose rows cover the role keys serves before one whose rows do not, then one whose family declares exactly the roles those keys place, then the lowest rank. A slot all the elected dataset's index rows of the five-tuple hold with one value (`CodeIndex.dataset_pins`, read through `code_index.members.elected_pins`, for example `dac2aAidType` and `crsTypeOfFlow` on `attributedAmount`) is a pin: the election implies it, no declared default binds it, a qualifier naming its value is bound, and the sentence states no pinned slot. Every identity Search mints or reads is canonical (`code_index.members.canonical_pins`): of the pinned slots the identity declares bound to their pin or unbound, the largest subset whose release leaves the election and the family unchanged is unbound, the first in lexicographic order of sorted property names among subsets of one size, and every other one is bound to its pin (a code only that dataset reports, bound by the question), whichever of them the bind model or the client bound. A first answer mints its identity canonical, a refusal's flagged identity included, whatever the bind model drafted; dataset_resubmit and the options door read the posted identity in canonical form, so an identity posted with a pin bound and one posted without mint one `spec_id`, elect one family and denote one population. The resubmit and the options door refuse `facet_not_available` for an identity naming a dataset that does not publish its family, whatever slots it declares, as the Datasets mint does. The measure vote that forces open measure axes reads the same election. resolved.trace.resolved_sources and the sentence name the one dataset elected for an index-resolved family, under its declared label. A disclosed alternative candidate names the dataset its shape names, as the elected shape does, so its identity is the one a question electing it mints, and an alternative may answer from another dataset than the elected part. A candidate folded into resolved.alternative_measures as a measure sibling carries the identity a question electing its shape under the answer's bindings mints: the sibling shape's slots, the dataset that shape names, in canonical form. The parsed door and the parsed entries of a dataset_resubmit request interpret their parts alike (`parsed.interpret_parts`), so an identical part answers the same family with the same warnings on either door. A part's source words match whole against the origin catalogue's names and the registry's aliases, and no door binds a source from free text any other way | implemented | Pinned by `tests/engine/test_dataset_bind.py`, `tests/engine/test_dataset_election_grid.py`, `tests/engine/test_aid_dataset_bind_e2e.py`, `tests/engine/test_source_choice.py`, `tests/engine/test_source_scope.py` and `tests/engine/test_source_scan.py`. The contract has no clause for this binding. | +| C78b | A part names or excludes sources through its source scope (`ResolveState.scope`): the provenances its mentions allow, compiled before any other input resolves (`source_scope.compile_sources`), and whether it includes any. A parsed part, or the part of a dataset_resubmit parsed entry, posts its mentions in `ParsedPart.sources`, each by its words or by an admitted provenance, Dataset or Source reference. An included mention allows its provenances, a Dataset or Source less its opt-in products unless it publishes nothing else; a part with no include allows every admitted provenance; each excluded mention removes its provenances. An excluded mention whose provenances publish none of the part's recalled shapes removes nothing, and the part warns query_qualifier_dropped quoting its words (`source_choice.idle_exclusion_warnings`) and names it in no outcome as a source that limited the answer, one mark deciding both (`source_choice.with_idle_exclusions`). A shape's publishers are its family's datasets, else its listed rows' provenances, else those the catalogue records for its members, else any provenance but a declared dataset. An included mention none of whose provenances the answer's identities bind as their source (`source_choice.answer_provenances`) served nothing of it, and the answer warns query_qualifier_dropped quoting its words (`source_choice.idle_inclusion_warnings`), marked by the same idle mark (`source_choice.with_idle_inclusions`), so a part naming the CRS and the imputed table that the CRS alone answers warns about the imputed table. Excluded words matching no admitted source stand for no provenance, so they remove nothing and warn the same way, and the restatement keeps the mention with its words and no reference. Included words matching no admitted source refuse source_not_resolved and words naming two unrelated sources, included or excluded, refuse source_ambiguous, each a no_data part naming the words (`NoData.source_terms`, `NoData.ambiguous_sources`, and for source_ambiguous one `NoData.options` entry per candidate with the resolved mentions pinned, C50e). The deprecated `ParsedPart.provenance` and `source_text` fold into one included mention each. The served request schema lists the declared datasets as `provenance`'s values, in rank order, each a `const` with its declared label as `title`, its description and an `x-opt-in` flag. A part's restatement writes `sources` alone, holding the part's mentions, each with the reference it resolved to, so posting it compiles the same scope. A part with no mention restates, as one included mention, the declared dataset its identity binds as its source, where electing an opt-in shape named it, or a no-data part's settled dataset, and restates none when neither names one. Posting the restatement mints the same `spec_id`. A dataset declared opt-in (`DatasetDeclaration.opt_in`) serves a part that names no dataset only on a family (`CodeIndex.family_of`) no other dataset publishes. A named dataset binds the source slot, which hashes as facets.provenance. A part with any mention binds facets.provenance to the provenances its read plan serves, a value for one and a set for several (`spec_build.with_served_provenance`), so a mint or resubmit of the spec_id reads the sources that answered, and `resolved.facets.provenance` reports basis `bound`; the provisional sentence event and an alternate left unread carry no such pin. Shape candidacy is per part and reads the datasets that publish each shape's own family, in rank order (`shape_datasets`): the family the shape's members are recorded in (`shape_family`), which the shape's slot keys do not select again. A part with no scope keeps every shape. Under a scope, a shape of a family the index declares stays when an eligible dataset publishes it (`part_datasets`, through `code_index.members.eligible_among`): of its publishers the scope allows, those not opt-in, else all of them when the scope names a source or no publisher of the family is not opt-in, else none, so excluding a table never opens an opt-in table beside another. A shape whose five-tuple the index lists without a declared dataset (`CodeIndex.listed_statvars`) stays when a listed row's provenance is allowed; any other shape, a table the index does not declare, stays when the scope allows a provenance the index declares no dataset for, whether the part names or only excludes sources. The scoped read serves none of an excluded source's data, so a reading whose only data where the question asks is an excluded source has none there under the status rule (C45, `region.availability.has_data_where_asked`): "fertility rate in Kenya excluding the World Development Indicators" answers from the Kenya census. Every read of the part admits only allowed provenances, before DFO precedence (`origin_catalogue.Catalogue.scoped`, bound around the part's resolve), and recall adds a row of each family an allowed dataset publishes of a recalled five-tuple none of whose recalled families an allowed dataset publishes (`clients.graph.with_rival_families`), so a part whose scope allows a family recall found, as a part naming the DAC tables that recalls DAC2a, or whose exclusion removes no recalled family, recalls what the unscoped question recalls. Candidacy stamps those datasets on the shape (`ShapeDraft.datasets`), and the part reads only their rows. A role-bearing shape the index carries declares a role axis the query names when one of its stamped datasets holds that role on every row it publishes of the family (`place_roles.lacks_query_axis`, `code_index.members.family_declares_roles`), so the shape of a part naming a donor-only dataset lacks the recipient though another dataset of the family holds it; a family no dataset publishes declares the axes its rows hold. A shape whose one eligible dataset is opt-in names that dataset (`ShapeDraft.named_dataset`), so a part electing it binds the source slot and the dataset's invariants as a part naming the dataset does. A part that names a source takes candidacy before the score floor, the prunes and the ranking, since naming a table may elect a family the unscoped ranking puts lower, and refuses qualifier_not_bound before any bind call when its scope leaves none of its recalled shapes. A part that only excludes sources chooses among the readings the unscoped question reaches: the score floor, the measure-class check, the prunes, the five-tuple family election and the ranking run unscoped, and candidacy then filters the ranked menu before the top-K cap (`phase_rank_bind._phase_prune_and_rank`), unless every exclusion removes nothing; when the filter removes a reading the menu would have offered without the exclusion and what it leaves seats nothing, no shape or only the leaves of roots larger than the menu, the part refuses qualifier_not_bound before any bind call. A part the prunes or a declining bind call leave with no reading it accepts refuses qualifier_not_bound naming its sources only when its scope removed a reading (`ResolveState.scope_removed_readings`, `phase_rank_bind._declined_terminal`), and refuses variable_not_resolved otherwise. A part that names a source has a reading removed at candidacy. A part that only excludes has one removed when the menu would have offered a reading without the exclusion and does not offer it with it (`seating.seat_menu` over the unexcluded list and over what the filter leaves): a reading the filter took, or one a root displaced that seats only once the filter took some of its leaves. A leaf of a root larger than the menu, or a shape past the last seat, is offered neither way and is no loss to the part, and an excluding part whose unscoped prunes leave no reading has had none removed by its scope. Either refusal names in unbound_qualifiers the sources it named, else those it excluded, then each opt-in dataset in scope that publishes a recalled shape, with no flagged_match. A word every declared label of the datasets the part answers from holds (the settled dataset's, else each one the elected shape answers from) names the table and restricts nothing. An unplaced phrase of such words records nothing, and the qualifier coverage rule (C93) reads such a word as covered. A word those datasets state of the rows the part reads (`DatasetDeclaration.stated_words`, `phase_rank_bind._dataset_stated_words`) restricts nothing on them and is read the same way, by the coverage rule, in an unplaced phrase, in the code terms of a part naming a source, and by a value requirement: a dataset states a word of the rows with one measured property, and where it names a kind of party, of the rows whose party on that flow axis is of the kind. Every CRS row states "bilateral"; a DAC2a or DAC2b `amount` row states it where the part binds its recipient to countries, and states nothing where the recipient is an organisation or the part leaves the recipient open. So "bilateral ODA to Kenya" mints the `spec_id` "ODA to Kenya" mints, and "bilateral ODA grants to Kenya" on a part naming the CRS refuses nothing. The part states a word where its variable or a typed qualifier phrase holds it as a modifier of the measure, with no function word, connective or separator before it (`bind_repair.word_stated`); a word an occurrence negates or excludes is not stated. A recipient holds the kind only where every reading it can settle to has it (`place_roles.place_set_kinds`): a pinned recipient has the kind of the entity its dcid resolved to, a group the kind of its members, and a group the recipient role admits at its own node the node's kind too, so the EU, whose node is an organisation, holds none. In the five-tuple family election a family reads a word it states beside a rival the code index carries that reads the word in no phrase, and a stated word adds nothing beside a rival that codes it or one the index does not carry (`bound_values.elect_five_tuple_family`). Recall searches a text holding a stated word a second time without it and adds the series of the datasets that state it (`interpret._detect`). A dataset also declares a word that names one of its parties (`DatasetDeclaration.party_words`): "multilateral" names `DACMultilaterals`, the DAC's total of the multilateral organisations, as the provider of a DAC2a or DAC2b `amount` row to a country. A part states the word as it states a stated word, on a flow axis no name of the part is directed to, no operation runs across and the part does not ask every member of, while a name is directed to each axis of the word's `where` (`place_roles.stated_party_words`). Such a part is read with the word first (`pipeline.resolve_variable`): recall searches the text without the word and adds the series of the datasets that declare it, and a role-bearing shape whose every dataset declares the word for its measured property binds the party on that axis, beside the places the query names, where they fill the word's `where` and the shape reads each word next to the word (`place_roles.held_party_words`, `_beside_words_read`, `bind_place_roles`). The party's set passes role admission as a named set does (`phase_intake._prune_shapes_by_admission`). The reading stands where a shape binding the party reads every phrase any other pooled shape reads, the binding shapes then being the pool (`phase_rank_bind.with_party_reading`), and the answer holds the party alone on the word's axis in every Spec (`pipeline.binds_each_party`). So "multilateral ODA to Kenya" mints the `spec_id` "ODA from multilaterals to Kenya" mints. Otherwise the reading is withdrawn (`decisions.PartyReadingWithdrawn`) and the part is resolved again without party words: its reply equals the reply of an index declaring no party word, and no event of the first reading is sent. "multilateral aid for education to Kenya", "multilateral development bank ODA to Kenya" and "multilateral loans to Kenya" reply so, as does a part naming the provider itself, which states no party word. A declared party the graph gives no type has no set, and the read is logged and noted as degraded (`phase_intake._party_word_sets`). The raw-text response cache keys on each stated or party word the query negates (`bind_repair.stated_words_negated`). A part naming a source refuses qualifier_not_bound before any bind call on a variable code term no row of its candidate shapes holds (`phase_rank_bind._undeclared_qualifiers`). Every no_data outcome a scoped part reaches past compile, at candidacy, election or the read, names the sources that limited it (`RegionResult.source_scope`, set in `pipeline.resolve_variable`): a qualifier_not_bound outcome lists in unbound_qualifiers its category terms, then the sources the part named, else those it excluded, then any other source it could not bind, each once (a part that names sources, none of whose tables holds its bindings on one row, names those sources alone, `phase_election._unserved_sources`), as an unbound entry under its label whose `named` holds its references; the message and rendered_sentence close on "The search read only ." or "The search left out ." (`render.no_data.scope_sentence`), and a qualifier_not_bound outcome naming no term reads "No series for this query comes from ." or "... from a source other than .". The bind menu offers no source, and no bind-model draft binds the source. One rule names a dataset on a shape that names none (`bound_values.part_source`), applied to the elected shape at election vetting and to each alternate when it is walked: the shape names the dataset the part is served from among its stamped datasets within its scope, whenever its unnamed identity would resolve to another dataset at the Datasets mint, which selects a family by the identity's slot keys. A scope excluding the dataset that would serve so names the one the election serves among the eligible others, and a shape whose slot keys select another family than its own names its own dataset. A shape the index does not carry names none. The rule reads the role keys the identity binds its places under only where two or more datasets could serve. Under a scope leaving a shape one dataset every candidate names it. Each alternate is named by the same rule under its own representative's bindings and role keys, as the elected shape is under the model's, so an alternate's identity is the one a question electing its shape under the same scope mints. A code the bindings bind, the model's or one the query text forces, that a publisher of the family outside the scope holds and no row of the part's datasets holds refuses qualifier_not_bound naming the code (`phase_rank_bind._unheld_bound_codes`). A code term of the variable, read in every code system, that no row of the part's named dataset holds and that the elected shape's own codes do not read word for word refuses qualifier_not_bound once the bindings are final, in either coverage mode. The term is an unbound record with evidence `unavailable` naming its codes, and the elected identity is the flagged_match (`phase_rank_bind._unavailable_qualifiers`). "multilateral ODA", DAC1 aid type 2000, on a part naming the CRS refuses with CRS ODA as the near match. A part naming a source whose bind call elects no shape refuses qualifier_not_bound on the code terms no offered shape holds, with no flagged_match (`phase_rank_bind._unelected_qualifiers`): "education aid" naming IDS names CRS purpose 110 and IDS. A refusal on an exclusion settles the answer of a part that only excludes sources (`ResolveState.scope_settled`, set in `phase_rank_bind.exclusion_records` alone), so its UN pass does not run (C51e): our catalogue holds the measure and the part left its publishers out. Three refusals settle: the filter leaving no reading that seats, a declining bind call after the filter removed a reading, and the scoped read finding the elected data excluded where the question asks (`pipeline._excluded_from_the_place`). A refusal of the election for codes or rows the part's datasets lack settles nothing, whatever source it names, and nor does any refusal of a part that names a source; such a part reaches the UN pass when its scope allows a UN System Data Commons provenance. A part whose scope allows no UN System Data Commons provenance holds no UN shape and never waits for the UN resolve (C51e, `phase_intake._un_pool`); a part whose exclusions leave a UN provenance allowed keeps its UN shapes on the same terms as the undeclared tables of ours. On the raw_text door the extraction names no source; the engine scans the query against the source dictionary (`source_scan.scan_sources`): at each word the longest key that runs over whole words, ignoring case, except that a one-word key of at most five characters, an abbreviation such as IDS, CRS, WHO or UN, matches only where the query writes it in capitals. A hit inside a longer entity name is that entity ("Catholic Relief Services (CRS)"); a hit whose words are a whole variable is the measure ("adolescent birth rate"); a hit inside a longer code label the query writes is that code's words ("UN" in "UN peacekeeping"); a hit naming an organisation, a Source or a collection such as the DAC, that is an entity or lies in a variable's words is a party to the flow ("debt service to the World Bank", "World Bank core contributions"), unless a data word follows it ("OECD data") or "according to" precedes it; a hit naming a provenance or Dataset is a source wherever it stands ("education aid to Kenya from the CRS"). A source is excluded when a negation cue ("excluding", "except", "not", "without", "other than", "apart from", "not counting", "leaving out") stands just before it, with only articles or "from" between, and included otherwise. Each source qualifies the variables whose words hold its words, else the variable it directly precedes, else the nearest variable before it, else the nearest after it; one variable takes every source, and a source no rule places qualifies every variable, so "WEO inflation and population" scopes inflation to the WEO alone. A dropped qualifier that only names a scanned source ("excluding the CRS", "selon le SNPC") is applied by its mention and raises no warning (`source_scan.source_phrases`). An extracted entity whose words a scanned source covers is that source and no entity of the part, so "OECD data on education aid to Kenya" has Kenya as its only entity (`source_scan.SourceScan.source_entities`). Each part compiles its variable's mentions as a parsed part's are (`entry.raw_text._prepare_extraction`). On every door a part's variable text leaves out the words of each mention its scope resolved to a source, compared as the dictionary reads them, unless nothing would remain (`source_scope.without_source_words`, applied in `entry.parsed.interpret_parts` and `fanout._stream_one_variable`): "DAC2a ODA" naming DAC2a recalls, binds, restates and mints as "ODA" naming DAC2a. Words naming no admitted source are no mention, so raw text does not refuse source_not_resolved, and a key naming two unrelated sources refuses source_ambiguous. Words that leave a part no source answer that part qualifier_not_bound before any bind call, naming the sources it named, else those it excluded, while the other parts answer (`region.result.empty_scope_result`); parsed mentions that leave a part no source refuse request_invalid at the mention that empties the scope. The raw-text response cache keys on each variable's mentions. A mention posted with a ref reads one way. The bind model sees the codes of every dataset the part may answer from, less the slots they all pin to one value. The family's dataset is elected from the bindings and the role keys the identity binds its places under (`code_index.members.elect_dataset`): the named dataset, else among the part's datasets one whose rows cover the role keys serves before one whose rows do not, then one whose family declares exactly the roles those keys place, then the lowest rank. A slot all the elected dataset's index rows of the five-tuple hold with one value (`CodeIndex.dataset_pins`, read through `code_index.members.elected_pins`, for example `dac2aAidType` and `crsTypeOfFlow` on `attributedAmount`) is a pin: the election implies it, no declared default binds it, a qualifier naming its value is bound, and the sentence states no pinned slot. Every identity Search mints or reads is canonical (`code_index.members.canonical_pins`): of the pinned slots the identity declares bound to their pin or unbound, the largest subset whose release leaves the election and the family unchanged is unbound, the first in lexicographic order of sorted property names among subsets of one size, and every other one is bound to its pin (a code only that dataset reports, bound by the question), whichever of them the bind model or the client bound. A first answer mints its identity canonical, a refusal's flagged identity included, whatever the bind model drafted; dataset_resubmit and the options door read the posted identity in canonical form, so an identity posted with a pin bound and one posted without mint one `spec_id`, elect one family and denote one population. The resubmit and the options door refuse `facet_not_available` for an identity naming a dataset that does not publish its family, whatever slots it declares, as the Datasets mint does. The measure vote that forces open measure axes reads the same election. resolved.trace.resolved_sources and the sentence name the one dataset elected for an index-resolved family, under its declared label. A disclosed alternative candidate names the dataset its shape names, as the elected shape does, so its identity is the one a question electing it mints, and an alternative may answer from another dataset than the elected part. A candidate folded into resolved.alternative_measures as a measure sibling carries the identity a question electing its shape under the answer's bindings mints: the sibling shape's slots, the dataset that shape names, in canonical form. The parsed door and the parsed entries of a dataset_resubmit request interpret their parts alike (`parsed.interpret_parts`), so an identical part answers the same family with the same warnings on either door. A part's source words match whole against the origin catalogue's names and the registry's aliases, and no door binds a source from free text any other way | implemented | Pinned by `tests/engine/test_dataset_bind.py`, `tests/engine/test_dataset_election_grid.py`, `tests/engine/test_aid_dataset_bind_e2e.py`, `tests/engine/test_source_choice.py`, `tests/engine/test_source_scope.py` and `tests/engine/test_source_scan.py`. The contract has no clause for this binding. | | C79 | An open axis selecting differently across a spec's series carries selection: "per_series" instead of ref/label | implemented | | | C80 | A unit bound through the request form also carries request_basis per member | implemented | | | C81 | resolved.coverage is exact, breadth, or bare, all carrying has_data. Breadth carries an ordered dimensions list; a role-bearing discovered scope's own breadth carries the open axis (recipients or providers) first, then the source-coverage dimension(s) the role index contributes, then a trailing member-variables dimension counting the StatVars the scope drew from, with window set to the requested window or null; an exact coverage's evidence reads facet_metadata when its observation_count comes from the facet index and no series ships in that payload, and observed when a preview series ships beside it, including a partial delivery where some requested series failed and an observations_probe_failed warning names the read that failed; the count is the facet-index count in either case (an SSE response event always reads facet_metadata) | implemented | | diff --git a/packages/dfo-data-tools/src/entity_resolution/resolver.py b/packages/dfo-data-tools/src/entity_resolution/resolver.py index 0640e43b..d0e0b45f 100644 --- a/packages/dfo-data-tools/src/entity_resolution/resolver.py +++ b/packages/dfo-data-tools/src/entity_resolution/resolver.py @@ -365,24 +365,25 @@ def _unless_expired[*Ts, R](deadline: float | None, fn: Callable[[*Ts], R], /, * return fn(*args) -def _group_node_dcids_job(shared: SharedResolver) -> frozenset[str]: - """The dcids `Group.entity` can hold, for every group resolvekit knows: the group's own - id as a dcid, and each organisation that survives the gate beside it - (`EntityResolver._assemble_group`). A region the membership policy holds is left out, - since the policy answers its members ("Africa (DAC)" resolves to `africa`). Runs on the - shared resolver's single worker.""" +def _group_nodes_job(shared: SharedResolver) -> dict[str, str]: + """The dcids `Group.entity` can hold, for every group resolvekit knows, each with the + first registered group name that resolves to it: the group's own id as a dcid, and each + organisation that survives the gate beside it (`EntityResolver._assemble_group`). A + region the membership policy holds is left out, since the policy answers its members + ("Africa (DAC)" resolves to `africa`). Runs on the shared resolver's single worker.""" context = ResolutionContext(entity_types=ADMITTED_TYPES) - dcids: set[str] = set() + names: dict[str, str] = {} for name in shared.resolver.known_groups(): listed, _, _ = _resolve_text_survivors(shared, name, context) survivors = [candidate for candidate, _ in listed] if not survivors or survivors[0].entity_type not in _GROUP_TYPES: continue nodes = [survivors[0]] + [c for c in survivors[1:] if c.entity_type == _ORG_ENTITY_TYPE] - dcids.update( - dcid for node in nodes if (dcid := identity_to_dcid(node.entity_id)) is not None - ) - return frozenset(dcid for dcid in dcids if not shared.policy.holds_region(dcid)) + for node in nodes: + dcid = identity_to_dcid(node.entity_id) + if dcid is not None and not shared.policy.holds_region(dcid): + names.setdefault(dcid, name) + return names def _entity_from_confirmation( @@ -467,7 +468,7 @@ def __init__( self._cache: TLRUCache[_CacheKey, Resolution] = TLRUCache(maxsize=cache_size, ttu=_ttu) self._hits = 0 self._misses = 0 - self._group_node_dcids: frozenset[str] | None = None + self._group_nodes: Mapping[str, str] | None = None @property def membership(self) -> MembershipPolicy: @@ -501,11 +502,18 @@ async def group_node_dcids(self) -> frozenset[str]: """The dcids that are the node of a group the resolver knows, such as `EuropeanUnion`: every dcid a `Group.entity` can hold. The packs fix the set, so it is computed once and no graph is read.""" - if self._group_node_dcids is None: - self._group_node_dcids = await asyncio.get_running_loop().run_in_executor( - self._shared.executor, _group_node_dcids_job, self._shared + return frozenset(await self.group_node_names()) + + async def group_node_names(self) -> Mapping[str, str]: + """Each dcid of `group_node_dcids` with a registered group name that resolves to it, + such as `EuropeanUnion` to "European Union": the text that asks for the group's + members, which a pin on the node cannot. Computed once from the packs, with no + graph read.""" + if self._group_nodes is None: + self._group_nodes = await asyncio.get_running_loop().run_in_executor( + self._shared.executor, _group_nodes_job, self._shared ) - return self._group_node_dcids + return self._group_nodes async def resolve( self, diff --git a/src/search/README.md b/src/search/README.md index c1e68d1b..7b3bf65e 100644 --- a/src/search/README.md +++ b/src/search/README.md @@ -99,11 +99,11 @@ A part asks for every member of a flow role in one of two ways: `open_roles` on Two tables that publish one structural question are chosen between before bind. Each table reads the query in its own codes, and it reads a phrase when its rows hold a code the phrase names, a phrase naming several codes included (`retrieval/bound_values.query_phrases_read`). Of two families of one five-tuple and role class, the one reading fewer of the query's phrases leaves, whether or not the code index carries them (`elect_five_tuple_family`). "Imputed multilateral ODA to basic education" reads the sector only on ONE's imputed sectors table, so DAC2a's imputed total leaves. A table can also state a word of every row it serves without coding it (`DatasetDeclaration.stated_words`): every CRS row is bilateral, since the import leaves out core contributions to multilateral organisations, and a DAC2a or DAC2b amount is bilateral where its recipient is a country. Such a word restricts nothing on those rows, so a part reads it as it reads a word of the table's label (`region/phase_rank_bind._dataset_stated_words`), and "bilateral ODA to Kenya" answers as "ODA to Kenya" does. The part states the word where it modifies the measure ("net bilateral ODA"): a negated or excluded occurrence ("non-bilateral", "excluding bilateral flows") and one past a function word, a connective or a separator ("ODA net of bilateral flows") are not read (`bind_repair.word_stated`). In the election a family reads a word it states beside a rival the code index carries that reads the word in no phrase, so "bilateral ODA from Germany by recipient" leaves DAC2a, whose open recipient axis holds multilateral organisations, for the CRS. Beside a rival that codes the word a stated word adds nothing, so "bilateral ODA from France" stays DAC1's bilateral aid type, and it adds nothing beside a family the index does not carry, so a debt series by bilateral creditors compares with the CRS as it does without the word. The graph's text search matches series names, which hold no stated word, so recall searches a text holding one a second time without it and adds the series of the tables that state it (`interpret._detect`). A table can also declare that a word names one of its parties (`DatasetDeclaration.party_words`): "multilateral" names `DACMultilaterals`, the DAC's published total of the multilateral organisations, as the provider of a DAC2a or DAC2b amount to a country. A part states such a word as it states a stated word, on a flow axis the request leaves unnamed: no place is directed to it, no operation runs across it and the part does not ask every member of it, while a name is directed to each axis the declaration reads a kind on (`region/place_roles.stated_party_words`). Such a part is read with the word first (`region/pipeline.resolve_variable`). Recall also searches the text without the word for the series of the tables that declare it, intake reads the party's node as a place set (`region/phase_intake._party_word_sets`), and a shape whose every dataset declares the word binds the party on that axis beside the places the query names, where the shape reads each word next to the word (`held_party_words`, `_beside_words_read`, bound in `bind_place_roles`). The party's set passes role admission as a named set does. The reading stands where a shape binding the party reads every phrase any other pooled shape reads, the word counted as read; the binding shapes are then the pool (`region/phase_rank_bind.with_party_reading`). "Multilateral ODA to Kenya" answers DAC2a's flows from the multilaterals total, as "ODA from multilaterals to Kenya" does. The reading is withdrawn where no shape binds the party ("multilateral development bank ODA to Kenya"), where another shape reads a phrase the binding shapes do not (the CRS sector of "multilateral aid for education to Kenya"), where another shape reads the word as a value of its own, in a phrase of its codes or by a graph label ("multilateral loans to Kenya", a World Bank IDS creditor type), and where the answer it reaches does not hold the party in every Spec. A withdrawn reading leaves only its model usage and failed reads on the reply: the part is resolved again without party words, exactly as a request stating none, under the same deadline, and the first reading's events are never sent. A stated party word that binds no party is therefore dropped or refused as it is without the declaration. A request that names the provider itself, by a place or an alias of the party, states no party word. A declared party the graph gives no type has no set, and the read is logged and noted as degraded. A negated or excluded word names no party, and the response cache keys on it (`bind_repair.stated_words_negated`). Of families that read the same phrases, one holding no series at the places the part names, on the roles they bind, leaves when another holds one, as the request's coverage snapshot records them (`region/phase_rank_bind._series_reader`). "Other official flows from Germany to Brazil" reads alike on the CRS and DAC2b, and DAC2b, whose bilateral donors report only against aggregate recipients, leaves for the CRS, while "other official flows to Brazil" keeps both. The rule stands down when the part names no place, when coverage holds none of a family's members, and when no family holds a series there, where the part refuses for want of data. Between a family the index carries and one it does not that read alike, the stronger recall score stays. Codes of different tables that name one concept are declared as a group in `code_index/declarations.py` (`CONCEPT_GROUPS`), and the export gives each code the union of the group's texts, so "food aid" reads on DAC2a's developmental food aid as on CRS purpose 520, and "ODA" on DAC2a 206 as on the CRS flow type and DAC1 1010. DAC2a's imputed multilateral code also reads "imputed", so "imputed aid from Norway" keeps DAC2a's imputed line. Then a role-bearing shape that publishes on a flow axis the query neither names nor opens leaves for one that declares fewer such axes and reads every phrase it reads, across five-tuples (`region/place_roles.without_unasked_axes`). "Aid from Japan" reads DAC1's donor totals over DAC2a's and CRS's flows by recipient, and "imputed multilateral ODA from Germany" keeps DAC2a's imputed table, since DAC1 does not read "imputed". Shapes of another measure compare because a question naming no measure can recall them. "ODA from Japan" leaves CRS commitments for DAC1's donor disbursements, and "ODA commitments from Japan" keeps commitments over those disbursements, since a measure word reads only on the shape whose five-tuple or constraint value it names. A donor-only "grant equivalent ODA from France" elects DAC1's grant-equivalent total over the CRS grant equivalents: recall adds a row of that DAC1 family beside the CRS hits (`clients/graph.with_rival_families`, applied by `recall_hits`), it reads the measure word as the CRS does, and it declares no recipient axis. A row recall adds this way displaces only shapes of its own five-tuple, since the query did not recall it, and derivation confirms it beside every recalled hit, so it takes no candidate's place. "Aid attributed to donors via multilaterals" keeps the imputed tables, whose measure reads "attributed to donors", while "aid from Japan", which names no measure, reads DAC1's donor totals over them. Both shapes must be ones the code index carries, since another shape's axes are those of its recalled sample and it reads no index code, and the rule stands down when a named place has no direction. -A parsed entity asks for the members of the set its text names with `members: true`: "African countries" is `{"text": "Africa", "members": true}`, and each member is its own series. Without the flag a continent, UN subregion or group binds its own node where that node holds the series and its members where it does not. The flag has no effect on a single place. Entity text whose wording names members, such as `EU member states`, reads as `members: true` through the rows of `entity_resolution/data/query_aliases.json` that declare it. With a pinned `dcid` the flag is honoured for a continent or subregion that the membership policy holds or omits (`MembershipPolicy.holds_region`), whatever the node's graph type, and refuses `request_invalid` at `/input/parts/{i}/entities/{j}` for a group's own node. A part that posts one entity text with both values refuses `request_invalid` at `/input/parts/{i}/entities`. A posted `false` is the field left out, so the part's `restatement` and an `entity_ambiguous` part's `data_query` carry `members` on the wire only when it is true. On the `raw_text` door the extractor states the same reading for each entity it yields (`ExtractedEntity.members`): it reads "African countries" and "countries in Africa" as `{"text": "Africa", "members": true}` and "Africa" or "Africa as a whole" as the default. The language model reads the wording and the engine matches no words. Two mentions of one text hold one reading, and the mention that asks for the members decides it. +A parsed entity asks for the members of the set its text names with `members: true`: "African countries" is `{"text": "Africa", "members": true}`, and each member is its own series. Without the flag a continent, UN subregion or group binds its own node where that node holds the series and its members where it does not. The flag has no effect on a single place. Entity text whose wording names members, such as `EU member states`, reads as `members: true` through the rows of `entity_resolution/data/query_aliases.json` that declare it. With a pinned `dcid` the flag is honoured for a continent or subregion that the membership policy holds or omits (`MembershipPolicy.holds_region`), whatever the node's graph type, and refuses `request_invalid` at `/input/parts/{i}/entities/{j}` for a group's own node. A part that posts one entity text with both values refuses `request_invalid` at `/input/parts/{i}/entities`. A posted `false` is the field left out, so the part's `restatement` and the `data_query` of each of a refused part's `no_data.options` carry `members` on the wire only when it is true. On the `raw_text` door the extractor states the same reading for each entity it yields (`ExtractedEntity.members`): it reads "African countries" and "countries in Africa" as `{"text": "Africa", "members": true}` and "Africa" or "Africa as a whole" as the default. The language model reads the wording and the engine matches no words. Two mentions of one text hold one reading, and the mention that asks for the members decides it. Every `no_data` part carries `no_data.message`, the plain-language sentence for its `reason` with the indicator, place or category wording the reason names, so a client can show a person the reason beside its code. -An `entity_ambiguous` part carries `no_data.data_query`, a one-part `ParsedInput` built from the part's parsed inputs whether or not it carries a restatement: every entity that resolved to one node is pinned to its dcid, except a set read as its members, and each ambiguous wording is left unpinned. A client pins the candidate the user picks from `no_data.ambiguous_entities` and posts the part back through the `parsed` door or as a `dataset_resubmit` `parsed` entry. The part's sentence plan marks each ambiguous wording as a `slot` token carrying the role the query gave it, so the client hangs the candidate menu on that phrase. +A part refused as a choice (`entity_ambiguous`, `source_ambiguous`, `source_code_ambiguous`, `role_not_resolved`, `unit_not_resolved`) carries `no_data.options`, one ready request per choice, each a one-part `ParsedInput` with a label and the request words it settles, built from the part's parsed inputs whether or not it carries a restatement (`conjoin._options`). An entity option pins the candidate on every row with the ambiguous wording and every entity that resolved to one node, leaving named groups, sets read as their members and other ambiguous wordings unpinned, so a second ambiguous wording refuses again with the first pick kept. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid, since a pin cannot ask for a group's members. A source option sets the candidate's ref on the ambiguous mention; a source-code option names the holding provenance as the part's only included source, since included sources union. A role option states a direction for every name the question left unstated (`place_roles.direction_readings`): each untold set takes an axis the shape declares a role on and the part does not ask open, and every such axis must hold a place. It is offered while that gives at most six readings, and none is offered when the single untold place was read in each direction and no reading holds data. A unit option names one of `no_data.unit_alternatives` in place of the ambiguous currency words, so "constant 2015 francs" offers "constant 2015 XOF" (`unit.bind.unit_text_naming`). An option whose part breaks the `ParsedPart` bounds, or whose stated directions its own wording contradicts, is left out. Data for an option is not checked. The client posts the chosen option's `data_query` unchanged as the request's `input`, and its one part is a `dataset_resubmit` `parsed` entry's `part`. The part's sentence plan marks each ambiguous wording as a `slot` token carrying the role the query gave it, so the client hangs the candidate menu on that phrase. Timing splits the same way. Each part's own `timing_ms.by_step` names the phases that variable's own leg ran (`recall`, `shape`, `bind`, `materialise`, `answer`), and `total` sums them. `shape_arcs`, `shape_probe`, `shape_probe_coverage` (inside `shape_probe`), `shape_labels`, `shape_groups` and `shape_admission` time stages inside `shape`, so `total` leaves them out (`conjoin.NESTED_SHAPE_STEPS`). `diagnostics.timing_ms` carries the whole request's wall-clock `total` plus `by_step` for the steps every part shares: `coverage` for the request's coverage snapshot and `extract` for extraction. diff --git a/src/search/engine/assemble.py b/src/search/engine/assemble.py index f6d0ce3b..37b83191 100644 --- a/src/search/engine/assemble.py +++ b/src/search/engine/assemble.py @@ -38,11 +38,11 @@ NoData, NoDataDenominator, NoDataGate, + NoDataOption, NoDataPart, NoDataReason, Operation, Ordering, - ParsedInput, ParsedPart, ResolvedQualifier, RoleAxis, @@ -470,7 +470,7 @@ def build_no_data_part( ambiguous_candidates: list[str] | None = None, ambiguous_entities: list[AmbiguousEntityGroup] | None = None, ambiguous_roles: Mapping[str, RoleAxis] | None = None, - data_query: ParsedInput | None = None, + options: list[NoDataOption] | None = None, source_terms: list[str] | None = None, ambiguous_sources: list[AmbiguousSourceGroup] | None = None, qualifier_terms: list[ResolvedQualifier] | None = None, @@ -501,8 +501,8 @@ def build_no_data_part( query-bound role values for alternate validation, including when both roles are bound. ``ambiguous_candidates`` feeds the wording. ``ambiguous_entities`` carries structured graph candidates, and ``ambiguous_roles`` names the flow axis of each ambiguous wording - the sentence marks as a slot. ``data_query`` is the part's parsed form for - ``entity_ambiguous``, ``source_ambiguous`` and ``source_code_ambiguous``. + the sentence marks as a slot. ``options`` are the ready requests of a refusal that + asks for a choice (`conjoin._options`). ``source_terms`` are the source wordings a ``source_not_resolved`` or ``source_ambiguous`` outcome rests on, or the series code a ``source_code_*`` outcome rests on, and ``ambiguous_sources`` pairs each ambiguous one with its candidates. For @@ -653,7 +653,7 @@ def build_no_data_part( unbound_qualifiers=[*(qualifier_terms or ()), *(unbound_sources or ())], unit_alternatives=unit_alternatives, denominator=denominator, - data_query=data_query, + options=options or [], ), rendered_sentence=rendered, sentence_plan=plan, diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index 551ad7d5..4b8d1dba 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -9,9 +9,11 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import TYPE_CHECKING +from pydantic import ValidationError + from search.engine.assemble import ( build_candidates_part, build_definite_part, @@ -23,24 +25,38 @@ from search.engine.departures import surviving_facts from search.engine.envelope import make_diagnostics, make_query_echo, make_response, now_ms from search.engine.region.source_choice import scope_records +from search.engine.role_text import ( + parsed_directions_contradicted, + role_axis_for_text, + role_text_for_axis, +) from search.engine.same_shape import FacetOptionsReader, collapse_same_shape, fold_key +from search.engine.unit.bind import unit_text_naming from search.engine.unit.conversion import conversion_unavailable_warning from search.models import ( AmbiguousEntityCandidate, AmbiguousEntityGroup, AmbiguousSourceCandidate, AmbiguousSourceGroup, + NoDataOption, + NoDataReason, + ParsedEntity, ParsedInput, + ParsedPart, Resolution, ResolvedQualifier, + RoleAxis, + SourceMention, Timing, Warning, ) +from search.render.labels import _join_labels from search.render.no_data import ScopeSources from skills_index import SkillsIndex if TYPE_CHECKING: from search.engine.region import RegionResult + from search.engine.region.result import AmbiguousEntityCandidate as EngineCandidate from search.models import GraphRef, Part, QueryEcho # --------------------------------------------------------------------------- @@ -237,33 +253,167 @@ def _unbound_terms( return terms or None, list(sources.values()) or None, naming -def _data_query(region: RegionResult) -> ParsedInput | None: - """Return the part's parsed form for an `entity_ambiguous`, `source_ambiguous` or - `source_code_ambiguous` outcome. - - On `entity_ambiguous` it is the region's parsed form with every entity the region - resolved to one node pinned to its dcid, and each ambiguous wording left unpinned for - the client to pin the user's pick. A leg that cannot name a direction withholds its - restatement and still carries this form. On `source_ambiguous` it is the parsed form - as it stands: its source mentions already carry the references they resolved to, and - each ambiguous wording none. On `source_code_ambiguous` it is the parsed form as it - stands too, for the client to add the source the user picks to its `sources`. `None` for - every other reason, and when the region has no parsed form. - """ - if region.parsed_form is None: +def _option(mentions: Sequence[str], label: str, part: ParsedPart) -> NoDataOption | None: + """One option posting `part`, or `None` when `part` breaks the `ParsedPart` bounds or + states a direction its own wording contradicts, which the parsed door refuses.""" + try: + posted = ParsedPart.model_validate(part.model_dump()) + except ValidationError: return None - if region.no_data_reason in ("source_ambiguous", "source_code_ambiguous"): - return ParsedInput(parts=[region.parsed_form]) - if region.no_data_reason != "entity_ambiguous": + if parsed_directions_contradicted(posted): return None - part = region.parsed_form - pinned = [ + return NoDataOption( + mentions=list(mentions), label=label, data_query=ParsedInput(parts=[posted]) + ) + + +def _entity_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: + """One option per ambiguous wording and candidate: the part with every entity that + resolved to one node pinned, and every row with the wording pinned to the candidate. + A row asked for its members takes a group's own node by the group's registered name, + with no dcid, since a pin cannot ask for a group's members. Other ambiguous wordings + stay unpinned.""" + base = [ entity.model_copy( update={"dcid": region.resolved_entity_dcids.get(entity.text, entity.dcid)} ) for entity in part.entities or () ] - return ParsedInput(parts=[part.model_copy(update={"entities": pinned or None})]) + + def chosen(entity: ParsedEntity, candidate: EngineCandidate) -> ParsedEntity: + if entity.members and candidate.group_name is not None: + return entity.model_copy(update={"text": candidate.group_name, "dcid": None}) + return entity.model_copy(update={"dcid": candidate.dcid}) + + return [ + _option( + [group.text], + candidate.name, + part.model_copy( + update={ + "entities": [ + chosen(entity, candidate) if entity.text == group.text else entity + for entity in base + ] + } + ), + ) + for group in region.ambiguous_entities + for candidate in group.candidates + ] + + +def _source_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: + """One option per ambiguous source wording and candidate, setting the candidate's ref on + every ref-less mention with that wording; a wording mentioned twice gives its options + once. A label names an excluded source as excluded.""" + options: list[NoDataOption | None] = [] + for text in dict.fromkeys(group.text for group in region.ambiguous_sources): + group = next(group for group in region.ambiguous_sources if group.text == text) + mentions = [m for m in part.sources if m.ref is None and m.text == text] + excluded = bool(mentions) and mentions[0].polarity == "exclude" + for candidate in group.candidates: + sources = [ + m.model_copy(update={"ref": candidate.ref}) if m in mentions else m + for m in part.sources + ] + label = f"Excluding {candidate.label}" if excluded else candidate.label + options.append(_option([text], label, part.model_copy(update={"sources": sources}))) + return options + + +def _source_code_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: + """One option per provenance holding the part's code, naming it as the part's only + included source: included scopes union, so a source added beside the others would leave + both holders allowed. Exclusions stay.""" + excluded = [mention for mention in part.sources if mention.polarity == "exclude"] + return [ + _option( + [group.text], + candidate.label, + part.model_copy(update={"sources": [*excluded, SourceMention(ref=candidate.ref)]}), + ) + for group in region.ambiguous_sources + for candidate in group.candidates + ] + + +def _role_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: + """One option per reading of the directions the question left untold + (`RegionResult.role_assignments`): every row of a mapped text states its axis, and a row + the reading repeats is dropped. The label reads the option's own directions.""" + words = region.entity_words or {} + mentions = [words.get(name, name) for name in region.unresolved_entity_names] + options: list[NoDataOption | None] = [] + for reading in region.role_assignments: + rows = [ + entity.model_copy(update={"role_text": role_text_for_axis(reading[entity.text])}) + if entity.text in reading + else entity + for entity in part.entities or () + ] + entities = [row for i, row in enumerate(rows) if row not in rows[:i]] + label = _direction_label(entities) + options.append(_option(mentions, label, part.model_copy(update={"entities": entities}))) + return options + + +def _direction_label(entities: Sequence[ParsedEntity]) -> str: + """The directions `entities` state, in words: origins "to" destinations, or "From" or + "To" with the names when one axis holds them.""" + by_axis: dict[RoleAxis, list[str]] = {"origin": [], "destination": []} + for entity in entities: + axis = role_axis_for_text(entity.role_text) + if axis is not None and entity.text not in by_axis[axis]: + by_axis[axis].append(entity.text) + origins, destinations = (_join_labels(by_axis[axis]) for axis in ("origin", "destination")) + if origins and destinations: + return f"{origins} to {destinations}" + return f"From {origins}" if origins else f"To {destinations}" + + +def _unit_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: + """One option per currency a `unit_not_resolved` refusal offers, its unit text naming + that currency in place of the ambiguous words (`unit.bind.unit_text_naming`). A role + currency's label names the role's currency.""" + if part.unit_text is None: + return [] + options: list[NoDataOption | None] = [] + for alternative in region.unit_alternatives: + unit_text = unit_text_naming(part.unit_text, alternative.currency) + if unit_text is None: + continue + label = ( + alternative.label + if isinstance(alternative.currency, str) + else f"{alternative.label}'s currency" + ) + options.append( + _option([part.unit_text], label, part.model_copy(update={"unit_text": unit_text})) + ) + return options + + +_OPTION_BUILDERS: dict[ + NoDataReason, Callable[[RegionResult, ParsedPart], list[NoDataOption | None]] +] = { + "entity_ambiguous": _entity_options, + "source_ambiguous": _source_options, + "source_code_ambiguous": _source_code_options, + "role_not_resolved": _role_options, + "unit_not_resolved": _unit_options, +} + + +def _options(region: RegionResult) -> list[NoDataOption]: + """The ready requests of a refusal that asks for a choice, each a one-part parsed input + built from the region's parsed form. Empty for every other reason, when the region has + no parsed form (its inputs exceed the `ParsedPart` bounds), and for an option whose part + breaks those bounds or contradicts its own wording.""" + build = _OPTION_BUILDERS.get(region.no_data_reason) if region.no_data_reason else None + if build is None or region.parsed_form is None: + return [] + return [option for option in build(region, region.parsed_form) if option is not None] def _build_part( @@ -401,7 +551,7 @@ def _build_part( ambiguous_roles={ entity.text: entity.role for entity in region.ambiguous_entities if entity.role }, - data_query=_data_query(region), + options=_options(region), source_terms=list(region.source_terms) or None, ambiguous_sources=_ambiguous_source_groups(region), qualifier_terms=qualifier_terms, diff --git a/src/search/engine/entry/parsed.py b/src/search/engine/entry/parsed.py index a187686e..19f960d9 100644 --- a/src/search/engine/entry/parsed.py +++ b/src/search/engine/entry/parsed.py @@ -60,6 +60,7 @@ from search.engine.role_text import ( ROLE_TEXT_NOT_RECOGNISED, DoorRole, + parsed_role_query, role_axis_for_text, ) from search.engine.source_code import PartCode, compile_part_code, without_code_words @@ -93,7 +94,6 @@ def _role_query_for_part(part: ParsedPart) -> tuple[str, list[Warning]]: `_entity_roles_for_part`. Return warnings for unrecognised role text. Those entities remain undirected. """ - words = [part.variable_text] warnings: list[Warning] = [] for entity in part.entities or (): axis = role_axis_for_text(entity.role_text) @@ -110,8 +110,7 @@ def _role_query_for_part(part: ParsedPart) -> tuple[str, list[Warning]]: ), ) ) - words.append(entity.text) - return " ".join(words), warnings + return parsed_role_query(part), warnings def _entity_roles_for_part(part: ParsedPart) -> dict[str, list[DoorRole]]: diff --git a/src/search/engine/fanout.py b/src/search/engine/fanout.py index 49d6c842..06ba80d2 100644 --- a/src/search/engine/fanout.py +++ b/src/search/engine/fanout.py @@ -224,7 +224,7 @@ def _leg_parsed_form( open_axes: Collection[RoleAxis] = (), separate: bool = False, ) -> ParsedPart | None: - """Build the leg's inputs as one parsed part, the form `data_query` pins into. + """Build the leg's inputs as one parsed part, the form `NoData.options` are built from. Each entity appears once per distinct axis the door gave it, with that axis as `role_text`, as the parsed door reads a name repeated on opposite axes. An entity diff --git a/src/search/engine/region/phase_intake.py b/src/search/engine/region/phase_intake.py index 3787cbc8..d4b310b4 100644 --- a/src/search/engine/region/phase_intake.py +++ b/src/search/engine/region/phase_intake.py @@ -265,23 +265,35 @@ async def _phase_check_entities(state: ResolveState) -> RegionResult | None: } if not reasons: return None + ambiguous = [name for name, name_reason in reasons.items() if name_reason == "entity_ambiguous"] + # A pin on a group's own node cannot ask for its members, so a candidate of a wording + # asked for its members carries the group's registered name for its option to post. + group_names = ( + await state.resolver.group_node_names() + if any(name in state.member_entities for name in ambiguous) + else {} + ) ambiguous_entities = tuple( AmbiguousEntity( text=name, candidates=tuple( AmbiguousEntityCandidate( - dcid=candidate.dcid, name=candidate.name, kind=candidate.kind + dcid=candidate.dcid, + name=candidate.name, + kind=candidate.kind, + group_name=( + group_names.get(candidate.dcid) if name in state.member_entities else None + ), ) for candidate in cast(Ambiguous, rcl.resolutions[name]).candidates ), role=_sole_axis(state, name), ) - for name, name_reason in reasons.items() - if name_reason == "entity_ambiguous" + for name in ambiguous ) - # The part's `data_query` pins these on an `entity_ambiguous` refusal. A group, and a - # set read as its members, stay unpinned: re-resolving the text keeps the members, - # whether an alias row or the part's flag asked for them. + # Each option of an `entity_ambiguous` refusal pins these. A group, and a set read as + # its members, stay unpinned: re-resolving the text keeps the members, whether an alias + # row or the part's flag asked for them. resolved_entity_dcids = { name: place_set.node.dcid for name in dict.fromkeys(state.entities) diff --git a/src/search/engine/region/phase_place_materialise.py b/src/search/engine/region/phase_place_materialise.py index 469239ea..70038d47 100644 --- a/src/search/engine/region/phase_place_materialise.py +++ b/src/search/engine/region/phase_place_materialise.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Collection, Iterable, Mapping, Sequence from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass -from typing import Final, cast +from typing import Final, Literal, cast import dc_graph from dfo_platform.executor import submit_with_context @@ -269,6 +269,8 @@ async def _phase_place_bindings(state: ResolveState) -> RegionResult | None: return await _role_incompatible_no_data(state) if bound == "role_not_resolved": return await _role_not_resolved_no_data(state) + if bound == "no_direction_holds": + return await _role_not_resolved_no_data(state, offer=False) state.elected_place_roles = bound # The bound roles elect one role class among the shape's members, and every later # step reads the shape narrowed to it. The election reads a held family whole, so it @@ -384,19 +386,25 @@ def _election_refusal( return state.no_data("variable_not_resolved", llm_usage=state.var_usage) -def _settle_direction(state: ResolveState) -> PlaceAssignment | PlaceRoleRefusal: +def _settle_direction( + state: ResolveState, +) -> PlaceAssignment | PlaceRoleRefusal | Literal["no_direction_holds"]: """The elected shape's places when one is named without a direction no rule places (`place_roles.direction_assignments`): the availability read runs once per role assignment (`availability.series_at_places`). Exactly one with data is served, and the answer states the direction it reads the place on (`departures.direction_inferred`). When several hold data the first is served and each other is offered beside it as a `direction` candidate: a copy of the elected shape on the menu, bound on its own - assignment (`ResolveState.direction_rivals`). Otherwise the places refuse - ``role_not_resolved``, as a question whose direction no data settles.""" + assignment (`ResolveState.direction_rivals`). When readings exist and none holds data + the places refuse ``no_direction_holds``, a ``role_not_resolved`` that offers no + reading, since each is known empty. Otherwise they refuse ``role_not_resolved``, as a + question whose direction no data settles.""" shape = state.shape_draft readings = direction_assignments(state, shape) series = series_at_places(state) held = [(axis, assignment) for axis, assignment in readings if series(shape, assignment)] + if readings and not held: + return "no_direction_holds" name = undirected_place_name(state) if not held or name is None: return "role_not_resolved" diff --git a/src/search/engine/region/phase_shared.py b/src/search/engine/region/phase_shared.py index d5763693..b3217c48 100644 --- a/src/search/engine/region/phase_shared.py +++ b/src/search/engine/region/phase_shared.py @@ -23,7 +23,11 @@ requested_window_from_date_request, role_bindings, ) -from search.engine.region.place_roles import request_named_groups, untold_direction_names +from search.engine.region.place_roles import ( + direction_readings, + request_named_groups, + untold_direction_names, +) from search.engine.region.requirements import refusal_terms, settle_answer, settle_flagged from search.engine.region.result import RegionResult from search.engine.region.state import ResolveState @@ -406,15 +410,18 @@ async def _role_incompatible_no_data(state: ResolveState) -> RegionResult: ) -async def _role_not_resolved_no_data(state: ResolveState) -> RegionResult: +async def _role_not_resolved_no_data(state: ResolveState, *, offer: bool = True) -> RegionResult: """Return `role_not_resolved` when place roles cannot be mapped. This covers multiple places without resolvable axes, a solitary place without an axis, or wording that contradicts a supplied axis. A `flagged_match` requires a resolved role map, which this refusal lacks. The names whose direction it could not tell - (`untold_direction_names`) ride as the result's `unresolved_entity_names`.""" + (`untold_direction_names`) ride as the result's `unresolved_entity_names`, and the + readings of their directions (`direction_readings`) as its `role_assignments`, unless + `offer` is false: every reading was read and none holds data.""" return state.no_data( "role_not_resolved", llm_usage=state.var_usage, unresolved_entity_names=untold_direction_names(state), + role_assignments=direction_readings(state) if offer else (), ) diff --git a/src/search/engine/region/place_roles.py b/src/search/engine/region/place_roles.py index e8b9289a..decd562c 100644 --- a/src/search/engine/region/place_roles.py +++ b/src/search/engine/region/place_roles.py @@ -1130,6 +1130,65 @@ def untold_direction_names(state: ResolveState) -> tuple[str, ...]: return tuple(contradicted.values()) +# The most readings of an unstated direction a `role_not_resolved` refusal offers. Two sets +# on a shape with a role on each axis give 2, three give 6, four 14; past this the refusal +# offers none and its message stands. +MAX_DIRECTION_READINGS = 6 + + +def direction_readings(state: ResolveState) -> tuple[dict[str, RoleAxis], ...]: + """The readings a `role_not_resolved` refusal offers for the directions the question + leaves untold, each mapping every request text of every untold set to one flow axis. + + The untold sets are those no text of which carries a direction, else those whose stated + direction the query's wording contradicts (`untold_direction_names`). Each takes one + axis on which the elected shape declares a role and that the part does not ask open + (`ResolveState.open_axes`, or an operation's `over`); an open axis counts as named and + takes no set. A reading is kept when every such axis then holds a set, a directed one + included. Readings run over the sets in query order, origin first. Empty when the shape + declares no role, no set is untold, or more than `MAX_DIRECTION_READINGS` readings are + kept; the count stops there, so no reading past it is built.""" + shape = state.shape_draft + if shape is None or not is_role_bearing(shape): + return () + named = part_sets(state) + texts: dict[str, list[str]] = {} + for name in dict.fromkeys(state.entities): + if (place_set := state.place_sets.get(name)) is not None: + texts.setdefault(place_key(place_set), []).append(name) + directed = { + key: {role.axis for name in names for role in state.entity_roles.get(name) or ()} + for key, names in texts.items() + } + untold = [key for key in named if not directed[key]] + if not untold: + contradicted = _contradicted_names( + state, [name for names in texts.values() for name in names] + ) + untold = list(dict.fromkeys(place_key(state.place_sets[name]) for name in contradicted)) + if not untold: + return () + over = {operation.over for operation in state.operations} + declared = role_names_by_axis(shape) + axes: tuple[RoleAxis, ...] = tuple( + axis + for axis in ("origin", "destination") + if axis in declared and axis not in state.open_axes and axis not in over + ) + held = {axis for key, told in directed.items() if key not in untold for axis in told} + needed = set(axes) - held + readings: list[dict[str, RoleAxis]] = [] + for choice in itertools.product(axes, repeat=len(untold)): + if not needed <= set(choice): + continue + if len(readings) == MAX_DIRECTION_READINGS: + return () + readings.append( + {text: axis for key, axis in zip(untold, choice, strict=True) for text in texts[key]} + ) + return tuple(readings) + + def _contradicted_names(state: ResolveState, names: Iterable[str]) -> tuple[str, ...]: """The names in `names`, in order, one of whose stated directions the query's own wording contradicts (`role_text.contradicts`).""" diff --git a/src/search/engine/region/result.py b/src/search/engine/region/result.py index d16ecaf5..7ad6d04a 100644 --- a/src/search/engine/region/result.py +++ b/src/search/engine/region/result.py @@ -75,11 +75,16 @@ class FacetAxisFilter: @dataclass(frozen=True) class AmbiguousEntityCandidate: - """One graph node that could match an ambiguous query wording.""" + """One graph node that could match an ambiguous query wording. + + `group_name` is the registered name of the group whose own node the candidate is, set + when the part asks for the wording's members: a pin on a group's node cannot ask for + its members, so the candidate's option posts this name instead. `None` otherwise.""" dcid: str name: str kind: str + group_name: str | None = None @dataclass(frozen=True) @@ -174,7 +179,7 @@ class RegionResult: # its sentence names. `None` for every other outcome. source_code_refusal: SourceCodeRefusal | None = None # Entity names that resolved to exactly one graph node, with that node's dcid, - # populated only for `entity_ambiguous`. The part's `data_query` pins them. + # populated only for `entity_ambiguous`. Each of the part's options pins them. resolved_entity_dcids: Mapping[str, str] = field(default_factory=dict) # Resolved entities without an admitted role in resolution order, populated # only for `entity_role_not_admitted`. @@ -204,6 +209,11 @@ class RegionResult: # Capped currencies a `unit_not_resolved` refusal offers. Empty for every other # outcome and for a refusal with no candidates. unit_alternatives: tuple[UnitAlternative, ...] = () + # The readings of a `role_not_resolved` refusal's unstated directions, each mapping + # every request text of every set whose direction the question leaves untold to one + # flow axis (`place_roles.direction_readings`). Empty for every other outcome, past + # `place_roles.MAX_DIRECTION_READINGS` readings, and when no reading holds data. + role_assignments: tuple[Mapping[str, RoleAxis], ...] = () # The denominator the question named and the one the flagged match divides by, set on a # `denominator_not_available` refusal after election. `None` on every other outcome. denominator: NoDataDenominator | None = None @@ -277,7 +287,7 @@ class RegionResult: # while part assembly reads restatement from each original region. restatement: ParsedPart | None = None # A resolved leg's inputs as one parsed part, set whether or not `restatement` - # echoes them. `NoData.data_query` pins into it. The parsed doors set it to the + # echoes them. `NoData.options` are built from it. The parsed doors set it to the # posted part. `None` when `ParsedPart` bounds reject the inputs, on soft-deadline # placeholders, and on resubmit identity, candidate and placeholder regions. parsed_form: ParsedPart | None = None diff --git a/src/search/engine/region/state.py b/src/search/engine/region/state.py index be4f3b29..5ff1e695 100644 --- a/src/search/engine/region/state.py +++ b/src/search/engine/region/state.py @@ -1323,6 +1323,7 @@ def no_data( unbound_sources: tuple[ResolvedQualifier, ...] = (), unit_alternatives: tuple[UnitAlternative, ...] = (), denominator: NoDataDenominator | None = None, + role_assignments: tuple[Mapping[str, RoleAxis], ...] = (), candidate: _Candidate | None = None, ) -> RegionResult: """Build a no-data result, preserving `cause`. A `no_observations` answer over an @@ -1388,6 +1389,7 @@ def no_data( unbound_sources=unbound_sources, unit_alternatives=unit_alternatives, denominator=denominator, + role_assignments=role_assignments, no_data_role_axis=( open_role_axis(_shape, _place.roles.resolved_entities) if isinstance(_mat_result, NoDataDraft) and _mat_result.reason == "no_observations" diff --git a/src/search/engine/role_text.py b/src/search/engine/role_text.py index ce1b062a..ecb21f0a 100644 --- a/src/search/engine/role_text.py +++ b/src/search/engine/role_text.py @@ -10,7 +10,7 @@ from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass -from search.models import RoleAxis +from search.models import ParsedPart, RoleAxis # Warning for parsed role_text values absent from the lexicon. ROLE_TEXT_NOT_RECOGNISED = "role_text_not_recognised" @@ -353,6 +353,24 @@ def contradicts(query: str, *, span: str, axis: RoleAxis) -> bool: return saw_contradiction +def parsed_role_query(part: ParsedPart) -> str: + """The query the parsed door checks `part`'s role text against: its variable text and + each entity's text, space-separated so direction cues stay local.""" + return " ".join([part.variable_text, *(entity.text for entity in part.entities or ())]) + + +def parsed_directions_contradicted(part: ParsedPart) -> bool: + """Whether the parsed door would find one of `part`'s stated directions contradicted + by its own wording (`contradicts` over `parsed_role_query`), which refuses + ``role_not_resolved``.""" + query = parsed_role_query(part) + for entity in part.entities or (): + axis = role_axis_for_text(entity.role_text) + if axis is not None and contradicts(query, span=entity.text, axis=axis): + return True + return False + + @dataclass(frozen=True, slots=True) class DoorRole: """One occurrence of a door-supplied flow axis and its verbatim text span. diff --git a/src/search/engine/unit/bind.py b/src/search/engine/unit/bind.py index 2ed26e87..1a7cbb1e 100644 --- a/src/search/engine/unit/bind.py +++ b/src/search/engine/unit/bind.py @@ -101,6 +101,7 @@ "bind_unit_text", "names_price_basis", "names_unit_request", + "unit_text_naming", "with_unit_lexicon_version", ] @@ -275,6 +276,9 @@ class _ClassifyOutcome: prices_token: bool = False # "beside" or fixed role per match. _assemble resolves "beside" against bound roles. role_currency_mentions: tuple[str, ...] = () + # The (start, length) in tokens of the first ambiguous currency mention, else of the + # first "beside" role currency mention: the words a refusal's alternatives replace. + choice_span: tuple[int, int] | None = None @dataclass(frozen=True) @@ -331,6 +335,8 @@ def _classify(tokens: list[str]) -> _ClassifyOutcome: base_year_cue = False prices_token = False role_currency_mentions: list[str] = [] + ambiguous_span: tuple[int, int] | None = None + beside_span: tuple[int, int] | None = None i = 0 n = len(tokens) @@ -357,8 +363,11 @@ def _classify(tokens: list[str]) -> _ClassifyOutcome: # Report alternatives for the first ambiguous mention only. if not ambiguous_alternatives: ambiguous_alternatives = hit.alternatives + ambiguous_span = (i, matched_len) elif hit.kind == "role_currency": role_currency_mentions.append(hit.value) + if hit.value == "beside" and beside_span is None: + beside_span = (i, matched_len) i += matched_len continue @@ -393,6 +402,7 @@ def _classify(tokens: list[str]) -> _ClassifyOutcome: base_year_cue=base_year_cue, prices_token=prices_token, role_currency_mentions=tuple(role_currency_mentions), + choice_span=ambiguous_span or beside_span, ) @@ -640,6 +650,35 @@ def bind_unit_text( return _complete(assembled, policy=policy) +def _phrases_naming(currency: IdentityCurrency) -> list[str]: + """The lexicon's words that name `currency` alone: an ISO code that collides with no + English word, then the code's curated aliases; for a role shape, its fixed-role + phrases.""" + if isinstance(currency, str): + code = [] if currency in AMBIGUOUS_ISO_CODES else [currency] + return [*code, *(alias for alias, named in CURRENCY_ALIASES.items() if named == currency)] + return [phrase for phrase, role in ROLE_CURRENCY_PHRASES.items() if role == currency.role] + + +def unit_text_naming(text: str, currency: IdentityCurrency) -> str | None: + """`text` with the words its `unit_not_resolved` alternatives stand for (the first + ambiguous currency name, else a role-relative currency phrase) replaced by words that + name `currency` alone, such as "constant 2015 XOF" for "constant 2015 francs". The + result is read back through the binder, with no role bound, and kept only when it binds + `currency`. `None` when `text` has no such words or no replacement binds.""" + tokens = _tokenise(_normalise(text)) + span = _classify(tokens).choice_span + if span is None: + return None + start, length = span + for words in _phrases_naming(currency): + named = " ".join([*tokens[:start], words, *tokens[start + length :]]) + parsed = _parse(named, bound_roles=frozenset()) + if isinstance(parsed, _Assembled) and parsed.currency == currency: + return named + return None + + def names_price_basis(text: str) -> bool: """Whether `text` names a price basis ("constant 2022 dollars", "in real terms"), read with the binder's own lexicon. A dropped phrase that does is cleared by the unit token.""" diff --git a/src/search/models/__init__.py b/src/search/models/__init__.py index a0a27e58..5673f402 100644 --- a/src/search/models/__init__.py +++ b/src/search/models/__init__.py @@ -122,6 +122,7 @@ CandidateSet, NoData, NoDataDenominator, + NoDataOption, UnitAlternative, ) from .request import ( @@ -346,6 +347,7 @@ "NoDataCategory", "NoDataDenominator", "NoDataGate", + "NoDataOption", "NoDataPart", "NoDataReason", "ObservationPoint", diff --git a/src/search/models/grammar.py b/src/search/models/grammar.py index e2bbee06..4c783943 100644 --- a/src/search/models/grammar.py +++ b/src/search/models/grammar.py @@ -451,7 +451,7 @@ class SentenceToken(StrictModel): "with the role the request gave it (null when it gave none), no entity_refs " "and no binding_ref, and text equal to one NoData.ambiguous_entities[i].text " "on the same part. The client titles that wording's candidate menu by this " - "role and pins the pick into NoData.data_query." + "role and posts the pick's entry of NoData.options." ), ) binding_ref: str | None = Field( diff --git a/src/search/models/outcomes.py b/src/search/models/outcomes.py index c2f12baf..a8376e6e 100644 --- a/src/search/models/outcomes.py +++ b/src/search/models/outcomes.py @@ -78,7 +78,8 @@ class AmbiguousEntityCandidate(StrictModel): an AmbiguousEntityGroup's own `candidates`. Mirrors engine.region.result. AmbiguousEntityCandidate field-for-field, with `label` in place of that dataclass's `name` (this contract's own vocabulary for a display - string, matching ResolvedEntity.label).""" + string, matching ResolvedEntity.label), and without its `group_name`, + which only NoData.options read.""" ref: str = Field(description="the candidate's own dcid") label: str = Field(description="the candidate's display name") @@ -157,6 +158,37 @@ class UnitAlternative(StrictModel): label: str = Field(description="the currency's display name, or the role's reader-facing noun") +class NoDataOption(StrictModel): + """One answer to the choice a no_data refusal asks for: the words it settles, a label + for people, and the request that settles them -- one entry of NoData.options.""" + + mentions: list[str] = Field( + description=( + "the request's own words this option settles, in query order: the ambiguous " + "entity wording, the ambiguous source wording, the part's source_code, the " + "part's unit_text, or for role_not_resolved every name whose direction the " + "option states" + ) + ) + label: str = Field( + description=( + "the choice in words for a menu: the candidate's display name, prefixed " + "'Excluding' for a source the part excludes; for role_not_resolved the " + "direction the option reads, such as 'France to Germany' or 'To Kenya and " + "Uganda'; for a role currency the role's currency, such as \"recipient's " + 'currency". Clients must not parse it' + ) + ) + data_query: ParsedInput = Field( + description=( + "a one-part parsed input settling this choice only. Post it unchanged as the " + "input of /api/search/resolve; its one part is a dataset_resubmit parsed " + "entry's part. A second ambiguous wording refuses again. Whether data exists " + "for it is not checked" + ) + ) + + class NoDataDenominator(StrictModel): """The denominators a denominator_not_available refusal compared: the one the question named and the one its flagged match divides by -- NoData.denominator.""" @@ -391,9 +423,8 @@ class NoData(StrictModel): "every ambiguous source wording responsible for a source_ambiguous outcome, in " "query order, each paired with the admitted sources it could name, and for " "source_code_ambiguous the part's source_code paired with the provenances " - "holding it. Empty for every other no_data reason. A client posts the chosen " - "candidate's ref on that mention of data_query, or for a source code as a new " - "mention in its sources, and posts the part back." + "holding it. Empty for every other no_data reason. A client posts the matching " + "entry of options." ), ) unit_alternatives: list[UnitAlternative] | None = Field( @@ -434,24 +465,27 @@ class NoData(StrictModel): "denominator asked and divide them" ), ) - data_query: ParsedInput | None = Field( - default=None, + options: list[NoDataOption] = Field( + default_factory=list, description=( - "the parsed form of this part, one part long, set when reason is " - "entity_ambiguous, source_ambiguous or source_code_ambiguous: its variable " - "text, entities with " - "role_text, time, unit, sources, and operations. On entity_ambiguous every " - "entity that resolved to one graph node is pinned to its dcid and each " - "ambiguous wording left unpinned. A named group stays unpinned too, so it " - "resolves to the same members again. On source_ambiguous every source " - "mention that resolved carries its ref and each ambiguous wording carries " - "none. On source_code_ambiguous it carries the code and the part's mentions. " - "A client pins the candidate the user picks from ambiguous_entities or " - "ambiguous_sources and posts the part back, through the parsed door " - "or as a dataset_resubmit parsed entry. It is set when the part has no " - "restatement too: an entity whose direction the question leaves unnamed " - "carries no role_text. Null for every other no_data reason, and when the " - "part's inputs exceed the ParsedPart bounds" + "one ready request per choice of an entity_ambiguous, source_ambiguous, " + "source_code_ambiguous, role_not_resolved or unit_not_resolved refusal, in " + "query then candidate order. An entity option pins the candidate on every row " + "with the ambiguous wording and every entity that resolved to one node; named " + "groups, sets read as members and other ambiguous wordings stay unpinned, so a " + "second ambiguity refuses again with the first pick kept. A candidate that is a " + "group's own node, asked for its members, posts the group's registry name with " + "no dcid. A source option sets the candidate's ref on the ambiguous mention; a " + "source-code option names the holding provenance as the part's only included " + "source. A role option states a direction for every name the question left " + "unstated, offered while the shape's roles give at most six readings. A unit " + "option names one of unit_alternatives in place of the ambiguous currency " + "words. Empty for other reasons; when the part's inputs exceed the ParsedPart " + "bounds; on a role_not_resolved refusal with more than six readings, whose " + "readings all hold no data, or that the shape refused before placing names; " + "and on a unit_not_resolved refusal without unit_alternatives. It can list " + "fewer entries than the candidates when a candidate's request cannot be " + "posted, such as a group node with no registry name" ), ) diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py new file mode 100644 index 00000000..8a19d806 --- /dev/null +++ b/tests/engine/test_choice_options.py @@ -0,0 +1,294 @@ +"""A refusal that asks for a choice lists one ready request per choice +(`NoData.options`), and each request posted back unchanged through the parsed door does +not refuse with the same reason again. + +Every round trip runs the composed search app in process over offline doubles. The +"fertility rate"/"Kenya" texts match the recorded LLM fixtures; the role-bearing cases +elect their one shape through a scripted bind.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +import origin_catalogue.service as catalogue_service_module +from origin_catalogue import DFO, ProvenanceRow, SourceRef +from search.engine.bind import _UniversalBindOutput +from search.engine.quantity_read import QuantityOutput +from tests.engine._fake_sdmx import FakeSdmxClient +from tests.engine._harness import materialise_double_from_obs +from tests.engine._quantity import NO_QUANTITY +from tests.engine._search_app import search_app +from tests.fixtures import FakeGraph, FakeLLM, _load_json, make_resolver +from tests.origin_catalogue.fake_catalogue import FakeCatalogue, fake_catalogue_service + +_FERTILITY = "fertility rate" +_COD = "country/COD" +_FERTILITY_SV = "FertilityRate_Person_Female" +_FLOW_SV = "TESTFIXTURE/ChoiceOptions/Flow" +_PLACES = { + "Norway": "country/NOR", + "Kenya": "country/KEN", + "Uganda": "country/UGA", + "Rwanda": "country/RWA", +} + + +class _ShapeZeroLLM: + """Elects shape 0 with no bindings, for the unrecorded role-bearing fixture.""" + + async def generate_structured(self, *, prompt, system, schema): + if schema is QuantityOutput: + return NO_QUANTITY + assert schema.__name__ == "_UniversalBindOutput" + return _UniversalBindOutput(chosen_shape_index=0, bindings=[]), None + + +def _fertility_client(llm: FakeLLM | _ShapeZeroLLM | None = None) -> TestClient: + obs = { + **_load_json("graph_obs.json"), + f"FertilityRate_Person_Female|{_COD}": [ + {"earliestDate": "1960", "latestDate": "2023", "obsCount": 64} + ], + } + graph = FakeGraph(obs=obs) + materialise_graph, materialise_sdmx = materialise_double_from_obs( + {key: facets for key, facets in obs.items() if key.startswith(_FERTILITY_SV)} + ) + return TestClient( + search_app( + graph=graph, + resolver=make_resolver(graph), + llm=llm or FakeLLM(), + sdmx=FakeSdmxClient(), + materialise_graph=materialise_graph, + materialise_sdmx=materialise_sdmx, + ), + raise_server_exceptions=False, + ) + + +def _flow_client() -> TestClient: + """A flow StatVar declaring a creditor and a debtor role, over four countries.""" + graph = FakeGraph( + nodes={ + **{dcid: {"label": name, "type": "Country"} for name, dcid in _PLACES.items()}, + _FLOW_SV: { + "label": "Test flow", + "arcs": { + "populationType": {"nodes": [{"dcid": "EconomicActivity"}]}, + "measuredProperty": {"nodes": [{"dcid": "amount"}]}, + "statType": {"nodes": [{"dcid": "measuredValue"}]}, + "observationProperties": {"nodes": [{"dcid": "creditor"}, {"dcid": "debtor"}]}, + }, + }, + }, + detect={ + text: {"svs": [_FLOW_SV], "cosine_scores": [0.9]} for text in ("aid", "aid to Norway") + }, + resolve=dict(_PLACES), + obs={}, + ) + return _client(graph, _ShapeZeroLLM()) + + +def _client(graph: FakeGraph, llm: _ShapeZeroLLM) -> TestClient: + return TestClient( + search_app(graph=graph, resolver=make_resolver(graph), llm=llm, sdmx=FakeSdmxClient()), + raise_server_exceptions=False, + ) + + +def _no_data(client: TestClient, part: dict) -> dict: + """The no_data of the one part `part` answers, posted through the parsed door.""" + response = client.post( + "/api/search/resolve", json={"input": {"kind": "parsed", "parts": [part]}} + ) + assert response.status_code == 200, response.text + (answered,) = response.json()["parts"] + return answered.get("no_data") or {} + + +def _assert_options_settle(client: TestClient, no_data: dict) -> None: + """Each option's request, posted unchanged, is a valid request that does not refuse + with the same reason: it answers, or fails on something the choice does not decide.""" + for option in no_data["options"]: + response = client.post("/api/search/resolve", json={"input": option["data_query"]}) + assert response.status_code not in (400, 422), (option["label"], response.text) + if response.status_code != 200: + continue + (answered,) = response.json()["parts"] + reason = (answered.get("no_data") or {}).get("reason") + assert reason != no_data["reason"], option["label"] + + +def test_an_ambiguous_entity_offers_each_candidate_and_each_option_resolves_it(): + client = _fertility_client() + + no_data = _no_data(client, {"variable_text": _FERTILITY, "entities": [{"text": "Congo"}]}) + + assert no_data["reason"] == "entity_ambiguous" + (group,) = no_data["ambiguous_entities"] + assert [(o["mentions"], o["label"]) for o in no_data["options"]] == [ + (["Congo"], candidate["label"]) for candidate in group["candidates"] + ] + assert [o["data_query"]["parts"][0]["entities"] for o in no_data["options"]] == [ + [{"text": "Congo", "dcid": candidate["ref"]}] for candidate in group["candidates"] + ] + _assert_options_settle(client, no_data) + + +def test_an_ambiguous_source_offers_each_candidate_and_each_option_resolves_it( + monkeypatch: pytest.MonkeyPatch, +): + """Two publishers' tables share one name, so the name refuses source_ambiguous.""" + words = "Shared Tables" + rows = [ + ProvenanceRow( + id=f"dc/base/{publisher}Tables", + data_commons=DFO, + source=SourceRef(ref=f"dc/s/{publisher}", label=publisher), + name=words, + url="https://example.org", + ) + for publisher in ("Alpha", "Beta") + ] + service = fake_catalogue_service(FakeCatalogue(provenances={row.id: row for row in rows})) + with catalogue_service_module._singleton.poisoned_for_test(service): + # A scoped answer reaches bind with a frame no recorded fixture holds. + client = _fertility_client(_ShapeZeroLLM()) + no_data = _no_data( + client, + { + "variable_text": _FERTILITY, + "entities": [{"text": "Kenya"}], + "sources": [{"text": words}], + }, + ) + + assert no_data["reason"] == "source_ambiguous" + (group,) = no_data["ambiguous_sources"] + assert [ + (o["mentions"], o["data_query"]["parts"][0]["sources"]) for o in no_data["options"] + ] == [ + ([words], [{"text": words, "ref": candidate["ref"], "polarity": "include"}]) + for candidate in group["candidates"] + ] + _assert_options_settle(client, no_data) + + +@pytest.mark.parametrize( + ("unit_text", "posted"), + [ + ("francs", ["CHF", "XAF", "XOF"]), + ("constant 2015 francs", ["constant 2015 CHF", "constant 2015 XAF", "constant 2015 XOF"]), + ], + ids=["bare", "beside-a-basis"], +) +def test_an_ambiguous_currency_offers_each_alternative_and_each_option_binds_it(unit_text, posted): + client = _fertility_client() + + no_data = _no_data( + client, + {"variable_text": _FERTILITY, "entities": [{"text": "Kenya"}], "unit_text": unit_text}, + ) + + assert no_data["reason"] == "unit_not_resolved" + assert [o["label"] for o in no_data["options"]] == [ + alternative["label"] for alternative in no_data["unit_alternatives"] + ] + assert [o["data_query"]["parts"][0]["unit_text"] for o in no_data["options"]] == posted + assert {tuple(o["mentions"]) for o in no_data["options"]} == {(unit_text,)} + _assert_options_settle(client, no_data) + + +def _entities(*names: str, role_text: str | None = None) -> list[dict]: + return [ + {"text": name} if role_text is None else {"text": name, "role_text": role_text} + for name in names + ] + + +@pytest.mark.parametrize( + ("entities", "open_roles", "labels"), + [ + pytest.param( + _entities("Norway", "Kenya"), + None, + ["Norway to Kenya", "Kenya to Norway"], + id="two-untold", + ), + pytest.param( + _entities("Norway", "Kenya", "Uganda"), + None, + [ + "Norway and Kenya to Uganda", + "Norway and Uganda to Kenya", + "Norway to Kenya and Uganda", + "Kenya and Uganda to Norway", + "Kenya to Norway and Uganda", + "Uganda to Norway and Kenya", + ], + id="three-untold", + ), + pytest.param( + _entities("Norway", "Kenya", "Uganda", "Rwanda"), None, [], id="four-past-the-cap" + ), + pytest.param( + _entities("Kenya", "Uganda"), + ["creditor"], + ["To Kenya and Uganda"], + id="open-axis-takes-no-place", + ), + pytest.param( + [{"text": "Norway", "role_text": "from"}, {"text": "Kenya"}], + None, + [], + id="one-untold-read-both-ways-empty", + ), + ], +) +def test_an_untold_direction_offers_each_reading_within_the_cap(entities, open_roles, labels): + client = _flow_client() + part = {"variable_text": "aid", "entities": entities} + if open_roles is not None: + part["open_roles"] = open_roles + + no_data = _no_data(client, part) + + assert no_data["reason"] == "role_not_resolved" + assert [o["label"] for o in no_data["options"]] == labels + _assert_options_settle(client, no_data) + + +def test_a_role_option_states_a_direction_for_every_untold_name(): + client = _flow_client() + + no_data = _no_data(client, {"variable_text": "aid", "entities": _entities("Norway", "Kenya")}) + + assert [o["mentions"] for o in no_data["options"]] == [["Norway", "Kenya"]] * 2 + assert [o["data_query"]["parts"][0]["entities"] for o in no_data["options"]] == [ + [{"text": "Norway", "role_text": "from"}, {"text": "Kenya", "role_text": "to"}], + [{"text": "Norway", "role_text": "to"}, {"text": "Kenya", "role_text": "from"}], + ] + + +def test_a_reading_its_own_wording_contradicts_is_not_offered(): + """ "aid to Norway" contradicts Norway as the creditor. The one reading that fills the + creditor axis puts Norway back on it, which the parsed door refuses again, so the + refusal offers nothing.""" + client = _flow_client() + + no_data = _no_data( + client, + { + "variable_text": "aid to Norway", + "entities": [ + {"text": "Norway", "role_text": "from"}, + {"text": "Kenya", "role_text": "to"}, + ], + }, + ) + + assert no_data["reason"] == "role_not_resolved" + assert no_data["options"] == [] diff --git a/tests/engine/test_conjoin_assemble_and_enrichment.py b/tests/engine/test_conjoin_assemble_and_enrichment.py index 18f0ecf2..936cdaae 100644 --- a/tests/engine/test_conjoin_assemble_and_enrichment.py +++ b/tests/engine/test_conjoin_assemble_and_enrichment.py @@ -27,6 +27,8 @@ CandidatesPart, CoverageBare, NoDataPart, + ParsedEntity, + ParsedPart, QueryEcho, SlotKey, SlotOptions, @@ -217,6 +219,9 @@ def test_assemble_region_threads_every_unresolved_entity_term(): ) +_KEN = "country/KEN" + + def test_assemble_region_entity_not_resolved_keeps_ambiguous_candidates_off_the_wire(): """The entity check names an ambiguous entity with its candidates whatever reason it elects. The wire carries candidates only on entity_ambiguous, the one reason whose part @@ -229,6 +234,10 @@ def test_assemble_region_entity_not_resolved_keeps_ambiguous_candidates_off_the_ warnings=(), timing_by_step={}, unresolved_entity_names=("Congo", "Atlantis"), + parsed_form=ParsedPart( + variable_text="population", + entities=[ParsedEntity(text="Congo"), ParsedEntity(text="Atlantis")], + ), ambiguous_entities=( AmbiguousEntity( text="Congo", @@ -248,6 +257,7 @@ def test_assemble_region_entity_not_resolved_keeps_ambiguous_candidates_off_the_ assert part.no_data.entity_terms == ["Congo", "Atlantis"] assert part.no_data.ambiguous_entities == [] + assert part.no_data.options == [] def test_assemble_region_threads_every_ambiguous_entity_term(): @@ -292,6 +302,100 @@ def test_assemble_region_threads_every_ambiguous_entity_term(): ) +_TWO_AMBIGUOUS = RegionResult( + variable_text="ODA", + status="no_data", + specs=(), + no_data_reason="entity_ambiguous", + warnings=(), + timing_by_step={}, + parsed_form=ParsedPart( + variable_text="ODA", + entities=[ + ParsedEntity(text="Korea", role_text="from"), + ParsedEntity(text="Congo", role_text="to"), + ParsedEntity(text="Kenya", role_text="to"), + ], + ), + resolved_entity_dcids={"Kenya": "country/KEN"}, + ambiguous_entities=( + AmbiguousEntity( + text="Korea", + candidates=( + AmbiguousEntityCandidate(dcid="country/KOR", name="South Korea", kind="Country"), + AmbiguousEntityCandidate(dcid="country/PRK", name="North Korea", kind="Country"), + ), + role="origin", + ), + AmbiguousEntity( + text="Congo", + candidates=( + AmbiguousEntityCandidate(dcid="country/COD", name="Congo [DRC]", kind="Country"), + AmbiguousEntityCandidate( + dcid="country/COG", name="Congo [Republic]", kind="Country" + ), + ), + role="destination", + ), + ), +) + + +def test_each_option_pins_one_ambiguous_wording_and_leaves_the_other_bare(): + """Two ambiguous wordings give one option per wording and candidate, in query order; + each pins its wording and every entity that resolved to one node, and leaves the other + wording unpinned for a second refusal.""" + part = _entity_terms_part(_TWO_AMBIGUOUS) + + def pins(option) -> list[tuple[str, str | None]]: + return [(e.text, e.dcid) for e in option.data_query.parts[0].entities] + + assert [(o.mentions, o.label, pins(o)) for o in part.no_data.options] == [ + (["Korea"], "South Korea", [("Korea", "country/KOR"), ("Congo", None), ("Kenya", _KEN)]), + (["Korea"], "North Korea", [("Korea", "country/PRK"), ("Congo", None), ("Kenya", _KEN)]), + (["Congo"], "Congo [DRC]", [("Korea", None), ("Congo", "country/COD"), ("Kenya", _KEN)]), + ( + ["Congo"], + "Congo [Republic]", + [("Korea", None), ("Congo", "country/COG"), ("Kenya", _KEN)], + ), + ] + + +def test_a_group_candidate_on_a_members_row_posts_the_group_name_without_a_pin(): + """A pin on a group's own node cannot ask for its members, so the candidate's option + posts the group's registered name with the members flag and no dcid.""" + region = dataclasses.replace( + _TWO_AMBIGUOUS, + parsed_form=ParsedPart( + variable_text="ODA", entities=[ParsedEntity(text="the Union", members=True)] + ), + resolved_entity_dcids={}, + ambiguous_entities=( + AmbiguousEntity( + text="the Union", + candidates=( + AmbiguousEntityCandidate( + dcid="EuropeanUnion", + name="European Union", + kind="continental_union", + group_name="European Union", + ), + AmbiguousEntityCandidate(dcid="africa", name="Africa", kind="continent"), + ), + ), + ), + ) + + part = _entity_terms_part(region) + + assert [o.data_query.parts[0].entities for o in part.no_data.options] == [ + [ParsedEntity(text="European Union", members=True)], + [ParsedEntity(text="the Union", members=True, dcid="africa")], + ] + assert [o.mentions for o in part.no_data.options] == [["the Union"], ["the Union"]] + + def test_assemble_region_non_entity_reason_ignores_unresolved_entity_names(): """C50b: entity_terms is empty for every no_data reason other than entity_not_resolved, entity_ambiguous, and entity_role_not_admitted, even diff --git a/tests/engine/test_operations_e2e.py b/tests/engine/test_operations_e2e.py index d8d2ee89..ab203d41 100644 --- a/tests/engine/test_operations_e2e.py +++ b/tests/engine/test_operations_e2e.py @@ -1,5 +1,5 @@ """End-to-end tests for operations on the raw-text and parsed doors, and for the -data_query and slot token an entity_ambiguous part carries. +options and slot token an entity_ambiguous part carries. An operation rides on the part of the variable extract assigned it, and its phrase reaches that variable's bind the way a dropped qualifier's does, with no @@ -820,7 +820,7 @@ def test_an_invalid_role_span_leaves_its_qualifier_unapplied_with_a_departure(): # --------------------------------------------------------------------------- -# entity_ambiguous: data_query and the ambiguous wording's slot token +# entity_ambiguous: options and the ambiguous wording's slot token # --------------------------------------------------------------------------- _CONGO_NODES = { @@ -865,9 +865,9 @@ def _ambiguous_request() -> ResolveRequest: ) -def test_an_ambiguous_part_carries_a_data_query_that_resolves_once_the_pick_is_pinned(): - """The data_query pins Kenya, leaves Congo unpinned, and keeps the operation; - posting it back as a dataset_resubmit parsed entry with Congo pinned resolves.""" +def test_an_ambiguous_part_carries_an_option_per_candidate_that_resolves_when_posted(): + """Each option pins Kenya, pins Congo to its candidate, and keeps the operation; + posting the DRC option's part as a dataset_resubmit parsed entry resolves.""" graph = _congo_graph() response = offline_resolve( _ambiguous_request(), graph=graph, llm=_ambiguous_congo_llm(), sdmx=_EMPTY_SDMX @@ -876,29 +876,26 @@ def test_an_ambiguous_part_carries_a_data_query_that_resolves_once_the_pick_is_p part = response.parts[0] assert isinstance(part, NoDataPart) assert part.no_data.reason == "entity_ambiguous" - data_query = part.no_data.data_query - assert data_query is not None - (query_part,) = data_query.parts + candidates = part.no_data.ambiguous_entities[0].candidates + options = part.no_data.options + assert [(o.mentions, o.label) for o in options] == [(["Congo"], c.label) for c in candidates] rank = Operation(verb="rank", phrase="top 5 donors", over="origin") - assert query_part.variable_text == _QUERY - assert query_part.operations == [rank] - assert [(e.text, e.role_text, e.dcid) for e in query_part.entities or ()] == [ - ("Kenya", None, _E2E_KEN_DCID), - ("Congo", "to", None), - ] - candidates = {c.ref for c in part.no_data.ambiguous_entities[0].candidates} - assert "country/COD" in candidates - - picked = query_part.model_copy( - update={ - "entities": [ - e.model_copy(update={"dcid": "country/COD"}) if e.text == "Congo" else e - for e in query_part.entities or () - ] - } - ) + for option, candidate in zip(options, candidates, strict=True): + (query_part,) = option.data_query.parts + assert query_part.variable_text == _QUERY + assert query_part.operations == [rank] + assert [(e.text, e.role_text, e.dcid) for e in query_part.entities or ()] == [ + ("Kenya", None, _E2E_KEN_DCID), + ("Congo", "to", candidate.ref), + ] + + (picked,) = [o for o in options if o.data_query.parts[0].entities[1].dcid == "country/COD"] resubmitted = offline_resolve( - ResolveRequest(input=DatasetResubmitInput(entries=[ResubmitParsedEntry(part=picked)])), + ResolveRequest( + input=DatasetResubmitInput( + entries=[ResubmitParsedEntry(part=picked.data_query.parts[0])] + ) + ), graph=graph, llm=_ambiguous_congo_llm(), sdmx=_EMPTY_SDMX, @@ -911,8 +908,8 @@ def test_an_ambiguous_part_carries_a_data_query_that_resolves_once_the_pick_is_p def test_a_group_named_beside_an_ambiguous_wording_is_echoed_unpinned(): - """The data_query echoes a resolver group by its text alone: pinning it to one - member's dcid would resubmit that member for the group.""" + """Each option echoes a resolver group by its text alone: pinning it to one member's + dcid would resubmit that member for the group.""" g7 = ("CAN", "DEU", "FRA", "GBR", "ITA", "JPN", "USA") graph = FakeGraph( nodes={ @@ -940,11 +937,10 @@ def test_a_group_named_beside_an_ambiguous_wording_is_echoed_unpinned(): assert isinstance(part, NoDataPart) assert part.no_data.reason == "entity_ambiguous" - assert part.no_data.data_query is not None - (query_part,) = part.no_data.data_query.parts - assert [(e.text, e.dcid) for e in query_part.entities or ()] == [ - ("G7", None), - ("Congo", None), + options = part.no_data.options + assert [o.data_query.parts[0].entities for o in options] == [ + [ParsedEntity(text="G7"), ParsedEntity(text="Congo", role_text="to", dcid=dcid)] + for dcid in ("country/COD", "country/COG") ] @@ -969,10 +965,10 @@ def test_an_ambiguous_wording_is_its_own_slot_token_in_the_sentence(): assert response.rendered_sentence.startswith('"Congo" matches more than one entity') -def test_an_ambiguous_part_without_a_restatement_still_carries_a_data_query(): +def test_an_ambiguous_part_without_a_restatement_still_carries_options(): """Congo named on both axes leaves the direction unnameable, so the part withholds - its restatement; its data_query still lists Congo once per axis, unpinned, beside - Kenya pinned, and keeps the operation.""" + its restatement; each option still lists Congo once per axis, pinned to its candidate, + beside Kenya pinned, and keeps the operation.""" llm = _ScriptedLLM( _extraction( entities=[ @@ -1001,16 +997,18 @@ def test_an_ambiguous_part_without_a_restatement_still_carries_a_data_query(): assert isinstance(part, NoDataPart) assert part.no_data.reason == "entity_ambiguous" assert part.restatement is None - data_query = part.no_data.data_query - assert data_query is not None - (query_part,) = data_query.parts - assert query_part.variable_text == _QUERY - assert query_part.operations == [Operation(verb="rank", phrase="top 5 donors", over="origin")] - assert [(e.text, e.role_text, e.dcid) for e in query_part.entities or ()] == [ - ("Kenya", None, _E2E_KEN_DCID), - ("Congo", "from", None), - ("Congo", "to", None), - ] + assert len(part.no_data.options) == 2 + for option, dcid in zip(part.no_data.options, ("country/COD", "country/COG"), strict=True): + (query_part,) = option.data_query.parts + assert query_part.variable_text == _QUERY + assert query_part.operations == [ + Operation(verb="rank", phrase="top 5 donors", over="origin") + ] + assert [(e.text, e.role_text, e.dcid) for e in query_part.entities or ()] == [ + ("Kenya", None, _E2E_KEN_DCID), + ("Congo", "from", dcid), + ("Congo", "to", dcid), + ] # --------------------------------------------------------------------------- diff --git a/tests/engine/test_place_bindings.py b/tests/engine/test_place_bindings.py index 8fb7a4f7..f9761a87 100644 --- a/tests/engine/test_place_bindings.py +++ b/tests/engine/test_place_bindings.py @@ -137,6 +137,26 @@ def test_solitary_axis_less_place_refuses_role_not_resolved(): assert result.specs == () assert result.flagged_match is None assert sdmx.series_calls == [] + # One place cannot fill both axes the shape declares, so no reading is kept. + assert result.role_assignments == () + + +def test_two_axis_less_places_offer_each_reading_of_their_directions(): + """ "aid Norway Kenya" leaves both directions untold: each reading puts one place on + each axis, origin first.""" + result = _run( + entities=["Norway", "Kenya"], + resolve={"Norway": _NOR, "Kenya": _KEN}, + sv_arcs=_declaring_arcs(observation_properties=["creditor", "debtor"]), + role_query="aid Norway Kenya", + sdmx=_sdmx({}), + ) + assert result.no_data_reason == "role_not_resolved" + assert result.unresolved_entity_names == ("Norway", "Kenya") + assert result.role_assignments == ( + {"Norway": "origin", "Kenya": "destination"}, + {"Norway": "destination", "Kenya": "origin"}, + ) def test_place_with_a_door_occurrence_still_binds_on_that_axis(monkeypatch): diff --git a/tests/engine/test_place_set_examples.py b/tests/engine/test_place_set_examples.py index 6bc5bd52..e286e1e5 100644 --- a/tests/engine/test_place_set_examples.py +++ b/tests/engine/test_place_set_examples.py @@ -342,23 +342,17 @@ def test_a_regions_node_posted_with_its_group_is_read_at_the_node(doors: Doors) _CONGOS = ("country/COD", "country/COG") -def _data_query(response: ResolveResponse) -> ParsedPart: - """The one part of an `entity_ambiguous` answer's `data_query`.""" +def _option(response: ResolveResponse, dcid: str) -> ParsedPart: + """The one part of the option of an `entity_ambiguous` answer that picks `dcid`.""" (part,) = response.parts assert part.status == "no_data", part.status assert part.no_data.reason == "entity_ambiguous" - assert part.no_data.data_query is not None - (query_part,) = part.no_data.data_query.parts - return query_part - - -def _pick(part: ParsedPart, text: str, dcid: str) -> ParsedPart: - """`part` with the entity `text` pinned to `dcid`, as a client posts the user's pick.""" - entities = [ - entity.model_copy(update={"dcid": dcid}) if entity.text == text else entity - for entity in part.entities or () + (query_part,) = [ + option.data_query.parts[0] + for option in part.no_data.options + if any(entity.dcid == dcid for entity in option.data_query.parts[0].entities or ()) ] - return part.model_copy(update={"entities": entities}) + return query_part @pytest.mark.parametrize( @@ -384,14 +378,14 @@ def _pick(part: ParsedPart, text: str, dcid: str) -> ParsedPart: ), ], ) -def test_a_data_query_posted_back_keeps_the_reading_of_each_set( +def test_an_option_posted_back_keeps_the_reading_of_each_set( monkeypatch: pytest.MonkeyPatch, africa: ParsedEntity, echoed: tuple[str, str | None, bool], places: list[str], ) -> None: - """Africa beside the ambiguous "Congo": the answer is `entity_ambiguous`, and its - `data_query` states `members` where the part asked for Africa's members and leaves out + """Africa beside the ambiguous "Congo": the answer is `entity_ambiguous`, and each of + its options states `members` where the part asked for Africa's members and leaves out the flag where it did not. A set read as its members, by the flag or through an alias row that declares its text names members, is echoed unpinned, and Africa read whole is pinned to its node. Posted back with Congo picked, the part binds what the same request @@ -407,11 +401,11 @@ def test_a_data_query_posted_back_keeps_the_reading_of_each_set( assert subject_reading(WHO, AFRICA) == [AFRICA] picked = ParsedEntity(text="Congo", dcid=_CONGOS[0]) - query_part = _data_query(doors.first_answer(WHO, africa, "Congo")) + query_part = _option(doors.first_answer(WHO, africa, "Congo"), _CONGOS[0]) entity = query_part.entities[0] assert (entity.text, entity.dcid, bool(entity.members)) == echoed - again = the_spec(doors.post(_pick(query_part, "Congo", _CONGOS[0]))) + again = the_spec(doors.post(query_part)) direct = the_spec(doors.first_answer(WHO, africa, picked)) assert bound(again) == bound(direct) == {_SUBJECT: sorted([*places, _CONGOS[0]])} assert again.spec_id == direct.spec_id diff --git a/tests/engine/test_source_choice.py b/tests/engine/test_source_choice.py index 51761b2a..5e9f83b4 100644 --- a/tests/engine/test_source_choice.py +++ b/tests/engine/test_source_choice.py @@ -507,7 +507,7 @@ def test_an_unresolved_source_answers_no_data_quoting_its_words(): assert part.no_data.reason == "source_not_resolved" assert part.no_data.source_terms == ["XYZ statistics"] assert part.no_data.ambiguous_sources == [] - assert part.no_data.data_query is None + assert part.no_data.options == [] assert "XYZ statistics" in part.no_data.message @@ -598,18 +598,21 @@ def test_a_no_data_outcome_under_a_scope_names_its_sources( ) -def test_an_ambiguous_source_answers_no_data_with_its_candidates_and_the_part_to_post(): - """The part names each candidate by ref, label and kind, and `data_query` is the part - with every resolved mention carrying its ref and the ambiguous one none.""" +@pytest.mark.parametrize(("polarity", "prefix"), [("include", ""), ("exclude", "Excluding ")]) +def test_an_ambiguous_source_answers_no_data_with_an_option_per_candidate(polarity, prefix): + """The part names each candidate by ref, label and kind, and offers one option per + candidate: the part with the ambiguous mention carrying that candidate's ref, keeping + its words and polarity, and every resolved mention carrying its own ref. An excluded + mention's label says so.""" candidates = ( SourceReference(ref="dc/s/A", label="Source A", kind="source"), SourceReference(ref="dc/d/B", label="Dataset B", kind="dataset"), SourceReference(ref="provenance/C", label="Table C", kind="provenance"), ) - mentions = ( - SourceMention(text="ambiguous"), - SourceMention(text="crs", ref="provenance/OECDCreditorReportingSystem", polarity="exclude"), + crs = SourceMention( + text="crs", ref="provenance/OECDCreditorReportingSystem", polarity="exclude" ) + mentions = (SourceMention(text="ambiguous", polarity=polarity), crs) refusal = SourceRefusal( reason="source_ambiguous", terms=("ambiguous",), @@ -631,8 +634,14 @@ def test_an_ambiguous_source_answers_no_data_with_its_candidates_and_the_part_to ("dc/d/B", "Dataset B", "dataset"), ("provenance/C", "Table C", "provenance"), ] - assert part.no_data.data_query is not None - assert part.no_data.data_query.parts[0].sources == list(mentions) + assert [(o.mentions, o.label, o.data_query.parts[0].sources) for o in part.no_data.options] == [ + ( + ["ambiguous"], + f"{prefix}{c.label}", + [SourceMention(text="ambiguous", ref=c.ref, polarity=polarity), crs], + ) + for c in candidates + ] # --------------------------------------------------------------------------- diff --git a/tests/engine/test_source_code.py b/tests/engine/test_source_code.py index a39bfb5d..02ceedc1 100644 --- a/tests/engine/test_source_code.py +++ b/tests/engine/test_source_code.py @@ -16,8 +16,10 @@ from code_index.shared import get_index from code_index.types import IndexStamp, SourceCodeRow from datasets_api.adapter import resolve_definition_members +from origin_catalogue import DFO, ProvenanceRow, SourceRef from search.engine.bind import SlotBindingDraft, _UniversalBindOutput from search.engine.core import resolve_async +from search.engine.entry import parsed as parsed_entry from search.engine.quantity_read import QuantityOutput from search.engine.region import phase_place_materialise, pipeline from search.engine.region.pipeline import resolve_variable @@ -43,6 +45,7 @@ from tests.engine._fake_sdmx import FakeSdmxClient from tests.engine._quantity import NO_QUANTITY from tests.fixtures import FakeGraph, make_resolver +from tests.origin_catalogue.fake_catalogue import FakeCatalogue _IDS = "provenance/WorldBankInternationalDebtStatistics" _CRS = "provenance/OECDCreditorReportingSystem" @@ -768,3 +771,59 @@ def test_the_query_scan_fills_a_held_code(query, measures, expected): ) def test_a_codes_words_leave_the_variable_text(variable, expected): assert without_code_words(variable, _DEGG) == expected + + +class _TwoHolderIndex: + """The packaged index, with DT.TDS.DEGG.CD's StatVars also filed under the CRS, so + two provenances hold the code.""" + + def __init__(self, index: CodeIndex) -> None: + self._index = index + + def __getattr__(self, name: str): + return getattr(self._index, name) + + def source_code_members(self, code: str): + members = self._index.source_code_members(code) + return {**members, _CRS: members[_IDS]} if code.upper() == _DEGG else members + + +def test_a_code_two_named_sources_hold_offers_each_as_the_only_included_source( + monkeypatch, bind_catalogue +): + """Both holders are named, and included sources union, so the code is ambiguous between + them. Each option names one holder as the part's only included source and keeps the + exclusion; posted back, neither refuses source_code_ambiguous again.""" + rows = [ + ProvenanceRow( + id=ref, + data_commons=DFO, + source=SourceRef(ref=f"dc/s/{label}", label=label), + name=label, + url="https://example.org", + ) + for ref, label in ((_IDS, "IDS"), (_CRS, "CRS"), ("dc/base/Other", "Other")) + ] + bind_catalogue(FakeCatalogue(provenances={row.id: row for row in rows})) + monkeypatch.setattr(parsed_entry, "get_index", lambda: _TwoHolderIndex(get_index())) + excluded = SourceMention(ref="dc/base/Other", polarity="exclude") + part = ParsedPart( + variable_text="debt service", + entities=[ParsedEntity(text="Kenya")], + source_code=_DEGG, + sources=[SourceMention(ref=_IDS), SourceMention(ref=_CRS), excluded], + ) + + answer = _answer(part, monkeypatch) + + assert answer.no_data.reason == "source_code_ambiguous" + options = answer.no_data.options + assert [(o.mentions, o.data_query.parts[0].sources) for o in options] == [ + ([_DEGG], [excluded, SourceMention(ref=ref)]) for ref in (_IDS, _CRS) + ] + # The coverage holds the code's observations under IDS alone, so the CRS option reads + # none; it refuses on its data, not on the choice. + ids, crs = (_answer(o.data_query.parts[0], monkeypatch) for o in options) + assert ids.status == "definite" + assert crs.status == "no_data" + assert crs.no_data.reason != "source_code_ambiguous" diff --git a/tests/engine/test_unit_bind.py b/tests/engine/test_unit_bind.py index 243002bc..fd11bc05 100644 --- a/tests/engine/test_unit_bind.py +++ b/tests/engine/test_unit_bind.py @@ -25,6 +25,7 @@ _tokenise, bind_unit_text, names_unit_request, + unit_text_naming, with_unit_lexicon_version, ) from search.engine.unit.lexicon import ( @@ -1174,3 +1175,26 @@ def test_a_bound_outcome_states_its_lexicon_version_on_a_copy_of_the_spec() -> N def test_names_unit_request_is_whether_the_binder_accepts_the_phrase(text, expected): assert names_unit_request(text) is expected assert isinstance(bind_unit_text(text, policy=_policy()), BoundUnitRequest) is expected + + +@pytest.mark.parametrize( + ("text", "currency", "named"), + [ + ("Constant 2015 Francs", "XOF", "constant 2015 XOF"), + ("kr", "SEK", "SEK"), + # A code that spells an English word is replaced by its curated name. + ("try", "TRY", "turkish lira"), + ("local currency", IdentityCurrencyRole(role="destination"), "recipient currency"), + ("own currency", IdentityCurrencyRole(role="origin"), "donor currency"), + # No ambiguous words to replace. + ("USD", "USD", None), + ], +) +def test_unit_text_naming_replaces_the_ambiguous_words_with_ones_naming_the_choice( + text, currency, named +) -> None: + assert unit_text_naming(text, currency) == named + if named is not None: + rebound = _bind(named) + assert not isinstance(rebound, UnitNotResolved) + assert rebound.currency == currency diff --git a/tests/entity_resolution/test_resolver.py b/tests/entity_resolution/test_resolver.py index f56e882b..855ae852 100644 --- a/tests/entity_resolution/test_resolver.py +++ b/tests/entity_resolution/test_resolver.py @@ -759,12 +759,15 @@ def test_group_node_dcids_are_the_nodes_a_group_resolves_to(shared_resolver): async def resolve_and_look_up(): (union,) = await resolver.resolve(["European Union"]) - return union, await resolver.group_node_dcids() + return union, await resolver.group_node_dcids(), await resolver.group_node_names() - union, group_nodes = _run(resolve_and_look_up()) + union, group_nodes, names = _run(resolve_and_look_up()) assert isinstance(union, Group) and union.entity is not None assert union.entity.dcid in group_nodes + assert set(names) == group_nodes + (again,) = _run(resolver.resolve([names[union.entity.dcid]])) + assert isinstance(again, Group) and again.entity == union.entity assert not group_nodes & {*(member.dcid for member in union.members), "africa"} # The one graph read is the resolve's confirmation: the lookup reads the packs alone. assert len(graph.node_arcs_many_calls) == 1 diff --git a/tests/fixtures/first_answer_place_scenarios.json b/tests/fixtures/first_answer_place_scenarios.json index 3e463f07..a7a0be4c 100644 --- a/tests/fixtures/first_answer_place_scenarios.json +++ b/tests/fixtures/first_answer_place_scenarios.json @@ -481,7 +481,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -1261,7 +1261,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -1519,7 +1519,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -2152,7 +2152,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -2389,7 +2389,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -2939,7 +2939,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -3556,7 +3556,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -4591,7 +4591,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -4854,7 +4854,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -5622,7 +5622,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -5909,7 +5909,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -6617,7 +6617,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -6915,7 +6915,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -7421,7 +7421,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -8023,7 +8023,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -8687,7 +8687,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -8913,7 +8913,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -9090,7 +9090,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -10081,7 +10081,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -10697,7 +10697,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -11450,7 +11450,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -11676,7 +11676,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -12294,7 +12294,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -12942,7 +12942,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -13727,7 +13727,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -14265,7 +14265,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -14736,7 +14736,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -15271,7 +15271,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -15743,7 +15743,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -16202,7 +16202,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -16674,7 +16674,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -17179,7 +17179,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -17638,7 +17638,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -17838,7 +17838,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -18785,7 +18785,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -18991,7 +18991,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -19615,7 +19615,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -19821,7 +19821,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -20357,7 +20357,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -20818,7 +20818,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -21279,7 +21279,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -21435,7 +21435,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -22183,7 +22183,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -22758,7 +22758,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -23380,7 +23380,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -24120,7 +24120,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -24326,7 +24326,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -25086,7 +25086,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -25292,7 +25292,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -25958,7 +25958,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -26716,7 +26716,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -26929,7 +26929,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -27091,7 +27091,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -28069,7 +28069,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -28275,7 +28275,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -29078,7 +29078,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -29284,7 +29284,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -29446,7 +29446,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -30374,7 +30374,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -30571,7 +30571,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -31538,7 +31538,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -31825,7 +31825,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -32568,7 +32568,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -32855,7 +32855,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -33579,7 +33579,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -34351,7 +34351,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -34557,7 +34557,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -35311,7 +35311,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -35517,7 +35517,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -36243,7 +36243,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -36449,7 +36449,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -36932,7 +36932,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -37614,7 +37614,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -37857,7 +37857,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -38548,7 +38548,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -38791,7 +38791,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -39906,7 +39906,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -40149,7 +40149,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -40791,7 +40791,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -41073,7 +41073,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -41715,7 +41715,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, @@ -41921,7 +41921,7 @@ "served_count": 1 }, "trace": { - "capability_version": "cap_3f35867df5a65761", + "capability_version": "cap_f7a8361afa01add3", "conversion": null, "default_reading": { "applied": true, diff --git a/tests/models/test_answer_kinds.py b/tests/models/test_answer_kinds.py index f296cae1..a8241be0 100644 --- a/tests/models/test_answer_kinds.py +++ b/tests/models/test_answer_kinds.py @@ -168,11 +168,11 @@ def test_a_malformed_operation_refuses(self, payload): Operation.model_validate(payload) -class TestNoDataQuery: - def test_data_query_defaults_to_none(self): - assert NoData(reason="no_observations").data_query is None +class TestNoDataOptions: + def test_options_default_to_empty(self): + assert NoData(reason="no_observations").options == [] - def test_data_query_carries_a_parsed_input(self): + def test_an_option_carries_a_parsed_input(self): query = ParsedInput( parts=[ ParsedPart( @@ -181,10 +181,16 @@ def test_data_query_carries_a_parsed_input(self): ) ] ) - no_data = NoData.model_validate( - {"reason": "entity_ambiguous", "data_query": query.model_dump(mode="json")} - ) - assert no_data.data_query == query + option = {"mentions": ["Congo"], "label": "Congo [DRC]", "data_query": query} + no_data = NoData.model_validate({"reason": "entity_ambiguous", "options": [option]}) + assert no_data.options[0].data_query == query + + def test_a_refusal_carrying_data_query_fails_validation(self): + query = ParsedInput(parts=[ParsedPart(variable_text="ODA")]) + with pytest.raises(ValidationError): + NoData.model_validate( + {"reason": "entity_ambiguous", "data_query": query.model_dump(mode="json")} + ) class TestNoDataNearMatch: From c18924faa8b874dd8300b7e00a8bebb164fd3976 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 18:23:28 +0200 Subject: [PATCH 03/14] fix(search): count words for every member of a role as met when the answer 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. --- src/search/README.md | 2 +- src/search/engine/region/pipeline.py | 5 +- src/search/engine/region/result.py | 28 +++++++- src/search/engine/response_cache_flow.py | 26 +++++-- src/search/engine/response_cache_keys.py | 7 +- src/search/engine/role_text.py | 62 +++++++++++++++++ ...st_qualifier_suppression_and_provenance.py | 18 +++-- tests/engine/test_response_cache_keys.py | 14 ++++ tests/engine/test_response_cache_signals.py | 25 +++++-- tests/engine/test_role_scope_pipeline_e2e.py | 69 +++++++++++++++++++ tests/engine/test_role_text.py | 28 ++++++++ 11 files changed, 263 insertions(+), 21 deletions(-) diff --git a/src/search/README.md b/src/search/README.md index c1e68d1b..76c9b1d8 100644 --- a/src/search/README.md +++ b/src/search/README.md @@ -95,7 +95,7 @@ Every part also carries `restatement`: a `ParsedPart` reproducing that part's ow A part's `operations` are the things the question asks done with its data that search does not do, such as a ranking or an explanation. On the raw-text door extract assigns each operation to one variable, so only that variable's part carries it. Its phrase goes to that variable's bind in the same block as the dropped qualifiers, so "top 5 donors for education aid" lets bind leave the donor axis open and the part covers every donor. An operation raises no `query_qualifier_dropped` warning and adds no note to the sentence. The parsed and `dataset_resubmit` doors echo the operations each posted part carries. A request part carries at most 8, the most extract keeps for a whole query. -A part asks for every member of a flow role in one of two ways: `open_roles` on a `ParsedPart` lists at most two `role_text` words (`creditor`, `donor`, `recipient`, `debtor`, `origin`, `destination`), and an operation whose `over` is `origin` or `destination` opens that axis on every door. A role named in neither the part's entities nor its open roles is the total over its members, so a question that names no recipient or creditor reads the series published without one. An open role joins the election only where a member the bound roles alone elect, or a member at the same constraint values such as the by-creditor twin of a total, declares it: search elects those members, the answer holds one series per member, and the identity states the axis in `open_roles`. Otherwise the question is read as if the role were unnamed, and the identity omits an open role its elected members declare no role on; on a role-bearing answer the part warns `query_qualifier_dropped` that the breakdown was not applied. A word that names no role, or a role an entity of the same part binds through `role_text`, refuses `request_invalid` at `/input/parts/{i}/open_roles/{j}`. An entity the part binds on the axis without `role_text` wins over the open role: the open role drops and the part warns `query_qualifier_dropped`. +A part asks for every member of a flow role in one of two ways: `open_roles` on a `ParsedPart` lists at most two `role_text` words (`creditor`, `donor`, `recipient`, `debtor`, `origin`, `destination`), and an operation whose `over` is `origin` or `destination` opens that axis on every door. A role named in neither the part's entities nor its open roles is the total over its members, so a question that names no recipient or creditor reads the series published without one. An open role joins the election only where a member the bound roles alone elect, or a member at the same constraint values such as the by-creditor twin of a total, declares it: search elects those members, the answer holds one series per member, and the identity states the axis in `open_roles`. Otherwise the question is read as if the role were unnamed, and the identity omits an open role its elected members declare no role on; on a role-bearing answer the part warns `query_qualifier_dropped` that the breakdown was not applied. A word that names no role, or a role an entity of the same part binds through `role_text`, refuses `request_invalid` at `/input/parts/{i}/open_roles/{j}`. An entity the part binds on the axis without `role_text` wins over the open role: the open role drops and the part warns `query_qualifier_dropped`. On the raw-text door, a phrase extraction drops that asks for every member of a role ("all donors", "every provider", "to all creditors", or "from all countries", whose preposition names the side) opens no axis, and a role-bearing answer that binds no place on that axis meets it: one series per member, or the total over them. Such a phrase raises no `query_qualifier_dropped` warning there; on a subject-only answer, or one that names a place on that axis, it warns as any dropped phrase does (`role_text.every_member_axis`, `region.result.every_member_phrases`). Two tables that publish one structural question are chosen between before bind. Each table reads the query in its own codes, and it reads a phrase when its rows hold a code the phrase names, a phrase naming several codes included (`retrieval/bound_values.query_phrases_read`). Of two families of one five-tuple and role class, the one reading fewer of the query's phrases leaves, whether or not the code index carries them (`elect_five_tuple_family`). "Imputed multilateral ODA to basic education" reads the sector only on ONE's imputed sectors table, so DAC2a's imputed total leaves. A table can also state a word of every row it serves without coding it (`DatasetDeclaration.stated_words`): every CRS row is bilateral, since the import leaves out core contributions to multilateral organisations, and a DAC2a or DAC2b amount is bilateral where its recipient is a country. Such a word restricts nothing on those rows, so a part reads it as it reads a word of the table's label (`region/phase_rank_bind._dataset_stated_words`), and "bilateral ODA to Kenya" answers as "ODA to Kenya" does. The part states the word where it modifies the measure ("net bilateral ODA"): a negated or excluded occurrence ("non-bilateral", "excluding bilateral flows") and one past a function word, a connective or a separator ("ODA net of bilateral flows") are not read (`bind_repair.word_stated`). In the election a family reads a word it states beside a rival the code index carries that reads the word in no phrase, so "bilateral ODA from Germany by recipient" leaves DAC2a, whose open recipient axis holds multilateral organisations, for the CRS. Beside a rival that codes the word a stated word adds nothing, so "bilateral ODA from France" stays DAC1's bilateral aid type, and it adds nothing beside a family the index does not carry, so a debt series by bilateral creditors compares with the CRS as it does without the word. The graph's text search matches series names, which hold no stated word, so recall searches a text holding one a second time without it and adds the series of the tables that state it (`interpret._detect`). A table can also declare that a word names one of its parties (`DatasetDeclaration.party_words`): "multilateral" names `DACMultilaterals`, the DAC's published total of the multilateral organisations, as the provider of a DAC2a or DAC2b amount to a country. A part states such a word as it states a stated word, on a flow axis the request leaves unnamed: no place is directed to it, no operation runs across it and the part does not ask every member of it, while a name is directed to each axis the declaration reads a kind on (`region/place_roles.stated_party_words`). Such a part is read with the word first (`region/pipeline.resolve_variable`). Recall also searches the text without the word for the series of the tables that declare it, intake reads the party's node as a place set (`region/phase_intake._party_word_sets`), and a shape whose every dataset declares the word binds the party on that axis beside the places the query names, where the shape reads each word next to the word (`held_party_words`, `_beside_words_read`, bound in `bind_place_roles`). The party's set passes role admission as a named set does. The reading stands where a shape binding the party reads every phrase any other pooled shape reads, the word counted as read; the binding shapes are then the pool (`region/phase_rank_bind.with_party_reading`). "Multilateral ODA to Kenya" answers DAC2a's flows from the multilaterals total, as "ODA from multilaterals to Kenya" does. The reading is withdrawn where no shape binds the party ("multilateral development bank ODA to Kenya"), where another shape reads a phrase the binding shapes do not (the CRS sector of "multilateral aid for education to Kenya"), where another shape reads the word as a value of its own, in a phrase of its codes or by a graph label ("multilateral loans to Kenya", a World Bank IDS creditor type), and where the answer it reaches does not hold the party in every Spec. A withdrawn reading leaves only its model usage and failed reads on the reply: the part is resolved again without party words, exactly as a request stating none, under the same deadline, and the first reading's events are never sent. A stated party word that binds no party is therefore dropped or refused as it is without the declaration. A request that names the provider itself, by a place or an alias of the party, states no party word. A declared party the graph gives no type has no set, and the read is logged and noted as degraded. A negated or excluded word names no party, and the response cache keys on it (`bind_repair.stated_words_negated`). Of families that read the same phrases, one holding no series at the places the part names, on the roles they bind, leaves when another holds one, as the request's coverage snapshot records them (`region/phase_rank_bind._series_reader`). "Other official flows from Germany to Brazil" reads alike on the CRS and DAC2b, and DAC2b, whose bilateral donors report only against aggregate recipients, leaves for the CRS, while "other official flows to Brazil" keeps both. The rule stands down when the part names no place, when coverage holds none of a family's members, and when no family holds a series there, where the part refuses for want of data. Between a family the index carries and one it does not that read alike, the stronger recall score stays. Codes of different tables that name one concept are declared as a group in `code_index/declarations.py` (`CONCEPT_GROUPS`), and the export gives each code the union of the group's texts, so "food aid" reads on DAC2a's developmental food aid as on CRS purpose 520, and "ODA" on DAC2a 206 as on the CRS flow type and DAC1 1010. DAC2a's imputed multilateral code also reads "imputed", so "imputed aid from Norway" keeps DAC2a's imputed line. Then a role-bearing shape that publishes on a flow axis the query neither names nor opens leaves for one that declares fewer such axes and reads every phrase it reads, across five-tuples (`region/place_roles.without_unasked_axes`). "Aid from Japan" reads DAC1's donor totals over DAC2a's and CRS's flows by recipient, and "imputed multilateral ODA from Germany" keeps DAC2a's imputed table, since DAC1 does not read "imputed". Shapes of another measure compare because a question naming no measure can recall them. "ODA from Japan" leaves CRS commitments for DAC1's donor disbursements, and "ODA commitments from Japan" keeps commitments over those disbursements, since a measure word reads only on the shape whose five-tuple or constraint value it names. A donor-only "grant equivalent ODA from France" elects DAC1's grant-equivalent total over the CRS grant equivalents: recall adds a row of that DAC1 family beside the CRS hits (`clients/graph.with_rival_families`, applied by `recall_hits`), it reads the measure word as the CRS does, and it declares no recipient axis. A row recall adds this way displaces only shapes of its own five-tuple, since the query did not recall it, and derivation confirms it beside every recalled hit, so it takes no candidate's place. "Aid attributed to donors via multilaterals" keeps the imputed tables, whose measure reads "attributed to donors", while "aid from Japan", which names no measure, reads DAC1's donor totals over them. Both shapes must be ones the code index carries, since another shape's axes are those of its recalled sample and it reads no index code, and the rule stands down when a named place has no direction. diff --git a/src/search/engine/region/pipeline.py b/src/search/engine/region/pipeline.py index 0b1c7be8..fc06cc21 100644 --- a/src/search/engine/region/pipeline.py +++ b/src/search/engine/region/pipeline.py @@ -63,7 +63,7 @@ from search.engine.region.place_roles import group_request_names, stated_party_words from search.engine.region.related_read import cancel_related_reads, settle_related_reads from search.engine.region.requirements import unbinding_phrases, with_requirement_departures -from search.engine.region.result import RegionResult +from search.engine.region.result import RegionResult, every_member_phrases from search.engine.region.source_choice import ( allows_un_system, answer_provenances, @@ -712,6 +712,9 @@ async def _run_phases(state: ResolveState) -> RegionResult: | state.applied_dropped_qualifier_phrases | unbinding_phrases(state.requirements) | state.slot_default_phrases + | every_member_phrases( + result, state.dropped_qualifier_phrases, query=state.role_query + ) if state.shape_draft is not None else _refused_pinned_phrases(state, result) ) diff --git a/src/search/engine/region/result.py b/src/search/engine/region/result.py index d16ecaf5..8568bc5e 100644 --- a/src/search/engine/region/result.py +++ b/src/search/engine/region/result.py @@ -27,6 +27,7 @@ ) from search.engine.retrieval.member_facts import MemberFacts from search.engine.retrieval.retrieve import MemberPopulation +from search.engine.role_text import every_member_axis from search.engine.shape import ShapeDraft from search.engine.source_code import SourceCodeRefusal from search.engine.source_scope import AmbiguousSource, SourceRefusal, SourceScope @@ -193,8 +194,9 @@ class RegionResult: # refusal about the elected reading. A refusal that judged no answer is judged on it # (`phase_shared.settle_unjudged_refusal`). refused_answer: tuple[ShapeDraft, tuple[SlotBindingDraft, ...]] | None = None - # Stripped, case-folded qualifier spans forced into bindings by this region, or - # answered by a slot default it warns about. Same-shape merges union the sets. + # Stripped, case-folded qualifier spans forced into bindings by this region, answered + # by a slot default it warns about, or asking for every member of a flow role its + # answer leaves unbound (`every_member_phrases`). Same-shape merges union the sets. # The entry door uses them to retain or drop `QUERY_QUALIFIER_DROPPED` warnings # across the request. admitted_qualifier_phrases: frozenset[str] = frozenset() @@ -522,6 +524,28 @@ def with_dropped_open_roles(region: RegionResult, open_axes: Collection[RoleAxis return dataclasses.replace(region, warnings=tuple(warnings), departure_facts=departure_facts) +def every_member_phrases( + region: RegionResult, phrases: Sequence[str], *, query: str +) -> frozenset[str]: + """The `phrases`, stripped and casefolded, that ask for every member of a flow axis + (`role_text.every_member_axis`, reading prepositions in `query`) and that `region`'s + answer meets. + + A role-bearing answer (`RegionResult.answered_roles`) that binds no entity on the axis + holds every member on it: one series per member where it declares a role there, and + the total over them where it declares none. A subject-only answer and a result with no + Spec meet none, so their phrases stay dropped. + """ + if not region.specs or all(role == "observationAbout" for role in region.answered_roles or ()): + return frozenset() + bound = {key for spec in region.specs for key in spec.definition.identity.entities} + return frozenset( + phrase.strip().casefold() + for phrase in phrases + if (axis := every_member_axis(phrase, query=query)) is not None and axis not in bound + ) + + def _bound_open_role_warning(axis: RoleAxis) -> Warning: """The `query_qualifier_dropped` warning for an open role a named place binds.""" noun = axis_noun(axis) diff --git a/src/search/engine/response_cache_flow.py b/src/search/engine/response_cache_flow.py index 45824159..cf87f3d0 100644 --- a/src/search/engine/response_cache_flow.py +++ b/src/search/engine/response_cache_flow.py @@ -57,7 +57,7 @@ to_entry, ) from search.engine.response_cache_memo import PartMemo -from search.engine.role_text import DoorRole, contradicts +from search.engine.role_text import DoorRole, contradicts, every_member_axis from search.engine.unit.conversion_catalogue import current_conversion_catalogue from search.engine.written_phrase import written_phrase from search.models import ( @@ -80,8 +80,9 @@ # --------------------------------------------------------------------------- -# Raw-text inputs absent from Extraction: one `role_query` reader checks each -# DoorRole occurrence on every leg. Five `query_text` readers run only for one- +# Raw-text inputs absent from Extraction: two `role_query` readers run on every leg. One +# checks each DoorRole occurrence, the other reads the flow axis each dropped phrase asks +# every member of (`role_text.every_member_axis`). Five `query_text` readers run only for one- # variable requests: qualifier matches and ties (one reading serves both), # two-reading spans, unsupported phrases, default triggers, and the defaulted # slots the query names. @@ -108,6 +109,7 @@ class Layer2Signals: declared_default_slots_named: tuple[str, ...] written_variable_phrases: tuple[str | None, ...] query_text_negated_stated_words: tuple[str, ...] + every_member_axes: tuple[str | None, ...] def _role_contradiction(name: str, role: DoorRole, *, query: str) -> dict[str, object]: @@ -127,9 +129,11 @@ def compute_layer2_signals( entity_roles: Mapping[str, Sequence[DoorRole]], query_text: str | None, variables: Sequence[str] = (), + dropped_qualifiers: Sequence[str] = (), ) -> Layer2Signals: - """`query` supplies every leg's raw role signal; `entity_roles` lists its - door-provided occurrences in mention order. `query_text` is set only for a + """`query` supplies every leg's raw role signals; `entity_roles` lists its + door-provided occurrences in mention order, and each of `dropped_qualifiers` gives the + flow axis it asks every member of, read with the preposition `query` puts before it. `query_text` is set only for a single-variable request, matching the pipeline's own raw-text readers. Each of `variables` gives the stretch of `query_text` that writes it (`written_phrase`), which the named-member binding reads. @@ -139,6 +143,9 @@ def compute_layer2_signals( for name, roles in entity_roles.items() for role in roles ) + every_member_axes = tuple( + every_member_axis(phrase, query=query) for phrase in dropped_qualifiers + ) if query_text is None: return Layer2Signals( role_contradictions=role_contradictions, @@ -151,6 +158,7 @@ def compute_layer2_signals( declared_default_slots_named=(), written_variable_phrases=(), query_text_negated_stated_words=(), + every_member_axes=every_member_axes, ) index = get_index() measure_index = get_measure_index() @@ -180,6 +188,7 @@ def compute_layer2_signals( written_phrase(variable, query_text) for variable in variables ), query_text_negated_stated_words=stated_words_negated(query_text, index=index), + every_member_axes=every_member_axes, ) @@ -384,7 +393,11 @@ async def lookup_parsed( return None assert self._scope is not None signals = compute_layer2_signals( - query=query, entity_roles=entity_roles, query_text=query_text, variables=variables + query=query, + entity_roles=entity_roles, + query_text=query_text, + variables=variables, + dropped_qualifiers=dropped_qualifiers, ) key = layer2_key( variables=variables, @@ -405,6 +418,7 @@ async def lookup_parsed( declared_default_slots_named=signals.declared_default_slots_named, written_variable_phrases=signals.written_variable_phrases, query_text_negated_stated_words=signals.query_text_negated_stated_words, + every_member_axes=signals.every_member_axes, scope=self._scope, separate=separate, ) diff --git a/src/search/engine/response_cache_keys.py b/src/search/engine/response_cache_keys.py index aef1a8ed..7eadd72a 100644 --- a/src/search/engine/response_cache_keys.py +++ b/src/search/engine/response_cache_keys.py @@ -251,6 +251,7 @@ def layer2_key( source_codes: Sequence[str | None] = (), separate: Sequence[bool] = (), written_variable_phrases: Sequence[str | None] = (), + every_member_axes: Sequence[str | None] = (), ) -> str: """Key the effective extraction and raw-text signals over the shared scope. @@ -276,7 +277,9 @@ def layer2_key( slots with a declared default whose plain name the query names, the stretch of the query that writes each variable (`written_phrase.written_phrase`), which the key states only when one is given, and the words a dataset states of its rows that the query negates - (`bind_repair.stated_words_negated`), which the key states only when there is one. They keep + (`bind_repair.stated_words_negated`), which the key states only when there is one, and + the flow axis each dropped qualifier asks every member of (`role_text.every_member_axis`), + which the key states only when one does. They keep layer 2 sound when distinct queries produce the same extraction without hashing raw query text, preserving reuse when those readers agree. """ @@ -306,6 +309,8 @@ def layer2_key( document["separate"] = separate_positions if any(phrase is not None for phrase in written_variable_phrases): document["written_variable_phrases"] = list(written_variable_phrases) + if any(axis is not None for axis in every_member_axes): + document["every_member_axes"] = list(every_member_axes) return _digest("l2", document) diff --git a/src/search/engine/role_text.py b/src/search/engine/role_text.py index ce1b062a..2a1977c7 100644 --- a/src/search/engine/role_text.py +++ b/src/search/engine/role_text.py @@ -353,6 +353,68 @@ def contradicts(query: str, *, span: str, axis: RoleAxis) -> bool: return saw_contradiction +# Words that ask for every member of what follows them: "all donors", "every provider". +_EVERY_WORDS: frozenset[str] = frozenset({"all", "every", "each"}) +# Nouns that name the places on either side of a flow without naming the side: "all +# countries". The preposition that governs them names it. +_PLACE_NOUNS: frozenset[str] = frozenset({"country", "countries"}) +# The axis each role preposition names. +_ROLE_PREP_AXES: dict[str, RoleAxis] = { + prep: _ROLE_TEXT_TO_AXIS[prep] for prep in _DONOR_PREPS | _RECIPIENT_PREPS +} + + +def _noun_axis(token: str) -> RoleAxis | None: + """The axis a role noun names, singular or plural ("donor", "beneficiaries"). A + preposition names none here, since it governs the phrase and is no noun of it.""" + if token in _ROLE_PREP_AXES: + return None + singular = token[:-3] + "y" if token.endswith("ies") else token.removesuffix("s") + return role_axis_for_text(token) or role_axis_for_text(singular) + + +def _governing_axes(query_tokens: list[str], phrase_tokens: list[str]) -> set[RoleAxis]: + """The axes the role prepositions directly before each occurrence of the phrase name.""" + return { + axis + for start in _span_starts(query_tokens, phrase_tokens) + if start > 0 and (axis := _ROLE_PREP_AXES.get(query_tokens[start - 1])) is not None + } + + +def every_member_axis(phrase: str, *, query: str) -> RoleAxis | None: + """The flow axis whose every member ``phrase`` asks for, or None. + + The phrase is an optional ``from`` or ``to``, then ``all``, ``every`` or ``each``, an + optional ``the``, and nouns that each name a role (``all donors``, ``every provider``, + ``to all creditors``) or a place on either side (``all countries``, ``all donor + countries``). A role noun names the axis, so ``to all creditors`` asks for every + origin. Place nouns alone take the axis of the preposition that governs them: the + phrase's own, else the one before the phrase in ``query``, which must name a single + axis. Any other word, two role nouns on opposite axes, or no axis gives None, so ``in + all sectors`` and ``all bilateral donors`` ask for no role. + """ + tokens = _tokenize(phrase) + prep_axis = _ROLE_PREP_AXES.get(tokens[0]) if tokens else None + rest = tokens[1:] if prep_axis is not None else tokens + if not rest or rest[0] not in _EVERY_WORDS: + return None + nouns = rest[2:] if rest[1:2] == ["the"] else rest[1:] + if not nouns: + return None + named: set[RoleAxis] = set() + for token in nouns: + if token in _PLACE_NOUNS: + continue + axis = _noun_axis(token) + if axis is None: + return None + named.add(axis) + if not named: + named = {prep_axis} if prep_axis is not None else _governing_axes(_tokenize(query), tokens) + return next(iter(named)) if len(named) == 1 else None + + @dataclass(frozen=True, slots=True) class DoorRole: """One occurrence of a door-supplied flow axis and its verbatim text span. diff --git a/tests/engine/test_qualifier_suppression_and_provenance.py b/tests/engine/test_qualifier_suppression_and_provenance.py index 7efefa93..d06bfbe7 100644 --- a/tests/engine/test_qualifier_suppression_and_provenance.py +++ b/tests/engine/test_qualifier_suppression_and_provenance.py @@ -165,24 +165,30 @@ def test_two_dropped_qualifiers_keeps_only_the_unadmitted_one(): ] == [("measure_not_applied", "an entirely unrelated qualifier")] -def test_unadmitted_dropped_qualifier_survives_unchanged(): +@pytest.mark.parametrize( + "phrase", + [ + "a phrase naming nothing in the index", + # Asks for every member of a flow role, which a subject-only answer has none of. + "from all donors", + ], +) +def test_unadmitted_dropped_qualifier_survives_unchanged(phrase): """A dropped qualifier no region admitted survives unchanged -- the regression guard that behaviour still works unchanged.""" response = offline_resolve( make_request(_E2E_QUERY), graph=_e2e_graph(), - llm=_ExtractAndBindLLM(dropped_qualifiers=["a phrase naming nothing in the index"]), + llm=_ExtractAndBindLLM(dropped_qualifiers=[phrase]), sdmx=FakeSdmxClient(observations={}, availability={}), ) warnings = [w for w in response.diagnostics.warnings if w.code == QUERY_QUALIFIER_DROPPED] assert len(warnings) == 1 - assert warnings[0].message == ( - 'The requested qualifier "a phrase naming nothing in the index" was not applied.' - ) + assert warnings[0].message == f'The requested qualifier "{phrase}" was not applied.' assert warnings[0].message not in response.parts[0].spec.resolved.rendered_sentence assert [ (d.code, d.asked) for d in response.parts[0].departures if d.code == "measure_not_applied" - ] == [("measure_not_applied", "a phrase naming nothing in the index")] + ] == [("measure_not_applied", phrase)] def test_terminal_before_election_reports_no_admitted_phrases(): diff --git a/tests/engine/test_response_cache_keys.py b/tests/engine/test_response_cache_keys.py index 4706be78..7ae013e3 100644 --- a/tests/engine/test_response_cache_keys.py +++ b/tests/engine/test_response_cache_keys.py @@ -121,6 +121,7 @@ def _layer2_key( source_codes: list[str | None] | None = None, separate: list[bool] | None = None, written_variable_phrases: list[str | None] | None = None, + every_member_axes: list[str | None] | None = None, ) -> str: """`layer2_key` with every raw-text signal defaulting to empty, for a test that means to vary exactly one argument.""" @@ -168,6 +169,7 @@ def _layer2_key( written_variable_phrases=( written_variable_phrases if written_variable_phrases is not None else () ), + every_member_axes=every_member_axes if every_member_axes is not None else (), ) @@ -624,6 +626,18 @@ def test_the_written_stretch_of_a_variable_changes_layer2_key() -> None: assert plain == _layer2_key(variables=variables, written_variable_phrases=[None]) +def test_the_axis_a_dropped_phrase_asks_every_member_of_changes_layer2_key() -> None: + """ "aid to Ethiopia from all countries" and "aid to Ethiopia to all countries" can + extract alike, and only the first asks for every provider. A phrase asking for no + axis keys as an extraction without the field.""" + dropped = ["all countries"] + plain = _layer2_key(dropped_qualifiers=dropped) + origin = _layer2_key(dropped_qualifiers=dropped, every_member_axes=["origin"]) + destination = _layer2_key(dropped_qualifiers=dropped, every_member_axes=["destination"]) + assert len({plain, origin, destination}) == 3 + assert plain == _layer2_key(dropped_qualifiers=dropped, every_member_axes=[None]) + + def test_both_spellings_of_a_series_code_share_the_parsed_key() -> None: def key(code: str | None) -> str: part = ParsedPart(variable_text="debt service", source_code=code) diff --git a/tests/engine/test_response_cache_signals.py b/tests/engine/test_response_cache_signals.py index b3f21424..f6cb99d4 100644 --- a/tests/engine/test_response_cache_signals.py +++ b/tests/engine/test_response_cache_signals.py @@ -32,7 +32,7 @@ def _count_state_attribute_reads(attr: str) -> int: def test_query_text_and_role_query_reader_counts_are_covered() -> None: - """Pin the 7 `query_text` readers and 1 `role_query` reader covered by cache signals.""" + """Pin the 7 `query_text` readers and 2 `role_query` readers covered by cache signals.""" query_text_reads = _count_state_attribute_reads("query_text") role_query_reads = _count_state_attribute_reads("role_query") assert query_text_reads == 7, ( @@ -40,9 +40,9 @@ def test_query_text_and_role_query_reader_counts_are_covered() -> None: "not the 7 compute_layer2_signals computes signals for. Update " "compute_layer2_signals (and this test) to cover the new reader." ) - assert role_query_reads == 1, ( + assert role_query_reads == 2, ( f"state.role_query has {role_query_reads} readers in src/search/engine/region/, " - "not the 1 compute_layer2_signals computes a signal for. Update " + "not the 2 compute_layer2_signals computes signals for. Update " "compute_layer2_signals (and this test) to cover the new reader." ) @@ -257,7 +257,7 @@ def test_negated_stated_words_against_the_real_index( def test_layer2_signals_carries_one_field_per_numbered_signal() -> None: - """The dataclass has one field for each of the ten cache signals.""" + """The dataclass has one field for each of the eleven cache signals.""" field_names = {f.name for f in fields(Layer2Signals)} assert field_names == { "role_contradictions", @@ -270,9 +270,26 @@ def test_layer2_signals_carries_one_field_per_numbered_signal() -> None: "declared_default_slots_named", "written_variable_phrases", "query_text_negated_stated_words", + "every_member_axes", } +def test_a_dropped_phrase_of_place_nouns_takes_its_axis_from_the_query() -> None: + """Two queries that extract "all countries" alike key apart by the preposition before + it, on a request of several variables too.""" + + def axes(query: str) -> tuple[str | None, ...]: + return compute_layer2_signals( + query=query, + entity_roles={}, + query_text=None, + dropped_qualifiers=["all countries", "in all sectors"], + ).every_member_axes + + assert axes("aid to Ethiopia from all countries in all sectors") == ("origin", None) + assert axes("aid from Ethiopia to all countries in all sectors") == ("destination", None) + + def test_each_variable_carries_the_stretch_of_the_query_that_writes_it() -> None: """Two wordings that extract alike key apart when they write the phrase differently, and a request with no query text carries none.""" diff --git a/tests/engine/test_role_scope_pipeline_e2e.py b/tests/engine/test_role_scope_pipeline_e2e.py index ccf5674c..e862e696 100644 --- a/tests/engine/test_role_scope_pipeline_e2e.py +++ b/tests/engine/test_role_scope_pipeline_e2e.py @@ -157,6 +157,7 @@ def _run( coverage_rows: list[CoverageRow] | None = None, coverage_role_names: dict[str, RoleNames] | None = None, coverage_facet_metadata: dict[str, FacetMetadata] | None = None, + dropped_qualifier_phrases: list[str] | None = None, ): coverage_snapshot = empty_coverage_snapshot() if coverage_rows is not None: @@ -192,6 +193,7 @@ def _run( sdmx=sdmx, base_timing={}, unit_text=unit_text, + dropped_qualifier_phrases=dropped_qualifier_phrases, coverage=coverage_snapshot, options=EngineOptions(include_observations=include_observations), ) @@ -711,6 +713,73 @@ def test_origin_only_signature_discovers_the_provider_scope(monkeypatch): assert sorted(m.ref for m in spec.resolved.scope.members) == [_DEU, _FRA] +@pytest.mark.parametrize( + ("role_query", "phrase", "observation_properties", "admitted"), + [ + pytest.param( + "aid from France to all recipients", + "to all recipients", + ["creditor", "debtor"], + True, + id="open-axis-the-answer-serves-member-by-member", + ), + pytest.param( + "aid from France to all recipients", + "to all recipients", + ["creditor"], + True, + id="axis-the-answer-totals-over", + ), + pytest.param( + "aid from France and all donors", + "all donors", + ["creditor", "debtor"], + False, + id="axis-a-named-place-binds", + ), + pytest.param( + "aid from France in all sectors", + "in all sectors", + ["creditor", "debtor"], + False, + id="all-names-no-role", + ), + ], +) +def test_a_dropped_phrase_asking_for_every_member_of_a_role_is_met_by_an_answer_leaving_it_unbound( + monkeypatch, role_query, phrase, observation_properties, admitted +): + """The phrase is admitted, so its `query_qualifier_dropped` warning and departure go, + when the role-bearing answer binds no place on the axis the phrase names.""" + sv_dcid = _SV_DCID if "debtor" in observation_properties else _ORIGIN_ONLY_SV_DCID + if "debtor" in observation_properties: + rows, role_names = _coverage_seed_for( + "debtor", [_KEN], [sv_dcid], filters={"creditor": [_FRA]} + ) + observations = {sv_dcid: {(("creditor", _FRA), ("debtor", _KEN)): [_facet()]}} + else: + rows, role_names = _coverage_seed_for("creditor", [_FRA], [sv_dcid]) + observations = {sv_dcid: {(("creditor", _FRA),): [_facet()]}} + + result = _run( + entities=["France"], + sv_dcid=sv_dcid, + sv_arcs=_declaring_arcs(observation_properties=observation_properties), + resolve={"France": _FRA}, + sv_score=0.9, + role_query=role_query, + sdmx=FakeSdmxClient(observations=observations, availability={}), + monkeypatch=monkeypatch, + coverage_rows=rows, + coverage_role_names=role_names, + dropped_qualifier_phrases=[phrase], + ) + + assert result.status == "definite", result + assert result.specs[0].definition.identity.entities.get("origin") == [_FRA] + assert (phrase in result.admitted_qualifier_phrases) is admitted + + # --------------------------------------------------------------------------- # Windowed / latest requests # --------------------------------------------------------------------------- diff --git a/tests/engine/test_role_text.py b/tests/engine/test_role_text.py index 0fe1a07e..cdfb288f 100644 --- a/tests/engine/test_role_text.py +++ b/tests/engine/test_role_text.py @@ -9,6 +9,7 @@ from search.engine.role_text import ( contradicts, debt_axes, + every_member_axis, role_axis_for_text, ) from search.models import ParsedEntity, ParsedPart, RoleAxis @@ -31,6 +32,33 @@ def test_each_axis_name_resolves_to_its_own_axis(axis: RoleAxis) -> None: assert role_axis_for_text(axis) == axis +@pytest.mark.parametrize( + ("phrase", "query", "axis"), + [ + ("all donors", "aid from all donors to Ethiopia in 2022", "origin"), + ("from all donors", "aid to Ethiopia from all donors in 2022", "origin"), + ("every provider", "aid to Ethiopia from every provider in 2022", "origin"), + ("to all creditors", "debt owed to all creditors by Kenya", "origin"), + ("all the recipients", "aid from Germany to all the recipients", "destination"), + ("each beneficiary", "aid from Germany to each beneficiary", "destination"), + ("all countries", "aid to Ethiopia from all countries in 2022", "origin"), + ("to all countries", "aid from France to all countries", "destination"), + ("all donor countries", "aid to Ethiopia from all donor countries", "origin"), + ("all countries", "aid from all countries to all countries", None), + ("all countries", "GDP of all countries", None), + ("in all sectors", "aid to Ethiopia in all sectors in 2022", None), + ("all bilateral donors", "aid from all bilateral donors to Kenya", None), + ("all donors and recipients", "aid between all donors and recipients", None), + ("all", "aid to Ethiopia from all", None), + ("donors", "aid to Ethiopia from donors", None), + ], +) +def test_every_member_axis_reads_the_role_a_phrase_asks_every_member_of( + phrase: str, query: str, axis: RoleAxis | None +) -> None: + assert every_member_axis(phrase, query=query) == axis + + class TestUnrecognised: @pytest.mark.parametrize( "word", From 89c50f192289417ddae6329ca17b3cab0be716e6 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 18:55:21 +0200 Subject: [PATCH 04/14] fix(search): name every ambiguous currency in its option and judge every-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. --- docs/conformance-search-api-v1.md | 2 +- src/search/README.md | 2 +- .../engine/region/candidate_collapse.py | 3 + src/search/engine/region/requirements.py | 16 +++-- src/search/engine/region/result.py | 27 +++++++-- src/search/engine/role_text.py | 18 +++++- src/search/engine/unit/bind.py | 21 +++++-- src/search/engine/unit/conversion.py | 7 --- src/search/engine/unit/lexicon.py | 5 +- src/search/models/outcomes.py | 18 +++--- tests/engine/test_role_scope_pipeline_e2e.py | 59 +++++++++++++++++++ tests/engine/test_role_text.py | 39 ++++++------ tests/engine/test_unit_bind.py | 23 ++++++++ 13 files changed, 186 insertions(+), 54 deletions(-) diff --git a/docs/conformance-search-api-v1.md b/docs/conformance-search-api-v1.md index a1efaecb..b3f9bf70 100644 --- a/docs/conformance-search-api-v1.md +++ b/docs/conformance-search-api-v1.md @@ -110,7 +110,7 @@ Gap rows state what is needed to close the clause and name the open issue that t | C50b | For entity_not_resolved, entity_ambiguous, and entity_role_not_admitted, no_data.entity_terms carries every entity wording responsible for that outcome in query order. It is an empty list for every other no_data reason, so clients need not parse rendered_sentence to identify the entity. | implemented | Additive response field; it does not alter any frozen enum or registry code. | | C50c | For entity_ambiguous only, no_data.ambiguous_entities carries one group per ambiguous wording (empty for every other reason), each with the graph candidates it could name (ref, label, kind), `ref` holding the candidate's dcid. Every group's text equals some restatement.entities[i].text on the same part. A subject-role entity_ambiguous part (e.g. "GDP of Congo", no role_text posted) carries a real restatement instead of null. The part also carries no_data.options, one per (ambiguous wording, candidate) in query order, each with the wording, the candidate's label, and a one-part parsed input built from the part's parsed inputs (variable text, entities with role_text, time, unit and sources, operations) pinning that candidate by text and every entity that resolved to one node; named groups, sets read as members and other ambiguous wordings stay unpinned. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid (C50d). Posting an option's data_query unchanged resolves that wording. options is built whether or not the part carries a restatement; an entity whose direction the question leaves unnamed carries no role_text. options is empty past the ParsedPart bounds. The part's sentence plan marks each ambiguous wording as a slot token carrying the role the request gave it, no entity_refs and no binding_ref, so the composed sentence stamps it with the part's index and a client hangs the candidate menu on it; the rendered text is unchanged. | implemented | Additive response field; it does not alter any frozen enum or registry code or `rendered_sentence`. Pinned by `tests/engine/test_render_e2e.py` (`test_no_data_sentence_names_ambiguous_entity_candidates`, `test_no_data_restatement_for_directional_ambiguous_entity`) and `tests/engine/test_operations_e2e.py` (option round trip, options without a restatement, slot token, operations through the resubmit door's bind). | | C50d | ParsedEntity.dcid pins a parsed-door entity to a specific graph node: the pin seeds entry/parsed.py's pre_resolved map directly, skipping this door's own name resolution for that entity, once the dcid confirms against the graph. An unconfirmable dcid (absent, or present but type-less) refuses. `members: true` on a pinned entity binds the countries the membership policy holds for the node when the node is a continent or subregion that the policy holds or omits (`MembershipPolicy.holds_region`), whatever its graph type, and has no effect on a pinned single place. A pinned dcid that is a resolver group's own node (`EntityResolver.group_node_dcids`, for example `EuropeanUnion`) is one place: without the flag the Spec binds that node, and with `members: true` the request refuses request_invalid at `{part}/entities/{j}`, since a group's members come from resolving its name, which a pin skips. | implemented | Reuses invalid_role_ref (422) for the unconfirmable case -- the same code region/promote_places.py's `_phase_where_places` and options/resolve.py's `_typed_anchors` already raise for an identical posted-ref-not-in-graph fact; no new registry code minted. Pinned by `tests/engine/test_parsed_pipeline.py` (`TestPinnedDcidC2`). The `members` rules are pinned by `tests/engine/test_place_set_invariants.py`. | -| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". They are offered while that gives at most six readings, none past it; none when the single untold place was read in each direction and no reading holds data; none on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code or a curated alias, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Additive response field replacing `NoData.data_query`; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | +| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". They are offered while that gives at most six readings, none past it; none when the single untold place was read in each direction and no reading holds data; none on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code, a curated alias or its display name, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Additive response field replacing `NoData.data_query`; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | | C51 | Within a part, when several reasons apply, the first in resolution order wins (entity, variable, role, unit, slice, denominator, observations, facet) | partial | The resolution order is documented (`NO_DATA_RESOLUTION_ORDER`), and every site that picks one reason from several elects through `elect_no_data_reason`: the entity check (`phase_intake._phase_check_entities`), the refusal of a part with no admissible interpretation (`InterpretationSet.refusal_reason`), and the materialise and candidate refusals (`codes._elect_no_data_reason`). The entity check refuses with the reason elected over the ones its unresolved names mint and names every such name: an ambiguous name with its candidates, an oversized group as too large, and the rest as unresolved, so a part naming an oversized group and an ambiguous name refuses entity_ambiguous. Pinned by `tests/models/test_no_data_category.py`, which also finds every reason the code mints and checks each has a rank, and `tests/engine/test_group_resolution.py` (`test_mixed_entity_refusals_elect_one_reason_and_name_every_entity`). No structural enforcement catches a phase check reordered against the order. Live verification of the role/unit/facet positions is blocked until role-declaring StatVars are loaded on the live graph. Issues: #11/#12 (live-graph half); none (enforcement) | | C51a | Parts sharing one five-tuple shape collapse onto one spec: every member answers with the same spec_id, and every member but the first carries a part-scoped binding_folded warning naming the fold. A parsed part posted with `separate: true`, or a raw_text variable its query compares with another (C42d), is compared with the others and does not fold. It answers its own spec in query order with no binding_folded, its response part and restatement state the mark, and the other parts of its shape fold among themselves (`same_shape.fold_key`, which `collapse_same_shape` and the combiner both group by). A fold whose members bind one slot to different values merges them into a set, then drops each value that descends from another merged value, since the fold reads its parts as a union (HIV/AIDS 13040 folded with reproductive health 130 binds 130); one value left binds a value. The merged trace keeps each member's qualifier record of a text that differs in offsets, operation or selected codes. The merged spec states a set binding's values in the order its identity holds the refs, and counts an open role over the union of the members' own populations, so it reads as the resubmit of its spec_id does | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_two_variables_collapse_onto_one_merged_spec_through_fanout`), `tests/engine/test_conjoin_five_tuple_and_slots.py` (`test_collapse_same_shape_states_its_set_in_identity_order`, `test_collapse_same_shape_drops_a_merged_value_nested_under_another`, `test_collapse_same_shape_keeps_every_union_record_of_one_text`), `tests/engine/test_conjunction.py` (`test_a_separate_part_never_folds_and_the_others_fold_as_before`), `tests/engine/test_parsed_pipeline.py` (`test_a_part_marked_separate_never_folds_into_a_same_shape_part`) and `tests/engine/test_same_shape.py` (`test_a_merged_answer_unions_each_members_own_known_origin_population`). | | C51b | A candidates part carries observations_omitted when the request set include_observations, since a candidates part has not committed to one spec to fetch observations against; max_candidates applies per part; the warning ships on the buffered and SSE transports alike, since it states the request and not a transport choice | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_candidates_part_carries_observations_omitted_alongside_definite_with_observations`). | diff --git a/src/search/README.md b/src/search/README.md index 4c23ccdc..b396c84e 100644 --- a/src/search/README.md +++ b/src/search/README.md @@ -95,7 +95,7 @@ Every part also carries `restatement`: a `ParsedPart` reproducing that part's ow A part's `operations` are the things the question asks done with its data that search does not do, such as a ranking or an explanation. On the raw-text door extract assigns each operation to one variable, so only that variable's part carries it. Its phrase goes to that variable's bind in the same block as the dropped qualifiers, so "top 5 donors for education aid" lets bind leave the donor axis open and the part covers every donor. An operation raises no `query_qualifier_dropped` warning and adds no note to the sentence. The parsed and `dataset_resubmit` doors echo the operations each posted part carries. A request part carries at most 8, the most extract keeps for a whole query. -A part asks for every member of a flow role in one of two ways: `open_roles` on a `ParsedPart` lists at most two `role_text` words (`creditor`, `donor`, `recipient`, `debtor`, `origin`, `destination`), and an operation whose `over` is `origin` or `destination` opens that axis on every door. A role named in neither the part's entities nor its open roles is the total over its members, so a question that names no recipient or creditor reads the series published without one. An open role joins the election only where a member the bound roles alone elect, or a member at the same constraint values such as the by-creditor twin of a total, declares it: search elects those members, the answer holds one series per member, and the identity states the axis in `open_roles`. Otherwise the question is read as if the role were unnamed, and the identity omits an open role its elected members declare no role on; on a role-bearing answer the part warns `query_qualifier_dropped` that the breakdown was not applied. A word that names no role, or a role an entity of the same part binds through `role_text`, refuses `request_invalid` at `/input/parts/{i}/open_roles/{j}`. An entity the part binds on the axis without `role_text` wins over the open role: the open role drops and the part warns `query_qualifier_dropped`. On the raw-text door, a phrase extraction drops that asks for every member of a role ("all donors", "every provider", "to all creditors", or "from all countries", whose preposition names the side) opens no axis, and a role-bearing answer that binds no place on that axis meets it: one series per member, or the total over them. Such a phrase raises no `query_qualifier_dropped` warning there; on a subject-only answer, or one that names a place on that axis, it warns as any dropped phrase does (`role_text.every_member_axis`, `region.result.every_member_phrases`). +A part asks for every member of a flow role in one of two ways: `open_roles` on a `ParsedPart` lists at most two `role_text` words (`creditor`, `donor`, `recipient`, `debtor`, `origin`, `destination`), and an operation whose `over` is `origin` or `destination` opens that axis on every door. A role named in neither the part's entities nor its open roles is the total over its members, so a question that names no recipient or creditor reads the series published without one. An open role joins the election only where a member the bound roles alone elect, or a member at the same constraint values such as the by-creditor twin of a total, declares it: search elects those members, the answer holds one series per member, and the identity states the axis in `open_roles`. Otherwise the question is read as if the role were unnamed, and the identity omits an open role its elected members declare no role on; on a role-bearing answer the part warns `query_qualifier_dropped` that the breakdown was not applied. A word that names no role, or a role an entity of the same part binds through `role_text`, refuses `request_invalid` at `/input/parts/{i}/open_roles/{j}`. An entity the part binds on the axis without `role_text` wins over the open role: the open role drops and the part warns `query_qualifier_dropped`. On the raw-text door, a phrase extraction drops that asks for every member of a role ("all donors", "every provider", "to all creditors", or "from all countries", whose preposition names the side) opens no axis, and a role-bearing answer that binds no place on that axis meets it: one series per member, or the total over them. An "each" or "every" phrase asks for the breakdown, so only an answer that declares a role on that axis meets it (`role_text.every_member_breakdown`), and a candidate set meets a phrase only when every candidate does. Such a phrase raises no `query_qualifier_dropped` warning there; on any other answer it warns as any dropped phrase does (`role_text.every_member_axis`, `region.result.every_member_phrases`). Two tables that publish one structural question are chosen between before bind. Each table reads the query in its own codes, and it reads a phrase when its rows hold a code the phrase names, a phrase naming several codes included (`retrieval/bound_values.query_phrases_read`). Of two families of one five-tuple and role class, the one reading fewer of the query's phrases leaves, whether or not the code index carries them (`elect_five_tuple_family`). "Imputed multilateral ODA to basic education" reads the sector only on ONE's imputed sectors table, so DAC2a's imputed total leaves. A table can also state a word of every row it serves without coding it (`DatasetDeclaration.stated_words`): every CRS row is bilateral, since the import leaves out core contributions to multilateral organisations, and a DAC2a or DAC2b amount is bilateral where its recipient is a country. Such a word restricts nothing on those rows, so a part reads it as it reads a word of the table's label (`region/phase_rank_bind._dataset_stated_words`), and "bilateral ODA to Kenya" answers as "ODA to Kenya" does. The part states the word where it modifies the measure ("net bilateral ODA"): a negated or excluded occurrence ("non-bilateral", "excluding bilateral flows") and one past a function word, a connective or a separator ("ODA net of bilateral flows") are not read (`bind_repair.word_stated`). In the election a family reads a word it states beside a rival the code index carries that reads the word in no phrase, so "bilateral ODA from Germany by recipient" leaves DAC2a, whose open recipient axis holds multilateral organisations, for the CRS. Beside a rival that codes the word a stated word adds nothing, so "bilateral ODA from France" stays DAC1's bilateral aid type, and it adds nothing beside a family the index does not carry, so a debt series by bilateral creditors compares with the CRS as it does without the word. The graph's text search matches series names, which hold no stated word, so recall searches a text holding one a second time without it and adds the series of the tables that state it (`interpret._detect`). A table can also declare that a word names one of its parties (`DatasetDeclaration.party_words`): "multilateral" names `DACMultilaterals`, the DAC's published total of the multilateral organisations, as the provider of a DAC2a or DAC2b amount to a country. A part states such a word as it states a stated word, on a flow axis the request leaves unnamed: no place is directed to it, no operation runs across it and the part does not ask every member of it, while a name is directed to each axis the declaration reads a kind on (`region/place_roles.stated_party_words`). Such a part is read with the word first (`region/pipeline.resolve_variable`). Recall also searches the text without the word for the series of the tables that declare it, intake reads the party's node as a place set (`region/phase_intake._party_word_sets`), and a shape whose every dataset declares the word binds the party on that axis beside the places the query names, where the shape reads each word next to the word (`held_party_words`, `_beside_words_read`, bound in `bind_place_roles`). The party's set passes role admission as a named set does. The reading stands where a shape binding the party reads every phrase any other pooled shape reads, the word counted as read; the binding shapes are then the pool (`region/phase_rank_bind.with_party_reading`). "Multilateral ODA to Kenya" answers DAC2a's flows from the multilaterals total, as "ODA from multilaterals to Kenya" does. The reading is withdrawn where no shape binds the party ("multilateral development bank ODA to Kenya"), where another shape reads a phrase the binding shapes do not (the CRS sector of "multilateral aid for education to Kenya"), where another shape reads the word as a value of its own, in a phrase of its codes or by a graph label ("multilateral loans to Kenya", a World Bank IDS creditor type), and where the answer it reaches does not hold the party in every Spec. A withdrawn reading leaves only its model usage and failed reads on the reply: the part is resolved again without party words, exactly as a request stating none, under the same deadline, and the first reading's events are never sent. A stated party word that binds no party is therefore dropped or refused as it is without the declaration. A request that names the provider itself, by a place or an alias of the party, states no party word. A declared party the graph gives no type has no set, and the read is logged and noted as degraded. A negated or excluded word names no party, and the response cache keys on it (`bind_repair.stated_words_negated`). Of families that read the same phrases, one holding no series at the places the part names, on the roles they bind, leaves when another holds one, as the request's coverage snapshot records them (`region/phase_rank_bind._series_reader`). "Other official flows from Germany to Brazil" reads alike on the CRS and DAC2b, and DAC2b, whose bilateral donors report only against aggregate recipients, leaves for the CRS, while "other official flows to Brazil" keeps both. The rule stands down when the part names no place, when coverage holds none of a family's members, and when no family holds a series there, where the part refuses for want of data. Between a family the index carries and one it does not that read alike, the stronger recall score stays. Codes of different tables that name one concept are declared as a group in `code_index/declarations.py` (`CONCEPT_GROUPS`), and the export gives each code the union of the group's texts, so "food aid" reads on DAC2a's developmental food aid as on CRS purpose 520, and "ODA" on DAC2a 206 as on the CRS flow type and DAC1 1010. DAC2a's imputed multilateral code also reads "imputed", so "imputed aid from Norway" keeps DAC2a's imputed line. Then a role-bearing shape that publishes on a flow axis the query neither names nor opens leaves for one that declares fewer such axes and reads every phrase it reads, across five-tuples (`region/place_roles.without_unasked_axes`). "Aid from Japan" reads DAC1's donor totals over DAC2a's and CRS's flows by recipient, and "imputed multilateral ODA from Germany" keeps DAC2a's imputed table, since DAC1 does not read "imputed". Shapes of another measure compare because a question naming no measure can recall them. "ODA from Japan" leaves CRS commitments for DAC1's donor disbursements, and "ODA commitments from Japan" keeps commitments over those disbursements, since a measure word reads only on the shape whose five-tuple or constraint value it names. A donor-only "grant equivalent ODA from France" elects DAC1's grant-equivalent total over the CRS grant equivalents: recall adds a row of that DAC1 family beside the CRS hits (`clients/graph.with_rival_families`, applied by `recall_hits`), it reads the measure word as the CRS does, and it declares no recipient axis. A row recall adds this way displaces only shapes of its own five-tuple, since the query did not recall it, and derivation confirms it beside every recalled hit, so it takes no candidate's place. "Aid attributed to donors via multilaterals" keeps the imputed tables, whose measure reads "attributed to donors", while "aid from Japan", which names no measure, reads DAC1's donor totals over them. Both shapes must be ones the code index carries, since another shape's axes are those of its recalled sample and it reads no index code, and the rule stands down when a named place has no direction. diff --git a/src/search/engine/region/candidate_collapse.py b/src/search/engine/region/candidate_collapse.py index 699a2f7c..0ef3a742 100644 --- a/src/search/engine/region/candidate_collapse.py +++ b/src/search/engine/region/candidate_collapse.py @@ -685,6 +685,9 @@ async def _assemble_candidate_set( candidate_budget_truncated=candidate_budget_truncated, slot_options_task=_make_slot_options_task(state, grounded, unique_specs), answered_roles=grounded.cand_answered_roles[_candidate_specs[0].spec_id], + candidate_answered_roles=tuple( + grounded.cand_answered_roles[spec.spec_id] for spec in _candidate_specs + ), ) diff --git a/src/search/engine/region/requirements.py b/src/search/engine/region/requirements.py index ab949f25..98743460 100644 --- a/src/search/engine/region/requirements.py +++ b/src/search/engine/region/requirements.py @@ -22,7 +22,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -from code_index.labels import PRICE_BASIS_AXIS +from code_index.labels import PRICE_BASIS_AXIS, PRICES_BY_QUALIFIER from code_index.shared import get_measure_index from search.engine.bind import SlotBindingDraft from search.engine.departures import ( @@ -47,7 +47,7 @@ ) from search.engine.retrieval.bound_values import applies_value from search.engine.shape import ShapeDraft -from search.engine.unit.conversion import PRICE_BASIS_MEASURES, PriceBasis +from search.engine.unit.conversion import PriceBasis from search.models import GraphRef, ResolvedQualifier if TYPE_CHECKING: @@ -331,13 +331,17 @@ def _value_term( def _serves_price_basis(rule: CodeRule, prices: PriceBasis) -> bool: """Whether an answer served under a unit request at `prices` meets the price-basis - `rule`: the measure value those prices serve (`PRICE_BASIS_MEASURES`) spells the reading - of one of its codes. Constant prices meet `RealValue` and `InflationAdjusted`, current - prices meet `Nominal`, and no unit request meets `PurchasingPowerParity`.""" + `rule`: the reading of one of its codes holds the measure value that states those + prices (`PRICES_BY_QUALIFIER`). Constant prices meet `RealValue` and `InflationAdjusted`, + current prices meet `Nominal`, and no unit request meets `PurchasingPowerParity`.""" if rule.property_dcid != PRICE_BASIS_AXIS: return False index = get_measure_index() - return any(PRICE_BASIS_MEASURES[prices] in index.reading_set(code) for code in rule.codes) + return any( + PRICES_BY_QUALIFIER.get(value) == prices + for code in rule.codes + for value in index.reading_set(code) + ) def _code_outcome(rule: CodeRule, held: frozenset[str], bound: Mapping[str, set[str]]) -> Outcome: diff --git a/src/search/engine/region/result.py b/src/search/engine/region/result.py index e68dddfe..92606cb8 100644 --- a/src/search/engine/region/result.py +++ b/src/search/engine/region/result.py @@ -27,7 +27,7 @@ ) from search.engine.retrieval.member_facts import MemberFacts from search.engine.retrieval.retrieve import MemberPopulation -from search.engine.role_text import every_member_axis +from search.engine.role_text import every_member_axis, every_member_breakdown from search.engine.shape import ShapeDraft from search.engine.source_code import SourceCodeRefusal from search.engine.source_scope import AmbiguousSource, SourceRefusal, SourceScope @@ -304,6 +304,10 @@ class RegionResult: # for a candidate set. `with_runnable_operations` reads it to tell which flow axes the # answer's data carries. None on every other result. answered_roles: tuple[str, ...] | None = None + # Every candidate's `answered_roles`, in candidate order, on a candidate set the + # pipeline assembles, so `every_member_phrases` judges every answer. None on every + # other result. + candidate_answered_roles: tuple[tuple[str, ...], ...] | None = None # Served measure partition and sibling partitions. Same-shape merges need # these to mint new identities after member materialisations are gone. measure_partition: MeasurePartition | None = None @@ -543,16 +547,29 @@ def every_member_phrases( A role-bearing answer (`RegionResult.answered_roles`) that binds no entity on the axis holds every member on it: one series per member where it declares a role there, and - the total over them where it declares none. A subject-only answer and a result with no - Spec meet none, so their phrases stay dropped. + the total over them where it declares none. An "all" phrase is met by either. An + "each" or "every" phrase asks for the breakdown (`role_text.every_member_breakdown`), + so only an answer declaring a role on the axis meets it, as for an open role + (`with_dropped_open_roles`). A candidate set meets a phrase only when every candidate + does (`RegionResult.candidate_answered_roles`). A subject-only answer and a result + with no Spec meet none, so their phrases stay dropped. """ - if not region.specs or all(role == "observationAbout" for role in region.answered_roles or ()): + if not region.specs: return frozenset() + answers = region.candidate_answered_roles or (region.answered_roles or (),) + declared_by_answer = [ + {axis_for_role(role) for role in roles if role != "observationAbout"} for roles in answers + ] + if not all(declared_by_answer): + return frozenset() + declared_by_all = set.intersection(*declared_by_answer) bound = {key for spec in region.specs for key in spec.definition.identity.entities} return frozenset( phrase.strip().casefold() for phrase in phrases - if (axis := every_member_axis(phrase, query=query)) is not None and axis not in bound + if (axis := every_member_axis(phrase, query=query)) is not None + and axis not in bound + and (axis in declared_by_all or not every_member_breakdown(phrase)) ) diff --git a/src/search/engine/role_text.py b/src/search/engine/role_text.py index 8a426c16..eecf6707 100644 --- a/src/search/engine/role_text.py +++ b/src/search/engine/role_text.py @@ -373,6 +373,9 @@ def parsed_directions_contradicted(part: ParsedPart) -> bool: # Words that ask for every member of what follows them: "all donors", "every provider". _EVERY_WORDS: frozenset[str] = frozenset({"all", "every", "each"}) +# The every-member words that ask for one value per member, a breakdown along the axis: +# "each recipient". "all recipients" is also met by the total over them. +_BREAKDOWN_WORDS: frozenset[str] = frozenset({"every", "each"}) # Nouns that name the places on either side of a flow without naming the side: "all # countries". The preposition that governs them names it. _PLACE_NOUNS: frozenset[str] = frozenset({"country", "countries"}) @@ -400,6 +403,19 @@ def _governing_axes(query_tokens: list[str], phrase_tokens: list[str]) -> set[Ro } +def _after_role_prep(tokens: list[str]) -> list[str]: + """`tokens` past a leading role preposition ("to all recipients" -> "all recipients").""" + return tokens[1:] if tokens and tokens[0] in _ROLE_PREP_AXES else tokens + + +def every_member_breakdown(phrase: str) -> bool: + """Whether an every-member ``phrase`` (`every_member_axis`) asks for one value per + member of its axis: ``each recipient`` and ``to every provider`` do, while ``all + recipients`` is also met by the total over them.""" + rest = _after_role_prep(_tokenize(phrase)) + return bool(rest) and rest[0] in _BREAKDOWN_WORDS + + def every_member_axis(phrase: str, *, query: str) -> RoleAxis | None: """The flow axis whose every member ``phrase`` asks for, or None. @@ -414,7 +430,7 @@ def every_member_axis(phrase: str, *, query: str) -> RoleAxis | None: """ tokens = _tokenize(phrase) prep_axis = _ROLE_PREP_AXES.get(tokens[0]) if tokens else None - rest = tokens[1:] if prep_axis is not None else tokens + rest = _after_role_prep(tokens) if not rest or rest[0] not in _EVERY_WORDS: return None nouns = rest[2:] if rest[1:2] == ["the"] else rest[1:] diff --git a/src/search/engine/unit/bind.py b/src/search/engine/unit/bind.py index 1a7cbb1e..b53d1152 100644 --- a/src/search/engine/unit/bind.py +++ b/src/search/engine/unit/bind.py @@ -222,8 +222,9 @@ def _normalise(text: str, *, casefold: bool = True) -> str: if casefold: normalised = normalised.casefold() normalised = normalised.replace(".", "") - # Apostrophe removal makes possessives match the lexicon's plain role phrases. - normalised = normalised.replace("'", "").replace("’", "") + # Apostrophe removal makes possessives match the lexicon's plain role phrases, and the + # okina (U+02BB) of the Tongan "pa'anga" display name match its plain spelling. + normalised = normalised.replace("'", "").replace("’", "").replace("\u02bb", "") normalised = normalised.replace("-", " ").replace("_", " ") return normalised @@ -243,6 +244,12 @@ def _tokenise(normalised: str) -> list[str]: return _TOKEN_RE.findall(normalised) +# Each refusal alternative's display name, as the binder reads it, names that currency. +_DISPLAY_NAME_CURRENCIES: dict[str, str] = { + " ".join(_tokenise(_normalise(name))): code for code, name in CURRENCY_DISPLAY_NAMES.items() +} + + def _split_erasure(text: str) -> list[str]: """Split ``text`` into maximal runs of non-separator characters.""" return [run for run in _SEPARATOR_RE.split(text) if run] @@ -294,6 +301,8 @@ def _phrase_lookup(phrase: str) -> _LookupHit | None: return _LookupHit("unsupported", UNSUPPORTED_PHRASES[phrase]) if phrase in CURRENCY_ALIASES: return _LookupHit("currency", CURRENCY_ALIASES[phrase]) + if phrase in _DISPLAY_NAME_CURRENCIES: + return _LookupHit("currency", _DISPLAY_NAME_CURRENCIES[phrase]) if phrase in AMBIGUOUS_CURRENCY_NAMES: return _LookupHit("ambiguous_currency", alternatives=AMBIGUOUS_CURRENCY_NAMES[phrase]) if phrase in PRICE_BASIS_PHRASES: @@ -652,11 +661,13 @@ def bind_unit_text( def _phrases_naming(currency: IdentityCurrency) -> list[str]: """The lexicon's words that name `currency` alone: an ISO code that collides with no - English word, then the code's curated aliases; for a role shape, its fixed-role - phrases.""" + English word, then the code's curated aliases, then its display name; for a role + shape, its fixed-role phrases.""" if isinstance(currency, str): code = [] if currency in AMBIGUOUS_ISO_CODES else [currency] - return [*code, *(alias for alias, named in CURRENCY_ALIASES.items() if named == currency)] + aliases = [alias for alias, named in CURRENCY_ALIASES.items() if named == currency] + names = [name for name, named in _DISPLAY_NAME_CURRENCIES.items() if named == currency] + return [*code, *aliases, *names] return [phrase for phrase, role in ROLE_CURRENCY_PHRASES.items() if role == currency.role] diff --git a/src/search/engine/unit/conversion.py b/src/search/engine/unit/conversion.py index e9a47ae5..5fac359b 100644 --- a/src/search/engine/unit/conversion.py +++ b/src/search/engine/unit/conversion.py @@ -95,13 +95,6 @@ _NOMINAL = "Nominal" -PRICE_BASIS_MEASURES: Final[Mapping[PriceBasis, str]] = { - "current": _NOMINAL, - "constant": "RealValue", -} -"""The measure value whose reading a unit request's `prices` serves: a series converted to -constant prices reads as `RealValue`, one served at current prices as `Nominal`.""" - @dataclass(frozen=True) class ConvertedYears: diff --git a/src/search/engine/unit/lexicon.py b/src/search/engine/unit/lexicon.py index a167039e..509784c2 100644 --- a/src/search/engine/unit/lexicon.py +++ b/src/search/engine/unit/lexicon.py @@ -7,7 +7,7 @@ from __future__ import annotations -LEXICON_VERSION = "unit_lexicon_v2" +LEXICON_VERSION = "unit_lexicon_v3" # --------------------------------------------------------------------------- # ISO codes that also spell common English words refuse as bare tokens. The @@ -161,7 +161,8 @@ # --------------------------------------------------------------------------- # Display names for every code that can appear in an ambiguity refusal. -# Other ISO codes fall back to the code itself. +# Other ISO codes fall back to the code itself. The binder also reads each name +# as a currency name, so a refusal's alternative can be requested by its label. # --------------------------------------------------------------------------- CURRENCY_DISPLAY_NAMES: dict[str, str] = { diff --git a/src/search/models/outcomes.py b/src/search/models/outcomes.py index a8376e6e..d908a109 100644 --- a/src/search/models/outcomes.py +++ b/src/search/models/outcomes.py @@ -478,14 +478,16 @@ class NoData(StrictModel): "no dcid. A source option sets the candidate's ref on the ambiguous mention; a " "source-code option names the holding provenance as the part's only included " "source. A role option states a direction for every name the question left " - "unstated, offered while the shape's roles give at most six readings. A unit " - "option names one of unit_alternatives in place of the ambiguous currency " - "words. Empty for other reasons; when the part's inputs exceed the ParsedPart " - "bounds; on a role_not_resolved refusal with more than six readings, whose " - "readings all hold no data, or that the shape refused before placing names; " - "and on a unit_not_resolved refusal without unit_alternatives. It can list " - "fewer entries than the candidates when a candidate's request cannot be " - "posted, such as a group node with no registry name" + "unstated, so that every axis the shape declares a role on and the part does " + "not ask open holds a place, offered while that gives at most six readings. A " + "unit option names one of unit_alternatives in place of the ambiguous currency " + "words. An option whose part breaks the ParsedPart bounds, or states a " + "direction its own wording contradicts, is left out. Empty for other reasons; " + "when the part's inputs exceed the ParsedPart bounds; on a role_not_resolved " + "refusal with more than six readings, with no reading that leaves a place on " + "every such axis, whose readings were all read with none holding data, or that " + "the shape refused before placing names; and on a unit_not_resolved refusal " + "without unit_alternatives" ), ) diff --git a/tests/engine/test_role_scope_pipeline_e2e.py b/tests/engine/test_role_scope_pipeline_e2e.py index e862e696..097e7608 100644 --- a/tests/engine/test_role_scope_pipeline_e2e.py +++ b/tests/engine/test_role_scope_pipeline_e2e.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import dataclasses import sys import pytest @@ -17,6 +18,7 @@ from search.engine.region import observations as _observations_pkg # noqa: F401 from search.engine.region import resolve_spec_resubmit from search.engine.region.pipeline import resolve_variable +from search.engine.region.result import every_member_phrases from search.engine.region.state import EngineOptions from search.models import CoverageBreadth, DateRange, ResubmitIdentityPayload, TimeWindow from series_coverage.schema import FacetMetadata, RoleNames @@ -730,6 +732,20 @@ def test_origin_only_signature_discovers_the_provider_scope(monkeypatch): True, id="axis-the-answer-totals-over", ), + pytest.param( + "aid from France to every recipient", + "to every recipient", + ["creditor", "debtor"], + True, + id="breakdown-over-an-axis-the-answer-declares", + ), + pytest.param( + "aid from France to each recipient", + "to each recipient", + ["creditor"], + False, + id="breakdown-over-an-axis-the-answer-totals-over", + ), pytest.param( "aid from France and all donors", "all donors", @@ -780,6 +796,49 @@ def test_a_dropped_phrase_asking_for_every_member_of_a_role_is_met_by_an_answer_ assert (phrase in result.admitted_qualifier_phrases) is admitted +@pytest.mark.parametrize( + ("rival_roles", "admitted"), + [ + pytest.param(("creditor", "debtor"), True, id="every-candidate-declares-the-axis"), + pytest.param(("creditor",), False, id="a-candidate-totals-over-the-axis"), + pytest.param(("observationAbout",), False, id="a-candidate-is-subject-only"), + ], +) +def test_a_candidate_set_meets_an_every_member_phrase_only_when_every_candidate_does( + monkeypatch, rival_roles, admitted +): + """A role-bearing first candidate does not speak for a rival that would not meet the + phrase on its own (`RegionResult.candidate_answered_roles`).""" + rows, role_names = _coverage_seed_for( + "debtor", [_KEN], [_SV_DCID], filters={"creditor": [_FRA]} + ) + role_query = "aid from France to every recipient" + result = _run( + entities=["France"], + sv_dcid=_SV_DCID, + sv_arcs=_declaring_arcs(observation_properties=["creditor", "debtor"]), + resolve={"France": _FRA}, + sv_score=0.9, + role_query=role_query, + sdmx=FakeSdmxClient( + observations={_SV_DCID: {(("creditor", _FRA), ("debtor", _KEN)): [_facet()]}}, + availability={}, + ), + monkeypatch=monkeypatch, + coverage_rows=rows, + coverage_role_names=role_names, + ) + assert result.status == "definite", result + assert result.answered_roles is not None + candidates = dataclasses.replace( + result, candidate_answered_roles=(result.answered_roles, rival_roles) + ) + + met = every_member_phrases(candidates, ["to every recipient"], query=role_query) + + assert ("to every recipient" in met) is admitted + + # --------------------------------------------------------------------------- # Windowed / latest requests # --------------------------------------------------------------------------- diff --git a/tests/engine/test_role_text.py b/tests/engine/test_role_text.py index cdfb288f..8e2517b9 100644 --- a/tests/engine/test_role_text.py +++ b/tests/engine/test_role_text.py @@ -10,6 +10,7 @@ contradicts, debt_axes, every_member_axis, + every_member_breakdown, role_axis_for_text, ) from search.models import ParsedEntity, ParsedPart, RoleAxis @@ -33,30 +34,32 @@ def test_each_axis_name_resolves_to_its_own_axis(axis: RoleAxis) -> None: @pytest.mark.parametrize( - ("phrase", "query", "axis"), + ("phrase", "query", "axis", "breakdown"), [ - ("all donors", "aid from all donors to Ethiopia in 2022", "origin"), - ("from all donors", "aid to Ethiopia from all donors in 2022", "origin"), - ("every provider", "aid to Ethiopia from every provider in 2022", "origin"), - ("to all creditors", "debt owed to all creditors by Kenya", "origin"), - ("all the recipients", "aid from Germany to all the recipients", "destination"), - ("each beneficiary", "aid from Germany to each beneficiary", "destination"), - ("all countries", "aid to Ethiopia from all countries in 2022", "origin"), - ("to all countries", "aid from France to all countries", "destination"), - ("all donor countries", "aid to Ethiopia from all donor countries", "origin"), - ("all countries", "aid from all countries to all countries", None), - ("all countries", "GDP of all countries", None), - ("in all sectors", "aid to Ethiopia in all sectors in 2022", None), - ("all bilateral donors", "aid from all bilateral donors to Kenya", None), - ("all donors and recipients", "aid between all donors and recipients", None), - ("all", "aid to Ethiopia from all", None), - ("donors", "aid to Ethiopia from donors", None), + ("all donors", "aid from all donors to Ethiopia in 2022", "origin", False), + ("from all donors", "aid to Ethiopia from all donors in 2022", "origin", False), + ("every provider", "aid to Ethiopia from every provider in 2022", "origin", True), + ("to all creditors", "debt owed to all creditors by Kenya", "origin", False), + ("each recipient", "aid from Germany to each recipient in 2022", "destination", True), + ("all the recipients", "aid from Germany to all the recipients", "destination", False), + ("each beneficiary", "aid from Germany to each beneficiary", "destination", True), + ("all countries", "aid to Ethiopia from all countries in 2022", "origin", False), + ("to all countries", "aid from France to all countries", "destination", False), + ("all donor countries", "aid to Ethiopia from all donor countries", "origin", False), + ("all countries", "aid from all countries to all countries", None, False), + ("all countries", "GDP of all countries", None, False), + ("in all sectors", "aid to Ethiopia in all sectors in 2022", None, False), + ("all bilateral donors", "aid from all bilateral donors to Kenya", None, False), + ("all donors and recipients", "aid between all donors and recipients", None, False), + ("all", "aid to Ethiopia from all", None, False), + ("donors", "aid to Ethiopia from donors", None, False), ], ) def test_every_member_axis_reads_the_role_a_phrase_asks_every_member_of( - phrase: str, query: str, axis: RoleAxis | None + phrase: str, query: str, axis: RoleAxis | None, breakdown: bool ) -> None: assert every_member_axis(phrase, query=query) == axis + assert every_member_breakdown(phrase) is breakdown class TestUnrecognised: diff --git a/tests/engine/test_unit_bind.py b/tests/engine/test_unit_bind.py index fd11bc05..2d64176b 100644 --- a/tests/engine/test_unit_bind.py +++ b/tests/engine/test_unit_bind.py @@ -29,6 +29,7 @@ with_unit_lexicon_version, ) from search.engine.unit.lexicon import ( + AMBIGUOUS_CURRENCY_NAMES, AMBIGUOUS_ISO_CODES, CONNECTIVE_WORDS, CURRENCY_ALIASES, @@ -1198,3 +1199,25 @@ def test_unit_text_naming_replaces_the_ambiguous_words_with_ones_naming_the_choi rebound = _bind(named) assert not isinstance(rebound, UnitNotResolved) assert rebound.currency == currency + + +@pytest.mark.parametrize( + ("text", "currency"), + [ + *((f"in {code}", code) for code in sorted(AMBIGUOUS_ISO_CODES)), + *( + (f"in {name}", code) + for name, codes in sorted(AMBIGUOUS_CURRENCY_NAMES.items()) + for code in codes + ), + ], +) +def test_every_refusal_alternative_has_words_that_bind_it(text, currency) -> None: + refused = _bind(text) + assert isinstance(refused, UnitNotResolved) + assert currency in {alternative.currency for alternative in refused.alternatives} + named = unit_text_naming(text, currency) + assert named is not None + rebound = _bind(named) + assert isinstance(rebound, BoundUnitRequest) + assert rebound.currency == currency From ce3fbc6ccbf6609e8f2b5fba0954249d546b9afc Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 19:45:14 +0200 Subject: [PATCH 05/14] fix(search): offer readings when a direction's coverage is unknown and 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. --- .../src/entity_resolution/resolver.py | 50 ++++++++++++----- src/search/engine/conjoin.py | 44 ++++++++------- .../engine/region/candidate_grounding.py | 34 ++++++------ src/search/engine/region/phase_answer.py | 10 +++- src/search/engine/region/phase_intake.py | 40 +++++++++----- .../engine/region/phase_place_materialise.py | 18 +++--- src/search/engine/region/requirements.py | 15 +++-- src/search/engine/region/result.py | 9 ++- src/search/engine/unit/conversion.py | 8 ++- src/search/models/outcomes.py | 7 ++- tests/engine/test_choice_options.py | 31 ++++++++++- .../test_conjoin_assemble_and_enrichment.py | 24 ++++++-- tests/engine/test_pinned_code_refusal.py | 44 +++++++++++++++ tests/entity_resolution/test_resolver.py | 55 +++++++++++++++++++ 14 files changed, 298 insertions(+), 91 deletions(-) diff --git a/packages/dfo-data-tools/src/entity_resolution/resolver.py b/packages/dfo-data-tools/src/entity_resolution/resolver.py index d0e0b45f..ae6de9db 100644 --- a/packages/dfo-data-tools/src/entity_resolution/resolver.py +++ b/packages/dfo-data-tools/src/entity_resolution/resolver.py @@ -365,25 +365,44 @@ def _unless_expired[*Ts, R](deadline: float | None, fn: Callable[[*Ts], R], /, * return fn(*args) -def _group_nodes_job(shared: SharedResolver) -> dict[str, str]: - """The dcids `Group.entity` can hold, for every group resolvekit knows, each with the - first registered group name that resolves to it: the group's own id as a dcid, and each - organisation that survives the gate beside it (`EntityResolver._assemble_group`). A - region the membership policy holds is left out, since the policy answers its members - ("Africa (DAC)" resolves to `africa`). Runs on the shared resolver's single worker.""" +@dataclass(frozen=True, slots=True) +class _GroupNodes: + """The dcids `Group.entity` can hold (`dcids`), and each of them with the first + registered group name that resolves to its group alone (`names`).""" + + dcids: frozenset[str] + names: Mapping[str, str] + + +def _group_nodes_job(shared: SharedResolver) -> _GroupNodes: + """The dcids `Group.entity` can hold, for every group resolvekit knows: the group's own + id as a dcid, and each organisation that survives the gate beside it + (`EntityResolver._assemble_group`). A region the membership policy holds is left out, + since the policy answers its members ("Africa (DAC)" resolves to `africa`). + + Each dcid takes the first registered name that resolves to its group alone: a name whose + top survivor is a group the registry holds (`_group_hit`), which `resolve` answers as + that `Group` whatever else survives. A name whose top survivor the registry does not + hold is an ordinary hit, which can be ambiguous, so its dcids take a later name or none. + Runs on the shared resolver's single worker.""" context = ResolutionContext(entity_types=ADMITTED_TYPES) + dcids: set[str] = set() names: dict[str, str] = {} for name in shared.resolver.known_groups(): listed, _, _ = _resolve_text_survivors(shared, name, context) survivors = [candidate for candidate, _ in listed] if not survivors or survivors[0].entity_type not in _GROUP_TYPES: continue + alone = _group_hit(shared, survivors[0]) is not None nodes = [survivors[0]] + [c for c in survivors[1:] if c.entity_type == _ORG_ENTITY_TYPE] for node in nodes: dcid = identity_to_dcid(node.entity_id) - if dcid is not None and not shared.policy.holds_region(dcid): + if dcid is None or shared.policy.holds_region(dcid): + continue + dcids.add(dcid) + if alone: names.setdefault(dcid, name) - return names + return _GroupNodes(dcids=frozenset(dcids), names=names) def _entity_from_confirmation( @@ -468,7 +487,7 @@ def __init__( self._cache: TLRUCache[_CacheKey, Resolution] = TLRUCache(maxsize=cache_size, ttu=_ttu) self._hits = 0 self._misses = 0 - self._group_nodes: Mapping[str, str] | None = None + self._group_nodes: _GroupNodes | None = None @property def membership(self) -> MembershipPolicy: @@ -502,13 +521,16 @@ async def group_node_dcids(self) -> frozenset[str]: """The dcids that are the node of a group the resolver knows, such as `EuropeanUnion`: every dcid a `Group.entity` can hold. The packs fix the set, so it is computed once and no graph is read.""" - return frozenset(await self.group_node_names()) + return (await self._group_node_lookup()).dcids async def group_node_names(self) -> Mapping[str, str]: - """Each dcid of `group_node_dcids` with a registered group name that resolves to it, - such as `EuropeanUnion` to "European Union": the text that asks for the group's - members, which a pin on the node cannot. Computed once from the packs, with no - graph read.""" + """Each dcid of `group_node_dcids` with a registered group name that resolves to its + group alone, such as `EuropeanUnion` to "European Union": the text that asks for the + group's members, which a pin on the node cannot. A dcid whose group has no such name + is left out. Computed once from the packs, with no graph read.""" + return (await self._group_node_lookup()).names + + async def _group_node_lookup(self) -> _GroupNodes: if self._group_nodes is None: self._group_nodes = await asyncio.get_running_loop().run_in_executor( self._shared.executor, _group_nodes_job, self._shared diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index 4b8d1dba..c0bdd332 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -271,8 +271,9 @@ def _entity_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption """One option per ambiguous wording and candidate: the part with every entity that resolved to one node pinned, and every row with the wording pinned to the candidate. A row asked for its members takes a group's own node by the group's registered name, - with no dcid, since a pin cannot ask for a group's members. Other ambiguous wordings - stay unpinned.""" + with no dcid, since a pin cannot ask for a group's members; a group node with no + registered name that resolves to it alone has no option. Other ambiguous wordings stay + unpinned.""" base = [ entity.model_copy( update={"dcid": region.resolved_entity_dcids.get(entity.text, entity.dcid)} @@ -280,27 +281,30 @@ def _entity_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption for entity in part.entities or () ] - def chosen(entity: ParsedEntity, candidate: EngineCandidate) -> ParsedEntity: - if entity.members and candidate.group_name is not None: + def chosen(entity: ParsedEntity, candidate: EngineCandidate) -> ParsedEntity | None: + if entity.members and candidate.group_node: + if candidate.group_name is None: + return None return entity.model_copy(update={"text": candidate.group_name, "dcid": None}) return entity.model_copy(update={"dcid": candidate.dcid}) - return [ - _option( - [group.text], - candidate.name, - part.model_copy( - update={ - "entities": [ - chosen(entity, candidate) if entity.text == group.text else entity - for entity in base - ] - } - ), - ) - for group in region.ambiguous_entities - for candidate in group.candidates - ] + options: list[NoDataOption | None] = [] + for group in region.ambiguous_entities: + for candidate in group.candidates: + entities = [ + chosen(entity, candidate) if entity.text == group.text else entity + for entity in base + ] + if None in entities: + continue + options.append( + _option( + [group.text], + candidate.name, + part.model_copy(update={"entities": entities}), + ) + ) + return options def _source_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: diff --git a/src/search/engine/region/candidate_grounding.py b/src/search/engine/region/candidate_grounding.py index 3ef57925..16f142c9 100644 --- a/src/search/engine/region/candidate_grounding.py +++ b/src/search/engine/region/candidate_grounding.py @@ -105,6 +105,7 @@ build_spec, build_stat_vars, ) +from search.engine.unit.conversion import bound_prices from search.engine.unit.refs import unit_currency from search.models import ( Coverage, @@ -702,6 +703,17 @@ def _finish_candidate( # Reuse materialised role facts without extra SDMX reads. Labelled entity options are # built only for a surviving definite answer. cand_role_axis_facts = role_axis_facts_for_materialised(cand_mat, shape=cand_shape) + cand_facet_bindings = ( + # A bound outcome guarantees a unit request. The type checker cannot carry that + # attribute invariant through this access. + {"unit": IdentityFacetValueRequest(request=cast(IdentityUnitRequest, state.unit_request))} + if state.bound_unit_outcome is not None + else ( + {"unit": cluster_currency_binding(groundwork.confirmed_facets)} + if prep.twin_unit_filters[cand_index] is not None + else None + ) + ) cand_spec = build_spec( shape=groundwork.shape_model, slots=groundwork.slots, @@ -733,25 +745,15 @@ def _finish_candidate( measure_siblings=cand_measure_siblings, open_axis_facts=cand_open_axis_facts, role_axis_facts=cand_role_axis_facts, - facet_bindings=( - # A bound outcome guarantees a unit request. The type checker cannot carry that - # attribute invariant through this access. - { - "unit": IdentityFacetValueRequest( - request=cast(IdentityUnitRequest, state.unit_request) - ) - } - if state.bound_unit_outcome is not None - else ( - {"unit": cluster_currency_binding(groundwork.confirmed_facets)} - if prep.twin_unit_filters[cand_index] is not None - else None - ) - ), + facet_bindings=cand_facet_bindings, text_recognized_properties=( text_recognized_properties if cand_bindings is state.bindings else frozenset() ), - query_qualifiers=answer_terms(state, cand_shape, cand_bindings), + # Judged under the unit request this candidate is served under, as the definite + # answer's trace is. + query_qualifiers=answer_terms( + state, cand_shape, cand_bindings, served=bound_prices(cand_facet_bindings) + ), measure_elected_properties=( measure_elected_properties if cand_bindings is state.bindings else frozenset() ), diff --git a/src/search/engine/region/phase_answer.py b/src/search/engine/region/phase_answer.py index 74472c27..7204a350 100644 --- a/src/search/engine/region/phase_answer.py +++ b/src/search/engine/region/phase_answer.py @@ -136,6 +136,7 @@ from search.engine.unit.bind import with_unit_lexicon_version from search.engine.unit.conversion import ( ConversionPeriod, + bound_prices, counted_conversion, pins_facets, served_prices, @@ -989,7 +990,14 @@ def _build_definite_spec( unresolved_slots=state.unresolved_slots, # Use qualifier bindings after election vetting and repair. text_recognized_properties=state.text_recognized_properties, - query_qualifiers=answer_terms(state, state.shape_draft, state.bindings), + # The trace is judged under the unit request the answer is served under, as its + # carve-outs judge it (`settle_answer`). + query_qualifiers=answer_terms( + state, + state.shape_draft, + state.bindings, + served=bound_prices(setup.facet_bindings), + ), facet_bindings=setup.facet_bindings, # Record properties forced by measure election. measure_elected_properties=state.measure_elected_properties, diff --git a/src/search/engine/region/phase_intake.py b/src/search/engine/region/phase_intake.py index d4b310b4..d5f1f1cb 100644 --- a/src/search/engine/region/phase_intake.py +++ b/src/search/engine/region/phase_intake.py @@ -12,7 +12,7 @@ import dc_graph from code_index.shared import get_index from dfo_platform.timings import PhaseTimings, collecting -from entity_resolution import Ambiguous, Group, Resolved +from entity_resolution import Ambiguous, Entity, Group, Resolved from origin_catalogue import recording_un_blocked_places from search.engine.clients.graph import DetectedSV, force_full_facets from search.engine.config import settings @@ -242,6 +242,22 @@ def _is_oversized_group(resolution: object) -> bool: return isinstance(resolution, Group) and len(resolution.members) > settings().max_child_places +def _ambiguous_candidate( + candidate: Entity, *, group_nodes: frozenset[str], group_names: Mapping[str, str] +) -> AmbiguousEntityCandidate: + """`candidate` as an ambiguous wording's candidate, marked a group node when it is one + of `group_nodes`, the group nodes of a wording asked for its members, with its group's + name from `group_names`.""" + group_node = candidate.dcid in group_nodes + return AmbiguousEntityCandidate( + dcid=candidate.dcid, + name=candidate.name, + kind=candidate.kind, + group_node=group_node, + group_name=group_names.get(candidate.dcid) if group_node else None, + ) + + async def _phase_check_entities(state: ResolveState) -> RegionResult | None: """Refuse a part naming an entity that did not resolve, before shape derivation and the bind call. @@ -267,23 +283,19 @@ async def _phase_check_entities(state: ResolveState) -> RegionResult | None: return None ambiguous = [name for name, name_reason in reasons.items() if name_reason == "entity_ambiguous"] # A pin on a group's own node cannot ask for its members, so a candidate of a wording - # asked for its members carries the group's registered name for its option to post. - group_names = ( - await state.resolver.group_node_names() - if any(name in state.member_entities for name in ambiguous) - else {} - ) + # asked for its members carries whether it is one, and the group's registered name for + # its option to post. + asks_members = any(name in state.member_entities for name in ambiguous) + group_nodes = await state.resolver.group_node_dcids() if asks_members else frozenset() + group_names = await state.resolver.group_node_names() if asks_members else {} ambiguous_entities = tuple( AmbiguousEntity( text=name, candidates=tuple( - AmbiguousEntityCandidate( - dcid=candidate.dcid, - name=candidate.name, - kind=candidate.kind, - group_name=( - group_names.get(candidate.dcid) if name in state.member_entities else None - ), + _ambiguous_candidate( + candidate, + group_nodes=group_nodes if name in state.member_entities else frozenset(), + group_names=group_names, ) for candidate in cast(Ambiguous, rcl.resolutions[name]).candidates ), diff --git a/src/search/engine/region/phase_place_materialise.py b/src/search/engine/region/phase_place_materialise.py index 70038d47..0c7c9ea4 100644 --- a/src/search/engine/region/phase_place_materialise.py +++ b/src/search/engine/region/phase_place_materialise.py @@ -395,16 +395,20 @@ def _settle_direction( answer states the direction it reads the place on (`departures.direction_inferred`). When several hold data the first is served and each other is offered beside it as a `direction` candidate: a copy of the elected shape on the menu, bound on its own - assignment (`ResolveState.direction_rivals`). When readings exist and none holds data - the places refuse ``no_direction_holds``, a ``role_not_resolved`` that offers no - reading, since each is known empty. Otherwise they refuse ``role_not_resolved``, as a - question whose direction no data settles.""" + assignment (`ResolveState.direction_rivals`). When readings exist and every read says + empty (`False`) the places refuse ``no_direction_holds``, a ``role_not_resolved`` that + offers no reading, since each is known empty. A read the coverage index cannot answer + (`None`) is unknown. Otherwise they refuse ``role_not_resolved``, as a question + whose direction no data settles.""" shape = state.shape_draft - readings = direction_assignments(state, shape) series = series_at_places(state) - held = [(axis, assignment) for axis, assignment in readings if series(shape, assignment)] - if readings and not held: + reads = [ + (axis, assignment, series(shape, assignment)) + for axis, assignment in direction_assignments(state, shape) + ] + if reads and all(read is False for *_, read in reads): return "no_direction_holds" + held = [(axis, assignment) for axis, assignment, read in reads if read] name = undirected_place_name(state) if not held or name is None: return "role_not_resolved" diff --git a/src/search/engine/region/requirements.py b/src/search/engine/region/requirements.py index 98743460..c362f3b8 100644 --- a/src/search/engine/region/requirements.py +++ b/src/search/engine/region/requirements.py @@ -374,12 +374,17 @@ def _reading(state: ResolveState, bindings: Sequence[SlotBindingDraft]) -> list[ def answer_terms( - state: ResolveState, shape: ShapeDraft, bindings: Sequence[SlotBindingDraft] + state: ResolveState, + shape: ShapeDraft, + bindings: Sequence[SlotBindingDraft], + *, + served: PriceBasis | None = None, ) -> list[ResolvedQualifier]: - """The query terms the trace of an answer of `shape` read under `bindings` records: the - elected reading's bound records when `bindings` are the elected ones, and the term of - each requirement the answer leaves unmet (`judge`).""" - unmet = judge(state.requirements, [(shape, bindings)], state=state).terms(0) + """The query terms the trace of an answer of `shape` read under `bindings` and served + under a unit request at `served` prices (None for none) records: the elected reading's + bound records when `bindings` are the elected ones, and the term of each requirement + the answer leaves unmet (`judge`).""" + unmet = judge(state.requirements, [(shape, bindings)], state=state, served=(served,)).terms(0) return _in_reading_order(_reading(state, bindings), unmet) diff --git a/src/search/engine/region/result.py b/src/search/engine/region/result.py index 92606cb8..98d3cb8d 100644 --- a/src/search/engine/region/result.py +++ b/src/search/engine/region/result.py @@ -78,13 +78,16 @@ class FacetAxisFilter: class AmbiguousEntityCandidate: """One graph node that could match an ambiguous query wording. - `group_name` is the registered name of the group whose own node the candidate is, set - when the part asks for the wording's members: a pin on a group's node cannot ask for - its members, so the candidate's option posts this name instead. `None` otherwise.""" + `group_node` is set when the candidate is a group's own node and the part asks for the + wording's members: a pin on a group's node cannot ask for its members, so the + candidate's option posts `group_name`, the group's registered name that resolves to it + alone, instead. A group node with no such name (`group_name` `None`) has no option. + `group_name` is `None` on every other candidate.""" dcid: str name: str kind: str + group_node: bool = False group_name: str | None = None diff --git a/src/search/engine/unit/conversion.py b/src/search/engine/unit/conversion.py index 5fac359b..70a0d85e 100644 --- a/src/search/engine/unit/conversion.py +++ b/src/search/engine/unit/conversion.py @@ -519,7 +519,13 @@ def conversion_period(*, served: YearRun, converted: ConvertedYears) -> Conversi def served_prices(spec: Spec) -> PriceBasis | None: """The price basis of the unit request the answer's identity binds its unit through, served natively or converted. None when no unit request binds it.""" - unit = (spec.definition.identity.facets or {}).get("unit") + return bound_prices(spec.definition.identity.facets) + + +def bound_prices(facets: Mapping[str, IdentityFacetBinding] | None) -> PriceBasis | None: + """The price basis of the unit request identity `facets` bind the unit through, None + when no unit request binds it.""" + unit = (facets or {}).get("unit") return unit.request.prices if isinstance(unit, IdentityFacetValueRequest) else None diff --git a/src/search/models/outcomes.py b/src/search/models/outcomes.py index d908a109..1ce99ea3 100644 --- a/src/search/models/outcomes.py +++ b/src/search/models/outcomes.py @@ -78,8 +78,8 @@ class AmbiguousEntityCandidate(StrictModel): an AmbiguousEntityGroup's own `candidates`. Mirrors engine.region.result. AmbiguousEntityCandidate field-for-field, with `label` in place of that dataclass's `name` (this contract's own vocabulary for a display - string, matching ResolvedEntity.label), and without its `group_name`, - which only NoData.options read.""" + string, matching ResolvedEntity.label), and without its `group_node` + and `group_name`, which only NoData.options read.""" ref: str = Field(description="the candidate's own dcid") label: str = Field(description="the candidate's display name") @@ -475,7 +475,8 @@ class NoData(StrictModel): "groups, sets read as members and other ambiguous wordings stay unpinned, so a " "second ambiguity refuses again with the first pick kept. A candidate that is a " "group's own node, asked for its members, posts the group's registry name with " - "no dcid. A source option sets the candidate's ref on the ambiguous mention; a " + "no dcid, and has no option when no registry name resolves to the group alone. " + "A source option sets the candidate's ref on the ambiguous mention; a " "source-code option names the holding provenance as the part's only included " "source. A role option states a direction for every name the question left " "unstated, so that every axis the shape declares a role on and the part does " diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index 8a19d806..0f156e95 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -15,6 +15,8 @@ from origin_catalogue import DFO, ProvenanceRow, SourceRef from search.engine.bind import _UniversalBindOutput from search.engine.quantity_read import QuantityOutput +from series_coverage.schema import RoleNames +from tests.engine._fake_coverage import patch_coverage from tests.engine._fake_sdmx import FakeSdmxClient from tests.engine._harness import materialise_double_from_obs from tests.engine._quantity import NO_QUANTITY @@ -243,12 +245,13 @@ def _entities(*names: str, role_text: str | None = None) -> list[dict]: pytest.param( [{"text": "Norway", "role_text": "from"}, {"text": "Kenya"}], None, - [], - id="one-untold-read-both-ways-empty", + ["Norway to Kenya"], + id="one-untold-read-unknown", ), ], ) def test_an_untold_direction_offers_each_reading_within_the_cap(entities, open_roles, labels): + """The coverage index holds no row of the flow, so no direction is read empty.""" client = _flow_client() part = {"variable_text": "aid", "entities": entities} if open_roles is not None: @@ -261,6 +264,30 @@ def test_an_untold_direction_offers_each_reading_within_the_cap(entities, open_r _assert_options_settle(client, no_data) +def test_an_untold_direction_read_empty_every_way_offers_nothing( + monkeypatch: pytest.MonkeyPatch, +): + """The coverage index holds the flow between Uganda and Rwanda only, so the one reading + of Kenya is read and holds no data.""" + patch_coverage( + monkeypatch, + [(_FLOW_SV, "f_flow", _PLACES["Uganda"], _PLACES["Rwanda"], [2020])], + role_names={_FLOW_SV: RoleNames(donor="creditor", recipient="debtor")}, + ) + client = _flow_client() + + no_data = _no_data( + client, + { + "variable_text": "aid", + "entities": [{"text": "Norway", "role_text": "from"}, {"text": "Kenya"}], + }, + ) + + assert no_data["reason"] == "role_not_resolved" + assert no_data["options"] == [] + + def test_a_role_option_states_a_direction_for_every_untold_name(): client = _flow_client() diff --git a/tests/engine/test_conjoin_assemble_and_enrichment.py b/tests/engine/test_conjoin_assemble_and_enrichment.py index 936cdaae..c2f6a721 100644 --- a/tests/engine/test_conjoin_assemble_and_enrichment.py +++ b/tests/engine/test_conjoin_assemble_and_enrichment.py @@ -8,6 +8,8 @@ import dataclasses import time +import pytest + from search.engine.conjoin import BINDING_FOLDED, assemble_region, combine_regions from search.engine.deadline import _cancel_and_await from search.engine.enrichment import _reconcile_after_collapse, _stream_slot_options @@ -362,9 +364,20 @@ def pins(option) -> list[tuple[str, str | None]]: ] -def test_a_group_candidate_on_a_members_row_posts_the_group_name_without_a_pin(): +@pytest.mark.parametrize( + ("group_name", "group_option"), + [ + ("European Union", [[ParsedEntity(text="European Union", members=True)]]), + (None, []), + ], + ids=["named", "no-name-resolves-to-the-group-alone"], +) +def test_a_group_candidate_on_a_members_row_posts_the_group_name_without_a_pin( + group_name, group_option +): """A pin on a group's own node cannot ask for its members, so the candidate's option - posts the group's registered name with the members flag and no dcid.""" + posts the group's registered name with the members flag and no dcid. A group node with + no name that resolves to its group alone has no option.""" region = dataclasses.replace( _TWO_AMBIGUOUS, parsed_form=ParsedPart( @@ -379,7 +392,8 @@ def test_a_group_candidate_on_a_members_row_posts_the_group_name_without_a_pin() dcid="EuropeanUnion", name="European Union", kind="continental_union", - group_name="European Union", + group_node=True, + group_name=group_name, ), AmbiguousEntityCandidate(dcid="africa", name="Africa", kind="continent"), ), @@ -390,10 +404,10 @@ def test_a_group_candidate_on_a_members_row_posts_the_group_name_without_a_pin() part = _entity_terms_part(region) assert [o.data_query.parts[0].entities for o in part.no_data.options] == [ - [ParsedEntity(text="European Union", members=True)], + *group_option, [ParsedEntity(text="the Union", members=True, dcid="africa")], ] - assert [o.mentions for o in part.no_data.options] == [["the Union"], ["the Union"]] + assert {tuple(o.mentions) for o in part.no_data.options} == {("the Union",)} def test_assemble_region_non_entity_reason_ignores_unresolved_entity_names(): diff --git a/tests/engine/test_pinned_code_refusal.py b/tests/engine/test_pinned_code_refusal.py index 2a16dbe7..a2868207 100644 --- a/tests/engine/test_pinned_code_refusal.py +++ b/tests/engine/test_pinned_code_refusal.py @@ -28,6 +28,7 @@ from search.engine.qualifier_match import QualifierMatch from search.engine.region.phase_answer import _assemble_carve_outs from search.engine.region.requirements import ( + answer_terms, judge, record_reading, refusal_terms, @@ -47,6 +48,7 @@ from search.engine.region.unrealisable import unbind_unrealisable from search.engine.retrieval.retrieve import BoundValuesUnrealisable, Materialised, MemberConflict from search.engine.shape import ShapeDraft, SlotKeyDraft +from search.engine.unit.conversion import bound_prices, served_prices from search.heading.departure_fact import DepartureFact from search.models import CoverageBare, GraphRef, ResolvedQualifier, Spec, StatVar, Warning from tests.conftest import minimal_spec @@ -526,3 +528,45 @@ def test_the_definite_answer_is_judged_under_its_spec_unit_request(prices, depar assert _carve_out(state, _unit_spec(prices)) is None assert bool(_departed(state)) is departs + + +@pytest.mark.parametrize(("prices", "unbound"), [("constant", []), ("current", [_CONSTANT])]) +def test_the_trace_judges_a_price_basis_slot_under_the_served_unit_request(prices, unbound): + """A shape holding `measurementQualifier` as a slot binds Nominal, so "constant" is + unmet on its bindings alone. Served in constant euros the trace and the carve-out both + count it met; served at current prices both name it.""" + state = _state() + state.shape_draft = replace( + _education_share(), + shape_id="flow_price_slot", + slot_keys=(SlotKeyDraft(axis="how", property_dcid="measurementQualifier", label="prices"),), + ) + state.measure_query_matches = ( + QualifierMatch( + code_system="measurementQualifier", + code_dcid="RealValue", + label="Real value", + source="synonym", + matched_text=_CONSTANT, + ), + ) + state.require_unapplied( + _CONSTANT, asked=_CONSTANT, property_dcid="measurementQualifier", origin="bind" + ) + state.bindings = [ + SlotBindingDraft( + axis="how", property_dcid="measurementQualifier", kind="value", value_dcids=["Nominal"] + ) + ] + spec = _unit_spec(prices) + + trace = answer_terms( + state, + state.shape_draft, + state.bindings, + served=bound_prices(spec.definition.identity.facets), + ) + refused = settle_answer(state, state.shape_draft, state.bindings, served=served_prices(spec)) + + assert [q.text for q in trace if q.disposition != "bound"] == unbound + assert [q.text for q in refused] == unbound diff --git a/tests/entity_resolution/test_resolver.py b/tests/entity_resolution/test_resolver.py index 855ae852..d22dbb72 100644 --- a/tests/entity_resolution/test_resolver.py +++ b/tests/entity_resolution/test_resolver.py @@ -11,6 +11,8 @@ import threading import time from collections.abc import Callable, Iterable +from types import SimpleNamespace +from typing import cast import pytest from cachetools import TLRUCache @@ -28,6 +30,7 @@ Resolved, pack, ) +from entity_resolution.membership import UnknownMembership from entity_resolution.regions import identity_to_dcid from entity_resolution.resolver import ( _QUERY_ALIASES, @@ -38,6 +41,7 @@ _ttu, _without_article_and_punctuation, ) +from entity_resolution.shared import SharedResolver from entity_resolution.types import KindIndex from graph_generation import FakeGeneration from graph_generation.config import ( @@ -773,6 +777,57 @@ async def resolve_and_look_up(): assert len(graph.node_arcs_many_calls) == 1 +_GROUP_G = "dfo_graph/GroupG" + + +class _GroupGRegistry: + """A registry holding the one group `GroupG`, with no members.""" + + def by_entity_id(self, entity_id: str) -> SimpleNamespace: + if entity_id != _GROUP_G: + raise UnknownMembership(entity_id) + return SimpleNamespace(ref="GroupG", label="Group G", members=lambda: (), member_labels={}) + + +@pytest.mark.parametrize( + ("known_groups", "expected"), + [ + (("Shared name", "Group G"), {"GroupG": "Group G"}), + (("Shared name",), {}), + ], + ids=["a-later-name-resolves-to-the-group-alone", "no-name-resolves-to-the-group-alone"], +) +def test_a_group_node_takes_only_a_name_that_resolves_to_the_group_alone( + monkeypatch, known_groups, expected +): + """ "Shared name" tops on a group the registry does not hold, beside `GroupG` as an + organisation, so `resolve` reads it as an ordinary hit, ambiguous between the two. + "Group G" tops on the registry's group, which `resolve` answers alone. `GroupG` stays a + group node either way, so a pin on it still cannot ask for its members.""" + import entity_resolution.resolver as resolver_module + + groups_x = CandidateSummary(entity_id="groups/X", entity_type="geo.organization") + group_g_as_org = CandidateSummary(entity_id=_GROUP_G, entity_type="org.organisation") + group_g = CandidateSummary(entity_id=_GROUP_G, entity_type="geo.organization") + survivors = {"Shared name": (groups_x, group_g_as_org), "Group G": (group_g,)} + + def scripted_survivors(shared, text, context): + listed = tuple((candidate, None) for candidate in survivors[text]) + return listed, survivors[text], "entity" + + monkeypatch.setattr(resolver_module, "_resolve_text_survivors", scripted_survivors) + shared = SimpleNamespace( + resolver=SimpleNamespace(known_groups=lambda: known_groups), + groups=_GroupGRegistry(), + policy=SimpleNamespace(holds_region=lambda dcid: False), + ) + + lookup = resolver_module._group_nodes_job(cast(SharedResolver, shared)) + + assert lookup.names == expected + assert lookup.dcids == {"groups/X", "GroupG"} + + @pytest.mark.parametrize( "text", [ From c2f933f1f79e9c8994ecb80a574da1d7d27864d7 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 22:45:31 +0200 Subject: [PATCH 06/14] fix(search): depart per part on an every-member phrase a sibling part 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. --- src/search/engine/assemble.py | 19 +++- src/search/engine/conjoin.py | 27 ++++-- src/search/engine/enrichment.py | 16 ++-- src/search/engine/entry/raw_text.py | 16 +++- src/search/engine/region/result.py | 6 ++ tests/engine/test_role_scope_pipeline_e2e.py | 95 +++++++++++++++++++- 6 files changed, 160 insertions(+), 19 deletions(-) diff --git a/src/search/engine/assemble.py b/src/search/engine/assemble.py index 37b83191..eeb43944 100644 --- a/src/search/engine/assemble.py +++ b/src/search/engine/assemble.py @@ -212,10 +212,23 @@ def _surviving_qualifier_warnings( """Keep dropped-qualifier warnings that remain true across all regions. Each warning stays paired with its extracted phrase. One region admitting a phrase - makes the request-wide warning false. Compare with the same strip-and-casefold - normalisation used by ``admitted_qualifier_phrases``. + makes the request-wide warning false. A part's departures read its own region's + admission instead (`unapplied_qualifier_warnings`, `RegionResult.unapplied_qualifier_warnings`). """ - admitted = {phrase for region in regions for phrase in region.admitted_qualifier_phrases} + admitted = frozenset( + phrase for region in regions for phrase in region.admitted_qualifier_phrases + ) + return unapplied_qualifier_warnings(qualifier_warnings, phrases, admitted=admitted) + + +def unapplied_qualifier_warnings( + qualifier_warnings: Sequence[Warning], + phrases: Sequence[str], + *, + admitted: frozenset[str], +) -> list[Warning]: + """The `qualifier_warnings` whose paired phrase `admitted` does not hold, compared with + the strip-and-casefold normalisation ``admitted_qualifier_phrases`` uses.""" return [ warning for warning, phrase in zip(qualifier_warnings, phrases, strict=True) diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index c0bdd332..22d97538 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -438,9 +438,11 @@ def _build_part( envelope, so part timing excludes it. Folded parts carry only ``binding_folded``. The representative's warnings appear on its first part. - A raised departure fact reaches the part only while the part's or the envelope's warnings hold - the warning it was raised beside. On a candidates part only warnings stamped with no - reading's `spec_id` count, since the part's departures speak for every reading it shows. + A raised departure fact reaches the part only while the part's or the envelope's warnings, + or the request-wide qualifier warnings its own region leaves unapplied + (`RegionResult.unapplied_qualifier_warnings`), hold the warning it was raised beside. On + a candidates part only warnings stamped with no reading's `spec_id` count, since the + part's departures speak for every reading it shows. """ own_timing_by_step = { step: ms for step, ms in region.timing_by_step.items() if step != _SHARED_TIMING_STEP @@ -477,7 +479,9 @@ def _build_part( include_skills=include_skills, skills_index=skills_index, departure_facts=( - *surviving_facts(raised, [*warnings, *envelope_warnings]), + *surviving_facts( + raised, [*warnings, *region.unapplied_qualifier_warnings, *envelope_warnings] + ), *(() if folded else representative.inferred_facts), ), entity_words=region.entity_words, @@ -502,7 +506,15 @@ def _build_part( # on; one raised for some readings stays on their stamped warnings. departure_facts=surviving_facts( raised, - [w for w in (*region.warnings, *envelope_warnings) if w.spec_id is None], + [ + w + for w in ( + *region.warnings, + *region.unapplied_qualifier_warnings, + *envelope_warnings, + ) + if w.spec_id is None + ], ), request_words=region.entity_words is not None, ) @@ -569,7 +581,10 @@ def _build_part( skills_index=skills_index, cause=region.cause, window_gap_fact=region.window_gap_fact, - departure_facts=surviving_facts(raised, [*region.warnings, *envelope_warnings]), + departure_facts=surviving_facts( + raised, + [*region.warnings, *region.unapplied_qualifier_warnings, *envelope_warnings], + ), request_words=region.entity_words is not None, source_code_refusal=region.source_code_refusal, ) diff --git a/src/search/engine/enrichment.py b/src/search/engine/enrichment.py index aa48bcdc..9d33186e 100644 --- a/src/search/engine/enrichment.py +++ b/src/search/engine/enrichment.py @@ -339,11 +339,11 @@ def late_departures( The list is built as the part built its own (`assemble.definite_departures`), from its own heading words, warnings and operations and the facts raised beside the warnings the - part or its envelope holds, so a folded part keeps the list of a folded part. The read's - observations join the Spec, so `modelled_values` reads modelled and projected series, - and places the UN front door refused during the read join as raised - `entity_unavailable` facts (`un_system.refused_places_fact`), merged into one per role - with those the part states. A client replaces the part's list with it.""" + part, its envelope or its region's unapplied qualifiers hold, so a folded part keeps the + list of a folded part. The read's observations join the Spec, so `modelled_values` reads + modelled and projected series, and places the UN front door refused during the read join + as raised `entity_unavailable` facts (`un_system.refused_places_fact`), merged into one + per role with those the part states. A client replaces the part's list with it.""" part = source.part spec = part.spec observed = next( @@ -371,7 +371,11 @@ def late_departures( return None raised = surviving_facts( {**source.region.departure_facts, **source.representative.departure_facts}, - [*part.warnings, *source.envelope_warnings], + [ + *part.warnings, + *source.region.unapplied_qualifier_warnings, + *source.envelope_warnings, + ], ) read = ( spec diff --git a/src/search/engine/entry/raw_text.py b/src/search/engine/entry/raw_text.py index 282a306f..e6000fd9 100644 --- a/src/search/engine/entry/raw_text.py +++ b/src/search/engine/entry/raw_text.py @@ -25,6 +25,7 @@ build_no_data_part, skills_index_incomplete_warning, skills_index_unavailable_warning, + unapplied_qualifier_warnings, ) from search.engine.clients.graph import EngineGraphClient from search.engine.clients.llm import SupportsLLM @@ -561,9 +562,20 @@ class _ExtractionPrep: def onto(self, region: RegionResult) -> RegionResult: """`region` holding what this extraction knows beyond it: the departures raised - beside the request-wide qualifier warnings and the query's words for each entity.""" + beside the request-wide qualifier warnings, those warnings whose phrase the region + leaves unapplied (`RegionResult.unapplied_qualifier_warnings`), and the query's words + for each entity.""" + unapplied = unapplied_qualifier_warnings( + self.qualifier_warnings, + self.dropped_qualifier_phrases, + admitted=region.admitted_qualifier_phrases, + ) return with_entity_words( - with_departure_facts(region, self.qualifier_facts), self.entity_words + dataclasses.replace( + with_departure_facts(region, self.qualifier_facts), + unapplied_qualifier_warnings=tuple(unapplied), + ), + self.entity_words, ) diff --git a/src/search/engine/region/result.py b/src/search/engine/region/result.py index 98d3cb8d..f87cc322 100644 --- a/src/search/engine/region/result.py +++ b/src/search/engine/region/result.py @@ -208,6 +208,12 @@ class RegionResult: # The entry door uses them to retain or drop `QUERY_QUALIFIER_DROPPED` warnings # across the request. admitted_qualifier_phrases: frozenset[str] = frozenset() + # The raw-text door's request-wide `QUERY_QUALIFIER_DROPPED` warnings whose phrase this + # region leaves unapplied (`raw_text._ExtractionPrep.onto`). The envelope states such a + # warning only while no region admits its phrase. The part's departures read these + # beside its own and the envelope's warnings, so a phrase a sibling part applied still + # departs on this one. No part states them. + unapplied_qualifier_warnings: tuple[Warning, ...] = () # Why a no_data outcome fired (models.NoData's own `cause`), populated # only for no_data_reason=="rate_coverage_gap". cause: Cause | None = None diff --git a/tests/engine/test_role_scope_pipeline_e2e.py b/tests/engine/test_role_scope_pipeline_e2e.py index 097e7608..eeb52587 100644 --- a/tests/engine/test_role_scope_pipeline_e2e.py +++ b/tests/engine/test_role_scope_pipeline_e2e.py @@ -12,11 +12,12 @@ from search.engine.bind import SlotBindingDraft, _UniversalBindOutput from search.engine.clients.graph import Facet from search.engine.concurrency import scoped_concurrency -from search.engine.extract import DateRequest +from search.engine.extract import DateRequest, ExtractedEntity, Extraction from search.engine.quantity_read import QuantityOutput from search.engine.region import materialise_step as materialise_step_module from search.engine.region import observations as _observations_pkg # noqa: F401 from search.engine.region import resolve_spec_resubmit +from search.engine.region.codes import QUERY_QUALIFIER_DROPPED from search.engine.region.pipeline import resolve_variable from search.engine.region.result import every_member_phrases from search.engine.region.state import EngineOptions @@ -29,7 +30,12 @@ patch_coverage, ) from tests.engine._fake_sdmx import FakeSdmxClient -from tests.engine._harness import door_roles_for_test_query, walk_options +from tests.engine._harness import ( + door_roles_for_test_query, + make_request, + offline_resolve, + walk_options, +) from tests.engine._quantity import NO_QUANTITY from tests.fixtures import FakeGraph, make_resolver @@ -839,6 +845,91 @@ def test_a_candidate_set_meets_an_every_member_phrase_only_when_every_candidate_ assert ("to every recipient" in met) is admitted +_TWO_PART_GDP_SV = "Amount_EconomicActivity_GrossDomesticProduction_Nominal" + + +class _TwoPartExtractLLM: + """Extracts a role-bearing "aid" and a subject-only "GDP" variable, both at Kenya, with + "from all donors" dropped, and elects the first shape with no bindings.""" + + async def generate_structured(self, *, prompt, system, schema): + if schema.__name__ == "Extraction": + return Extraction( + kind="resolution", + variables=["aid", "GDP"], + entities=[ExtractedEntity(text="Kenya")], + dates=[], + dropped_qualifiers=["from all donors"], + ), None + if schema is QuantityOutput: + return NO_QUANTITY + assert schema.__name__ == "_UniversalBindOutput" + return _UniversalBindOutput(chosen_shape_index=0, bindings=[]), None + + +def test_an_every_member_phrase_one_part_meets_still_departs_on_a_subject_only_sibling( + monkeypatch, +): + """The role-bearing aid part leaves the origin unbound and so meets "from all donors"; + the subject-only GDP part has no donors to meet it with. The request-wide warning goes, + as any part's admission clears it, but each part's `measure_not_applied` departure reads + its own region's admission (`RegionResult.unapplied_qualifier_warnings`).""" + rows, role_names = _coverage_seed_for("debtor", [_KEN], [_SV_DCID]) + patch_coverage(monkeypatch, rows, role_names=role_names) + graph = FakeGraph( + nodes={ + _KEN: {"label": "Kenya", "type": "Country"}, + _SV_DCID: { + "label": "Test flow", + "arcs": _declaring_arcs(observation_properties=["creditor", "debtor"]), + }, + _TWO_PART_GDP_SV: { + "label": "Gross domestic product", + "type": "StatisticalVariable", + "arcs": { + "populationType": {"nodes": [{"dcid": "EconomicActivity"}]}, + "measuredProperty": {"nodes": [{"dcid": "amount"}]}, + "statType": {"nodes": [{"dcid": "measuredValue"}]}, + "measurementQualifier": {"nodes": [{"dcid": "Nominal"}]}, + "constraintProperties": {"nodes": [{"dcid": "economicActivityType"}]}, + "economicActivityType": {"nodes": [{"dcid": "GrossDomesticProduction"}]}, + }, + }, + }, + detect={ + "aid": {"svs": [_SV_DCID], "cosine_scores": [0.9]}, + "GDP": {"svs": [_TWO_PART_GDP_SV], "cosine_scores": [0.9]}, + }, + resolve={"Kenya": _KEN}, + obs={ + f"{_TWO_PART_GDP_SV}|{_KEN}": [ + {"earliestDate": "2020", "latestDate": "2020", "obsCount": 3} + ] + }, + ) + + response = offline_resolve( + make_request("aid to Kenya and GDP of Kenya from all donors"), + graph=graph, + llm=_TwoPartExtractLLM(), + sdmx=FakeSdmxClient( + observations={_SV_DCID: {(("debtor", _KEN),): [_facet()]}}, availability={} + ), + ) + + assert [ + w.code for w in response.diagnostics.warnings if w.code == QUERY_QUALIFIER_DROPPED + ] == [] + assert [(p.variable_text, p.status) for p in response.parts] == [ + ("aid", "definite"), + ("GDP", "definite"), + ] + assert [ + [d.asked for d in part.departures if d.code == "measure_not_applied"] + for part in response.parts + ] == [[], ["from all donors"]] + + # --------------------------------------------------------------------------- # Windowed / latest requests # --------------------------------------------------------------------------- From e2323e2edd20b26fa20074e176c7f1dd55033bc0 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 23:22:58 +0200 Subject: [PATCH 07/14] fix(search): take role-option mentions from the readings and pin the 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. --- docs/conformance-search-api-v1.md | 2 ++ src/search/README.md | 2 ++ src/search/engine/conjoin.py | 15 ++++++++++----- tests/engine/test_choice_options.py | 8 +++----- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/conformance-search-api-v1.md b/docs/conformance-search-api-v1.md index b3f9bf70..b61d6d7c 100644 --- a/docs/conformance-search-api-v1.md +++ b/docs/conformance-search-api-v1.md @@ -250,6 +250,8 @@ Gap rows state what is needed to close the clause and name the open issue that t | C149 | A warning carries `params` only for `observations_series_truncated` (C150), `elected_shape_no_data` (C151) and `un_system_unavailable` (C107a), holding the counts and labels its message states so a client composes its own text without parsing the message; every other code's warning omits the key | implemented | Pinned by the C150 and C151 tests. | | C150 | `observations_series_truncated` carries `params: {shown, total, order}`: the series the preview ships and the confirmed series behind them, both integers, `shown` < `total`, and `order`, `"ranked"` when the preview read every series and ships the most recent and largest (latest year, then latest value, then identity) or `"fixed"` when the observations the answer keeps in each member's years exceed `role_bulk_rank_max_rows` or the read converts into each entity's own currency and the preview ships a selection made before any value was read, StatVars newest year first and taken round-robin. The message states the same order. A fixed selection takes only series with data in their member's years, StatVars ordered by their newest year with data there. The observations themselves carry the same `order` whenever they show fewer series than the answer holds, and omit it otherwise. The Datasets observations door selects a snapshot share's series by the same rule over its frame's rows and states its own `order` on the observations it serves | implemented | Pinned by `tests/engine/test_bulk_role_series_observations.py`, `tests/engine/test_population_headline.py` and, for the door against the live read on each window, `tests/datasets_api/test_observations_door.py` (`test_the_door_and_the_live_role_bulk_preview_agree_on_each_series_axes`). | | C151 | A part carries at most one `elected_shape_no_data` per election, however many empty readings it dropped, with `params: {no_data, elected}`: the shape labels of the readings with no data and of the readings answered instead, each listed once in first-seen order. A single-sibling re-election names the elected shape and its sibling; a candidates answer of several siblings names the elected shape and each candidate's shape label. A same-shape merge folds its parts' warnings that share a message into one, whose `no_data` and `elected` are the first-seen unions over the parts | implemented | Pinned by `tests/engine/test_zero_observation_election.py`, `tests/engine/test_candidates_measure_sibling_collapse.py`, and `tests/engine/test_conjoin_warnings_and_primary.py`. | +| C191 | A price-basis requirement the query names on `measurementQualifier` ("constant", "real", "at constant prices") is met by an answer served under a unit request whose price basis reads as one of the requirement's codes (`requirements._serves_price_basis`): constant prices meet `RealValue` and `InflationAdjusted`, current prices meet `Nominal`, and no unit request meets `PurchasingPowerParity`. A definite answer, or a candidate set every member of which is served so, carries no `query_qualifier_dropped` warning, `measure_not_applied` departure or sentence fact for it. The requirement still elects a publisher's series at that basis where one exists. An answer served under no unit request, or at the other basis, leaves it unmet, so "constant prices" over a series holding only current prices still warns | implemented | Pinned by `tests/engine/test_pinned_code_refusal.py`. | +| C192 | On the raw_text door a phrase extraction drops that asks for every member of a flow role ("all donors", "every provider", "to all creditors", "from all countries", the preposition naming the side; `role_text.every_member_axis`) opens no axis and is met by a role-bearing answer that binds no place on that axis (`region.result.every_member_phrases`). An "all" phrase is met by one series per member or by the total over them; an "each" or "every" phrase asks for the breakdown (`role_text.every_member_breakdown`), so only an answer declaring a role on the axis meets it. A candidate set meets a phrase only when every candidate does. A met phrase raises no `query_qualifier_dropped` warning and no `measure_not_applied` departure. A subject-only answer, an axis a named place binds, and words outside the role lexicon ("in all sectors") still warn. The response cache keys the two directions apart, so "from all countries" never serves "to all countries" | implemented | Pinned by `tests/engine/test_role_scope_pipeline_e2e.py`, `tests/engine/test_role_text.py` and `tests/engine/test_response_cache_keys.py`. | ## Refusals / errors diff --git a/src/search/README.md b/src/search/README.md index b396c84e..edc5c793 100644 --- a/src/search/README.md +++ b/src/search/README.md @@ -167,6 +167,8 @@ Each heading and byline slot token with text carries `states`, the refs a client `resolved.unit_conversion` lists what a unit request can convert the answer to (`search.engine.unit.conversion`). Each entry of `currencies` carries the ISO code, the noun the byline names the currency by ("Guatemalan quetzales"), the run of served years a current-price and a constant-price request convert, and the first and last base years a constant-price request converts at. Those years and base years are the ones the conversion accepts (`ConversionAvailability`). A row reads the terms `materialise.convert.row_terms` lists: its native currency's rate in its year, and at current prices its target's rate in that year, or at constant prices its target's rate in the base year and the deflator of its entity on the recipe's deflator role in its year and the base year, the recipe's fallback entity standing in for an entity the table holds no row for. The native currencies are those of the facets the answer's read plan serves, so an answer bound to current US dollars served natively keeps the dollar's rates in later offers. The deflator entities are every entity the plan serves on the deflator role, bound or open, read from the plan and the coverage index in memory. A missing rate refuses the conversion, so the rates bound every row's years. A missing deflator leaves out only its entity's rows, so the constant-price years and the base years run wherever at least one served entity's deflator holds them, and a donor whose deflator starts late shortens no other donor's run. The period a unit request shows (`convertibility.unit_request_period`) reads the same availability. Beside the list sit the default base year and the national currency of each role bound to one entity. The offer is null for a stock, a series not in nominal prices, a unit that is no currency, a provenance with no recipe, or tables that convert no served year. Every fact comes from the conversion catalogue the app loads at startup and reloads when the graph generation changes (`search.engine.unit.conversion_catalogue`), so an answer makes no read for it. Startup waits at most 15 s for the first load and then serves while the load continues in the background. A failed load logs an ERROR and retries after 5, 15 and 60 s, then every 300 s. A reload requested during a load runs once that load ends, so loads run one at a time. While no catalogue is loaded, a currency answer carries the `unit_conversion_unavailable` warning, so a client can tell an outage from an answer that cannot convert. +A price-basis requirement the query names on `measurementQualifier` ("constant", "real", "at constant prices") is met by an answer served under a unit request whose price basis reads as one of the requirement's codes (`requirements._serves_price_basis`): constant prices meet `RealValue` and `InflationAdjusted`, current prices meet `Nominal`, and no unit request meets `PurchasingPowerParity`. So "GDP of Kenya at constant prices" served from a Nominal series converted to constant US dollars carries no `query_qualifier_dropped` warning or departure for "constant", and a candidate set meets the requirement only when every member is served so. The requirement still elects a publisher's constant-price series where one exists, and an answer served under no unit request, or at the other basis, leaves it unmet. + A unit request whose tables cover only part of the served years is answered over the longest run of years every needed table covers. The part carries a `date_coverage_partial` warning and a `year_unavailable` departure on `when:`: "Exchange rates start in 1980, so this shows 1980 to 2024." The offer's year runs state that period before the reader picks the currency. Inside that period a constant-price conversion leaves out each entity's rows in the years its deflator lacks, and every row of an entity whose deflator lacks the base year (`materialise.convert`). The mint's manifest and the answer carry a `deflator_missing_omitted` warning, and the part a `year_unavailable` departure on `unit:` naming the entities that lose the most years, at most three, then a count: "Constant prices leave out Taiwan before 2023, Azerbaijan before 2022, and Cyprus before 2018, which the price deflators do not cover." (`convertibility.deflator_omission_departure`). Names come from `ConversionCatalogue.entity_names`, which holds every entity a deflator table holds and every place with a national currency; an entity outside both, such as a multilateral donor, joins the count ("and 2 others", or "some years of 2 entities" when none is named). When the loaded tables cannot say whose rows the conversion leaves out (`counted_conversion` is `UNDECIDED`: no catalogue, served facets under several recipes, or no single deflator role), a constant-price answer still carries the warning and a departure naming no entity: "Constant prices may leave out years the price deflators do not cover." A row whose deflator-role cell is null names no entity, so its conversion refuses `rate_coverage_gap` with cause `deflator_missing`, as does a request left with no row. Every slot menu on a definite answer is counted in memory before the Spec takes it (`search.engine.region.option_coverage.count_slot_options`). `SlotOption.served_rows` is the number of rows a pick's file would hold, in the same terms as `download.rows`: one per series and year inside the requested window, or under `latest` one per series holding its StatVar's latest year. Both read one code path, `CoverageIndex.series_years`, which ORs each series' year masks across the facets that serve it; the download sums the popcounts, and an option sums them over the series its pick reads. A sibling measure in `alternative_measures` carries the same count. `served_rows_exact` says whether the count equals the pick's own `download.rows`. It is false when a counted StatVar's series sit in more than one facet group (provenance, method and unit) in the window, since a per-series read elects one group per StatVar; when the answer reads in bulk and the pick's StatVars sit in more than one facet, since a bulk read elects one facet; under `latest`, when a StatVar has a facet observed more often than yearly, since the coverage index holds years and the read keeps the latest period; when the answer's identity binds a facet value (a unit, a provenance or a method), which the count does not apply, and for a probed option whose facets carry no complete date list. A unit request is applied: each counted series keeps the years its conversion keeps (`search.engine.unit.conversion.CountedConversion`), the years its entity's deflator holds at constant prices and none for an entity no currency names under a conversion into each entity's own currency, as `download.rows` counts them. The count is an upper bound under a unit request only when the loaded conversion tables cannot decide it (no catalogue, several recipes, a unit that is no currency, no single deflator or target role), when a counted series sits in a facet of a currency the answer does not serve, or when the coverage probe counts the menu. `download.rows_exact` and both `served_rows_exact` fields take their exactness from one rule (`counted_exact`). A same-shape merge ORs the masks of a series both answers serve. A StatVar the coverage artifact does not hold adds no rows, so the count covers the pick's other StatVars, and an option all of whose StatVars are missing from the artifact is omitted. A menu omits every option that would serve nothing except the slot's current value, so every option offered has data. The coverage probe under `include_option_coverage` runs only on menus the count leaves open: bound slots of graph-walked families, bound slots of subject-place families, and a menu whose count finds no series for any option while its members carry them. That last case means the coverage index and the members disagree, so it logs a WARNING and the probe counts the menu. The probe counts each option's plan as the download counts a standard plan (`download.pair_extent`). diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index 22d97538..dbeea19c 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -57,6 +57,7 @@ if TYPE_CHECKING: from search.engine.region import RegionResult from search.engine.region.result import AmbiguousEntityCandidate as EngineCandidate + from search.engine.region.result import AmbiguousSource from search.models import GraphRef, Part, QueryEcho # --------------------------------------------------------------------------- @@ -310,10 +311,12 @@ def chosen(entity: ParsedEntity, candidate: EngineCandidate) -> ParsedEntity | N def _source_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: """One option per ambiguous source wording and candidate, setting the candidate's ref on every ref-less mention with that wording; a wording mentioned twice gives its options - once. A label names an excluded source as excluded.""" + once, from its first group. A label names an excluded source as excluded.""" + groups: dict[str, AmbiguousSource] = {} + for group in region.ambiguous_sources: + groups.setdefault(group.text, group) options: list[NoDataOption | None] = [] - for text in dict.fromkeys(group.text for group in region.ambiguous_sources): - group = next(group for group in region.ambiguous_sources if group.text == text) + for text, group in groups.items(): mentions = [m for m in part.sources if m.ref is None and m.text == text] excluded = bool(mentions) and mentions[0].polarity == "exclude" for candidate in group.candidates: @@ -345,9 +348,11 @@ def _source_code_options(region: RegionResult, part: ParsedPart) -> list[NoDataO def _role_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: """One option per reading of the directions the question left untold (`RegionResult.role_assignments`): every row of a mapped text states its axis, and a row - the reading repeats is dropped. The label reads the option's own directions.""" + the reading repeats is dropped. Its mentions are the texts the readings map, in the + request's words. The label reads the option's own directions.""" words = region.entity_words or {} - mentions = [words.get(name, name) for name in region.unresolved_entity_names] + texts = region.role_assignments[0] if region.role_assignments else {} + mentions = list(dict.fromkeys(words.get(text, text) for text in texts)) options: list[NoDataOption | None] = [] for reading in region.role_assignments: rows = [ diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index 0f156e95..6d29b909 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -112,13 +112,11 @@ def _no_data(client: TestClient, part: dict) -> dict: def _assert_options_settle(client: TestClient, no_data: dict) -> None: - """Each option's request, posted unchanged, is a valid request that does not refuse - with the same reason: it answers, or fails on something the choice does not decide.""" + """Each option's request, posted unchanged, succeeds and does not refuse with the same + reason: it answers, or refuses on something the choice does not decide.""" for option in no_data["options"]: response = client.post("/api/search/resolve", json={"input": option["data_query"]}) - assert response.status_code not in (400, 422), (option["label"], response.text) - if response.status_code != 200: - continue + assert response.status_code == 200, (option["label"], response.text) (answered,) = response.json()["parts"] reason = (answered.get("no_data") or {}).get("reason") assert reason != no_data["reason"], option["label"] From d135153591e623c76e25c6b31b6979605b8a4488 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Tue, 20 Oct 2026 23:42:27 +0200 Subject: [PATCH 08/14] fix(search): name a role refusal's untold places by the request's own 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. --- src/search/engine/conjoin.py | 16 +++-- src/search/engine/region/place_roles.py | 81 +++++++++++------------ src/search/models/outcomes.py | 5 +- tests/engine/test_choice_options.py | 31 +++++++++ tests/engine/test_place_set_invariants.py | 13 ++-- 5 files changed, 90 insertions(+), 56 deletions(-) diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index dbeea19c..9c4c48b5 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -181,7 +181,8 @@ def _no_data_entity_terms(region: RegionResult) -> tuple[list[str] | None, list[ return list(region.unresolved_entity_names) or None, None if region.no_data_reason == "role_not_resolved": words = region.entity_words or {} - return [words.get(name, name) for name in region.unresolved_entity_names] or None, None + terms = dict.fromkeys(words.get(name, name) for name in region.unresolved_entity_names) + return list(terms) or None, None return None, None @@ -348,11 +349,16 @@ def _source_code_options(region: RegionResult, part: ParsedPart) -> list[NoDataO def _role_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: """One option per reading of the directions the question left untold (`RegionResult.role_assignments`): every row of a mapped text states its axis, and a row - the reading repeats is dropped. Its mentions are the texts the readings map, in the - request's words. The label reads the option's own directions.""" + the reading repeats is dropped. Its mentions are the untold names + (`unresolved_entity_names`) the readings map, which every reading maps alike, in the + request's words and in query order. The label reads the option's own directions.""" words = region.entity_words or {} - texts = region.role_assignments[0] if region.role_assignments else {} - mentions = list(dict.fromkeys(words.get(text, text) for text in texts)) + mapped = region.role_assignments[0] if region.role_assignments else {} + mentions = list( + dict.fromkeys( + words.get(name, name) for name in region.unresolved_entity_names if name in mapped + ) + ) options: list[NoDataOption | None] = [] for reading in region.role_assignments: rows = [ diff --git a/src/search/engine/region/place_roles.py b/src/search/engine/region/place_roles.py index decd562c..795890ec 100644 --- a/src/search/engine/region/place_roles.py +++ b/src/search/engine/region/place_roles.py @@ -1101,33 +1101,38 @@ def place_order_of(dropped: tuple[str, RoleAxis, str]) -> tuple[bool, str, str, def untold_direction_names(state: ResolveState) -> tuple[str, ...]: - """The query names whose flow direction a `role_not_resolved` refusal could not tell, - in query order, one per place set: each set no text of which carries a direction, - under its name (`part_sets`), and each name that resolved to no set and carries none. - Else the names whose stated direction the query's own wording contradicts - (`role_text.contradicts`), the first such text of each set. Empty when every name's - direction is told, as when materialisation refuses the roles.""" - named = part_sets(state) - directed = { - place_key(state.place_sets[name]) - for name in state.entities - if name in state.place_sets and state.entity_roles.get(name) + """The request texts whose flow direction a `role_not_resolved` refusal could not tell, + in query order: every text of each untold group (`_untold_groups`). Empty when every + name's direction is told, as when materialisation refuses the roles.""" + untold = { + name for texts in _untold_groups(state, _text_groups(state)).values() for name in texts } - undirected: dict[str, str] = {} - for name in state.entities: + return tuple(name for name in dict.fromkeys(state.entities) if name in untold) + + +def _text_groups(state: ResolveState) -> dict[str, list[str]]: + """The request texts in groups, by group key, in query order: the texts that name one + place set under its place key, and each text that resolved to no set alone under + the text.""" + groups: dict[str, list[str]] = {} + for name in dict.fromkeys(state.entities): place_set = state.place_sets.get(name) - if place_set is None: - if not state.entity_roles.get(name): - undirected.setdefault(name, name) - elif (key := place_key(place_set)) not in directed: - undirected.setdefault(key, named[key].name) + groups.setdefault(name if place_set is None else place_key(place_set), []).append(name) + return groups + + +def _untold_groups(state: ResolveState, groups: dict[str, list[str]]) -> dict[str, list[str]]: + """The `groups` whose flow direction the question leaves untold, in their order: those + no text of which carries a direction, else those one of whose texts states a direction + the query's own wording contradicts (`role_text.contradicts`).""" + undirected = { + key: texts + for key, texts in groups.items() + if not any(state.entity_roles.get(name) for name in texts) + } if undirected: - return tuple(undirected.values()) - contradicted: dict[str, str] = {} - for name in _contradicted_names(state, state.entities): - place_set = state.place_sets.get(name) - contradicted.setdefault(name if place_set is None else place_key(place_set), name) - return tuple(contradicted.values()) + return undirected + return {key: texts for key, texts in groups.items() if _contradicted_names(state, texts)} # The most readings of an unstated direction a `role_not_resolved` refusal offers. Two sets @@ -1140,34 +1145,26 @@ def direction_readings(state: ResolveState) -> tuple[dict[str, RoleAxis], ...]: """The readings a `role_not_resolved` refusal offers for the directions the question leaves untold, each mapping every request text of every untold set to one flow axis. - The untold sets are those no text of which carries a direction, else those whose stated - direction the query's wording contradicts (`untold_direction_names`). Each takes one - axis on which the elected shape declares a role and that the part does not ask open - (`ResolveState.open_axes`, or an operation's `over`); an open axis counts as named and - takes no set. A reading is kept when every such axis then holds a set, a directed one + The untold sets are the place sets among the untold groups (`_untold_groups`): those no + text of which carries a direction, else those whose stated direction the query's + wording contradicts. Each takes one axis on which the elected shape declares a role and + that the part does not ask open (`ResolveState.open_axes`, or an operation's `over`); an + open axis counts as named and takes no set. A reading is kept when every such axis then holds a set, a directed one included. Readings run over the sets in query order, origin first. Empty when the shape declares no role, no set is untold, or more than `MAX_DIRECTION_READINGS` readings are kept; the count stops there, so no reading past it is built.""" shape = state.shape_draft if shape is None or not is_role_bearing(shape): return () - named = part_sets(state) - texts: dict[str, list[str]] = {} - for name in dict.fromkeys(state.entities): - if (place_set := state.place_sets.get(name)) is not None: - texts.setdefault(place_key(place_set), []).append(name) + groups = _text_groups(state) + texts = {key: names for key, names in groups.items() if names[0] in state.place_sets} + untold = [key for key in _untold_groups(state, groups) if key in texts] + if not untold: + return () directed = { key: {role.axis for name in names for role in state.entity_roles.get(name) or ()} for key, names in texts.items() } - untold = [key for key in named if not directed[key]] - if not untold: - contradicted = _contradicted_names( - state, [name for names in texts.values() for name in names] - ) - untold = list(dict.fromkeys(place_key(state.place_sets[name]) for name in contradicted)) - if not untold: - return () over = {operation.over for operation in state.operations} declared = role_names_by_axis(shape) axes: tuple[RoleAxis, ...] = tuple( diff --git a/src/search/models/outcomes.py b/src/search/models/outcomes.py index 1ce99ea3..aa6de4a3 100644 --- a/src/search/models/outcomes.py +++ b/src/search/models/outcomes.py @@ -386,8 +386,9 @@ class NoData(StrictModel): description=( "the query entity text responsible for an entity-resolution outcome, in " "query order: every entity the engine could not resolve for " - "entity_not_resolved, every ambiguous wording for entity_ambiguous, or " - "every resolved-but-unadmitted entity for entity_role_not_admitted. Empty " + "entity_not_resolved, every ambiguous wording for entity_ambiguous, every " + "resolved-but-unadmitted entity for entity_role_not_admitted, or every name " + "whose flow direction the query leaves untold for role_not_resolved. Empty " "for every other no_data reason. This structured list is the client-facing " "source of those terms; rendered_sentence may quote the same wording for " "people, but clients must not parse it." diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index 6d29b909..3267fd90 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -8,6 +8,8 @@ from __future__ import annotations +import re + import pytest from fastapi.testclient import TestClient @@ -298,6 +300,35 @@ def test_a_role_option_states_a_direction_for_every_untold_name(): ] +@pytest.mark.parametrize( + ("entities", "names"), + [ + pytest.param( + [{"text": "KE", "dcid": "country/KEN"}, {"text": "Norway"}], + ["KE", "Norway"], + id="pinned-only-text", + ), + pytest.param( + [{"text": "Kenya"}, {"text": "Norway"}, {"text": "KEN", "dcid": "country/KEN"}], + ["Kenya", "Norway", "KEN"], + id="two-texts-of-one-set-apart", + ), + ], +) +def test_a_role_refusal_names_every_untold_text_in_query_order(entities, names): + """The options' mentions, the refusal's entity terms and its message name the same + request texts in query order, a pin's key included in place of its node's label.""" + client = _flow_client() + + no_data = _no_data(client, {"variable_text": "aid", "entities": entities}) + + assert no_data["reason"] == "role_not_resolved" + assert [o["mentions"] for o in no_data["options"]] == [names] * 2 + assert no_data["entity_terms"] == names + untold, _ = no_data["message"].split(". ", 1) + assert re.findall(r'"([^"]+)"', untold) == names + + def test_a_reading_its_own_wording_contradicts_is_not_offered(): """ "aid to Norway" contradicts Norway as the creditor. The one reading that fills the creditor axis puts Norway back on it, which the parsed door refuses again, so the diff --git a/tests/engine/test_place_set_invariants.py b/tests/engine/test_place_set_invariants.py index 267e5a1e..ac48dcad 100644 --- a/tests/engine/test_place_set_invariants.py +++ b/tests/engine/test_place_set_invariants.py @@ -1038,7 +1038,7 @@ def test_a_groups_pinned_node_beside_its_name_is_admitted_as_the_group( pytest.param( [pinned(EU, _THE_UNION), LABEL[EU]], [destination("Kenya")], - [LABEL[EU]], + None, id="pinned-node-and-name", ), pytest.param( @@ -1049,7 +1049,7 @@ def test_a_groups_pinned_node_beside_its_name_is_admitted_as_the_group( ), ], ) -def test_a_direction_refusal_names_each_untold_set_once( +def test_a_direction_refusal_names_every_text_of_each_untold_set( monkeypatch: pytest.MonkeyPatch, texts: list[str | ParsedEntity], others: list[str | ParsedEntity], @@ -1058,17 +1058,16 @@ def test_a_direction_refusal_names_each_untold_set_once( ) -> None: """Aid needs each place's direction. With no aid flow in either direction, no reading of an unstated direction holds data, so the answer refuses. A set no text of which is - directed is named once, by a text the request posted for it. A pin's key is not used, - and a set one text directs is not named. With no `untold` given, the name is the first - text posted.""" + directed is named by every text the request posted for it, in query order, a pin's key + included, and a set one text directs is not named. With no `untold` given, the names + are the set's texts as posted.""" monkeypatch.setattr(world, "FLOW_DATA", {**world.FLOW_DATA, AID: ()}) doors = Doors(monkeypatch) ordered = texts[::-1] if reverse else texts response = doors.first_answer(AID, *ordered, *others) - first = ordered[0] - expected = untold or [first if isinstance(first, str) else first.text] + expected = untold or [text if isinstance(text, str) else text.text for text in ordered] assert no_data_terms(response) == ("role_not_resolved", expected) From 2c396976bab0bdf6384976a413668cd5000639a9 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 00:03:59 +0200 Subject: [PATCH 09/14] fix(search): word a role refusal by its untold places, naming one place'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. --- src/search/engine/assemble.py | 6 +++- src/search/engine/conjoin.py | 15 ++++++++ src/search/engine/region/phase_shared.py | 6 ++-- src/search/engine/region/place_roles.py | 24 ++++++++----- src/search/engine/region/result.py | 4 +++ src/search/engine/region/state.py | 2 ++ src/search/render/no_data.py | 46 +++++++++++++++++++++--- tests/engine/test_choice_options.py | 29 ++++++++++----- 8 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/search/engine/assemble.py b/src/search/engine/assemble.py index eeb43944..763fce0c 100644 --- a/src/search/engine/assemble.py +++ b/src/search/engine/assemble.py @@ -478,6 +478,7 @@ def build_no_data_part( alternate_match: Spec | None = None, elected_stat_vars: list[GraphRef] | None = None, entity_terms: list[str] | None = None, + entity_groups: Sequence[Sequence[str]] | None = None, role_axis: EntityRoleKey | None = None, bound_role_dcids: Mapping[EntityRoleKey, Sequence[str]] | None = None, ambiguous_candidates: list[str] | None = None, @@ -509,7 +510,8 @@ def build_no_data_part( code lives on ``NoData.gate``. ``elected_stat_vars`` lets sentence composition name an exact series match before a nearest substitute. - ``entity_terms`` preserves the place wording seen by the engine. ``role_axis`` names an + ``entity_terms`` preserves the place wording seen by the engine, and ``entity_groups`` + groups a ``role_not_resolved`` refusal's terms by the place each names. ``role_axis`` names an open origin or destination axis in ``no_observations`` copy. ``bound_role_dcids`` records query-bound role values for alternate validation, including when both roles are bound. ``ambiguous_candidates`` feeds the wording. ``ambiguous_entities`` carries structured @@ -603,6 +605,7 @@ def build_no_data_part( elected_stat_vars=elected_stat_vars, variable_term=variable_text, entity_terms=entity_terms, + entity_groups=entity_groups, role_axis=role_axis, bound_role_dcids=bound_role_dcids, ambiguous_candidates=ambiguous_candidates, @@ -644,6 +647,7 @@ def build_no_data_part( reason, variable_term=variable_text, entity_terms=entity_terms, + entity_groups=entity_groups, role_axis=role_axis, ambiguous_candidates=ambiguous_candidates, elected_stat_vars=elected_stat_vars, diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index 9c4c48b5..5fa50ea3 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -186,6 +186,20 @@ def _no_data_entity_terms(region: RegionResult) -> tuple[list[str] | None, list[ return None, None +def _untold_place_terms(region: RegionResult) -> list[list[str]] | None: + """The terms of a ``role_not_resolved`` refusal grouped by the place each names + (``RegionResult.untold_groups``), in the request's own words as + `_no_data_entity_terms` gives them, so the sentence counts places. ``None`` for every + other reason.""" + if region.no_data_reason != "role_not_resolved": + return None + words = region.entity_words or {} + return [ + list(dict.fromkeys(words.get(name, name) for name in group)) + for group in region.untold_groups + ] or None + + def _ambiguous_entity_groups(region: RegionResult) -> list[AmbiguousEntityGroup]: """Convert region ambiguity data to the wire shape, for an ``entity_ambiguous`` region only. A region refused for another reason can still name ambiguous entities @@ -571,6 +585,7 @@ def _build_part( alternate_match=region.alternate_match, elected_stat_vars=region.elected_stat_vars, entity_terms=entity_terms, + entity_groups=_untold_place_terms(region), role_axis=region.no_data_role_axis, bound_role_dcids=region.no_data_bound_role_dcids, ambiguous_candidates=ambiguous_candidates, diff --git a/src/search/engine/region/phase_shared.py b/src/search/engine/region/phase_shared.py index b3217c48..90ffef18 100644 --- a/src/search/engine/region/phase_shared.py +++ b/src/search/engine/region/phase_shared.py @@ -26,6 +26,7 @@ from search.engine.region.place_roles import ( direction_readings, request_named_groups, + untold_direction_groups, untold_direction_names, ) from search.engine.region.requirements import refusal_terms, settle_answer, settle_flagged @@ -416,12 +417,13 @@ async def _role_not_resolved_no_data(state: ResolveState, *, offer: bool = True) This covers multiple places without resolvable axes, a solitary place without an axis, or wording that contradicts a supplied axis. A `flagged_match` requires a resolved role map, which this refusal lacks. The names whose direction it could not tell - (`untold_direction_names`) ride as the result's `unresolved_entity_names`, and the - readings of their directions (`direction_readings`) as its `role_assignments`, unless + (`untold_direction_names`) ride as the result's `unresolved_entity_names`, grouped by + place (`untold_direction_groups`) as its `untold_groups`, and the readings of their directions (`direction_readings`) as its `role_assignments`, unless `offer` is false: every reading was read and none holds data.""" return state.no_data( "role_not_resolved", llm_usage=state.var_usage, unresolved_entity_names=untold_direction_names(state), + untold_groups=untold_direction_groups(state), role_assignments=direction_readings(state) if offer else (), ) diff --git a/src/search/engine/region/place_roles.py b/src/search/engine/region/place_roles.py index 795890ec..8179cf76 100644 --- a/src/search/engine/region/place_roles.py +++ b/src/search/engine/region/place_roles.py @@ -1102,14 +1102,19 @@ def place_order_of(dropped: tuple[str, RoleAxis, str]) -> tuple[bool, str, str, def untold_direction_names(state: ResolveState) -> tuple[str, ...]: """The request texts whose flow direction a `role_not_resolved` refusal could not tell, - in query order: every text of each untold group (`_untold_groups`). Empty when every - name's direction is told, as when materialisation refuses the roles.""" - untold = { - name for texts in _untold_groups(state, _text_groups(state)).values() for name in texts - } + in query order: every text of each untold group (`untold_direction_groups`). Empty when + every name's direction is told, as when materialisation refuses the roles.""" + untold = {name for texts in untold_direction_groups(state) for name in texts} return tuple(name for name in dict.fromkeys(state.entities) if name in untold) +def untold_direction_groups(state: ResolveState) -> tuple[tuple[str, ...], ...]: + """The request texts of each place a `role_not_resolved` refusal could not tell the + flow direction of, one tuple per untold group (`_untold_groups`), each in query order, + so a refusal names one place set read under several texts as one place.""" + return tuple(tuple(texts) for texts in _untold_groups(state, _text_groups(state)).values()) + + def _text_groups(state: ResolveState) -> dict[str, list[str]]: """The request texts in groups, by group key, in query order: the texts that name one place set under its place key, and each text that resolved to no set alone under @@ -1149,10 +1154,11 @@ def direction_readings(state: ResolveState) -> tuple[dict[str, RoleAxis], ...]: text of which carries a direction, else those whose stated direction the query's wording contradicts. Each takes one axis on which the elected shape declares a role and that the part does not ask open (`ResolveState.open_axes`, or an operation's `over`); an - open axis counts as named and takes no set. A reading is kept when every such axis then holds a set, a directed one - included. Readings run over the sets in query order, origin first. Empty when the shape - declares no role, no set is untold, or more than `MAX_DIRECTION_READINGS` readings are - kept; the count stops there, so no reading past it is built.""" + open axis counts as named and takes no set. A reading is kept when every such axis then + holds a set, a directed one included. Readings run over the sets in query order, origin + first. Empty when the shape declares no role, no set is untold, or more than + `MAX_DIRECTION_READINGS` readings are kept; the count stops there, so no reading past + it is built.""" shape = state.shape_draft if shape is None or not is_role_bearing(shape): return () diff --git a/src/search/engine/region/result.py b/src/search/engine/region/result.py index f87cc322..667d6b36 100644 --- a/src/search/engine/region/result.py +++ b/src/search/engine/region/result.py @@ -155,6 +155,10 @@ class RegionResult: # direction the query does not tell (`place_roles.untold_direction_names`). Empty # otherwise. unresolved_entity_names: tuple[str, ...] = () + # The same `role_not_resolved` names grouped by the place each names, one tuple per + # untold place set in query order (`place_roles.untold_direction_groups`), so the + # refusal's sentence counts places. Empty for every other outcome. + untold_groups: tuple[tuple[str, ...], ...] = () # Query qualifiers without a final binding, including terms absent from the # index vocabulary. Populated only for `qualifier_not_bound`. Full records # live on `flagged_match.resolved.trace.query_qualifiers`. diff --git a/src/search/engine/region/state.py b/src/search/engine/region/state.py index 5ff1e695..2a36ba4d 100644 --- a/src/search/engine/region/state.py +++ b/src/search/engine/region/state.py @@ -1315,6 +1315,7 @@ def no_data( nearest_real: tuple[NearestRealSpec, ...] | None = None, llm_usage: dict | None = None, unresolved_entity_names: tuple[str, ...] = (), + untold_groups: tuple[tuple[str, ...], ...] = (), ambiguous_entities: tuple[AmbiguousEntity, ...] = (), resolved_entity_dcids: Mapping[str, str] | None = None, role_not_admitted_entities: tuple[RoleNotAdmittedEntity, ...] = (), @@ -1376,6 +1377,7 @@ def no_data( ), llm_usage=llm_usage, unresolved_entity_names=unresolved_entity_names, + untold_groups=untold_groups, ambiguous_entities=ambiguous_entities, resolved_entity_dcids=dict(resolved_entity_dcids or {}), role_not_admitted_entities=role_not_admitted_entities, diff --git a/src/search/render/no_data.py b/src/search/render/no_data.py index ff24cdcf..909f20c2 100644 --- a/src/search/render/no_data.py +++ b/src/search/render/no_data.py @@ -193,6 +193,7 @@ def no_data_phrase( *, variable_term: str | None = None, entity_terms: list[str] | None = None, + entity_groups: Sequence[Sequence[str]] | None = None, role_axis: EntityRoleKey | None = None, ambiguous_candidates: list[str] | None = None, elected_stat_vars: list[GraphRef] | None = None, @@ -212,6 +213,9 @@ def no_data_phrase( role_incompatible. Entity terms are quoted and joined for entity_not_resolved, entity_ambiguous, entity_role_not_admitted, and role_not_resolved, which asks who among them is giving and who receiving. + ``entity_groups`` holds role_not_resolved's terms grouped by the place each names, and + the phrase counts and names places from it, one place named by several texts as one + (`_untold_places`); without it each term is a place. ``ambiguous_candidates`` adds graph matches to entity_ambiguous. For entity_role_not_admitted, ``variable_term`` names the indicator whose data is unavailable for the entity. @@ -293,13 +297,16 @@ def no_data_phrase( ) return f"{joined} {verb} more than one entity in the graph. Be more specific." if reason == "role_not_resolved": - if plural: + places = _untold_places(entity_groups or [[t] for t in terms]) + place_list = _join_labels(places) + if len(places) != 1: return ( - f"Could not tell who is giving and who is receiving among {joined}. " + f"Could not tell who is giving and who is receiving among {place_list}. " f"{_NAME_A_DIRECTION}" ) return ( - f"Could not tell whether {joined} is giving or receiving. {_NAME_A_DIRECTION}" + f"Could not tell whether {place_list} is giving or receiving. " + f"{_NAME_A_DIRECTION}" ) if reason == "entity_role_not_admitted": if variable_term: @@ -309,6 +316,19 @@ def no_data_phrase( return _NO_DATA_PHRASES.get(reason, _GENERIC_NO_DATA) +def _untold_places(groups: Sequence[Sequence[str]]) -> list[str]: + """Each untold place quoted once, as a role_not_resolved phrase names it: its first + text, then any other texts it was named by in parentheses, `"the union" ("European + Union")`. A group left with no text after stripping names no place.""" + places = [] + for group in groups: + texts = [f'"{t.strip()}"' for t in group if t and t.strip()] + if texts: + others = f" ({', '.join(texts[1:])})" if len(texts) > 1 else "" + places.append(f"{texts[0]}{others}") + return places + + def _source_phrase( reason: NoDataReason, source_terms: list[str] | None, candidates: list[str] | None ) -> str: @@ -439,6 +459,7 @@ def no_data_message( *, variable_term: str | None = None, entity_terms: list[str] | None = None, + entity_groups: Sequence[Sequence[str]] | None = None, role_axis: EntityRoleKey | None = None, ambiguous_candidates: list[str] | None = None, elected_stat_vars: list[GraphRef] | None = None, @@ -457,6 +478,7 @@ def no_data_message( reason, variable_term=variable_term, entity_terms=entity_terms, + entity_groups=entity_groups, role_axis=role_axis, ambiguous_candidates=ambiguous_candidates, elected_stat_vars=elected_stat_vars, @@ -678,6 +700,7 @@ def no_data_sentence_plan( elected_stat_vars: list[GraphRef] | None = None, variable_term: str | None = None, entity_terms: list[str] | None = None, + entity_groups: Sequence[Sequence[str]] | None = None, role_axis: EntityRoleKey | None = None, bound_role_dcids: Mapping[EntityRoleKey, Sequence[str]] | None = None, ambiguous_candidates: list[str] | None = None, @@ -747,6 +770,7 @@ def no_data_sentence_plan( elected_stat_vars=elected_stat_vars, variable_term=variable_term, entity_terms=entity_terms, + entity_groups=entity_groups, role_axis=role_axis, bound_role_dcids=bound_role_dcids, ambiguous_candidates=ambiguous_candidates, @@ -773,6 +797,7 @@ def _no_data_verdict_plan( elected_stat_vars: list[GraphRef] | None, variable_term: str | None, entity_terms: list[str] | None, + entity_groups: Sequence[Sequence[str]] | None, role_axis: EntityRoleKey | None, bound_role_dcids: Mapping[EntityRoleKey, Sequence[str]] | None, ambiguous_candidates: list[str] | None, @@ -799,6 +824,7 @@ def _no_data_verdict_plan( reason, variable_term=variable_term, entity_terms=entity_terms, + entity_groups=entity_groups, role_axis=role_axis, elected_stat_vars=elected_stat_vars, ) @@ -815,6 +841,7 @@ def _no_data_verdict_plan( reason, variable_term=variable_term, entity_terms=entity_terms, + entity_groups=entity_groups, role_axis=role_axis, qualifier_terms=qualifier_terms, scope=scope, @@ -830,7 +857,11 @@ def _no_data_verdict_plan( ) elif flagged_match is not None: phrase = no_data_phrase( - reason, variable_term=variable_term, entity_terms=entity_terms, role_axis=role_axis + reason, + variable_term=variable_term, + entity_terms=entity_terms, + entity_groups=entity_groups, + role_axis=role_axis, ) plan = _checked_match_plan( phrase, @@ -841,7 +872,11 @@ def _no_data_verdict_plan( ) elif nearest_real: phrase = no_data_phrase( - reason, variable_term=variable_term, entity_terms=entity_terms, role_axis=role_axis + reason, + variable_term=variable_term, + entity_terms=entity_terms, + entity_groups=entity_groups, + role_axis=role_axis, ) # Siblings keep the how properties that set them apart, as their own plans do. lead = _near_match_lead_plan( @@ -865,6 +900,7 @@ def _no_data_verdict_plan( reason, variable_term=variable_term, entity_terms=entity_terms, + entity_groups=entity_groups, role_axis=role_axis, ambiguous_candidates=ambiguous_candidates, elected_stat_vars=elected_stat_vars, diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index 3267fd90..523ee3ba 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -8,8 +8,6 @@ from __future__ import annotations -import re - import pytest from fastapi.testclient import TestClient @@ -301,32 +299,45 @@ def test_a_role_option_states_a_direction_for_every_untold_name(): @pytest.mark.parametrize( - ("entities", "names"), + ("entities", "names", "untold"), [ pytest.param( [{"text": "KE", "dcid": "country/KEN"}, {"text": "Norway"}], ["KE", "Norway"], + 'Could not tell who is giving and who is receiving among "KE" and "Norway"', id="pinned-only-text", ), pytest.param( [{"text": "Kenya"}, {"text": "Norway"}, {"text": "KEN", "dcid": "country/KEN"}], ["Kenya", "Norway", "KEN"], + 'Could not tell who is giving and who is receiving among "Kenya" ("KEN") and "Norway"', id="two-texts-of-one-set-apart", ), + pytest.param( + [ + {"text": "KE", "dcid": "country/KEN"}, + {"text": "Kenya"}, + {"text": "Norway", "role_text": "from"}, + ], + ["KE", "Kenya"], + 'Could not tell whether "KE" ("Kenya") is giving or receiving', + id="one-set-named-twice", + ), ], ) -def test_a_role_refusal_names_every_untold_text_in_query_order(entities, names): - """The options' mentions, the refusal's entity terms and its message name the same - request texts in query order, a pin's key included in place of its node's label.""" +def test_a_role_refusal_names_every_untold_text_in_query_order(entities, names, untold): + """The options' mentions and the refusal's entity terms name the same request texts in + query order, a pin's key included in place of its node's label. The message counts + places, naming one place set's several texts as one place.""" client = _flow_client() no_data = _no_data(client, {"variable_text": "aid", "entities": entities}) assert no_data["reason"] == "role_not_resolved" - assert [o["mentions"] for o in no_data["options"]] == [names] * 2 + assert no_data["options"] + assert [o["mentions"] for o in no_data["options"]] == [names] * len(no_data["options"]) assert no_data["entity_terms"] == names - untold, _ = no_data["message"].split(". ", 1) - assert re.findall(r'"([^"]+)"', untold) == names + assert no_data["message"].split(". ", 1)[0] == untold def test_a_reading_its_own_wording_contradicts_is_not_offered(): From a4c72103013be1fbfa9b97b5f41357d50f51c1c4 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 00:23:53 +0200 Subject: [PATCH 10/14] fix(search): name each untold place once in role option labels, and document 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. --- docs/conformance-search-api-v1.md | 4 +- src/search/README.md | 4 +- src/search/engine/conjoin.py | 66 ++++++++++++++++++----------- src/search/render/no_data.py | 27 +++++++----- tests/engine/test_choice_options.py | 27 +++++++++--- 5 files changed, 84 insertions(+), 44 deletions(-) diff --git a/docs/conformance-search-api-v1.md b/docs/conformance-search-api-v1.md index b61d6d7c..d7768100 100644 --- a/docs/conformance-search-api-v1.md +++ b/docs/conformance-search-api-v1.md @@ -110,7 +110,7 @@ Gap rows state what is needed to close the clause and name the open issue that t | C50b | For entity_not_resolved, entity_ambiguous, and entity_role_not_admitted, no_data.entity_terms carries every entity wording responsible for that outcome in query order. It is an empty list for every other no_data reason, so clients need not parse rendered_sentence to identify the entity. | implemented | Additive response field; it does not alter any frozen enum or registry code. | | C50c | For entity_ambiguous only, no_data.ambiguous_entities carries one group per ambiguous wording (empty for every other reason), each with the graph candidates it could name (ref, label, kind), `ref` holding the candidate's dcid. Every group's text equals some restatement.entities[i].text on the same part. A subject-role entity_ambiguous part (e.g. "GDP of Congo", no role_text posted) carries a real restatement instead of null. The part also carries no_data.options, one per (ambiguous wording, candidate) in query order, each with the wording, the candidate's label, and a one-part parsed input built from the part's parsed inputs (variable text, entities with role_text, time, unit and sources, operations) pinning that candidate by text and every entity that resolved to one node; named groups, sets read as members and other ambiguous wordings stay unpinned. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid (C50d). Posting an option's data_query unchanged resolves that wording. options is built whether or not the part carries a restatement; an entity whose direction the question leaves unnamed carries no role_text. options is empty past the ParsedPart bounds. The part's sentence plan marks each ambiguous wording as a slot token carrying the role the request gave it, no entity_refs and no binding_ref, so the composed sentence stamps it with the part's index and a client hangs the candidate menu on it; the rendered text is unchanged. | implemented | Additive response field; it does not alter any frozen enum or registry code or `rendered_sentence`. Pinned by `tests/engine/test_render_e2e.py` (`test_no_data_sentence_names_ambiguous_entity_candidates`, `test_no_data_restatement_for_directional_ambiguous_entity`) and `tests/engine/test_operations_e2e.py` (option round trip, options without a restatement, slot token, operations through the resubmit door's bind). | | C50d | ParsedEntity.dcid pins a parsed-door entity to a specific graph node: the pin seeds entry/parsed.py's pre_resolved map directly, skipping this door's own name resolution for that entity, once the dcid confirms against the graph. An unconfirmable dcid (absent, or present but type-less) refuses. `members: true` on a pinned entity binds the countries the membership policy holds for the node when the node is a continent or subregion that the policy holds or omits (`MembershipPolicy.holds_region`), whatever its graph type, and has no effect on a pinned single place. A pinned dcid that is a resolver group's own node (`EntityResolver.group_node_dcids`, for example `EuropeanUnion`) is one place: without the flag the Spec binds that node, and with `members: true` the request refuses request_invalid at `{part}/entities/{j}`, since a group's members come from resolving its name, which a pin skips. | implemented | Reuses invalid_role_ref (422) for the unconfirmable case -- the same code region/promote_places.py's `_phase_where_places` and options/resolve.py's `_typed_anchors` already raise for an identical posted-ref-not-in-graph fact; no new registry code minted. Pinned by `tests/engine/test_parsed_pipeline.py` (`TestPinnedDcidC2`). The `members` rules are pinned by `tests/engine/test_place_set_invariants.py`. | -| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". They are offered while that gives at most six readings, none past it; none when the single untold place was read in each direction and no reading holds data; none on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code, a curated alias or its display name, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Additive response field replacing `NoData.data_query`; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | +| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". The refusal's entity_terms and every role option's mentions list each request text of every untold place set, in query order and in the request's own words, a pinned key included; the message and the option labels name each untold place once, by its first text with its further texts in parentheses (`Kenya ("KEN") to Norway`). They are offered while that gives at most six readings, none past it; none when the single untold place was read in each direction and no reading holds data; none on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code, a curated alias or its display name, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Additive response field replacing `NoData.data_query`; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | | C51 | Within a part, when several reasons apply, the first in resolution order wins (entity, variable, role, unit, slice, denominator, observations, facet) | partial | The resolution order is documented (`NO_DATA_RESOLUTION_ORDER`), and every site that picks one reason from several elects through `elect_no_data_reason`: the entity check (`phase_intake._phase_check_entities`), the refusal of a part with no admissible interpretation (`InterpretationSet.refusal_reason`), and the materialise and candidate refusals (`codes._elect_no_data_reason`). The entity check refuses with the reason elected over the ones its unresolved names mint and names every such name: an ambiguous name with its candidates, an oversized group as too large, and the rest as unresolved, so a part naming an oversized group and an ambiguous name refuses entity_ambiguous. Pinned by `tests/models/test_no_data_category.py`, which also finds every reason the code mints and checks each has a rank, and `tests/engine/test_group_resolution.py` (`test_mixed_entity_refusals_elect_one_reason_and_name_every_entity`). No structural enforcement catches a phase check reordered against the order. Live verification of the role/unit/facet positions is blocked until role-declaring StatVars are loaded on the live graph. Issues: #11/#12 (live-graph half); none (enforcement) | | C51a | Parts sharing one five-tuple shape collapse onto one spec: every member answers with the same spec_id, and every member but the first carries a part-scoped binding_folded warning naming the fold. A parsed part posted with `separate: true`, or a raw_text variable its query compares with another (C42d), is compared with the others and does not fold. It answers its own spec in query order with no binding_folded, its response part and restatement state the mark, and the other parts of its shape fold among themselves (`same_shape.fold_key`, which `collapse_same_shape` and the combiner both group by). A fold whose members bind one slot to different values merges them into a set, then drops each value that descends from another merged value, since the fold reads its parts as a union (HIV/AIDS 13040 folded with reproductive health 130 binds 130); one value left binds a value. The merged trace keeps each member's qualifier record of a text that differs in offsets, operation or selected codes. The merged spec states a set binding's values in the order its identity holds the refs, and counts an open role over the union of the members' own populations, so it reads as the resubmit of its spec_id does | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_two_variables_collapse_onto_one_merged_spec_through_fanout`), `tests/engine/test_conjoin_five_tuple_and_slots.py` (`test_collapse_same_shape_states_its_set_in_identity_order`, `test_collapse_same_shape_drops_a_merged_value_nested_under_another`, `test_collapse_same_shape_keeps_every_union_record_of_one_text`), `tests/engine/test_conjunction.py` (`test_a_separate_part_never_folds_and_the_others_fold_as_before`), `tests/engine/test_parsed_pipeline.py` (`test_a_part_marked_separate_never_folds_into_a_same_shape_part`) and `tests/engine/test_same_shape.py` (`test_a_merged_answer_unions_each_members_own_known_origin_population`). | | C51b | A candidates part carries observations_omitted when the request set include_observations, since a candidates part has not committed to one spec to fetch observations against; max_candidates applies per part; the warning ships on the buffered and SSE transports alike, since it states the request and not a transport choice | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_candidates_part_carries_observations_omitted_alongside_definite_with_observations`). | @@ -251,7 +251,7 @@ Gap rows state what is needed to close the clause and name the open issue that t | C150 | `observations_series_truncated` carries `params: {shown, total, order}`: the series the preview ships and the confirmed series behind them, both integers, `shown` < `total`, and `order`, `"ranked"` when the preview read every series and ships the most recent and largest (latest year, then latest value, then identity) or `"fixed"` when the observations the answer keeps in each member's years exceed `role_bulk_rank_max_rows` or the read converts into each entity's own currency and the preview ships a selection made before any value was read, StatVars newest year first and taken round-robin. The message states the same order. A fixed selection takes only series with data in their member's years, StatVars ordered by their newest year with data there. The observations themselves carry the same `order` whenever they show fewer series than the answer holds, and omit it otherwise. The Datasets observations door selects a snapshot share's series by the same rule over its frame's rows and states its own `order` on the observations it serves | implemented | Pinned by `tests/engine/test_bulk_role_series_observations.py`, `tests/engine/test_population_headline.py` and, for the door against the live read on each window, `tests/datasets_api/test_observations_door.py` (`test_the_door_and_the_live_role_bulk_preview_agree_on_each_series_axes`). | | C151 | A part carries at most one `elected_shape_no_data` per election, however many empty readings it dropped, with `params: {no_data, elected}`: the shape labels of the readings with no data and of the readings answered instead, each listed once in first-seen order. A single-sibling re-election names the elected shape and its sibling; a candidates answer of several siblings names the elected shape and each candidate's shape label. A same-shape merge folds its parts' warnings that share a message into one, whose `no_data` and `elected` are the first-seen unions over the parts | implemented | Pinned by `tests/engine/test_zero_observation_election.py`, `tests/engine/test_candidates_measure_sibling_collapse.py`, and `tests/engine/test_conjoin_warnings_and_primary.py`. | | C191 | A price-basis requirement the query names on `measurementQualifier` ("constant", "real", "at constant prices") is met by an answer served under a unit request whose price basis reads as one of the requirement's codes (`requirements._serves_price_basis`): constant prices meet `RealValue` and `InflationAdjusted`, current prices meet `Nominal`, and no unit request meets `PurchasingPowerParity`. A definite answer, or a candidate set every member of which is served so, carries no `query_qualifier_dropped` warning, `measure_not_applied` departure or sentence fact for it. The requirement still elects a publisher's series at that basis where one exists. An answer served under no unit request, or at the other basis, leaves it unmet, so "constant prices" over a series holding only current prices still warns | implemented | Pinned by `tests/engine/test_pinned_code_refusal.py`. | -| C192 | On the raw_text door a phrase extraction drops that asks for every member of a flow role ("all donors", "every provider", "to all creditors", "from all countries", the preposition naming the side; `role_text.every_member_axis`) opens no axis and is met by a role-bearing answer that binds no place on that axis (`region.result.every_member_phrases`). An "all" phrase is met by one series per member or by the total over them; an "each" or "every" phrase asks for the breakdown (`role_text.every_member_breakdown`), so only an answer declaring a role on the axis meets it. A candidate set meets a phrase only when every candidate does. A met phrase raises no `query_qualifier_dropped` warning and no `measure_not_applied` departure. A subject-only answer, an axis a named place binds, and words outside the role lexicon ("in all sectors") still warn. The response cache keys the two directions apart, so "from all countries" never serves "to all countries" | implemented | Pinned by `tests/engine/test_role_scope_pipeline_e2e.py`, `tests/engine/test_role_text.py` and `tests/engine/test_response_cache_keys.py`. | +| C192 | On the raw_text door a phrase extraction drops that asks for every member of a flow role ("all donors", "every provider", "to all creditors", "from all countries", the preposition naming the side; `role_text.every_member_axis`) opens no axis and is met by a role-bearing answer that binds no place on that axis (`region.result.every_member_phrases`). An "all" phrase is met by one series per member or by the total over them; an "each" or "every" phrase asks for the breakdown (`role_text.every_member_breakdown`), so only an answer declaring a role on the axis meets it. A candidate set meets a phrase only when every candidate does. A met phrase raises no `query_qualifier_dropped` warning and no `measure_not_applied` departure. A subject-only answer, an axis a named place binds, and words outside the role lexicon ("in all sectors") leave the phrase unmet. The warning is request-wide and stays while no part meets the phrase; each part whose own answer leaves it unmet departs `measure_not_applied`, so in "aid to Kenya and GDP of Kenya from all donors" no warning appears and the GDP part departs. The response cache keys the two directions apart, so "from all countries" never serves "to all countries" | implemented | Pinned by `tests/engine/test_role_scope_pipeline_e2e.py`, `tests/engine/test_role_text.py` and `tests/engine/test_response_cache_keys.py`. | ## Refusals / errors diff --git a/src/search/README.md b/src/search/README.md index edc5c793..ea59dec6 100644 --- a/src/search/README.md +++ b/src/search/README.md @@ -95,7 +95,7 @@ Every part also carries `restatement`: a `ParsedPart` reproducing that part's ow A part's `operations` are the things the question asks done with its data that search does not do, such as a ranking or an explanation. On the raw-text door extract assigns each operation to one variable, so only that variable's part carries it. Its phrase goes to that variable's bind in the same block as the dropped qualifiers, so "top 5 donors for education aid" lets bind leave the donor axis open and the part covers every donor. An operation raises no `query_qualifier_dropped` warning and adds no note to the sentence. The parsed and `dataset_resubmit` doors echo the operations each posted part carries. A request part carries at most 8, the most extract keeps for a whole query. -A part asks for every member of a flow role in one of two ways: `open_roles` on a `ParsedPart` lists at most two `role_text` words (`creditor`, `donor`, `recipient`, `debtor`, `origin`, `destination`), and an operation whose `over` is `origin` or `destination` opens that axis on every door. A role named in neither the part's entities nor its open roles is the total over its members, so a question that names no recipient or creditor reads the series published without one. An open role joins the election only where a member the bound roles alone elect, or a member at the same constraint values such as the by-creditor twin of a total, declares it: search elects those members, the answer holds one series per member, and the identity states the axis in `open_roles`. Otherwise the question is read as if the role were unnamed, and the identity omits an open role its elected members declare no role on; on a role-bearing answer the part warns `query_qualifier_dropped` that the breakdown was not applied. A word that names no role, or a role an entity of the same part binds through `role_text`, refuses `request_invalid` at `/input/parts/{i}/open_roles/{j}`. An entity the part binds on the axis without `role_text` wins over the open role: the open role drops and the part warns `query_qualifier_dropped`. On the raw-text door, a phrase extraction drops that asks for every member of a role ("all donors", "every provider", "to all creditors", or "from all countries", whose preposition names the side) opens no axis, and a role-bearing answer that binds no place on that axis meets it: one series per member, or the total over them. An "each" or "every" phrase asks for the breakdown, so only an answer that declares a role on that axis meets it (`role_text.every_member_breakdown`), and a candidate set meets a phrase only when every candidate does. Such a phrase raises no `query_qualifier_dropped` warning there; on any other answer it warns as any dropped phrase does (`role_text.every_member_axis`, `region.result.every_member_phrases`). +A part asks for every member of a flow role in one of two ways: `open_roles` on a `ParsedPart` lists at most two `role_text` words (`creditor`, `donor`, `recipient`, `debtor`, `origin`, `destination`), and an operation whose `over` is `origin` or `destination` opens that axis on every door. A role named in neither the part's entities nor its open roles is the total over its members, so a question that names no recipient or creditor reads the series published without one. An open role joins the election only where a member the bound roles alone elect, or a member at the same constraint values such as the by-creditor twin of a total, declares it: search elects those members, the answer holds one series per member, and the identity states the axis in `open_roles`. Otherwise the question is read as if the role were unnamed, and the identity omits an open role its elected members declare no role on; on a role-bearing answer the part warns `query_qualifier_dropped` that the breakdown was not applied. A word that names no role, or a role an entity of the same part binds through `role_text`, refuses `request_invalid` at `/input/parts/{i}/open_roles/{j}`. An entity the part binds on the axis without `role_text` wins over the open role: the open role drops and the part warns `query_qualifier_dropped`. On the raw-text door, a phrase extraction drops that asks for every member of a role ("all donors", "every provider", "to all creditors", or "from all countries", whose preposition names the side) opens no axis, and a role-bearing answer that binds no place on that axis meets it: one series per member, or the total over them. An "each" or "every" phrase asks for the breakdown, so only an answer that declares a role on that axis meets it (`role_text.every_member_breakdown`), and a candidate set meets a phrase only when every candidate does. A met phrase raises no `query_qualifier_dropped` warning and no departure. The warning is request-wide, so it stays while no part of the request meets the phrase, and each part whose own answer leaves the phrase unmet carries a `measure_not_applied` departure for it: in "aid to Kenya and GDP of Kenya from all donors" the aid part meets "from all donors", the response carries no warning, and the GDP part departs (`role_text.every_member_axis`, `region.result.every_member_phrases`, `assemble.unapplied_qualifier_warnings`). Two tables that publish one structural question are chosen between before bind. Each table reads the query in its own codes, and it reads a phrase when its rows hold a code the phrase names, a phrase naming several codes included (`retrieval/bound_values.query_phrases_read`). Of two families of one five-tuple and role class, the one reading fewer of the query's phrases leaves, whether or not the code index carries them (`elect_five_tuple_family`). "Imputed multilateral ODA to basic education" reads the sector only on ONE's imputed sectors table, so DAC2a's imputed total leaves. A table can also state a word of every row it serves without coding it (`DatasetDeclaration.stated_words`): every CRS row is bilateral, since the import leaves out core contributions to multilateral organisations, and a DAC2a or DAC2b amount is bilateral where its recipient is a country. Such a word restricts nothing on those rows, so a part reads it as it reads a word of the table's label (`region/phase_rank_bind._dataset_stated_words`), and "bilateral ODA to Kenya" answers as "ODA to Kenya" does. The part states the word where it modifies the measure ("net bilateral ODA"): a negated or excluded occurrence ("non-bilateral", "excluding bilateral flows") and one past a function word, a connective or a separator ("ODA net of bilateral flows") are not read (`bind_repair.word_stated`). In the election a family reads a word it states beside a rival the code index carries that reads the word in no phrase, so "bilateral ODA from Germany by recipient" leaves DAC2a, whose open recipient axis holds multilateral organisations, for the CRS. Beside a rival that codes the word a stated word adds nothing, so "bilateral ODA from France" stays DAC1's bilateral aid type, and it adds nothing beside a family the index does not carry, so a debt series by bilateral creditors compares with the CRS as it does without the word. The graph's text search matches series names, which hold no stated word, so recall searches a text holding one a second time without it and adds the series of the tables that state it (`interpret._detect`). A table can also declare that a word names one of its parties (`DatasetDeclaration.party_words`): "multilateral" names `DACMultilaterals`, the DAC's published total of the multilateral organisations, as the provider of a DAC2a or DAC2b amount to a country. A part states such a word as it states a stated word, on a flow axis the request leaves unnamed: no place is directed to it, no operation runs across it and the part does not ask every member of it, while a name is directed to each axis the declaration reads a kind on (`region/place_roles.stated_party_words`). Such a part is read with the word first (`region/pipeline.resolve_variable`). Recall also searches the text without the word for the series of the tables that declare it, intake reads the party's node as a place set (`region/phase_intake._party_word_sets`), and a shape whose every dataset declares the word binds the party on that axis beside the places the query names, where the shape reads each word next to the word (`held_party_words`, `_beside_words_read`, bound in `bind_place_roles`). The party's set passes role admission as a named set does. The reading stands where a shape binding the party reads every phrase any other pooled shape reads, the word counted as read; the binding shapes are then the pool (`region/phase_rank_bind.with_party_reading`). "Multilateral ODA to Kenya" answers DAC2a's flows from the multilaterals total, as "ODA from multilaterals to Kenya" does. The reading is withdrawn where no shape binds the party ("multilateral development bank ODA to Kenya"), where another shape reads a phrase the binding shapes do not (the CRS sector of "multilateral aid for education to Kenya"), where another shape reads the word as a value of its own, in a phrase of its codes or by a graph label ("multilateral loans to Kenya", a World Bank IDS creditor type), and where the answer it reaches does not hold the party in every Spec. A withdrawn reading leaves only its model usage and failed reads on the reply: the part is resolved again without party words, exactly as a request stating none, under the same deadline, and the first reading's events are never sent. A stated party word that binds no party is therefore dropped or refused as it is without the declaration. A request that names the provider itself, by a place or an alias of the party, states no party word. A declared party the graph gives no type has no set, and the read is logged and noted as degraded. A negated or excluded word names no party, and the response cache keys on it (`bind_repair.stated_words_negated`). Of families that read the same phrases, one holding no series at the places the part names, on the roles they bind, leaves when another holds one, as the request's coverage snapshot records them (`region/phase_rank_bind._series_reader`). "Other official flows from Germany to Brazil" reads alike on the CRS and DAC2b, and DAC2b, whose bilateral donors report only against aggregate recipients, leaves for the CRS, while "other official flows to Brazil" keeps both. The rule stands down when the part names no place, when coverage holds none of a family's members, and when no family holds a series there, where the part refuses for want of data. Between a family the index carries and one it does not that read alike, the stronger recall score stays. Codes of different tables that name one concept are declared as a group in `code_index/declarations.py` (`CONCEPT_GROUPS`), and the export gives each code the union of the group's texts, so "food aid" reads on DAC2a's developmental food aid as on CRS purpose 520, and "ODA" on DAC2a 206 as on the CRS flow type and DAC1 1010. DAC2a's imputed multilateral code also reads "imputed", so "imputed aid from Norway" keeps DAC2a's imputed line. Then a role-bearing shape that publishes on a flow axis the query neither names nor opens leaves for one that declares fewer such axes and reads every phrase it reads, across five-tuples (`region/place_roles.without_unasked_axes`). "Aid from Japan" reads DAC1's donor totals over DAC2a's and CRS's flows by recipient, and "imputed multilateral ODA from Germany" keeps DAC2a's imputed table, since DAC1 does not read "imputed". Shapes of another measure compare because a question naming no measure can recall them. "ODA from Japan" leaves CRS commitments for DAC1's donor disbursements, and "ODA commitments from Japan" keeps commitments over those disbursements, since a measure word reads only on the shape whose five-tuple or constraint value it names. A donor-only "grant equivalent ODA from France" elects DAC1's grant-equivalent total over the CRS grant equivalents: recall adds a row of that DAC1 family beside the CRS hits (`clients/graph.with_rival_families`, applied by `recall_hits`), it reads the measure word as the CRS does, and it declares no recipient axis. A row recall adds this way displaces only shapes of its own five-tuple, since the query did not recall it, and derivation confirms it beside every recalled hit, so it takes no candidate's place. "Aid attributed to donors via multilaterals" keeps the imputed tables, whose measure reads "attributed to donors", while "aid from Japan", which names no measure, reads DAC1's donor totals over them. Both shapes must be ones the code index carries, since another shape's axes are those of its recalled sample and it reads no index code, and the rule stands down when a named place has no direction. @@ -103,7 +103,7 @@ A parsed entity asks for the members of the set its text names with `members: tr Every `no_data` part carries `no_data.message`, the plain-language sentence for its `reason` with the indicator, place or category wording the reason names, so a client can show a person the reason beside its code. -A part refused as a choice (`entity_ambiguous`, `source_ambiguous`, `source_code_ambiguous`, `role_not_resolved`, `unit_not_resolved`) carries `no_data.options`, one ready request per choice, each a one-part `ParsedInput` with a label and the request words it settles, built from the part's parsed inputs whether or not it carries a restatement (`conjoin._options`). An entity option pins the candidate on every row with the ambiguous wording and every entity that resolved to one node, leaving named groups, sets read as their members and other ambiguous wordings unpinned, so a second ambiguous wording refuses again with the first pick kept. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid, since a pin cannot ask for a group's members. A source option sets the candidate's ref on the ambiguous mention; a source-code option names the holding provenance as the part's only included source, since included sources union. A role option states a direction for every name the question left unstated (`place_roles.direction_readings`): each untold set takes an axis the shape declares a role on and the part does not ask open, and every such axis must hold a place. It is offered while that gives at most six readings, and none is offered when the single untold place was read in each direction and no reading holds data. A unit option names one of `no_data.unit_alternatives` in place of the ambiguous currency words, so "constant 2015 francs" offers "constant 2015 XOF" (`unit.bind.unit_text_naming`). An option whose part breaks the `ParsedPart` bounds, or whose stated directions its own wording contradicts, is left out. Data for an option is not checked. The client posts the chosen option's `data_query` unchanged as the request's `input`, and its one part is a `dataset_resubmit` `parsed` entry's `part`. The part's sentence plan marks each ambiguous wording as a `slot` token carrying the role the query gave it, so the client hangs the candidate menu on that phrase. +A part refused as a choice (`entity_ambiguous`, `source_ambiguous`, `source_code_ambiguous`, `role_not_resolved`, `unit_not_resolved`) carries `no_data.options`, one ready request per choice, each a one-part `ParsedInput` with a label and the request words it settles, built from the part's parsed inputs whether or not it carries a restatement (`conjoin._options`). An entity option pins the candidate on every row with the ambiguous wording and every entity that resolved to one node, leaving named groups, sets read as their members and other ambiguous wordings unpinned, so a second ambiguous wording refuses again with the first pick kept. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid, since a pin cannot ask for a group's members. A source option sets the candidate's ref on the ambiguous mention; a source-code option names the holding provenance as the part's only included source, since included sources union. A role option states a direction for every name the question left unstated (`place_roles.direction_readings`): each untold set takes an axis the shape declares a role on and the part does not ask open, and every such axis must hold a place. It is offered while that gives at most six readings, and none is offered when the single untold place was read in each direction and no reading holds data. The refusal's `entity_terms` and every role option's `mentions` list each request text of every untold place set, in query order and in the request's own words, a pinned key included. The message and the option labels name each untold place once, by its first text with its further texts in parentheses: `Could not tell who is giving and who is receiving among "Kenya" ("KEN") and "Norway"`, with the options `Kenya ("KEN") to Norway` and `Norway to Kenya ("KEN")` (`render.no_data.untold_place_name`). A unit option names one of `no_data.unit_alternatives` in place of the ambiguous currency words, so "constant 2015 francs" offers "constant 2015 XOF" (`unit.bind.unit_text_naming`). An option whose part breaks the `ParsedPart` bounds, or whose stated directions its own wording contradicts, is left out. Data for an option is not checked. The client posts the chosen option's `data_query` unchanged as the request's `input`, and its one part is a `dataset_resubmit` `parsed` entry's `part`. The part's sentence plan marks each ambiguous wording as a `slot` token carrying the role the query gave it, so the client hangs the candidate menu on that phrase. Timing splits the same way. Each part's own `timing_ms.by_step` names the phases that variable's own leg ran (`recall`, `shape`, `bind`, `materialise`, `answer`), and `total` sums them. `shape_arcs`, `shape_probe`, `shape_probe_coverage` (inside `shape_probe`), `shape_labels`, `shape_groups` and `shape_admission` time stages inside `shape`, so `total` leaves them out (`conjoin.NESTED_SHAPE_STEPS`). `diagnostics.timing_ms` carries the whole request's wall-clock `total` plus `by_step` for the steps every part shares: `coverage` for the request's coverage snapshot and `extract` for extraction. diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index 5fa50ea3..b6e9c851 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -9,7 +9,7 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING from pydantic import ValidationError @@ -51,7 +51,7 @@ Warning, ) from search.render.labels import _join_labels -from search.render.no_data import ScopeSources +from search.render.no_data import ScopeSources, untold_place_name from skills_index import SkillsIndex if TYPE_CHECKING: @@ -180,17 +180,23 @@ def _no_data_entity_terms(region: RegionResult) -> tuple[list[str] | None, list[ if region.no_data_reason == "entity_not_resolved": return list(region.unresolved_entity_names) or None, None if region.no_data_reason == "role_not_resolved": - words = region.entity_words or {} - terms = dict.fromkeys(words.get(name, name) for name in region.unresolved_entity_names) - return list(terms) or None, None + return _untold_terms(region) or None, None return None, None +def _untold_terms(region: RegionResult) -> list[str]: + """Every request text of each place set a ``role_not_resolved`` refusal could not tell + the direction of (``RegionResult.unresolved_entity_names``), in query order and in the + request's own words (``RegionResult.entity_words``), each once: the refusal's entity + terms and every role option's mentions.""" + words = region.entity_words or {} + return list(dict.fromkeys(words.get(name, name) for name in region.unresolved_entity_names)) + + def _untold_place_terms(region: RegionResult) -> list[list[str]] | None: """The terms of a ``role_not_resolved`` refusal grouped by the place each names - (``RegionResult.untold_groups``), in the request's own words as - `_no_data_entity_terms` gives them, so the sentence counts places. ``None`` for every - other reason.""" + (``RegionResult.untold_groups``), in the request's own words as `_untold_terms` gives + them, so the sentence counts places. ``None`` for every other reason.""" if region.no_data_reason != "role_not_resolved": return None words = region.entity_words or {} @@ -363,16 +369,10 @@ def _source_code_options(region: RegionResult, part: ParsedPart) -> list[NoDataO def _role_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: """One option per reading of the directions the question left untold (`RegionResult.role_assignments`): every row of a mapped text states its axis, and a row - the reading repeats is dropped. Its mentions are the untold names - (`unresolved_entity_names`) the readings map, which every reading maps alike, in the - request's words and in query order. The label reads the option's own directions.""" - words = region.entity_words or {} - mapped = region.role_assignments[0] if region.role_assignments else {} - mentions = list( - dict.fromkeys( - words.get(name, name) for name in region.unresolved_entity_names if name in mapped - ) - ) + the reading repeats is dropped. Its mentions are the refusal's own entity terms + (`_untold_terms`), the same for every reading. The label reads the option's own + directions (`_direction_label`).""" + mentions = _untold_terms(region) options: list[NoDataOption | None] = [] for reading in region.role_assignments: rows = [ @@ -382,25 +382,41 @@ def _role_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | for entity in part.entities or () ] entities = [row for i, row in enumerate(rows) if row not in rows[:i]] - label = _direction_label(entities) + label = _direction_label(entities, region) options.append(_option(mentions, label, part.model_copy(update={"entities": entities}))) return options -def _direction_label(entities: Sequence[ParsedEntity]) -> str: +def _direction_label(entities: Sequence[ParsedEntity], region: RegionResult) -> str: """The directions `entities` state, in words: origins "to" destinations, or "From" or - "To" with the names when one axis holds them.""" - by_axis: dict[RoleAxis, list[str]] = {"origin": [], "destination": []} + "To" with the names when one axis holds them. Each untold place set + (``RegionResult.untold_groups``) is named once, as the refusal's message names it + (`untold_place_name`): `Kenya ("KEN") to Norway`.""" + words = region.entity_words or {} + group_of = {name: group for group in region.untold_groups for name in group} + by_axis: dict[RoleAxis, list[tuple[str, ...]]] = {"origin": [], "destination": []} for entity in entities: axis = role_axis_for_text(entity.role_text) - if axis is not None and entity.text not in by_axis[axis]: - by_axis[axis].append(entity.text) - origins, destinations = (_join_labels(by_axis[axis]) for axis in ("origin", "destination")) + group = group_of.get(entity.text, (entity.text,)) + if axis is not None and group not in by_axis[axis]: + by_axis[axis].append(group) + origins, destinations = ( + _axis_label(by_axis[axis], words) for axis in ("origin", "destination") + ) if origins and destinations: return f"{origins} to {destinations}" return f"From {origins}" if origins else f"To {destinations}" +def _axis_label(groups: Sequence[Sequence[str]], words: Mapping[str, str]) -> str: + """The places `groups` holds, one per group of request texts, in the request's words.""" + names = ( + untold_place_name([words.get(text, text) for text in group], quote_first=False) + for group in groups + ) + return _join_labels([name for name in names if name]) + + def _unit_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: """One option per currency a `unit_not_resolved` refusal offers, its unit text naming that currency in place of the ambiguous words (`unit.bind.unit_text_naming`). A role diff --git a/src/search/render/no_data.py b/src/search/render/no_data.py index 909f20c2..6f09ee0e 100644 --- a/src/search/render/no_data.py +++ b/src/search/render/no_data.py @@ -317,16 +317,23 @@ def no_data_phrase( def _untold_places(groups: Sequence[Sequence[str]]) -> list[str]: - """Each untold place quoted once, as a role_not_resolved phrase names it: its first - text, then any other texts it was named by in parentheses, `"the union" ("European - Union")`. A group left with no text after stripping names no place.""" - places = [] - for group in groups: - texts = [f'"{t.strip()}"' for t in group if t and t.strip()] - if texts: - others = f" ({', '.join(texts[1:])})" if len(texts) > 1 else "" - places.append(f"{texts[0]}{others}") - return places + """Each untold place as a role_not_resolved phrase names it, its first text quoted + (`untold_place_name`). A group left with no text after stripping names no place.""" + return [name for group in groups if (name := untold_place_name(group, quote_first=True))] + + +def untold_place_name(texts: Sequence[str], *, quote_first: bool) -> str | None: + """One place a `role_not_resolved` refusal names, from the request texts that name it in + query order: its first text, then its further texts, each once, quoted in parentheses. + The message quotes the first text as well, `"the union" ("European Union")`, and an + option label leaves it bare, `the union ("European Union")`. ``None`` when no text is + left after stripping.""" + stripped = list(dict.fromkeys(t.strip() for t in texts if t and t.strip())) + if not stripped: + return None + first = f'"{stripped[0]}"' if quote_first else stripped[0] + others = ", ".join(f'"{t}"' for t in stripped[1:]) + return f"{first} ({others})" if others else first def _source_phrase( diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index 523ee3ba..d5cdd4af 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -33,6 +33,7 @@ "Kenya": "country/KEN", "Uganda": "country/UGA", "Rwanda": "country/RWA", + "European Union": "EuropeanUnion", } @@ -71,7 +72,8 @@ def _fertility_client(llm: FakeLLM | _ShapeZeroLLM | None = None) -> TestClient: def _flow_client() -> TestClient: - """A flow StatVar declaring a creditor and a debtor role, over four countries.""" + """A flow StatVar declaring a creditor and a debtor role, over four countries and the + European Union.""" graph = FakeGraph( nodes={ **{dcid: {"label": name, "type": "Country"} for name, dcid in _PLACES.items()}, @@ -299,18 +301,20 @@ def test_a_role_option_states_a_direction_for_every_untold_name(): @pytest.mark.parametrize( - ("entities", "names", "untold"), + ("entities", "names", "untold", "labels"), [ pytest.param( [{"text": "KE", "dcid": "country/KEN"}, {"text": "Norway"}], ["KE", "Norway"], 'Could not tell who is giving and who is receiving among "KE" and "Norway"', + ["KE to Norway", "Norway to KE"], id="pinned-only-text", ), pytest.param( [{"text": "Kenya"}, {"text": "Norway"}, {"text": "KEN", "dcid": "country/KEN"}], ["Kenya", "Norway", "KEN"], 'Could not tell who is giving and who is receiving among "Kenya" ("KEN") and "Norway"', + ['Kenya ("KEN") to Norway', 'Norway to Kenya ("KEN")'], id="two-texts-of-one-set-apart", ), pytest.param( @@ -321,14 +325,26 @@ def test_a_role_option_states_a_direction_for_every_untold_name(): ], ["KE", "Kenya"], 'Could not tell whether "KE" ("Kenya") is giving or receiving', + ['Norway to KE ("Kenya")'], id="one-set-named-twice", ), + pytest.param( + [ + {"text": "the union", "dcid": "EuropeanUnion"}, + {"text": "European Union"}, + {"text": "Kenya", "role_text": "to"}, + ], + ["the union", "European Union"], + 'Could not tell whether "the union" ("European Union") is giving or receiving', + ['the union ("European Union") to Kenya'], + id="pinned-group-named-twice", + ), ], ) -def test_a_role_refusal_names_every_untold_text_in_query_order(entities, names, untold): +def test_a_role_refusal_names_every_untold_text_in_query_order(entities, names, untold, labels): """The options' mentions and the refusal's entity terms name the same request texts in - query order, a pin's key included in place of its node's label. The message counts - places, naming one place set's several texts as one place.""" + query order, a pin's key included in place of its node's label. The message and the + option labels name each untold place once, its further texts in parentheses.""" client = _flow_client() no_data = _no_data(client, {"variable_text": "aid", "entities": entities}) @@ -338,6 +354,7 @@ def test_a_role_refusal_names_every_untold_text_in_query_order(entities, names, assert [o["mentions"] for o in no_data["options"]] == [names] * len(no_data["options"]) assert no_data["entity_terms"] == names assert no_data["message"].split(". ", 1)[0] == untold + assert [o["label"] for o in no_data["options"]] == labels def test_a_reading_its_own_wording_contradicts_is_not_offered(): From a7e99ff8b2fa1ca1dac34d9d96945b70cfc423ae Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 01:35:13 +0200 Subject: [PATCH 11/14] docs(search): word the choice-option and qualifier comments as plain 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. --- docs/conformance-search-api-v1.md | 2 +- src/search/engine/assemble.py | 2 +- src/search/engine/conjoin.py | 6 +++--- src/search/engine/region/phase_shared.py | 5 +++-- src/search/engine/region/place_roles.py | 4 ++-- src/search/engine/region/requirements.py | 2 +- src/search/engine/region/result.py | 2 +- src/search/engine/response_cache_flow.py | 5 +++-- src/search/models/outcomes.py | 4 ++-- tests/engine/test_choice_options.py | 2 +- tests/engine/test_conjoin_assemble_and_enrichment.py | 4 ++-- tests/engine/test_operations_e2e.py | 2 +- tests/engine/test_pinned_code_refusal.py | 2 +- tests/engine/test_source_code.py | 2 +- 14 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/conformance-search-api-v1.md b/docs/conformance-search-api-v1.md index d7768100..86bab6fb 100644 --- a/docs/conformance-search-api-v1.md +++ b/docs/conformance-search-api-v1.md @@ -110,7 +110,7 @@ Gap rows state what is needed to close the clause and name the open issue that t | C50b | For entity_not_resolved, entity_ambiguous, and entity_role_not_admitted, no_data.entity_terms carries every entity wording responsible for that outcome in query order. It is an empty list for every other no_data reason, so clients need not parse rendered_sentence to identify the entity. | implemented | Additive response field; it does not alter any frozen enum or registry code. | | C50c | For entity_ambiguous only, no_data.ambiguous_entities carries one group per ambiguous wording (empty for every other reason), each with the graph candidates it could name (ref, label, kind), `ref` holding the candidate's dcid. Every group's text equals some restatement.entities[i].text on the same part. A subject-role entity_ambiguous part (e.g. "GDP of Congo", no role_text posted) carries a real restatement instead of null. The part also carries no_data.options, one per (ambiguous wording, candidate) in query order, each with the wording, the candidate's label, and a one-part parsed input built from the part's parsed inputs (variable text, entities with role_text, time, unit and sources, operations) pinning that candidate by text and every entity that resolved to one node; named groups, sets read as members and other ambiguous wordings stay unpinned. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid (C50d). Posting an option's data_query unchanged resolves that wording. options is built whether or not the part carries a restatement; an entity whose direction the question leaves unnamed carries no role_text. options is empty past the ParsedPart bounds. The part's sentence plan marks each ambiguous wording as a slot token carrying the role the request gave it, no entity_refs and no binding_ref, so the composed sentence stamps it with the part's index and a client hangs the candidate menu on it; the rendered text is unchanged. | implemented | Additive response field; it does not alter any frozen enum or registry code or `rendered_sentence`. Pinned by `tests/engine/test_render_e2e.py` (`test_no_data_sentence_names_ambiguous_entity_candidates`, `test_no_data_restatement_for_directional_ambiguous_entity`) and `tests/engine/test_operations_e2e.py` (option round trip, options without a restatement, slot token, operations through the resubmit door's bind). | | C50d | ParsedEntity.dcid pins a parsed-door entity to a specific graph node: the pin seeds entry/parsed.py's pre_resolved map directly, skipping this door's own name resolution for that entity, once the dcid confirms against the graph. An unconfirmable dcid (absent, or present but type-less) refuses. `members: true` on a pinned entity binds the countries the membership policy holds for the node when the node is a continent or subregion that the policy holds or omits (`MembershipPolicy.holds_region`), whatever its graph type, and has no effect on a pinned single place. A pinned dcid that is a resolver group's own node (`EntityResolver.group_node_dcids`, for example `EuropeanUnion`) is one place: without the flag the Spec binds that node, and with `members: true` the request refuses request_invalid at `{part}/entities/{j}`, since a group's members come from resolving its name, which a pin skips. | implemented | Reuses invalid_role_ref (422) for the unconfirmable case -- the same code region/promote_places.py's `_phase_where_places` and options/resolve.py's `_typed_anchors` already raise for an identical posted-ref-not-in-graph fact; no new registry code minted. Pinned by `tests/engine/test_parsed_pipeline.py` (`TestPinnedDcidC2`). The `members` rules are pinned by `tests/engine/test_place_set_invariants.py`. | -| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". The refusal's entity_terms and every role option's mentions list each request text of every untold place set, in query order and in the request's own words, a pinned key included; the message and the option labels name each untold place once, by its first text with its further texts in parentheses (`Kenya ("KEN") to Norway`). They are offered while that gives at most six readings, none past it; none when the single untold place was read in each direction and no reading holds data; none on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code, a curated alias or its display name, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Additive response field replacing `NoData.data_query`; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | +| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". The refusal's entity_terms and every role option's mentions list each request text of every untold place set, in query order and in the request's own words, a pinned key included; the message and the option labels name each untold place once, by its first text with its further texts in parentheses (`Kenya ("KEN") to Norway`). They are offered while that gives at most six readings. None is offered past six, when the single untold place was read in each direction and no reading holds data, or on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code, a curated alias or its display name, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Response field; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | | C51 | Within a part, when several reasons apply, the first in resolution order wins (entity, variable, role, unit, slice, denominator, observations, facet) | partial | The resolution order is documented (`NO_DATA_RESOLUTION_ORDER`), and every site that picks one reason from several elects through `elect_no_data_reason`: the entity check (`phase_intake._phase_check_entities`), the refusal of a part with no admissible interpretation (`InterpretationSet.refusal_reason`), and the materialise and candidate refusals (`codes._elect_no_data_reason`). The entity check refuses with the reason elected over the ones its unresolved names mint and names every such name: an ambiguous name with its candidates, an oversized group as too large, and the rest as unresolved, so a part naming an oversized group and an ambiguous name refuses entity_ambiguous. Pinned by `tests/models/test_no_data_category.py`, which also finds every reason the code mints and checks each has a rank, and `tests/engine/test_group_resolution.py` (`test_mixed_entity_refusals_elect_one_reason_and_name_every_entity`). No structural enforcement catches a phase check reordered against the order. Live verification of the role/unit/facet positions is blocked until role-declaring StatVars are loaded on the live graph. Issues: #11/#12 (live-graph half); none (enforcement) | | C51a | Parts sharing one five-tuple shape collapse onto one spec: every member answers with the same spec_id, and every member but the first carries a part-scoped binding_folded warning naming the fold. A parsed part posted with `separate: true`, or a raw_text variable its query compares with another (C42d), is compared with the others and does not fold. It answers its own spec in query order with no binding_folded, its response part and restatement state the mark, and the other parts of its shape fold among themselves (`same_shape.fold_key`, which `collapse_same_shape` and the combiner both group by). A fold whose members bind one slot to different values merges them into a set, then drops each value that descends from another merged value, since the fold reads its parts as a union (HIV/AIDS 13040 folded with reproductive health 130 binds 130); one value left binds a value. The merged trace keeps each member's qualifier record of a text that differs in offsets, operation or selected codes. The merged spec states a set binding's values in the order its identity holds the refs, and counts an open role over the union of the members' own populations, so it reads as the resubmit of its spec_id does | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_two_variables_collapse_onto_one_merged_spec_through_fanout`), `tests/engine/test_conjoin_five_tuple_and_slots.py` (`test_collapse_same_shape_states_its_set_in_identity_order`, `test_collapse_same_shape_drops_a_merged_value_nested_under_another`, `test_collapse_same_shape_keeps_every_union_record_of_one_text`), `tests/engine/test_conjunction.py` (`test_a_separate_part_never_folds_and_the_others_fold_as_before`), `tests/engine/test_parsed_pipeline.py` (`test_a_part_marked_separate_never_folds_into_a_same_shape_part`) and `tests/engine/test_same_shape.py` (`test_a_merged_answer_unions_each_members_own_known_origin_population`). | | C51b | A candidates part carries observations_omitted when the request set include_observations, since a candidates part has not committed to one spec to fetch observations against; max_candidates applies per part; the warning ships on the buffered and SSE transports alike, since it states the request and not a transport choice | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_candidates_part_carries_observations_omitted_alongside_definite_with_observations`). | diff --git a/src/search/engine/assemble.py b/src/search/engine/assemble.py index 763fce0c..f2e0209b 100644 --- a/src/search/engine/assemble.py +++ b/src/search/engine/assemble.py @@ -213,7 +213,7 @@ def _surviving_qualifier_warnings( Each warning stays paired with its extracted phrase. One region admitting a phrase makes the request-wide warning false. A part's departures read its own region's - admission instead (`unapplied_qualifier_warnings`, `RegionResult.unapplied_qualifier_warnings`). + admission (`unapplied_qualifier_warnings`, `RegionResult.unapplied_qualifier_warnings`). """ admitted = frozenset( phrase for region in regions for phrase in region.admitted_qualifier_phrases diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index b6e9c851..1d5655c6 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -388,10 +388,10 @@ def _role_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | def _direction_label(entities: Sequence[ParsedEntity], region: RegionResult) -> str: - """The directions `entities` state, in words: origins "to" destinations, or "From" or - "To" with the names when one axis holds them. Each untold place set + """The directions `entities` state, in words. Origins read "to" destinations, and one + axis alone reads "From" or "To" with its names. Each untold place set (``RegionResult.untold_groups``) is named once, as the refusal's message names it - (`untold_place_name`): `Kenya ("KEN") to Norway`.""" + (`untold_place_name`), as in `Kenya ("KEN") to Norway`.""" words = region.entity_words or {} group_of = {name: group for group in region.untold_groups for name in group} by_axis: dict[RoleAxis, list[tuple[str, ...]]] = {"origin": [], "destination": []} diff --git a/src/search/engine/region/phase_shared.py b/src/search/engine/region/phase_shared.py index 90ffef18..75351dfa 100644 --- a/src/search/engine/region/phase_shared.py +++ b/src/search/engine/region/phase_shared.py @@ -418,8 +418,9 @@ async def _role_not_resolved_no_data(state: ResolveState, *, offer: bool = True) wording that contradicts a supplied axis. A `flagged_match` requires a resolved role map, which this refusal lacks. The names whose direction it could not tell (`untold_direction_names`) ride as the result's `unresolved_entity_names`, grouped by - place (`untold_direction_groups`) as its `untold_groups`, and the readings of their directions (`direction_readings`) as its `role_assignments`, unless - `offer` is false: every reading was read and none holds data.""" + place (`untold_direction_groups`) as its `untold_groups`, and the readings of their + directions (`direction_readings`) as its `role_assignments`. The readings are left out + when `offer` is false, because every reading was read and none holds data.""" return state.no_data( "role_not_resolved", llm_usage=state.var_usage, diff --git a/src/search/engine/region/place_roles.py b/src/search/engine/region/place_roles.py index 8179cf76..b357ab7e 100644 --- a/src/search/engine/region/place_roles.py +++ b/src/search/engine/region/place_roles.py @@ -1141,7 +1141,7 @@ def _untold_groups(state: ResolveState, groups: dict[str, list[str]]) -> dict[st # The most readings of an unstated direction a `role_not_resolved` refusal offers. Two sets -# on a shape with a role on each axis give 2, three give 6, four 14; past this the refusal +# on a shape with a role on each axis give 2, three give 6, four 14. Past this the refusal # offers none and its message stands. MAX_DIRECTION_READINGS = 6 @@ -1157,7 +1157,7 @@ def direction_readings(state: ResolveState) -> tuple[dict[str, RoleAxis], ...]: open axis counts as named and takes no set. A reading is kept when every such axis then holds a set, a directed one included. Readings run over the sets in query order, origin first. Empty when the shape declares no role, no set is untold, or more than - `MAX_DIRECTION_READINGS` readings are kept; the count stops there, so no reading past + `MAX_DIRECTION_READINGS` readings are kept. The count stops there, so no reading past it is built.""" shape = state.shape_draft if shape is None or not is_role_bearing(shape): diff --git a/src/search/engine/region/requirements.py b/src/search/engine/region/requirements.py index c362f3b8..c98149c5 100644 --- a/src/search/engine/region/requirements.py +++ b/src/search/engine/region/requirements.py @@ -241,7 +241,7 @@ def judge( ) -> Judgement: """Judge each of `answers` on its own shape and bindings against every requirement. `served[i]` is the price basis of the unit request answer `i` is served under, None - when it is served under none; an empty `served` serves every answer under none. + when it is served under none. An empty `served` serves every answer under none. A `CodeRule` is met when the answer's bindings bind one of its codes or a descendant on its property, unmet when the answer's shape holds the property and binds none of them, diff --git a/src/search/engine/region/result.py b/src/search/engine/region/result.py index 667d6b36..44b9a9fe 100644 --- a/src/search/engine/region/result.py +++ b/src/search/engine/region/result.py @@ -81,7 +81,7 @@ class AmbiguousEntityCandidate: `group_node` is set when the candidate is a group's own node and the part asks for the wording's members: a pin on a group's node cannot ask for its members, so the candidate's option posts `group_name`, the group's registered name that resolves to it - alone, instead. A group node with no such name (`group_name` `None`) has no option. + alone. A group node with no such name (`group_name` `None`) has no option. `group_name` is `None` on every other candidate.""" dcid: str diff --git a/src/search/engine/response_cache_flow.py b/src/search/engine/response_cache_flow.py index cf87f3d0..90107c7b 100644 --- a/src/search/engine/response_cache_flow.py +++ b/src/search/engine/response_cache_flow.py @@ -133,8 +133,9 @@ def compute_layer2_signals( ) -> Layer2Signals: """`query` supplies every leg's raw role signals; `entity_roles` lists its door-provided occurrences in mention order, and each of `dropped_qualifiers` gives the - flow axis it asks every member of, read with the preposition `query` puts before it. `query_text` is set only for a - single-variable request, matching the pipeline's own raw-text readers. Each of + flow axis it asks every member of, read with the preposition `query` puts before it. + `query_text` is set only for a single-variable request, matching the pipeline's own + raw-text readers. Each of `variables` gives the stretch of `query_text` that writes it (`written_phrase`), which the named-member binding reads. """ diff --git a/src/search/models/outcomes.py b/src/search/models/outcomes.py index aa6de4a3..fe723d22 100644 --- a/src/search/models/outcomes.py +++ b/src/search/models/outcomes.py @@ -78,7 +78,7 @@ class AmbiguousEntityCandidate(StrictModel): an AmbiguousEntityGroup's own `candidates`. Mirrors engine.region.result. AmbiguousEntityCandidate field-for-field, with `label` in place of that dataclass's `name` (this contract's own vocabulary for a display - string, matching ResolvedEntity.label), and without its `group_node` + string, matching ResolvedEntity.label), except that it omits `group_node` and `group_name`, which only NoData.options read.""" ref: str = Field(description="the candidate's own dcid") @@ -160,7 +160,7 @@ class UnitAlternative(StrictModel): class NoDataOption(StrictModel): """One answer to the choice a no_data refusal asks for: the words it settles, a label - for people, and the request that settles them -- one entry of NoData.options.""" + for people, and the request that settles them. It is one entry of NoData.options.""" mentions: list[str] = Field( description=( diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index d5cdd4af..a9d92d9d 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -3,7 +3,7 @@ not refuse with the same reason again. Every round trip runs the composed search app in process over offline doubles. The -"fertility rate"/"Kenya" texts match the recorded LLM fixtures; the role-bearing cases +"fertility rate"/"Kenya" texts match the recorded LLM fixtures. The role-bearing cases elect their one shape through a scripted bind.""" from __future__ import annotations diff --git a/tests/engine/test_conjoin_assemble_and_enrichment.py b/tests/engine/test_conjoin_assemble_and_enrichment.py index c2f6a721..d7f98e9f 100644 --- a/tests/engine/test_conjoin_assemble_and_enrichment.py +++ b/tests/engine/test_conjoin_assemble_and_enrichment.py @@ -344,8 +344,8 @@ def test_assemble_region_threads_every_ambiguous_entity_term(): def test_each_option_pins_one_ambiguous_wording_and_leaves_the_other_bare(): - """Two ambiguous wordings give one option per wording and candidate, in query order; - each pins its wording and every entity that resolved to one node, and leaves the other + """Two ambiguous wordings give one option per wording and candidate, in query order. + Each pins its wording and every entity that resolved to one node, and leaves the other wording unpinned for a second refusal.""" part = _entity_terms_part(_TWO_AMBIGUOUS) diff --git a/tests/engine/test_operations_e2e.py b/tests/engine/test_operations_e2e.py index ab203d41..6080a585 100644 --- a/tests/engine/test_operations_e2e.py +++ b/tests/engine/test_operations_e2e.py @@ -967,7 +967,7 @@ def test_an_ambiguous_wording_is_its_own_slot_token_in_the_sentence(): def test_an_ambiguous_part_without_a_restatement_still_carries_options(): """Congo named on both axes leaves the direction unnameable, so the part withholds - its restatement; each option still lists Congo once per axis, pinned to its candidate, + its restatement. Each option still lists Congo once per axis, pinned to its candidate, beside Kenya pinned, and keeps the operation.""" llm = _ScriptedLLM( _extraction( diff --git a/tests/engine/test_pinned_code_refusal.py b/tests/engine/test_pinned_code_refusal.py index a2868207..14cb4fa7 100644 --- a/tests/engine/test_pinned_code_refusal.py +++ b/tests/engine/test_pinned_code_refusal.py @@ -456,7 +456,7 @@ def test_a_stand_in_is_judged_on_the_code_the_request_named(bound, unbound): assert _departed(state) == [] -# A price basis is met by the unit request an answer is served under: a Nominal series +# A price basis is met by the unit request an answer is served under. A Nominal series # converted to constant prices reads as RealValue. _CONSTANT = "constant" diff --git a/tests/engine/test_source_code.py b/tests/engine/test_source_code.py index 02ceedc1..07e1455d 100644 --- a/tests/engine/test_source_code.py +++ b/tests/engine/test_source_code.py @@ -822,7 +822,7 @@ def test_a_code_two_named_sources_hold_offers_each_as_the_only_included_source( ([_DEGG], [excluded, SourceMention(ref=ref)]) for ref in (_IDS, _CRS) ] # The coverage holds the code's observations under IDS alone, so the CRS option reads - # none; it refuses on its data, not on the choice. + # none and refuses on its data, with the choice settled. ids, crs = (_answer(o.data_query.parts[0], monkeypatch) for o in options) assert ids.status == "definite" assert crs.status == "no_data" From 4553224b2021e9fd966360822aee2b9610eb7af1 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 21 Oct 2026 08:39:39 +0200 Subject: [PATCH 12/14] docs(search): list the three cases that offer no choice options --- docs/conformance-search-api-v1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conformance-search-api-v1.md b/docs/conformance-search-api-v1.md index 86bab6fb..1e53ef7b 100644 --- a/docs/conformance-search-api-v1.md +++ b/docs/conformance-search-api-v1.md @@ -110,7 +110,7 @@ Gap rows state what is needed to close the clause and name the open issue that t | C50b | For entity_not_resolved, entity_ambiguous, and entity_role_not_admitted, no_data.entity_terms carries every entity wording responsible for that outcome in query order. It is an empty list for every other no_data reason, so clients need not parse rendered_sentence to identify the entity. | implemented | Additive response field; it does not alter any frozen enum or registry code. | | C50c | For entity_ambiguous only, no_data.ambiguous_entities carries one group per ambiguous wording (empty for every other reason), each with the graph candidates it could name (ref, label, kind), `ref` holding the candidate's dcid. Every group's text equals some restatement.entities[i].text on the same part. A subject-role entity_ambiguous part (e.g. "GDP of Congo", no role_text posted) carries a real restatement instead of null. The part also carries no_data.options, one per (ambiguous wording, candidate) in query order, each with the wording, the candidate's label, and a one-part parsed input built from the part's parsed inputs (variable text, entities with role_text, time, unit and sources, operations) pinning that candidate by text and every entity that resolved to one node; named groups, sets read as members and other ambiguous wordings stay unpinned. A candidate that is a group's own node, on a row asked for its members, posts the group's registered name with no dcid (C50d). Posting an option's data_query unchanged resolves that wording. options is built whether or not the part carries a restatement; an entity whose direction the question leaves unnamed carries no role_text. options is empty past the ParsedPart bounds. The part's sentence plan marks each ambiguous wording as a slot token carrying the role the request gave it, no entity_refs and no binding_ref, so the composed sentence stamps it with the part's index and a client hangs the candidate menu on it; the rendered text is unchanged. | implemented | Additive response field; it does not alter any frozen enum or registry code or `rendered_sentence`. Pinned by `tests/engine/test_render_e2e.py` (`test_no_data_sentence_names_ambiguous_entity_candidates`, `test_no_data_restatement_for_directional_ambiguous_entity`) and `tests/engine/test_operations_e2e.py` (option round trip, options without a restatement, slot token, operations through the resubmit door's bind). | | C50d | ParsedEntity.dcid pins a parsed-door entity to a specific graph node: the pin seeds entry/parsed.py's pre_resolved map directly, skipping this door's own name resolution for that entity, once the dcid confirms against the graph. An unconfirmable dcid (absent, or present but type-less) refuses. `members: true` on a pinned entity binds the countries the membership policy holds for the node when the node is a continent or subregion that the policy holds or omits (`MembershipPolicy.holds_region`), whatever its graph type, and has no effect on a pinned single place. A pinned dcid that is a resolver group's own node (`EntityResolver.group_node_dcids`, for example `EuropeanUnion`) is one place: without the flag the Spec binds that node, and with `members: true` the request refuses request_invalid at `{part}/entities/{j}`, since a group's members come from resolving its name, which a pin skips. | implemented | Reuses invalid_role_ref (422) for the unconfirmable case -- the same code region/promote_places.py's `_phase_where_places` and options/resolve.py's `_typed_anchors` already raise for an identical posted-ref-not-in-graph fact; no new registry code minted. Pinned by `tests/engine/test_parsed_pipeline.py` (`TestPinnedDcidC2`). The `members` rules are pinned by `tests/engine/test_place_set_invariants.py`. | -| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". The refusal's entity_terms and every role option's mentions list each request text of every untold place set, in query order and in the request's own words, a pinned key included; the message and the option labels name each untold place once, by its first text with its further texts in parentheses (`Kenya ("KEN") to Norway`). They are offered while that gives at most six readings. None is offered past six, when the single untold place was read in each direction and no reading holds data, or on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code, a curated alias or its display name, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Response field; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | +| C50e | no_data.options lists one ready request per choice of a refusal that asks for one, in query then candidate order, each with the request words it settles (`mentions`), a label for a menu that clients must not parse, and a one-part parsed input (`data_query`) a client posts unchanged as the request's input or as a dataset_resubmit parsed entry's part. source_ambiguous: each option sets the candidate's ref on the ambiguous mention, keeping its text and polarity; a label for an excluded mention reads "Excluding" and the candidate. source_code_ambiguous: each option names the holding provenance as the part's only included source, keeping exclusions and the code, since included sources union. role_not_resolved: each option states a direction for every name the question left unstated, each untold set on an axis the elected shape declares a role on and the part does not ask open (open_roles, or an operation's over), with every such axis holding a place; options run over the sets in query order, origin first, labelled "France to Germany" or "To Kenya and Uganda". The refusal's entity_terms and every role option's mentions list each request text of every untold place set, in query order and in the request's own words, a pinned key included; the message and the option labels name each untold place once, by its first text with its further texts in parentheses (`Kenya ("KEN") to Norway`). They are offered while that gives at most six readings. None is offered in three cases: past six readings, when the single untold place was read in each direction and no reading holds data, and on the shape-level refusals. unit_not_resolved with unit_alternatives: each option replaces the ambiguous currency words of unit_text with words naming that alternative alone, its ISO code, a curated alias or its display name, or a fixed-role currency phrase for a role shape, read back through the unit binder before it is offered. An option whose part breaks the ParsedPart bounds, or whose stated directions the parsed door's role check contradicts, is left out, so options can list fewer entries than the candidates. Data for an option is not checked. Empty for every other reason. | implemented | Response field; no registry code changes. Pinned by `tests/engine/test_choice_options.py` (each reason's options posted back through the parsed door do not refuse with the same reason) and `tests/engine/test_unit_bind.py` (`unit_text_naming`). | | C51 | Within a part, when several reasons apply, the first in resolution order wins (entity, variable, role, unit, slice, denominator, observations, facet) | partial | The resolution order is documented (`NO_DATA_RESOLUTION_ORDER`), and every site that picks one reason from several elects through `elect_no_data_reason`: the entity check (`phase_intake._phase_check_entities`), the refusal of a part with no admissible interpretation (`InterpretationSet.refusal_reason`), and the materialise and candidate refusals (`codes._elect_no_data_reason`). The entity check refuses with the reason elected over the ones its unresolved names mint and names every such name: an ambiguous name with its candidates, an oversized group as too large, and the rest as unresolved, so a part naming an oversized group and an ambiguous name refuses entity_ambiguous. Pinned by `tests/models/test_no_data_category.py`, which also finds every reason the code mints and checks each has a rank, and `tests/engine/test_group_resolution.py` (`test_mixed_entity_refusals_elect_one_reason_and_name_every_entity`). No structural enforcement catches a phase check reordered against the order. Live verification of the role/unit/facet positions is blocked until role-declaring StatVars are loaded on the live graph. Issues: #11/#12 (live-graph half); none (enforcement) | | C51a | Parts sharing one five-tuple shape collapse onto one spec: every member answers with the same spec_id, and every member but the first carries a part-scoped binding_folded warning naming the fold. A parsed part posted with `separate: true`, or a raw_text variable its query compares with another (C42d), is compared with the others and does not fold. It answers its own spec in query order with no binding_folded, its response part and restatement state the mark, and the other parts of its shape fold among themselves (`same_shape.fold_key`, which `collapse_same_shape` and the combiner both group by). A fold whose members bind one slot to different values merges them into a set, then drops each value that descends from another merged value, since the fold reads its parts as a union (HIV/AIDS 13040 folded with reproductive health 130 binds 130); one value left binds a value. The merged trace keeps each member's qualifier record of a text that differs in offsets, operation or selected codes. The merged spec states a set binding's values in the order its identity holds the refs, and counts an open role over the union of the members' own populations, so it reads as the resubmit of its spec_id does | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_two_variables_collapse_onto_one_merged_spec_through_fanout`), `tests/engine/test_conjoin_five_tuple_and_slots.py` (`test_collapse_same_shape_states_its_set_in_identity_order`, `test_collapse_same_shape_drops_a_merged_value_nested_under_another`, `test_collapse_same_shape_keeps_every_union_record_of_one_text`), `tests/engine/test_conjunction.py` (`test_a_separate_part_never_folds_and_the_others_fold_as_before`), `tests/engine/test_parsed_pipeline.py` (`test_a_part_marked_separate_never_folds_into_a_same_shape_part`) and `tests/engine/test_same_shape.py` (`test_a_merged_answer_unions_each_members_own_known_origin_population`). | | C51b | A candidates part carries observations_omitted when the request set include_observations, since a candidates part has not committed to one spec to fetch observations against; max_candidates applies per part; the warning ships on the buffered and SSE transports alike, since it states the request and not a transport choice | implemented | No gap. Pinned by `tests/engine/test_parts_shape.py` (`test_candidates_part_carries_observations_omitted_alongside_definite_with_observations`). | From cf576104a94a5efcb6f6d9bcdcc2deeeab58ba79 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 22 Oct 2026 17:22:56 +0200 Subject: [PATCH 13/14] fix(search): omit source choices that empty existing scope 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. --- src/search/engine/conjoin.py | 5 ++- src/search/engine/source_scope.py | 54 +++++++++++++++++++++++-- tests/engine/test_choice_options.py | 63 +++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index 1d5655c6..e104c522 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -332,7 +332,8 @@ def chosen(entity: ParsedEntity, candidate: EngineCandidate) -> ParsedEntity | N def _source_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: """One option per ambiguous source wording and candidate, setting the candidate's ref on every ref-less mention with that wording; a wording mentioned twice gives its options - once, from its first group. A label names an excluded source as excluded.""" + once, from its first group. Choices the source compiler found empty are omitted. + A label names an excluded source as excluded.""" groups: dict[str, AmbiguousSource] = {} for group in region.ambiguous_sources: groups.setdefault(group.text, group) @@ -341,6 +342,8 @@ def _source_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption mentions = [m for m in part.sources if m.ref is None and m.text == text] excluded = bool(mentions) and mentions[0].polarity == "exclude" for candidate in group.candidates: + if candidate.ref in group.blocked_choices: + continue sources = [ m.model_copy(update={"ref": candidate.ref}) if m in mentions else m for m in part.sources diff --git a/src/search/engine/source_scope.py b/src/search/engine/source_scope.py index 95358da4..653d0ae0 100644 --- a/src/search/engine/source_scope.py +++ b/src/search/engine/source_scope.py @@ -31,7 +31,7 @@ import re import unicodedata from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal from code_index.query import CodeIndex @@ -93,10 +93,15 @@ class SourceScope: @dataclass(frozen=True, slots=True) class AmbiguousSource: - """A mention's words that match two or more unrelated references.""" + """A mention's words that match two or more unrelated references. + + `blocked_choices` holds candidates that would empty the source scope when + every ref-less mention of these words is pinned to that candidate. + """ text: str candidates: tuple[SourceReference, ...] + blocked_choices: frozenset[str] = frozenset() @dataclass(frozen=True, slots=True) @@ -327,6 +332,49 @@ def _pinned_reference(dictionary: SourceDictionary, ref: str, *, pointer: str) - ) +def _source_choices( + mentions: Sequence[SourceMention], + resolved: Sequence[NamedSource | None], + ambiguous: Sequence[AmbiguousSource], + dictionary: SourceDictionary, +) -> tuple[AmbiguousSource, ...]: + """Mark choices that would leave no source after the existing exclusions. + + Other ambiguous wordings keep a repost in source resolution, so its final scope + cannot yet be checked. Use the same catalogue expansions as normal compilation, + including Dataset and Source exclusions, without adding reads to response assembly. + """ + if len({group.text for group in ambiguous}) != 1 or not any( + mention.polarity == "exclude" for mention in mentions + ): + return tuple(ambiguous) + group = ambiguous[0] + blocked: set[str] = set() + for candidate in group.candidates: + references = [ + candidate if mention.ref is None and mention.text == group.text else reference + for mention, reference in zip(mentions, resolved, strict=True) + ] + expansions = [ + frozenset() + if reference is None + else _expand(dictionary, reference, include=mention.polarity == "include") + for mention, reference in zip(mentions, references, strict=True) + ] + included = [ + expansion + for mention, expansion in zip(mentions, expansions, strict=True) + if mention.polarity == "include" + ] + allowed = frozenset().union(*included) if included else dictionary.every + for mention, expansion in zip(mentions, expansions, strict=True): + if mention.polarity == "exclude": + allowed -= expansion + if not allowed: + blocked.add(candidate.ref) + return tuple(replace(item, blocked_choices=frozenset(blocked)) for item in ambiguous) + + def compile_sources( mentions: Sequence[SourceMention], *, @@ -383,7 +431,7 @@ def compile_sources( return SourceRefusal( reason="source_ambiguous", terms=tuple(group.text for group in ambiguous), - ambiguous=tuple(ambiguous), + ambiguous=_source_choices(mentions, resolved, ambiguous, dictionary), mentions=pinned, ) diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index f5860623..c178efc4 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -179,6 +179,69 @@ def test_an_ambiguous_source_offers_each_candidate_and_each_option_resolves_it( _assert_options_settle(client, no_data) +@pytest.mark.parametrize("excluded_kind", ["provenance", "source"]) +def test_source_choices_omit_a_candidate_removed_by_existing_exclusions(excluded_kind): + words = "Shared Tables" + rows = [ + ProvenanceRow( + id=f"dc/base/{publisher}Tables", + data_commons=DFO, + source=SourceRef(ref=f"dc/s/{publisher}", label=publisher), + name=words, + url="https://example.org", + ) + for publisher in ("Alpha", "Beta") + ] + excluded = rows[0].id if excluded_kind == "provenance" else rows[0].source.ref + service = fake_catalogue_service(FakeCatalogue(provenances={row.id: row for row in rows})) + with catalogue_service_module._singleton.poisoned_for_test(service): + client = _fertility_client(_ShapeZeroLLM()) + no_data = _no_data( + client, + { + "variable_text": _FERTILITY, + "entities": [{"text": "Kenya"}], + "sources": [{"text": words}, {"ref": excluded, "polarity": "exclude"}], + }, + ) + assert no_data["reason"] == "source_ambiguous" + (option,) = no_data["options"] + assert option["data_query"]["parts"][0]["sources"] == [ + {"text": words, "ref": rows[1].id, "polarity": "include"}, + {"ref": excluded, "polarity": "exclude"}, + ] + _assert_options_settle(client, no_data) + + +def test_source_choices_omit_every_empty_mixed_polarity_choice(): + words = "Shared Tables" + rows = [ + ProvenanceRow( + id=f"dc/base/{publisher}Tables", + data_commons=DFO, + source=SourceRef(ref=f"dc/s/{publisher}", label=publisher), + name=words, + url="https://example.org", + ) + for publisher in ("Alpha", "Beta") + ] + service = fake_catalogue_service(FakeCatalogue(provenances={row.id: row for row in rows})) + with catalogue_service_module._singleton.poisoned_for_test(service): + no_data = _no_data( + _fertility_client(_ShapeZeroLLM()), + { + "variable_text": _FERTILITY, + "entities": [{"text": "Kenya"}], + "sources": [ + {"text": words, "polarity": "include"}, + {"text": words, "polarity": "exclude"}, + ], + }, + ) + assert no_data["reason"] == "source_ambiguous" + assert not no_data.get("options") + + @pytest.mark.parametrize( ("unit_text", "posted"), [ From f4dd4845f9ae3187b4cf49970e8acd5fb4a7e6d2 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 22 Oct 2026 23:28:10 +0200 Subject: [PATCH 14/14] fix(search): check every choice option and refuse contradictory sources `_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. --- src/search/engine/conjoin.py | 23 +++--- src/search/engine/source_scope.py | 97 ++++++++++++------------- tests/engine/test_choice_options.py | 106 ++++++++++++++++++++++++---- tests/engine/test_source_choice.py | 24 ++++++- tests/engine/test_source_scope.py | 35 ++++++++- 5 files changed, 208 insertions(+), 77 deletions(-) diff --git a/src/search/engine/conjoin.py b/src/search/engine/conjoin.py index e104c522..7d8d4a31 100644 --- a/src/search/engine/conjoin.py +++ b/src/search/engine/conjoin.py @@ -14,6 +14,7 @@ from pydantic import ValidationError +from origin_catalogue import current_catalogue from search.engine.assemble import ( build_candidates_part, build_definite_part, @@ -24,6 +25,7 @@ ) from search.engine.departures import surviving_facts from search.engine.envelope import make_diagnostics, make_query_echo, make_response, now_ms +from search.engine.errors import EngineInputError from search.engine.region.source_choice import scope_records from search.engine.role_text import ( parsed_directions_contradicted, @@ -31,6 +33,7 @@ role_text_for_axis, ) from search.engine.same_shape import FacetOptionsReader, collapse_same_shape, fold_key +from search.engine.source_scope import compile_part_sources from search.engine.unit.bind import unit_text_naming from search.engine.unit.conversion import conversion_unavailable_warning from search.models import ( @@ -276,14 +279,21 @@ def _unbound_terms( def _option(mentions: Sequence[str], label: str, part: ParsedPart) -> NoDataOption | None: - """One option posting `part`, or `None` when `part` breaks the `ParsedPart` bounds or - states a direction its own wording contradicts, which the parsed door refuses.""" + """One option posting `part`, or `None` when the parsed door would refuse it: `part` + breaks the `ParsedPart` bounds, states a direction its own wording contradicts, or + names sources that leave it none (`source_scope.compile_part_sources`, which reads the + bound catalogue's cached dictionary).""" try: posted = ParsedPart.model_validate(part.model_dump()) except ValidationError: return None if parsed_directions_contradicted(posted): return None + if posted.sources: + try: + compile_part_sources(posted, catalogue=current_catalogue(), pointer_prefix="") + except EngineInputError: + return None return NoDataOption( mentions=list(mentions), label=label, data_query=ParsedInput(parts=[posted]) ) @@ -332,8 +342,7 @@ def chosen(entity: ParsedEntity, candidate: EngineCandidate) -> ParsedEntity | N def _source_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | None]: """One option per ambiguous source wording and candidate, setting the candidate's ref on every ref-less mention with that wording; a wording mentioned twice gives its options - once, from its first group. Choices the source compiler found empty are omitted. - A label names an excluded source as excluded.""" + once, from its first group. A label names an excluded source as excluded.""" groups: dict[str, AmbiguousSource] = {} for group in region.ambiguous_sources: groups.setdefault(group.text, group) @@ -342,8 +351,6 @@ def _source_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption mentions = [m for m in part.sources if m.ref is None and m.text == text] excluded = bool(mentions) and mentions[0].polarity == "exclude" for candidate in group.candidates: - if candidate.ref in group.blocked_choices: - continue sources = [ m.model_copy(update={"ref": candidate.ref}) if m in mentions else m for m in part.sources @@ -456,8 +463,8 @@ def _unit_options(region: RegionResult, part: ParsedPart) -> list[NoDataOption | def _options(region: RegionResult) -> list[NoDataOption]: """The ready requests of a refusal that asks for a choice, each a one-part parsed input built from the region's parsed form. Empty for every other reason, when the region has - no parsed form (its inputs exceed the `ParsedPart` bounds), and for an option whose part - breaks those bounds or contradicts its own wording.""" + no parsed form (its inputs exceed the `ParsedPart` bounds), and for an option the parsed + door would refuse (`_option`).""" build = _OPTION_BUILDERS.get(region.no_data_reason) if region.no_data_reason else None if build is None or region.parsed_form is None: return [] diff --git a/src/search/engine/source_scope.py b/src/search/engine/source_scope.py index 653d0ae0..85fd38a0 100644 --- a/src/search/engine/source_scope.py +++ b/src/search/engine/source_scope.py @@ -31,7 +31,7 @@ import re import unicodedata from collections.abc import Mapping, Sequence -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Literal from code_index.query import CodeIndex @@ -93,15 +93,10 @@ class SourceScope: @dataclass(frozen=True, slots=True) class AmbiguousSource: - """A mention's words that match two or more unrelated references. - - `blocked_choices` holds candidates that would empty the source scope when - every ref-less mention of these words is pinned to that candidate. - """ + """A mention's words that match two or more unrelated references.""" text: str candidates: tuple[SourceReference, ...] - blocked_choices: frozenset[str] = frozenset() @dataclass(frozen=True, slots=True) @@ -332,47 +327,43 @@ def _pinned_reference(dictionary: SourceDictionary, ref: str, *, pointer: str) - ) -def _source_choices( +def _refuse_every_reading_empty( mentions: Sequence[SourceMention], - resolved: Sequence[NamedSource | None], - ambiguous: Sequence[AmbiguousSource], - dictionary: SourceDictionary, -) -> tuple[AmbiguousSource, ...]: - """Mark choices that would leave no source after the existing exclusions. - - Other ambiguous wordings keep a repost in source resolution, so its final scope - cannot yet be checked. Use the same catalogue expansions as normal compilation, - including Dataset and Source exclusions, without adding reads to response assembly. - """ - if len({group.text for group in ambiguous}) != 1 or not any( - mention.polarity == "exclude" for mention in mentions - ): - return tuple(ambiguous) - group = ambiguous[0] - blocked: set[str] = set() - for candidate in group.candidates: - references = [ - candidate if mention.ref is None and mention.text == group.text else reference - for mention, reference in zip(mentions, resolved, strict=True) - ] - expansions = [ - frozenset() - if reference is None - else _expand(dictionary, reference, include=mention.polarity == "include") - for mention, reference in zip(mentions, references, strict=True) - ] - included = [ - expansion - for mention, expansion in zip(mentions, expansions, strict=True) - if mention.polarity == "include" + group: AmbiguousSource, + *, + catalogue: Catalogue, + pointers: Sequence[str], +) -> None: + """Raise `request_invalid` when every candidate of `group`'s words leaves the part no + source. Each reading pins every ref-less mention of the words to one candidate and + compiles once without `refuse_empty`. A reading that leaves another wording ambiguous + counts as viable, so a contradiction spread over two wordings answers + `source_ambiguous` until the caller has chosen all but one of them, and the cost stays + one compile per candidate. The refusal points at the excluded mention that empties the + first candidate's reading, which compiles once more with `refuse_empty` to find it.""" + readings = [ + [ + mention.model_copy(update={"ref": candidate.ref}) + if mention.ref is None and mention.text == group.text + else mention + for mention in mentions ] - allowed = frozenset().union(*included) if included else dictionary.every - for mention, expansion in zip(mentions, expansions, strict=True): - if mention.polarity == "exclude": - allowed -= expansion - if not allowed: - blocked.add(candidate.ref) - return tuple(replace(item, blocked_choices=frozenset(blocked)) for item in ambiguous) + for candidate in group.candidates + ] + for reading in readings: + compiled = compile_sources( + reading, catalogue=catalogue, pointers=pointers, refuse_empty=False + ) + if not isinstance(compiled, SourceScope) or compiled.allowed: + return + try: + compile_sources(readings[0], catalogue=catalogue, pointers=pointers) + except EngineInputError as empty: + raise EngineInputError( + f"every source {group.text!r} may name leaves this part no source to answer from", + code="request_invalid", + pointer=empty.pointer, + ) from None def compile_sources( @@ -389,9 +380,11 @@ def compile_sources( unrelated ones, else the scope. Excluded words that match no source resolve to no reference and remove nothing. Raises `request_invalid` at a mention whose ref is not admitted, and, when `refuse_empty`, at the excluded mention that leaves the scope - empty. Without `refuse_empty` such a scope compiles allowing nothing, and its part - answers `qualifier_not_bound` (`region.result.empty_scope_result`), since the raw-text - door reads mentions an extraction model wrote, which the caller cannot correct.""" + empty, or that does so under every reading of ambiguous words + (`_refuse_every_reading_empty`). Without `refuse_empty` such a scope compiles allowing + nothing, and its part answers `qualifier_not_bound` (`region.result.empty_scope_result`), + since the raw-text door reads mentions an extraction model wrote, which the caller + cannot correct.""" if not mentions: return None dictionary = source_dictionary(catalogue) @@ -428,10 +421,14 @@ def compile_sources( reason="source_not_resolved", terms=tuple(unmatched), ambiguous=(), mentions=pinned ) if ambiguous: + if refuse_empty: + _refuse_every_reading_empty( + mentions, ambiguous[0], catalogue=catalogue, pointers=pointers + ) return SourceRefusal( reason="source_ambiguous", terms=tuple(group.text for group in ambiguous), - ambiguous=_source_choices(mentions, resolved, ambiguous, dictionary), + ambiguous=tuple(ambiguous), mentions=pinned, ) diff --git a/tests/engine/test_choice_options.py b/tests/engine/test_choice_options.py index c178efc4..84ed6dc9 100644 --- a/tests/engine/test_choice_options.py +++ b/tests/engine/test_choice_options.py @@ -113,15 +113,30 @@ def _no_data(client: TestClient, part: dict) -> dict: return answered.get("no_data") or {} +def _asked(no_data: dict) -> set[str]: + """The request words a refusal asks the caller to choose or correct.""" + groups = [*(no_data.get("ambiguous_sources") or ()), *(no_data.get("ambiguous_entities") or ())] + return { + *(group["text"] for group in groups), + *(no_data.get("entity_terms") or ()), + *(no_data.get("source_terms") or ()), + } + + def _assert_options_settle(client: TestClient, no_data: dict) -> None: - """Each option's request, posted unchanged, succeeds and does not refuse with the same - reason: it answers, or refuses on something the choice does not decide.""" + """Each option's request, posted unchanged, is accepted and settles the choice. The + part answers, or refuses without asking about the option's words again. A part has one + unit text, so a currency choice settles when the answer offers no currencies.""" for option in no_data["options"]: response = client.post("/api/search/resolve", json={"input": option["data_query"]}) assert response.status_code == 200, (option["label"], response.text) (answered,) = response.json()["parts"] - reason = (answered.get("no_data") or {}).get("reason") - assert reason != no_data["reason"], option["label"] + again = answered.get("no_data") or {} + assert not set(option["mentions"]) & _asked(again), (option["label"], again) + assert not (no_data.get("unit_alternatives") and again.get("unit_alternatives")), ( + option["label"], + again, + ) def test_an_ambiguous_entity_offers_each_candidate_and_each_option_resolves_it(): @@ -213,7 +228,9 @@ def test_source_choices_omit_a_candidate_removed_by_existing_exclusions(excluded _assert_options_settle(client, no_data) -def test_source_choices_omit_every_empty_mixed_polarity_choice(): +def test_a_source_wording_both_included_and_excluded_is_refused(): + """Every reading of the wording includes and excludes one candidate, so no choice can + leave the part a source: the request is refused at the excluded mention.""" words = "Shared Tables" rows = [ ProvenanceRow( @@ -227,19 +244,78 @@ def test_source_choices_omit_every_empty_mixed_polarity_choice(): ] service = fake_catalogue_service(FakeCatalogue(provenances={row.id: row for row in rows})) with catalogue_service_module._singleton.poisoned_for_test(service): - no_data = _no_data( - _fertility_client(_ShapeZeroLLM()), - { - "variable_text": _FERTILITY, - "entities": [{"text": "Kenya"}], - "sources": [ - {"text": words, "polarity": "include"}, - {"text": words, "polarity": "exclude"}, - ], + response = _fertility_client(_ShapeZeroLLM()).post( + "/api/search/resolve", + json={ + "input": { + "kind": "parsed", + "parts": [ + { + "variable_text": _FERTILITY, + "entities": [{"text": "Kenya"}], + "sources": [ + {"text": words, "polarity": "include"}, + {"text": words, "polarity": "exclude"}, + ], + } + ], + } }, ) + assert response.status_code == 400, response.text + problem = response.json() + assert (problem["code"], problem["pointer"]) == ("request_invalid", "/input/parts/0/sources/1") + + +def test_a_contradiction_over_two_source_wordings_is_refused_once_one_is_chosen(): + """Each wording is both included and excluded, so every pair of choices empties the + scope. Each reading of the first wording leaves the second ambiguous, so the part + answers source_ambiguous. Once the first is chosen, every reading of the second + empties the scope and the request is refused at its excluded mention.""" + rows = [ + ProvenanceRow( + id=f"dc/base/{publisher}{words.replace(' ', '')}", + data_commons=DFO, + source=SourceRef(ref=f"dc/s/{publisher}", label=publisher), + name=words, + url="https://example.org", + ) + for words, publishers in ( + ("Shared Tables", ("Alpha", "Beta")), + ("Joint Ledgers", ("Gamma", "Delta")), + ) + for publisher in publishers + ] + part = { + "variable_text": _FERTILITY, + "entities": [{"text": "Kenya"}], + "sources": [ + {"text": "Shared Tables", "polarity": "include"}, + {"text": "Shared Tables", "polarity": "exclude"}, + {"text": "Joint Ledgers", "polarity": "include"}, + {"text": "Joint Ledgers", "polarity": "exclude"}, + ], + } + service = fake_catalogue_service(FakeCatalogue(provenances={row.id: row for row in rows})) + with catalogue_service_module._singleton.poisoned_for_test(service): + client = _fertility_client(_ShapeZeroLLM()) + no_data = _no_data(client, part) assert no_data["reason"] == "source_ambiguous" - assert not no_data.get("options") + assert {group["text"] for group in no_data["ambiguous_sources"]} == { + "Shared Tables", + "Joint Ledgers", + } + chosen = [ + {**mention, "ref": rows[0].id} if mention["text"] == "Shared Tables" else mention + for mention in part["sources"] + ] + response = client.post( + "/api/search/resolve", + json={"input": {"kind": "parsed", "parts": [{**part, "sources": chosen}]}}, + ) + assert response.status_code == 400, response.text + problem = response.json() + assert (problem["code"], problem["pointer"]) == ("request_invalid", "/input/parts/0/sources/3") @pytest.mark.parametrize( diff --git a/tests/engine/test_source_choice.py b/tests/engine/test_source_choice.py index 9daa9155..73b14684 100644 --- a/tests/engine/test_source_choice.py +++ b/tests/engine/test_source_choice.py @@ -14,7 +14,7 @@ import pytest from code_index.shared import get_index -from origin_catalogue import BASE, UN_SYSTEM, Catalogue, ProvenanceRow, SourceRef +from origin_catalogue import BASE, UN_SYSTEM, Catalogue, DatasetRef, ProvenanceRow, SourceRef from origin_catalogue.context import current_source_scope from search.engine.clients.graph import ( DetectedSV, @@ -606,11 +606,29 @@ def test_a_no_data_outcome_under_a_scope_names_its_sources( @pytest.mark.parametrize(("polarity", "prefix"), [("include", ""), ("exclude", "Excluding ")]) -def test_an_ambiguous_source_answers_no_data_with_an_option_per_candidate(polarity, prefix): +def test_an_ambiguous_source_answers_no_data_with_an_option_per_candidate( + bind_catalogue, polarity, prefix +): """The part names each candidate by ref, label and kind, and offers one option per candidate: the part with the ambiguous mention carrying that candidate's ref, keeping its words and polarity, and every resolved mention carrying its own ref. An excluded - mention's label says so.""" + mention's label says so. The bound catalogue admits every ref an option posts.""" + bind_catalogue( + FakeCatalogue( + provenances={ + row.id: row + for row in ( + _sourced_row("provenance/A1", "Table A1", "dc/s/A", "Source A"), + dataclasses.replace( + _sourced_row("provenance/B1", "Table B1", "dc/s/D", "Source D"), + dataset=DatasetRef(ref="dc/d/B", label="Dataset B"), + ), + _sourced_row("provenance/C", "Table C", "dc/s/C", "Source C"), + _row(_CRS), + ) + } + ) + ) candidates = ( SourceReference(ref="dc/s/A", label="Source A", kind="source"), SourceReference(ref="dc/d/B", label="Dataset B", kind="dataset"), diff --git a/tests/engine/test_source_scope.py b/tests/engine/test_source_scope.py index 1cdb984c..eabe5dcc 100644 --- a/tests/engine/test_source_scope.py +++ b/tests/engine/test_source_scope.py @@ -247,14 +247,24 @@ def test_an_ambiguous_refusal_lists_the_candidates_and_pins_the_resolved_mention ) +# Both readings of the words include and exclude the same provenance. +_EVERY_READING_EMPTY = [_include("ONE Conversion Tables"), _exclude("ONE Conversion Tables")] + + @pytest.mark.parametrize( ("mentions", "pointer"), [ ([SourceMention(ref="provenance/Unknown")], "/sources/0"), ([_include("IDS"), SourceMention(ref="dc/base/WorldBank_IDS")], "/sources/1"), ([_include("CRS"), _exclude("IDS"), _exclude("crs")], "/sources/2"), + (_EVERY_READING_EMPTY, "/sources/1"), + ], + ids=[ + "unadmitted ref", + "registry-excluded ref", + "exclusion empties the scope", + "every reading of ambiguous words empties the scope", ], - ids=["unadmitted ref", "registry-excluded ref", "exclusion empties the scope"], ) def test_an_unadmitted_ref_or_an_empty_scope_refuses_request_invalid( catalogue: CatalogueSnapshot, mentions: list[SourceMention], pointer: str @@ -265,6 +275,29 @@ def test_an_unadmitted_ref_or_an_empty_scope_refuses_request_invalid( assert (caught.value.code, caught.value.pointer) == ("request_invalid", pointer) +@pytest.mark.parametrize( + ("mentions", "reason"), + [ + ([_include("CRS"), _exclude("crs")], None), + (_EVERY_READING_EMPTY, "source_ambiguous"), + ], + ids=["exclusion empties the scope", "every reading of ambiguous words empties the scope"], +) +def test_an_empty_scope_compiles_without_refuse_empty( + catalogue: CatalogueSnapshot, mentions: list[SourceMention], reason: str | None +) -> None: + """The raw-text door compiles mentions a model wrote, which the caller cannot correct, + so an empty scope answers no_data rather than refusing the request.""" + pointers = ["/input/query"] * len(mentions) + compiled = compile_sources(mentions, catalogue=catalogue, pointers=pointers, refuse_empty=False) + if reason is None: + assert isinstance(compiled, SourceScope) + assert not compiled.allowed + else: + assert isinstance(compiled, SourceRefusal) + assert compiled.reason == reason + + def test_no_mentions_compile_to_no_scope(catalogue: CatalogueSnapshot) -> None: assert compile_sources([], catalogue=catalogue, pointers=[]) is None