Skip to content

fix(charts): keep negative weekly hours inside the chart viewBox - #44

Merged
sturlese merged 4 commits into
mainfrom
fix/bughunt-line-chart-negative-values
Sep 4, 2026
Merged

fix(charts): keep negative weekly hours inside the chart viewBox#44
sturlese merged 4 commits into
mainfrom
fix/bughunt-line-chart-negative-values

Conversation

@sturlese

@sturlese sturlese commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Found by an autonomous bughunt iteration.

Bug

line_chart — the "Hours saved per week" card on the executive dashboard — clipped any week with negative hours_saved out of the SVG entirely.

>>> ys_of(line_chart("hours", ["W27", "W28", "W29"], [10.0, -20.0, 5.0], " h", "hours saved"))
[18.0, 582.0, 112.0]   # viewBox is "0 0 560 236"; the plot band is [18, 206]

y=582 in a 236px-tall viewBox: the browser clips it. The line dives off the bottom of the card and the vertex is invisible, so the week reads as missing data rather than as a bad week.

Negative weekly hours are normal, documented domain, not a degenerate input:

  • docs/metrics.md:42"completed | rejected, minutes m (or default) | −mnegative: review time spent, nothing produced"
  • report/terminal.py:17 — the sparkline carries the comment "hours_saved can be negative (a rejection-heavy week earns negative minutes)" and three pinning tests in tests/test_terminal.py
  • charts.hbar_chart"Handles negative values (a workflow can destroy value; the chart must be able to say so)"

Every sibling surface was made to say so. line_chart was the one that wasn't. This also cuts the wrong way against ADR-004: hiding a value-destroying week overstates the program's health.

Root cause

charts.py:83-89 fixed the y-domain to [0, _nice_top(max(values))]:

top = _nice_top(max(values))
ys = [_PAD_T + plot_h * (1 - v / top) for v in values]

For v < 0, 1 - v/top > 1, so y > _PAD_T + plot_h — below the plot floor, outside the viewBox.

Fix

Derive the domain from both extremes so it always contains zero, and place the axis on the zero line rather than the frame floor:

  • top / bottom are each _nice_top-rounded only on the side that actually needs it; bottom stays 0.0 whenever min(values) >= 0.
  • The area wash closes to zero_y, so a negative excursion reads as a dip below the axis instead of a full-height fill.
  • _grid_and_axis takes an optional bottom (default 0.0) and spreads its ticks over [bottom, top].

Non-negative data is coordinate-for-coordinate unchanged — with bottom=0 the zero line is the plot floor and every formula collapses to the original. That is pinned by a test, because the demo dashboard is CI's end-to-end golden.

Test

tests/test_charts.py, three cases:

test asserts
test_line_chart_keeps_a_negative_week_inside_the_viewbox mixed signs stay in [18, 206]; the negative week sits lowest
test_line_chart_all_negative_stays_in_band_and_hangs_below_the_axis all-negative stays in band and below the zero axis
test_line_chart_non_negative_framing_is_unchanged exact coordinates on the normal path — regression guard

Verified to fail without the fix (git stash on the source alone): AssertionError: [1146.0, 2086.0] and [18.0, 582.0, 112.0]. The non-negative guard passes on both sides, as a regression guard must.

Validation

All three CI jobs run green locally:

  • python -m pytest --cov=flightdeck --cov-fail-under=85261 passed, coverage 94.49%
  • ruff check src testsAll checks passed!
  • flightdeck demo + flightdeck audit verify → ledger verified, 2,434 entries, chain intact

🤖 Generated with Claude Code

https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx

sturlese and others added 4 commits September 4, 2026 00:39
The weekly line chart floored its y-domain at zero, so a week with negative
hours_saved -- which docs/metrics.md defines as normal (a rejected run earns
-m minutes) -- mapped far below the 236px viewBox and was clipped away by the
browser. The dashboard silently hid exactly the week an executive most needs
to see.

Derive the domain from both extremes so it always contains zero, wash the area
back to the zero line rather than the frame floor, and draw the axis on zero.
Non-negative data keeps bottom=0 and renders coordinate-for-coordinate as
before. The terminal sparkline already handled this input; the HTML surface was
the unfixed sibling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
…dentical

Gate review: the first commit widened the y-domain correctly but reformatted the
axis from "206" to "206.0", which also reached column_chart -- the AI-spend card,
which has nothing to do with negative hours -- and made the docstring's
"byte-for-byte as before" claim false on every non-negative input.

Emit whole coordinates through _coord() so they keep their integer form. The
non-negative path is now genuinely byte-identical: 0 diffs over 506 generated
cases for both line_chart and column_chart, and the demo dashboard matches main
exactly. The regression tests now pin the axis and area verbatim -- the two marks
the fix actually rewrote -- instead of only the line coordinates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
…tive

Gate review, second round. Keeping the negative week inside the viewBox was only
half the job: the ticks stayed anchored to thirds of the band while the axis moved
to a value-derived position, so a mixed-sign chart showed a heavier unlabeled rule
at an arbitrary height and three ticks none of which was zero. The reader could
see the dip but not read how deep it went -- which is the number the fix exists to
surface. Two smaller faults shared that root cause: adjacent ticks could both
render "-0.1" (or "-0.0", a signed zero the project forbids twice), and zero's
gridline could land on the axis, stacking two opaque strokes and costing the chart
one of its three promised hairlines.

Snap a two-sided domain to multiples of a nice step, so zero and the domain floor
are always labeled gridlines; take tick precision from the step rather than the
value, so neighbouring ticks stay distinguishable; skip the hairline under the
axis; and normalize a signed zero away in _fmt, matching format.money's contract.

A domain floored at zero still takes the established path and is byte-identical:
0 diffs over 2,006 non-negative cases for line_chart and column_chart, and the
demo dashboard matches main exactly. Over 5,583 mixed-sign cases: no value escapes
the plot band, no signed-zero tick, no duplicate or coincident rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
Round-2 review: test_line_chart_draws_no_hairline_under_the_zero_axis passed
unchanged on 678655f -- the exact code that did stack a hairline on the axis. The
axis is emitted through _coord ("18") while gridlines use ":.1f" ("18.0"), so the
string comparison missed at precisely the two integral positions where a
regression lands. Compare floats; verified it now fails on 678655f.

Also narrow two claims the review showed were overstated: the byte-identity of a
zero-floored domain holds except for a label that rounds to zero losing its minus
sign (the deliberate _fmt normalization, which also reaches column_chart's peak
label for -0.0), and a two-sided scale yields 2-4 hairlines rather than the fixed
three, since readable tick values matter more than a tidy count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
@sturlese

sturlese commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Gate verdict — PASS after two amendment rounds

Four adversarial reviews, each told to refute the fix from a different angle. Recording what they broke, since it shaped the final diff.

Round 1 — three findings, all real, all fixed:

  1. Regression angle. The first commit widened the domain correctly but reformatted the axis from 206 to 206.0 — and that reached column_chart, the AI-spend card, which has nothing to do with negative hours. The docstring's "byte-for-byte" claim was false on every non-negative input (2008/2008 cases). Fixed in 678655f by emitting whole coordinates through _coord.

  2. Visual angle (the one that mattered). Keeping the negative week on the page was only half the job. Ticks stayed anchored to thirds of the band while the axis moved to a value-derived position, so a mixed-sign chart showed a heavier unlabeled rule at an arbitrary height and three ticks none of which was zero. The reader could see the dip but not read how deep it went — the number the fix exists to surface. Two smaller faults shared that root cause: adjacent ticks could both render -0.1 (or -0.0, a signed zero this project forbids in format.py and charts._money), and zero's gridline could land on the axis, stacking two opaque strokes.

  3. Correctness angle. No math defect — ~1.2M generated inputs, zero band escapes, _nice_top proven to round outward for every normal float. It independently flagged the same unlabeled-floor problem as (2).

ab2d6d3 addressed all of it: a two-sided domain snaps to multiples of a nice step so zero and the domain floor are always labeled, tick precision comes from the step rather than the value, no hairline under the axis, and _fmt normalizes signed zeros away.

Round 2 — one blocking finding, fixed:

test_line_chart_draws_no_hairline_under_the_zero_axis was vacuous: it passed unchanged on 678655f, the exact code that did stack a hairline. The axis goes through _coord ("18") while gridlines use :.1f ("18.0"), so the string comparison missed at precisely the two integral positions where a regression lands. Now compares floats, and verified to fail on 678655f.

The same round narrowed two claims I had overstated, now corrected in the docstring: byte-identity holds except for a label that rounds to zero losing its minus sign, and a two-sided scale yields 2–4 hairlines rather than a fixed three.

Verified invariants

non-negative byte-identity 0 diffs / 2,006 cases, line_chart and column_chart
demo dashboard vs main byte-identical
band escapes, mixed sign 0 / 5,583 cases
signed-zero ticks 0
duplicate or coincident rules 0
independent re-verification ~1.5M targeted cases, snapping/band/labeling unbroken

Known follow-ups (not defects, deliberately out of scope)

  • Domain utilization can drop to 50%: the step is computed from the unsnapped span, so one negligible negative week rescales the chart ([3.0, -0.0001] → domain [-2, 4]). A second pass over the snapped span would recover it.
  • Wide ticks (-1,000 h, ~43px) could clip the 38px left gutter at |tick| ≥ 1000 h.
  • Two absurd-input crashes ([1e308, -1e308], [0.0, -5e-324]) where main also produced garbage.

CI green on all five jobs. Left open for human review.

@sturlese
sturlese merged commit 752ef57 into main Sep 4, 2026
5 checks passed
@sturlese
sturlese deleted the fix/bughunt-line-chart-negative-values branch September 4, 2026 07:39
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