feat: aggregates carry their own provenance (UX Rule 4, step 1) - #346
Merged
Conversation
An aggregate hides its uncertainty precisely BY aggregating. LifeRow already carries death_inferred, but LivesResponse.deaths was a bare count - so a widget showing '12 deaths' gave a user no way to know some were reconstructed from Corpse lines after CIG removed the Actor Death log entries. deaths_inferred is computed in the SAME pass as deaths, over the same list, so the split and the total cannot drift. A separately-derived split would eventually disagree with the total it describes. <Provenance> is a sibling of InferredBadge, not a change to it - that component answers 'is this row inferred', which is a different question from 'how much of this number is inferred', and its two call sites are correct. It renders NOTHING when inferred is 0: badging every number teaches people to ignore the badge, and the signal is only worth having while it is rare. Corrected the spec mid-implementation: an earlier draft claimed this step needed no backend work. Checking found per-row provenance exists but per-aggregate does not, so wiring the component would have built a mechanism with nothing to display - the exact failure this codebase keeps producing. A test caught a real bug in my own clamp: Math.max(total, inferred) defeated the clamp entirely and would have rendered '15 of 12', broadcasting an upstream bug to the user as a finding. Mutation-verified both halves.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First implementation step of
docs/superpowers/specs/2026-07-31-ux-rules-2-and-4-design.md.The problem
An aggregate hides its uncertainty precisely by aggregating.
LifeRowalready carriesdeath_inferred, and the session/event detail views already render anInferredBadge. ButLivesResponse.deathswas a bare count — so a widget showing "12 deaths" gave a user no way to know some were reconstructed fromCorpselines after CIG removed the Actor Death log entries.Measured:
InferredBadgeappears on 2 surfaces (both raw event views) and 0 of 25 widgets. Exactly inverted from where it matters.The change
LifeSummary.deaths_inferred, computed in the same pass asdeathsover the same list, so the split and the total cannot drift. A separately-derived split eventually disagrees with the total it describes.LivesResponse.deaths_inferredon the wire.<Provenance>— a sibling ofInferredBadge, not a change to it. That component answers "is this row inferred"; aggregates need "how much of this number is inferred", and its two existing call sites are correct.It renders nothing when
inferred === 0. Badging every number teaches people to ignore the badge; the signal is only worth having while it's rare.Two corrections made during implementation
I corrected the spec. An earlier draft claimed this step needed no backend work. Checking found per-row provenance exists but per-aggregate does not — so wiring the component would have produced a mechanism with nothing to display, which is the exact failure this codebase keeps producing. The spec now records that, rather than quietly fixing it.
A test caught a real bug in my own component. My clamp was
Math.min(inferred, Math.max(total, inferred)), which defeats the clamp entirely — it would have rendered"15 of 12", broadcasting an upstream bug to the user as a finding. NowMath.min(inferred, total).Test plan
387 + 2 core, 1015 + 987 server, 670 web, clippy clean, lint clean, no codegen drift.
Mutation-verified both halves:
inferred <= 0short-circuit → 5 tests become 2 failed, restored to 5.The core tests drive the real
derive_livesfunction rather than re-implementing its filter — the first draft did the latter, which asserted nothing about the code.Not in this PR
Rule 2 (context) and the remaining aggregates.
deathsis the proving case becausedeath_inferredalready existed per-life, so the split needed no new inference logic.