Skip to content

Overview planner charges for the sections it will actually run - #666

Open
philcunliffe wants to merge 1 commit into
masterfrom
fix/issue-665
Open

Overview planner charges for the sections it will actually run#666
philcunliffe wants to merge 1 commit into
masterfrom
fix/issue-665

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Partial fix for #665. It addresses the issue's item 2 only (the hardcoded
section count), plus a sibling defect with the same root cause. It deliberately
does not touch the calibration constant, the ratio's design, or the open
question - see "What this PR does NOT fix" below.

Correction (2026-08-07): latent on master today, but NOT once #667 lands.
PR #667 (wizard-first-ask) trims setup to a two-section subset -
first_look.js:187 calls collectOverview(runner, { into: partial, sections: FIRST_LOOK_SECTIONS })
and its LLP records the decision under #wizard-sections. With #667 merged and this PR
not, the wizard would request two sections while rowsAffordable charges for four,
narrowing the window to roughly half what the budget allows and truncating more than
necessary. Merge this PR before or together with #667.

The original note below is accurate for master in isolation and is kept for the record.

Current impact on master alone: latent. No shipped caller passes a sections subset today.
first_look.js:163 calls collectOverview(runner, { into: partial }), i.e. all
four, and LLP 0135 #first-look records that the earlier ['models','daily'] trim
was reverted as over-cautious. So no shipped caller's planning changes here,
and the Stopped here to keep setup moving message users see today is truthful

  • those sections really did not finish. What this PR fixes is the documented,
    already-tested subset seam behaving correctly when it is used, rather than silently
    halving the window and mis-naming sections.

One additive output change, found in review: collectOverview now stamps
out.sections on the result, and query.js:221 serialises the whole object, so
hyp query overview --json gains a sections key listing the sections planned.
Purely additive, no key removed or renamed, but noted because an out-of-repo script
could be reading that payload.

On the LLP citation in #665: there is no LLP 0195 on master (the corpus ends at
0194), so the reference does not resolve there. It does resolve on #667's branch,
where neutral has since renumbered that doc to 0198 to clear a collision with
fix/issue-653. The reporter was describing their own in-flight branch, and their
measured symptoms and causal story hold against it.

What was wrong

rowsAffordable computed const sectionCount = OVERVIEW_SECTIONS.length -
always 4 - while collectOverview runs opts.sections ?? OVERVIEW_SECTIONS
and executes only the requested subset. A caller asking for two sections was
charged for four, so its time cap came out half what the work costs, and the
window narrowed in proportion. The shortfall is invisible to the caller:
describeWindow reports the scope and the lever, never the reason, so a
time-bound window looks the same whether the estimate was right or double.

LLP 0135 #window already states the model as remaining / (perRowMs x 1.9 x sections). "sections" there is the count planned; the code read it as the
count that exists.

Sibling defect, same seam, opposite direction. missingSections filtered
OVERVIEW_SECTIONS unconditionally, though its own doc comment says "which of
the requested sections". A subset run therefore reported every unrequested
section as missing, and first_look.js prints that list verbatim: Stopped here to keep setup moving - the repos and tools sections did not finish. Those
sections were never started, so the sentence is false in exactly the way
LLP 0135 #overrun says it must not be ("'no repos' and 'the repos section did
not finish' are different claims and only one is true"). Fixed in the same PR
because it is the same missing thread, and it has its own regression test.

The fix

  • rowsAffordable takes sections (defaulting to all four) and divides by
    sections.length.
  • chooseOverviewWindow accepts sections in its opts and passes it through.
  • collectOverview passes the section list it is about to run.
  • collectOverview stamps out.sections on the result before any await, so an
    abandoned run still carries the plan; missingSections filters
    rows.sections ?? OVERVIEW_SECTIONS.
  • OverviewRows.sections added to src/core/query/types.d.ts.

No behaviour change for either shipped caller: hyp query overview and the
wizard both request all four sections today, so both still divide by 4. What
changes is that the documented subset seam now plans correctly instead of
silently halving the window and mis-naming the sections.

Evidence: fails before, passes after

Three tests in test/core/query-overview.test.js. On unmodified master (with
only the test file added):

not ok 30 - chooseOverviewWindow: the plan charges for the sections it will run, not all four
    Expected values to be strictly equal:
    3 !== 7
not ok 36 - collectOverview: the window is planned for the sections actually requested
    Expected values to be strictly equal:
    3 !== 7
not ok 37 - missingSections: a section nobody asked for is not reported as unfinished
    Expected values to be strictly deep-equal:
    + actual - expected
    + [
    +   'repos',
    +   'tools'
    + ]
    - []
# tests 62
# pass 59
# fail 3

The 3 !== 7 is the whole bug in one number: 20 days x 10k rows, a 2000ms
probe against a 5000ms budget leaves 3000ms at 0.01ms/row, which buys 39,473
rows (3 days) for four sections and 78,947 (7 days) for two. Master returns the
four-section answer to both. The ['repos', 'tools'] is the false claim the
wizard would have printed.

After the fix, same file:

# tests 62
# pass 62
# fail 0

Full suite node scripts/run-tests.js: green both ways - 3588 pass / 0 fail on
master, 3591 pass / 0 fail here (the three new tests). npx tsc -p tsconfig.json --noEmit: clean, exit 0.

What this PR does NOT fix

The issue is broader than this change. It should not be read as closed.

  • SECTION_COST_VS_PROBE is untouched at 1.9. The issue's item 1 asks for
    a re-measurement across several cache sizes; what exists is one data point
    (~3.4x at 91k rows) from a machine this branch cannot reach. Re-calibrating a
    constant on a single unreproducible measurement is the kind of unverified
    change that has no regression test to sit behind, and over-correcting to 3.4
    would narrow the window on every smaller cache where 1.9 is right. The
    constant and its comment still record the 48k-row measurement, which is the
    honest state of what is known.
  • The ratio is still a single constant. Item 3 (per-section weights, or a
    floor on the estimate) is a design change: the constant's own comment argues
    for a ratio over an absolute rate, and replacing it is a decision for a
    maintainer and an LLP, not a bugfix.
  • The open question is still open. Why truncation fired in the wizard when
    the arithmetic predicts ~4.7-5.7s against an 8s deadline is unresolved, and
    cannot be resolved from here: it needs a wizard.first_look span from a real
    91k-row cache. It may well indicate a cost the model does not account for at
    all, in which case this PR's arithmetic correction is necessary but not
    sufficient.
  • OVERVIEW_TIME_BUDGET_MS and FIRST_LOOK_BUDGET_MS are unchanged, per
    the issue and first_look.js: the deadline is a backstop, not a mechanism.

One incidental staleness left alone: LLP 0164 notes in passing that
"OVERVIEW_SECTIONS.length feeds rowsAffordable". That is still true of the
default path, and 0164 is an accepted decision record whose point (that it left
the overview budget untouched) is unaffected, so it was not edited.

Fixes #665

`rowsAffordable` divided the time budget by `OVERVIEW_SECTIONS.length`,
always four, while `collectOverview` runs only the sections it was asked
for. A subset caller was therefore charged for work nobody would do and
got a window narrowed in proportion, with nothing to show for it: a
window bound by time reports no reason, so the shortfall is invisible.
LLP 0135 #window already states the divisor as the sections planned.

`missingSections` had the same seam in reverse: it filtered the full
section list, so a subset run reported the sections nobody asked for as
sections that did not finish. The wizard prints that list verbatim
("the repos and tools sections did not finish"), which would have been a
false claim about work never started. `collectOverview` now stamps the
plan on the result, before any await, so an abandoned run still carries
what it meant to do.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 - 65e4844 - verdict: findings (1 non-blocking, fixed; 2 nits, left)

Core fix is correct. Nothing blocking.

Gates, verified independently

Check Result
node scripts/run-tests.js 3592 tests / 3591 pass / 0 fail / 1 skipped
npx tsc -p tsconfig.json --noEmit exit 0
Em dashes (U+2014) in diff 0
LLP anchors 0135#window, 0135#overrun both exist (:808, :864)

The regression tests genuinely prove the fix

Applied the PR's test file verbatim onto a clean origin/master worktree: exactly the
three claimed tests fail, and only those (59 pass / 3 fail). The arithmetic was re-derived
by hand and holds: 200k rows, probeMs 2000 → perRowMs 0.01, sectionBudgetMs 3000;
four sections afford 39,473 rows → 3 days, two sections afford 78,947 → 7 days.

Test 36 reaches the same result through the real collectOverview seam rather than
restating test 30, and test 37's third assert pins the emptyOverview() fallback. That is
the right shape.

The out.sections stamp holds up under attack

This was the part most likely to be subtly wrong, so it was attacked directly:

  • Set before every abandonment path? Yes. The stamp is synchronous, before
    const probeStart = clock() and the first await, with no early return or throw site
    ahead of it. Because withDeadline(collectOverview(...)) evaluates the call at the
    argument position, partial.sections exists before the promise reaches withDeadline.
    There is no window where an abandoned run holds a partial without its plan.
  • Hand-built OverviewRows? emptyOverview() omits sections, so
    ?? OVERVIEW_SECTIONS reproduces master exactly - which test 37 pins.
  • first_look.js? Requests all four, so missingSections filters all four, identical
    to master. Both call sites (:186 span attr, :203 the user-facing sentence) unaffected.
  • Empty section list? budgetMs / 0InfinityboundBy: 'rows', as the new
    comment claims. The 0/0 → NaN degenerate case is unreachable because
    sectionBudgetMs >= MIN_SECTION_BUDGET_MS (400). The comment is honest.

Only sharp edge left is contrived: reusing one into object across two collectOverview
calls with different subsets (last write wins). No such caller exists.

Finding 1 (non-blocking) - FIXED in the PR body

hyp query overview --json gains a sections key. query.js:221 serialises the whole
OverviewRows, and out.sections is now an enumerable own property, so the machine-readable
payload changes additively. No test pins the JSON key set, and the field is arguably useful,
but the PR body claimed no shipped caller's behaviour changes - and this is the surface most
likely to have an out-of-repo consumer. The body has been corrected to state the additive key
explicitly. No code change: suppressing it via a non-enumerable property would trade a
readable field for an obscure one.

Two nits, deliberately left

  • LLP 0164:193 has a descriptive aside, "OVERVIEW_SECTIONS.length feeds
    rowsAffordable", which after this PR is true only of the parameter default. The
    decision's actual claim on that line still holds and 0164 is Accepted, so the
    immutability rule argues for leaving it. Recorded so it is a choice, not an oversight.
  • The section-name union is now repeated six times (overview.js:163,241,391,484,515
    and types.d.ts:167); this PR adds three. An export type OverviewSection in
    types.d.ts would collapse them. Pre-existing pattern, out of scope for a fix PR, but
    it is now at the point where drift is plausible.

Deliberate omissions, confirmed correct

SECTION_COST_VS_PROBE left at 1.9, no ratio redesign, no time-budget change, the issue's
open question left open. Each needs measurement neutral cannot perform, and #665 stays open
after this merges.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 - 65e4844 - verdict: clean

The code is byte-identical to what round 1 reviewed. Round 1's only actionable finding was
that the PR body overclaimed, and that was fixed by amending the body, so the head never
moved. This round exists to confirm nothing actionable remains, not to re-review from scratch.

The body correction is accurate and sufficient

Verified empirically, not just by reading the code: collectOverview was run against a
stub runner in a worktree at this head, and the result keys came back
['providerRows','dailyRows','repoRows','toolRows','sections'], with JSON.stringify(out)
containing "sections". src/core/commands/query.js:221 serialises exactly that object,
confirming the trace.

The body now discloses that additive --json key explicitly. The rest of it holds up: the
"no shipped caller's planning changes" wording is correctly scoped to first_look.js:163
and the wizard, which both request all four sections, and the "What this PR does NOT fix"
section matches the diff (no touch to SECTION_COST_VS_PROBE, the ratio design, or the
budget constants).

Round 1's two deliberate omissions confirmed as the right call

  • LLP 0164:192 is Status: Accepted and unedited in this diff. Under CLAUDE.md's
    immutability rule, leaving the descriptive aside rather than editing it in place is
    correct; if it needs updating that belongs in an extending doc, not a bugfix PR.
  • The section-name union repetition is confirmed at 6 sites
    (overview.js:163,241,391,484,515, types.d.ts:166), 3 added here. The pattern
    pre-existed this PR at :163 and in the original types.d.ts, so collapsing it is
    genuinely out of scope for a targeted fix.

Fresh pass

Diff unchanged from round 1: overview.js, types.d.ts, and the test file only, no LLP
edits. The new assertions were walked against the probingRunner/answeringRunner helpers;
the arithmetic and the "requested but not landed" vs "never requested" distinction both hold.
No new issue.

Gates

  • node scripts/run-tests.js → 3592 total, 3591 pass / 0 fail / 1 skipped
  • npx tsc -p tsconfig.json --noEmit → exit 0

Nothing actionable remains at this head.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Overview planner under-estimates section cost (SECTION_COST_VS_PROBE measured 1.9x, actual ~3.4x)

1 participant