Skip to content

fix(harness): divide a share by its own dataset's total, keep a switch's plan valid and send the plan card's text - #90

Merged
jm-rivera merged 29 commits into
devfrom
fix/studio-plan-card-and-switch
Oct 28, 2026
Merged

jm-rivera merged 29 commits into
devfrom
fix/studio-plan-card-and-switch

Conversation

@jm-rivera

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

Copy link
Copy Markdown
Contributor

Deploy after DevelopmentFinanceObservatory/website#74, which reads the new plan fields, and after DevelopmentFinanceObservatory/apis#165, and before DevelopmentFinanceObservatory/apis#166, whose provider:contributions option this harness is the first to read.

"What share of US aid went to health in 2023?" divided CRS bilateral health aid ($8.48bn) by DAC1 total ODA ($65.0bn), which also counts core contributions to multilaterals, and gave 13.05%. A dataset requirement can now declare whole_of: {part, axis: "sector"}, the whole of another requirement with that axis left open. _aligned_bases in submission/service.py aligns every other input first, and compile_whole in planner/agent.py then compiles the whole from the part's final pin through Search's resubmit, with no search. The share reads 14.2% ($8.48bn of $59.7bn). A whole takes its part's scope and has no card rows of its own, so the part's row moves both. The plan validator (whole_of_fault in planner/plan.py) admits a whole only as the denominator of its part's share or quotient, read directly or summed, and refuses any other read as an invalid plan.

A share, quotient, sum or comparison is aligned on one provider scope. An input its dataset cannot serve on the other inputs' scope is refused as providers_unaligned for the planner to repair or decline. accept_provider_difference exempts one calculation, for a user who asked for another source's total. A code-set Decision.caveat states a derived whole (derived_whole) and inputs on different provider scopes (providers_differ), whether the planner flagged them or the user's switches set them. On dev Gemini the planner declared whole_of in 9 of 9 runs where it applies, in none of 6 where it does not, and set the flag only when asked for the DAC1 total.

Switching a plan's provider failed with "No plan was made for this request", because the planner's stated source text survived the re-pin and discovery refused it. repinned in planner/plan.py clears the stated source wherever a requirement is re-pinned. A failed switch re-offers the card with switch_failed (choice, code, reason, retryable) and sends no chat message.

plan_display.py writes the card's text: a title, a one-sentence summary (left off for a calculation over ratios), step labels, the answer step flagged gives_answer, and caveats on plan.proposed and plan.approved. choice_id is built from the requirement id and axis, so it holds across switches and merged rows. A Stop during a switch cancels the revision and re-offers the unchanged plan with a live pause, within SWITCH_REOFFER_SECONDS. Approving emits plan.approved, whose changed_from holds the label from the turn's first plan. These frames are deltas 22 and 23 in docs/studio-contract-v1.md. data-mcp learns contributions_counted.

Left out: provider records for DAC1's other aid types and DAC2a, and a whole_of on any axis but sector.

Testing: the harness, planner, translator and data-mcp scope and digest tests pass, and the full CI suite runs on this PR. Local harness at this branch with the website branch, against dev Search: the US health share divided by the CRS total and gave 14.19%, the DAC1 variant kept DAC1 with its caveat at 13.05%, a stopped switch left the plan approvable, and approving after a provider and a sector switch sent changed_from on both choices. ruff, ty and pre-commit are clean.

…mmary, answer step and failed switch

Contract only: wire.py gains PlanApproved, PlanSwitchFailed, PlanProposed.summary and switch_failed, and PlanStep.gives_answer, with fixtures and the delta in docs/studio-contract-v1.md. Nothing emits them yet.
…ason on the card and shorten the plan's text

A share whose whole comes from another dataset is realigned, with no model
call, to the part's own dataset with its sector unbound; a stated source
keeps its whole and a plan decision states the providers differ. Provider
scope aligns for shares, quotients and sums only. A switch clears the
stated source on the requirements it re-pins, a translation referral in
revise refuses as plan_not_ready with its reason, and every refusal code
has a sentence that names inputs in reader words. A failed switch re-offers
the card with switch_failed. Approving emits plan.approved. The plan card
carries a title, a summary and short step labels with the answer step
marked, and "Counted as" names each input once. The harness reads the
provider:contributions binding and counts_core_contributions reason Search
will send for DAC1 totals.
…e, and name every input in reader words

A whole's source counts as stated only when the user's own words carry a short name of its dataset (DAC1, CRS) that the part's dataset lacks; a planner-written source no longer keeps a cross-dataset whole. A whole read by another calculation refuses for repair with share_whole_underivable. part_name never returns a spec_id, so the Counted as line and every reader sentence name inputs by concept, measure or dataset title. The recorded share fixture keeps only what the tests read.
…is row blocked only by another choice

A quotient of a summed sector part over a total of the same parties from another dataset is a share, so its whole is realigned to the part's own total. A scope choice whose other options are unavailable only because of another choice (conflicts_with) stays on the plan card.
… a re-pinned whole's stated source

A share or quotient of a summed sector part over an unpinned total of the same parties compiles its whole from the part's own dataset with no search, so a total Search cannot settle no longer makes the planner decline the share. A whole compiled from its part drops the stated source text left from the old pin, with the same repinned helper a switch uses, so discovery no longer refuses it with pinned_source_not_found. A realigned whole replaces the planner's decision on its source with one written from the scope records.
refusal_sentence is model-facing: the planner repairs a plan by requirement id, so it names each requirement by id. refusal_reason, the user-facing switch notice, names inputs by what they count.
…d keep Studio choices stable

A dataset requirement's whole_of names the part it is the whole of and the axis left open; the harness validates it against the part's scope records and compiles the whole from the part's own dataset with no search. Undeclared shares are validated and never rewritten, and accept_provider_difference exempts one calculation from provider alignment. Decision.caveat marks the decisions plan.approved carries as caveats. choice_id is built from the requirement and axis, a Stop during a switch re-offers the unchanged plan, switch_failed carries retryable, and changed_from holds across switches. data-mcp learns contributions_counted.
…und across instances and deploys

plan.proposed carries the same caveats as plan.approved. A provider mismatch is refused as providers_unaligned for repair. A switch on an id from a plan paused before this release still finds its row, a second Stop on another instance ends the stream, and switch_stop is armed inside the stream. A Search outage during a switch is retryable, a stopped switch carries a neutral reason, and the turn's choice baseline resets on a redraft. Scope decisions carry their provenance, so the default reason is hidden structurally; card summaries key facts by role; contributions options are read by what they bind.
… read slot-bound provider options

accept_provider_difference skips only the provider-mismatch refusal. An unknown provider option drops that option alone. Caveat decisions are exempt from the decision cap, and providers_differ is written only when the inputs' provider scopes differ. A declared whole keeps its own switched choices. plan.approved goes out once, after the approve node accepts the answer. Provider options bound by slot, as on DAC1 totals, read with their scope. Card summaries keep the share clause under a rank or aggregate, card and step rows share one core, and a switch snapshots the drafting state once.
…oved exactly once

A providers_differ caveat is written unless every input has a provider record and they match, and every flagged calculation's caveat is recomputed on each revision, carried or restated. Each declared whole states its own derived_whole caveat. An unknown active provider scope drops that choice alone. A cancelled switch node re-raises its own cancellation, the re-offer deadline is checked before a ready frame, and a retried approval sends plan.approved if the first attempt did not. Refusal reasons name an earlier turn's pin from its own block; switch failures carry their ids once, and dataset names come from BlockDataset.name.
A decision with a caveat reaches the card only through caveats, never as a row or a requirement's citation. Two rows on one axis name their inputs. A providers_differ caveat states one plain sentence per input. A whitespace ask falls back to the plan title, losing the segment after plan.approved ends the stream, a restated decision a carried requirement cites is kept, and a whole that cannot be read names its own cause through unread_part.
A comparison subtracts its inputs, so it is aligned on provider like a
share, quotient or sum, with the same accept_provider_difference flag
and caveat. A carried decision cited only by a whole restated with
whole_of is dropped, row questions name inputs as the card does, a
caveat with no dataset name drops the "from" clause, and the card and
caveats share one naming rule (concept_name) that never returns a
fragment such as "for official development assistance".
A whole the planner restates by requirement id keeps the options the
user switched on its row, and alignment never moves a declared whole.
A calculation whose inputs the user set to different provider scopes
carries the providers_differ caveat. Three refusals name their
requirement, and a Stop ends as stopped however the revision unwinds.
Inputs other than declared wholes align first, then each whole compiles
from its part's final pin with its own switched options, so a part moved
by another calculation takes its whole with it. The derived-whole
caveat leaves out axes switched on the whole's row, a re-offered plan
clears the switch deadline, a move with no card row is listed once, a
whole is named by the axis it opens ("all sectors"), and a side read
from a shared retrieval names its entity.
A revision whose translation ends on an unanswered service refuses with
DISCOVERY_UNAVAILABLE or SURFACE_UNAVAILABLE, both retryable. A
declared whole offers no row on the axis it leaves open and a switch
on it is refused. A caveat whose rationale passes the limit falls back
to a shorter form, a segment lost at the re-offer deadline keeps its
reason, and the digest words a declared conflict as a conflict.
…ixed target

A PROVIDERS_UNALIGNED refusal records an optional decline, so the
planner can decline when no repair applies. A declared whole is a fixed
target for any other calculation that reads it, removes only the
planner decision that fixes its measure, and takes its part's scope
choices with their provenance, writing rows only for axes switched on
its own row.
…ties once

Which pins are wholes comes from the requirements that declare
whole_of, so a pin an earlier plan declared a whole behaves as a plain
requirement once the planner drops whole_of. A whole takes its part's
scope and has no row, so the code for switching a whole's own row is
removed. A retrieval named apart by its parties names them once, a
chip failure keeps its code and retry mode, and the digest reads a
declared conflict's cause only from a mapping.
…reading by the whole

A Stop landing in the same wait as a finishing revision ends the switch
as stopped. A failed or stopped switch restores the plan, the turn's
readings and blocks along with the drafting state, so a whole compiled
during it leaves no reading behind. A declared whole's reading is named
by the whole (whole_name), never by its part's words.
A whole_of requirement, alone or summed, may be read only as the
denominator of a share or quotient over its part; any other read, or
none, is refused at admit with INVALID_PLAN and text telling the
planner to fetch a plain total. The fixed-target alignment for other
calculations is removed. The switch node restores state on any
exception, and compile_whole keeps the part's source fields.
…set labels on every redraft

Only a pin that declared wholes alone hold loses its scope rows, so a
plain requirement on the same spec keeps them. The turn's switched
labels reset whenever the planner drafts, which covers an execute
replan as well as an amendment.
basis_free matched each basis phrase between word boundaries, so a label
ending in a bracket, such as "grant equivalents (OECD headline)", never
matched and the card kept the old basis words after a basis switch.
…share test

card_summary named only each ratio's numerator for a comparison or sum of
shares, so a difference of health shares read as health aid amounts. It
now returns None there and the card shows its title. The seam test for a
share whose total has no pin runs again for a share as well as a quotient,
and the slot length limit comes from planner.records.
…by its wider figure

A quotient that divides by its whole through a sum had its caveat worded
as a share, and the double-count refusal blamed imputed multilateral
flows when the wider input was a DAC1 total counting core contributions.
…ir slots

A planner decision on a derived_whole or providers_differ slot replaced
the code's decision and its caveat vanished; code's now holds the slot.
A sum whose inputs sit on two known provider scopes states the difference
even when a third input has no provider record.
@jm-rivera
jm-rivera merged commit 2179282 into dev Oct 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant