Skip to content

The heat projection's cost follows the file rather than the window #55

Closed

Description

@breferrari

Found by take-next's fifth pre-flight comparison, the one #34 added: an open SPEC.md §10 bullet that no issue names.

The bullet

The heat projection is the one drawn thing whose cost follows the file rather than the window. Placing a change needs its working-tree line number, and a line number is only known by counting the lines before it, so heat_of walks every line of a drawn file's hunks even though the viewport shows a screenful.

It has been open since #39 landed the heat strip. Nothing tracks it, so it is unassignable and would never be returned by the milestone-filtered query take-next runs first.

Why it is not urgent, said out loud

The bullet carries its own measurement and the measurement is comfortable. Release, over the 100-file 100k-line fixture, the shell frame went from 7.66ms to 8.14ms p99 against the 16ms I9 budget, while the core frame path, which executes none of this code, moved 6.96ms to 7.29ms on the same runs. So most of the difference is run-to-run variation and what is left is a few tenths of a millisecond.

It is also not a read. reads.rs does not move and I2a is untouched: this is arithmetic over lines already in memory, and it runs only for headings actually drawn, which is one or two per frame.

Why it is still worth a row

The shape is the one I4 exists to forbid: a drawn thing whose cost follows the file rather than the window. Everything else in the shell follows the screen. Rows above the window are counted rather than built, highlighting is asked for only on a row that is pushed, and a file the viewport never reaches is never diffed. This is the single exception, and #45 is the precedent for why a known instance of a forbidden shape should not sit both unfixed and ungated: that one was affordable in wall clock too, right up until a reader scrolled a Japanese README.

The fix, when it is taken

Carry the line number on Line rather than recomputing it. That is the same information the gutter already derives twice, so this is the by-product check §5.2 asks for, and it has paid four times in this project already.

Acceptance

  • heat_of's cost follows the viewport rather than the file, or the bullet is closed with a measured argument that it should not
  • A structural gate over a counter, not a wall-clock one, so it holds on a hosted runner. The pair paint.rs uses is the precedent: a count of what was examined, against a bound derived from the run rather than from a constant
  • The gate is driven red by mutation before it is trusted
  • SPEC.md §10's bullet is closed with whichever answer the measurement gives

Activity

  1. breferrari commented on Sep 30, 2026

    @breferrari
    OwnerAuthor

    Plan (pre-approved by the reader for this run)

    Taken from the Shelf by the unattended loop on 2026-09-30. next.sh is dry. The deferral reason on this row is "nothing had ever taken it", which is not a reason that can still hold, so the row is eligible.

    What it rests on

    • SPEC.md I4: "A frame builds only what it draws. Diffs, highlighting and reads of file content follow the window, never the worktree." The §10 bullet this issue tracks names the heat projection as the one drawn thing whose cost follows the file. This pass closes that bullet.
    • SPEC.md §5.2: "the expensive-looking property was a by-product of work already being done. Look for the by-product before you design." The by-product here is the diff itself: the projection depends on nothing but the hunks, so it is true for exactly as long as the cached diff is, and the frame path already knows that interval.
    • The vault note "The heat strip, and the line count that was already being computed" (2026-07-31) records the cost at a few tenths of a millisecond and names the fix as carrying the line number on Line. See the deviation below.
    • Nothing in the record argues against bounding this cost. The bullet itself says the shape is the one I4 exists to prevent.

    Premises

    Must be true How it can be false Answer
    heat_of runs once per drawn entry per frame It is already cached somewhere Read in the code. entry_of calls heat_of(diff) at every call, and it is called from the diff walk and the list walk on every frame (view.rs). No cache sits in front of it.
    A diff's heat is a pure function of the diff It reads something else Read in the code. heat_of(&FileDiff) reads diff.hunks and diff.lines only.
    The core knows when a cached diff is replaced The cache is keyed by something weaker Read in the code. Frame::diff reuses a Cached { taken, diff } entry only when reusable proves it, and replaces the whole entry when it recomputes. A value stored beside the diff is dropped exactly when the diff is.
    A second Frame::diff call for the same file in one frame is free It costs a second stat Read in the code. It costs a second stat on every settled worktree file (reusable calls fresh() each time). So the derived value has to come back from the same call that returns the diff, not from a second one.
    The existing frame with the most drawn headings is affordable The rail pane changed since Recorded in SPEC.md §10: a 200x40 pane drawing thirty-six pinned rows takes 0.71ms p50 and 0.79ms p99 against I9's 16ms. This pass re-measures it on both sides of the change with a_frame_beside_a_rail_holds_the_frame_budget.

    Checks on the record

    • I4, quoted above. Its budget is first paint under 100ms and the height count under 16ms. Neither is at risk; this pass removes a per-frame cost, it adds none.
    • I9: "steady-state frame time holds 60fps under continuous edits", 16ms. The frame this touches sits at 0.71ms p50 (§10). Headroom is more than 15ms.
    • I3: the derived value lives inside the diff cache, which is bounded by the viewport and swept with the diff. It adds one 192-byte array per cached diff and no new map. Nothing grows over a session that the diff cache does not already bound.
    • No refusal is cited by this issue or by the bullet.

    Deviation from the recorded fix, with the reason

    The bullet and the vault note name the fix as carrying the working-tree line number on Line. That removes the arithmetic in Hunk::positions but not the walk: counting added and removed lines into slices still visits every hunk line of every drawn file on every frame, so the cost class does not change. What changes the class is computing the projection once per computed diff and serving it from the cache while the diff is reused. That is the by-product §5.2 asks for, and it makes the per-frame cost a copy of 48 buckets per drawn heading.

    Promises

    crates/vigia-core/src/frame.rs

    • Cached gains derived: Option<Box<dyn Any + Send + Sync>>, dropped with the entry.
    • FrameStats gains pub derived: u64: values derived from a cached diff, each computed once per diff. This is a public field on a public struct, so it is a breaking addition for an exhaustive literal outside this workspace. Flagged for the bump level in the report.
    • pub fn diff_with<T: Any + Clone + Send + Sync>(&mut self, index: usize, derive: impl FnOnce(&FileDiff) -> T) -> Result<(&FileChange, &FileDiff, T)>. Calls diff, then serves the derived value from the slot or computes and stores it. A failed read is not cached, so its hunkless diff derives in constant time on each call.
    • Frame::diff is unchanged.

    crates/vigia/src/view.rs

    • Changed gains heat: [HeatBucket; HEAT_BUCKETS].
    • The diff walk and the list walk call frame.diff_with(index, heat_of) where they called frame.diff(index).
    • entry_of takes the heat as a parameter instead of calling heat_of.
    • heat_of, bucket_of, sampled and their unit tests do not move.

    crates/vigia-core/tests/support/mod.rs, crates/vigia-core/examples/timings.rs: delta carries the new counter.

    Tests

    • crates/vigia/tests/reads.rs::a_heat_strip_is_projected_once_per_diff: settle a frame, draw it twice. The first view derives at least one value and draws at least one heading with heat in it. The second view derives zero. Then edit one drawn file so its change moves to another part of it and let it settle. The next view derives no more values than it computed diffs, and that file's drawn heat differs from before. The first assertion is what the mutation reddens; the last is what a slot that outlived its diff would fail.
    • crates/vigia-core/tests/frame.rs::a_derived_value_is_computed_once_per_diff: diff_with on a settled frame derives once, then serves; after a write and settle, it derives again and the value follows the new diff.

    Mutation: skip the slot read in diff_with and watch the reads gate go red. Keep the old slot across a recompute and watch the edit assertion go red.

    Documents

    • SPEC.md §10: the bullet closes, struck through, with the mechanism and the before-and-after numbers from the rail frame.
    • ROADMAP.md: the row flips, and the Pull-forward log gets a line.

    Out of scope: slice_of walks a file's positions once per old-side note per frame. That is bounded by the notes a file carries, not by the file, and this pass does not touch it. Named in the report.

    Label: internal, chore: title. The pane shows nothing different. Said in the report.

  2. breferrari commented on Sep 30, 2026

    @breferrari
    OwnerAuthor

    Closed by #628.

    The heat strip is projected once per computed diff: Frame::diff_with keeps a value derived from a cached diff beside that diff and serves it while the diff is reused, and FrameStats::derived counts the derivations. Gates: crates/vigia/tests/reads.rs::heat_once_per_diff (a second screen over settled diffs derives 0; a rewrite of one file derives exactly as many strips as diffs it recomputed, and the strip follows the new diff) and crates/vigia-core/tests/frame.rs::derived_once_per_diff. Four mutations killed by those gates; a fifth, the shell projecting per frame while ignoring the frame's value, is refused by -D warnings.

    Rail frame, release, 200x40 pane drawing 36 pinned rows, two rounds each interleaved: p50 5.69ms and 5.65ms before, 5.67ms and 5.64ms after; p99 7.05ms and 7.11ms before, 6.74ms and 6.96ms after, against I9's 16ms. The frame did not move measurably; the shape did. SPEC.md §10's bullet is closed with those numbers.

    Left as it was: slice_of walks a file's positions once per old-side note per frame, bounded by the notes a file carries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions