Skip to content

fix(effort-viz): make primitive and lifecycle legible at a glance - #228

Merged
tonyketcham merged 14 commits into
toeknee/effort-viz-viewport-decision-chain-99c2from
toeknee/effort-viz-node-legibility-4df9
Jul 27, 2026
Merged

tonyketcham merged 14 commits into
toeknee/effort-viz-viewport-decision-chain-99c2from
toeknee/effort-viz-node-legibility-4df9

Conversation

@tonyketcham

Copy link
Copy Markdown
Collaborator

Summary of changes

The Effort Graph viz could not answer its most basic question: what kind of record is this? nodeColor hashed hue from the effortId and gave node kind only a ±0.06 lightness nudge, so every record in a cluster rendered as the same coloured circle. A correct per-kind palette already existed in RelationLegend.tsx and was dead code — and the legend drew its six swatches from the broken function, so it showed six identical dots next to six different labels. Lifecycle state was never drawn on the canvas at all.

Before and after comparison of the graph

Encoding

  • Hue carries the primitive. Effort membership was already delivered by three channels — the force layout's shared centroid, a large labelled hub, and hub-tinted membership spokes — so it was spending the strongest nominal channel on the attribute that needed it least.
  • Shape repeats hue as a colour-vision backstop, with each risky pair given maximally different outlines: amber/red merge under deuteranopia (diamond vs triangle), blue/violet under protanopia (circle vs square), blue/green under tritanopia (circle vs bar). Outlines are area-normalized in lib/glyphs.ts so silhouette cannot imply an importance ranking, and record chroma is kept level for the same reason.
  • Effort hubs became neutral rings with a cluster-tinted core. The core also covers the membership spokes converging on the centre, which previously showed through the ring's hole as clutter.
  • Legend and canvas derive from the same outlines and palette, so the key cannot drift from the render, and the legend lists only the relations the current generation actually contains.

Legend before and after

Lifecycle

lib/lifecycle.ts derives effective state from edges. Forward edges are authoritative and frontmatter state can lag them — a Decision replaced through an inline supersedes keeps state: accepted, so reading the field labelled retired reasoning as committed, on the record a reader most needs to get right. Retired records now fade with a struck-through label, and the drawer explains why its frontmatter disagrees.

Badges are keyed on primitive:state: a Risk's "accepted" means we chose to live with this hazard and must not borrow a Decision's reassuring green. Added the missing wontfix, mitigated, realized, and Effort status vocabulary. Open blocker Issues wear an amber warning outline.

Detail panel for a superseded Decision

Layout, camera, and vocabulary

  • Added cluster separation to the force model. Generic node repulsion separates records but lets clusters interleave, and position is the channel carrying cluster identity. Cluster aggregates moved onto the scratch object, removing three map allocations per frame.
  • Effort titles anchor above their cluster's bounding box instead of on the hub, which sits at the centroid and put every label on top of the records it names.
  • The camera eases into place instead of cutting after a 2.4s delay, keeps re-fitting while the layout spreads, and yields permanently once the reader pans or zooms. Only the always-present legend is reserved in the fit.
  • Vocabulary aligned with .agents/skills/effort-graph/glossary.md: Primitives not "node kinds", Relations not "Decision chain", relation groups named after the edges the CLI uses, and descriptions rewritten to match the glossary's claims (invalidates says a record was wrong, not "no longer valid"). The header counts primitives and lifecycle rather than nodes and edges — half the edges are synthesised membership spokes, and "173 nodes" says a dot appeared where "3 proposed Decisions" says someone owes a call.

Bugs fixed along the way

  • Node retraction never rendered. The scene resolved metadata from the current query result, so a deleted record vanished instantly while its edges withdrew gracefully.
  • Pointer hit radius was inverted. R3F hard-codes viewport.factor to 1 for orthographic cameras, so the "44 CSS px" calculation produced a constant 22 world units — a 176px target at default zoom and 22px at minimum, exactly backwards.
  • The first click permanently disabled auto-fit. OrbitControls fires start on pointer-down before anything moves, so tapping a record counted as a pan.
  • Selection committed on pointer-down while left-drag pans, so panning from a record opened the drawer.
  • computeLineDistances() allocated a fresh Float32BufferAttribute per dashed edge per frame.
  • The reduced-motion warm-up ran 240 synchronous physics steps on every live generation; BlockerRing ignored prefers-reduced-motion entirely.
  • A failed refetch pinned the view to stale data forever, because the generation marker advanced before the request.
  • The query is now assembled from schema introspection. Flatbread grows its schema from records on disk, so the fixed document omitted supersedes/superseded_by on Findings and Constraints — and a Finding has no state field, so supersession is its only retirement signal.
  • Supersession rendered twice (two opposing arrows, two drawer rows), reading as a mutual link where the datamodel has one authoritative direction.
  • Removed dead ambientLight (every material is unlit) and moved the atmospheric gradient off body, where an opaque app root hid it entirely.

Craft and accessibility

Type floor raised from 9–10px to 11–12px. Labels no longer change font weight on selection (it reflowed text under the pointer) and dropped their per-label backdrop blur. The status pill has a fixed width and counts use tabular figures. The canvas is keyboard reachable — arrow keys walk a stable order, Enter opens, Escape closes, the camera follows focus, moves are announced politely, and focus moves into the drawer and back. This is a focus proxy, not a full DOM mirror of the graph; the README says so. Theme follows prefers-color-scheme at runtime until the user picks a mode.

Also in this PR

  • Journal update: the Crumb Graph → Crumb Trail rebrand is refuted and Effort Graph is the product name, journaled through the typed mutations (Finding, superseding Decision, both rename Issues closed as wontfix). This is what the retired-record encoding is demonstrating on the canvas.
  • Journaled an upstream defect (iss-writedecision-with-supersedes-leaves-the-superse--by624gyf21ex42sv): WriteDecision with an inline supersedes appends the reverse projection but never transitions the target Decision's state, while the Supersede retro-link mutation does. Not fixed here — it also lets a superseded Decision mitigate a Risk, which deserves its own change.
  • Fixed pnpm test on this branch. LiveSchemaReloader gained a required subscribe when SSE events landed, but two test fakes were not updated, so both files failed to compile. Because ts-node's TSError renders as an opaque [Object: null prototype] under Node 22, ava reported only "Non-error object" with no message. Pre-existing at f1b2ae6, verified by bisecting against the merge-base in clean worktrees.
  • Wired examples/effort-viz into root test and typecheck — its tests and TypeScript were previously never checked by any root script.

Closes #

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • I added doc comments to any new public exports, and inline comments to any hard-to-understand areas
  • My changes generate no new console errors locally
  • If applicable, try to include a test that fails without this PR but passes with it

pnpm verify is green: 323 ava tests plus 46 effort-viz tests (lifecycle derivation, glyph/palette invariants, cluster forces, supersession normalization).

effort_graph_primitive_and_retired_legibility_demo.mp4

Does this introduce any non-backwards compatible changes?

  • Yes
  • No

Example-app only, plus two test-fake fixes. nodeColor / nodeOklch were removed from examples/effort-viz/lib/oklch.ts, which is not a published package.

Does this include any user config changes?

  • Yes
  • No
Open in Web Open in Cursor 

…t name

Journals the owner call through the typed effort-graph mutations rather than
hand-edited frontmatter:

- Finding (dead-end) recording why the crumb naming line does not hold:
  crumb reads as leftovers, collides with breadcrumb navigation, and hides
  the Effort primitive that the API, CLI, and skills already teach. Both
  prior Decisions listed exactly these as reversal criteria.
- Decision "Keep Effort Graph as the product name", superseding Crumb Trail
  (and transitively Crumb Graph), accepted.
- Both rename Issues resolved as wontfix, resolved_by the new Decision -
  the work is cancelled, not postponed.

Change-Id: Id0da8e7252e90b4fee9fac2ea10b307fbe5943c8
… canvas

The graph could not answer its most basic question: what kind of record is
this? `nodeColor` hashed hue from the *effortId* and gave node kind only a
+/-0.06 lightness nudge, so every record in an Effort cluster rendered as the
same coloured circle. A per-kind palette already existed in
`RelationLegend.tsx` and was dead code; the legend drew its six swatches from
the broken function, so it showed six identical dots next to six different
labels.

Encoding
- Hue now carries the primitive. Effort membership was already delivered by
  three channels - the force layout's shared centroid, a large labelled hub, and
  hub-tinted membership spokes - so it was spending the strongest nominal
  channel on the attribute that needed it least.
- Shape repeats hue as a colour-vision backstop: amber/red and blue/violet
  partially merge under deuteranopia. Outlines are area-normalized in
  `lib/glyphs.ts` so a triangle and a square read at equal visual weight;
  otherwise size would imply a ranking nobody intended.
- Effort hubs became neutral rings with a cluster-tinted core. The core also
  covers the membership spokes converging on the centre, which previously
  showed through the ring's hole as clutter.
- Legend swatches and canvas geometry now derive from the same outlines and
  palette, so the key cannot drift from the render, and the legend lists only
  the relations the current generation contains.

Lifecycle
- `lib/lifecycle.ts` derives effective state from edges. A superseded Decision
  keeps `state: accepted` in frontmatter, so reading the field labelled retired
  reasoning as committed - the worst possible error on the record a reader most
  needs to get right. Retired records now fade with a struck-through label, and
  the drawer explains why its frontmatter disagrees.
- Badges are keyed on `primitive:state`: a Risk's "accepted" means "we chose to
  live with this hazard" and must not borrow a Decision's reassuring green.
  Added the missing wontfix, mitigated, realized, and Effort status vocabulary.
- Open blocker Issues wear an amber warning outline.

Layout and camera
- Added cluster separation to the force model. Generic node repulsion separates
  records but lets clusters interleave, and position is the channel carrying
  cluster identity. Cluster aggregates moved onto the scratch object, removing
  three map allocations per frame.
- Effort titles anchor above their cluster's bounding box instead of on the hub,
  which sits at the centroid and put every label on top of the records it names.
- Camera eases into place instead of cutting after a 2.4s delay, keeps re-fitting
  while the layout spreads, and yields permanently once the reader pans or zooms.
  Only the always-present legend is reserved in the fit; the transient drawer no
  longer costs a quarter of the canvas.

Vocabulary
- Aligned with the effort-graph glossary: Primitives not "node kinds",
  Relations not "Decision chain", relation groups named after the edges the CLI
  uses, and the relation descriptions rewritten to match the glossary's claims
  (`invalidates` says a record was *wrong*, not that it is "no longer valid").
- The header counts primitives and lifecycle rather than nodes and edges: half
  the edges are synthesised membership spokes, and "173 nodes" says a dot
  appeared where "3 proposed Decisions" says someone owes a call.
- Relation rows render `directionHint` instead of a bare arrow glyph.

Craft, a11y, and bug fixes
- Node retraction never rendered: the scene resolved metadata from the current
  query result, so a deleted record vanished instantly while its edges withdrew
  gracefully. Metadata is now cached until the simulation drops the node.
- `computeLineDistances()` ran inside `useFrame`, allocating a fresh
  `Float32BufferAttribute` per dashed edge per frame. Now recomputed only when
  the vertex count changes.
- Selection committed on pointer-down while left-drag pans, so panning from a
  record opened the drawer. Now commits on pointer-up within an 8px slop.
- Records gained an oversized invisible circular hit mesh sized from
  `viewport.factor`, guaranteeing a 44 CSS px target at any zoom.
- Canvas is keyboard reachable: arrow keys walk a stable order, Enter opens,
  Escape closes, the camera follows focus, and moves are announced politely.
- Type floor raised from 9-10px to 11-12px. Labels no longer change font weight
  on selection (it reflowed text under the pointer) and dropped their per-label
  backdrop blur.
- Status pill has a fixed width and counts use tabular figures, so live updates
  cannot shift the header.
- Theme follows `prefers-color-scheme` at runtime until the user picks a mode,
  instead of persisting the resolved system value as an explicit choice.
- Reduced motion settles the layout before first paint rather than animating
  every spawn.
- Markdown links only become in-graph navigation when the target resolves to a
  record; fragments and unresolved relative links stayed anchors instead of
  becoming silent no-op buttons.
- Removed dead `ambientLight` (every material is unlit) and moved the
  atmospheric gradient off `body`, where an opaque app root hid it entirely.

Tests: lifecycle derivation and glyph/palette invariants (31 passing).
Change-Id: Ia53f416409d71f8f0866d25d6966d800e2ca97bf
Follow-up from an adversarial review pass on the encoding change.

Functional bugs
- Pointer hit radius was inverted. R3F hard-codes `viewport.factor` to 1 for
  orthographic cameras, so the "44 CSS px" calculation produced a constant 22
  world units - a 176px target at the default zoom and 22px at the minimum,
  exactly backwards. It now divides by live `camera.zoom` (one world unit is
  `zoom` CSS px for an R3F ortho frustum) and caps the padding at half the
  resting gap between records, so a generous target can never swallow a
  neighbour and make selection depend on scene order.
- The first click permanently disabled camera auto-fit. OrbitControls fires
  `start` on pointer-down before anything moves, so tapping a record counted as
  a pan and could strand records off-screen while the layout was still
  spreading. Takeover is now detected from raw wheel and drag-past-threshold
  input, which also avoids `change` events emitted by our own easing.
- `BlockerRing` rotated unconditionally. It is the only motion left once the
  layout settles, so under `prefers-reduced-motion` the canvas never came to
  rest. Now frozen.
- The reduced-motion warm-up ran 240 synchronous physics steps on every live
  generation, not just the first. With O(n^2) repulsion that is a multi-second
  freeze on a large graph - an accommodation worse than the animation it
  replaces. Now full only on first sync.
- A failed refetch pinned the view to stale data forever: the generation marker
  advanced before the request. It now advances on success, rolls back on
  failure so the generation can be retried, and refuses to let an out-of-order
  response overwrite newer data.

Encoding
- The query is now assembled from schema introspection. Flatbread derives its
  schema from records on disk, so a relation field only exists once some record
  uses it - and the fixed document omitted `supersedes`/`superseded_by` on
  Findings and Constraints. A Finding has no state field, so supersession is its
  only retirement signal, and dropping it rendered retired evidence as live.
  Risk lineage and mitigation were likewise unreachable.
- Constraint moved from a teal hexagon to a green bar. Blue Finding and teal
  Constraint collapse to near-identical cyan under tritanopia, and a hexagon is
  the least separable non-circle in the set, so both channels were weak at once.
  A bar also says "boundary" better than a hexagon did.
- Levelled record chroma. At equal lightness, chroma was the only intensity
  variance, so Constraint at 0.11 read as less important than Risk at 0.17 - a
  ranking the datamodel does not make.
- Resolved the hue collisions the Constraint move created: relation strokes keep
  one accent (h196, in the gap primitives leave between Constraint and Finding)
  for resolution, and invalidation drops its red, which collided with Risk. The
  "this was wrong" claim is carried more precisely by ghosting the invalidated
  record than by tinting the line pointing at it.
- Retired records were near-invisible in light mode (~1.8:1 against white).
  White leaves far less headroom above a record's lightness than black leaves
  below it, so the light-mode lightness push shrank and opacity rose.
- Legend line samples stopped drawing per-weight stroke widths. three.js
  `LineBasicMaterial` ignores `linewidth`, so the legend was advertising a
  distinction the canvas cannot render; weight now scales dash length, which is
  what actually varies.
- The legend's blocker sample shows an Issue diamond inside the warning
  triangle. A triangle inside a triangle read as a Risk.

Accessibility
- Opening a record moves focus to the drawer heading and returns it to the
  canvas on close. Without this, Enter appeared to open something a screen
  reader user could never read - the body and relations stayed unreachable.

Physics
- Cluster separation buckets member indices (CSR layout) instead of scanning
  every node per cluster pair, and floors the centroid distance rather than
  relying on the integrator's step clamp to absorb a near-divide-by-zero.
- A realized Risk now counts in the header. It is `settled` on the aliveness
  axis, so it was falling out of every total - and it is the record a reader
  most needs surfaced.

CI
- Wired `examples/effort-viz` into root `test` and `typecheck`. Its 46 tests
  and its TypeScript were previously never checked by any root script.

Also journals the upstream writer defect this encoding works around
(iss-writedecision-with-supersedes-leaves-the-superse--by624gyf21ex42sv): the
create path for `supersedes` appends the reverse projection but never
transitions the target Decision's state, while the `Supersede` retro-link
mutation does. Deriving lifecycle from edges is correct regardless, since edges
also cover legacy and hand-edited records.

Change-Id: Ia2a4f16bc9c4883477b5e7134c1619c6a1a50883
…abels

The primitive key is a two-column grid, and the gap between columns was barely
larger than the gap between a glyph and its own label. A reviewer reading the
panel paired each glyph with the label to its left and reported the whole key
off by one. Proximity now groups each pair unambiguously.

Change-Id: Id2bd3a2f719cf92437f05846f2a96a3cb8965e00
`LiveSchemaReloader` gained a required `subscribe` member when SSE generation
events landed, but two test fakes were not updated. Both files therefore failed
to compile, and because ts-node's `TSError` renders as an opaque
`[Object: null prototype]` under Node 22, ava reported only "Non-error object"
with no message - so `pnpm test` had been failing with no usable diagnostic.

Root `typecheck` only covers `@flatbread/proof`, so nothing type-checks
`packages/**` ahead of the suite and a compile error in a test file can only
surface this way.

Change-Id: I086d6a2884e9d0a3f222f678f529e5d80c5753f5
@tonyketcham
tonyketcham marked this pull request as ready for review July 26, 2026 06:17
@mergify

mergify Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Queued — the merge queue status continues in this comment ↓.

While FitCamera is easing toward a pending fit target, reader pan/zoom
must clear that target immediately and stop writing the camera —
takeover was only checked after the ease branch returned, so mid-ease
input lost until the ease finished.

Drop the world-space hit-padding cap (`MAX_HIT_PADDING_WORLD = 3`). At
minZoom (~0.5) the 44 CSS px diameter needs ~44 world units of radius;
the cap shrunk on-screen hits far below that floor. Hit scale is now
`max(glyphRadius, (MIN_HIT_DIAMETER_PX / 2) / zoom)`.

Change-Id: Iafbbf69f503a2f03fdb915083b3a9c7131765f70
Add supersedes/superseded_by to Risk RELATION_FIELDS (edges are the only
retirement signal). When schema is null, select scalars only so the query
does not hard-error against a live Flatbread schema that has not grown
every optional relation yet. Cover Risk supersession, null-schema safety,
and partial-schema filtering in query.test.ts.

Change-Id: Id7f6c0c239530af70dca0a23ef527a117b01b76b
Treat deferred as a ResolveIssue resolution (settled, not open) so deferred
blockers no longer get open-blocker treatment. Swap evidence directionHints
to match record→Finding edges, drop the stale invalidation warning-hue
comment, and align README glyph/retired-state wording with the encoding.

Change-Id: Ibb90e54cc22e54f52909144ac47365c1709c1aa6
Keep the last successful schema probe across introspection blips so a
transient SCHEMA_PROBE failure cannot fall back to an unsafe relation
selection. Defer the generation pill and graph paint until a refetch
commits, drop older-than-committed SSE responses before setNodes/setEdges,
and roll the request watermark plus pill back on failure. Label SSE loss
as Disconnected and query/schema failures as Error.

Change-Id: If0309220fcfc9f4bac706215d19963d886724c73
Change-Id: Ia05f2dcfc4b75b660ef2263dc11fd1bfa6d75307
Change-Id: I535829f53973d9cbb2bfd9f8eabf6680e6147ace
Change-Id: I7000fd6518cff4b3b8ab4b26d0d4c60f70ce7cce
Change-Id: Iee592ae50c60892d7e43a3a5dd9d69b0876b3e0d

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Review verdict

REQUEST_CHANGES — consensus HIGHs on false-live under null/sticky schema, missing invalidated_by parity, DetailDrawer rejection-as-supersession copy, plus documented retirement-signal cases without behavioral tests (normalize dangling superseded_by, lifecycle rejected_by).

Adversarial DAG (grok-4.5 high + composer-2.5): correctness-and-contracts, test-coverage-robustness, effort-viz-legibility, dx-and-examples, docs-and-positioning → judge.

Consensus findings

  • False-live / under-selection (useEffortGraphLive probe fallback + setStatus('live'), query null schema) — retirement edges can be omitted while status still reads healthy.
  • invalidated_by asymmetry — supersession is bidirectional; Finding invalidation is forward-only despite effort-graph reverse projections.
  • DetailDrawer overturn copy — edge-rejected reuses “Replaced by…” while the badge says Rejected.
  • Missing rejected_by / dangling-superseded_by coverage — documented retirement defenses lack behavioral tests.

Coverage plan (priority)

  1. normalize.test.ts — edge: superseder absent; keep superseded_by
  2. lifecycle.test.ts — negative: rejected_by overturns accepted; edge: already-rejected does not set overturnedByEdge
  3. useEffortGraphLive.test.ts — negative: probe-fail must not report unmarked live; transport-loss → disconnected vs error
  4. query.test.ts — positive: polymorphic fields without braces; ref fields with { id }
  5. lifecycle/normalize — reverse invalidated_by after catalog parity
  6. physics/glyphs — simulation cluster/aspect + pointy glyphExtent (MED/LOW)

Reviewer scoreboard

  • correctness-and-contracts: 7 findings, 6 coverage gaps, signal:HIGH
  • effort-viz-legibility: 6 findings, 5 coverage gaps, signal:HIGH
  • dx-and-examples: 5 findings, 5 coverage gaps, signal:HIGH
  • docs-and-positioning: 5 findings, 3 coverage gaps, signal:MED
  • test-coverage-robustness: 8 findings, 7 coverage gaps, signal:HIGH

Pedantry filtered. Inline comments cover the actionable HIGH blockers; MED/LOW (FitCamera, legend opacity, README counts, crumb Issue body contradiction) are in the plan / follow-ups.

Open in Web View Automation 

Sent by Cursor Automation: Flatbread PR Review

Comment thread examples/effort-viz/app/components/DetailDrawer.tsx
Comment thread examples/effort-viz/lib/useEffortGraphLive.ts
Comment thread examples/effort-viz/lib/useEffortGraphLive.ts Outdated
Comment thread examples/effort-viz/lib/useEffortGraphLive.ts
Comment thread examples/effort-viz/lib/query.ts
Comment thread examples/effort-viz/lib/lifecycle.ts
Comment thread examples/effort-viz/lib/normalize.ts
title: 'Implement Crumb Trail rename across packages, CLI, paths, and skills'
kind: gap
status: open
status: wontfix

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (docs-and-positioning) — Frontmatter is wontfix/resolved_by, but the body still says Crumb Trail branding and “Implementation remains pending,” inviting agents to reopen cancelled rename work.

Minimal fix: Append a short resolution note that the rename is cancelled and Effort Graph is the product name. (Same issue on the Crumb Graph twin Issue.)

When relationship fields cannot be confirmed, keep showing records but
label the connection Partial instead of Live so retirement links are not
mistaken for complete. Split rejected overturn drawer copy, cover
dangling superseded_by and rejected_by, and close out the cancelled
Crumb rename Issues.

Co-authored-by: Cursor <cursoragent@cursor.com>
Change-Id: I063a0ae8113b6b75a6d3d152f51dc97aa21af7cc
@tonyketcham

Copy link
Copy Markdown
Collaborator Author

@Mergifyio queue

@mergify

mergify Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

queue

🛑 The pull request has been removed from the queue default

Details

The pull request #228 has been manually updated.

You can take a look at Mergify Merge Queue check runs for more details about the failure.

@tonyketcham

Copy link
Copy Markdown
Collaborator Author

@Mergifyio queue

@mergify

mergify Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • ✅ Entered queue — 2026-07-27 23:53 UTC · Rule: owner-bypass · triggered by @tonyketcham with the @mergifyio queue command
  • ✅ Checks skipped · PR is already up-to-date
  • ✅ Merged — 2026-07-27 23:53 UTC · at 4386ad765cf824166088eaeb40b0c695072f4f32

This pull request spent 43 seconds in the queue, including 8 seconds running CI.

Required conditions to merge
  • author = tonyketcham
  • check-success = build (20.x, ubuntu-latest)
  • check-success = build (22.x, ubuntu-latest)
  • check-success = integration-nextjs (20.x, macos-latest)
  • check-success = integration-nextjs (20.x, ubuntu-latest)
  • check-success = integration-nextjs (20.x, windows-latest)
  • check-success = integration-nextjs (22.x, macos-latest)
  • check-success = integration-nextjs (22.x, ubuntu-latest)
  • check-success = integration-nextjs (22.x, windows-latest)
  • check-success = integration-sveltekit (20.x, macos-latest)
  • check-success = integration-sveltekit (20.x, ubuntu-latest)
  • check-success = integration-sveltekit (20.x, windows-latest)
  • check-success = integration-sveltekit (22.x, macos-latest)
  • check-success = integration-sveltekit (22.x, ubuntu-latest)
  • check-success = integration-sveltekit (22.x, windows-latest)
  • check-success = lint (20.x, ubuntu-latest)
  • check-success = lint (22.x, ubuntu-latest)
  • check-success = test (20.x, ubuntu-latest)
  • check-success = test (22.x, ubuntu-latest)

@mergify mergify Bot added the queued label Jul 27, 2026
@tonyketcham
tonyketcham merged commit fc3634f into toeknee/effort-viz-viewport-decision-chain-99c2 Jul 27, 2026
20 checks passed
@tonyketcham
tonyketcham deleted the toeknee/effort-viz-node-legibility-4df9 branch July 27, 2026 23:53
@mergify mergify Bot removed the queued label Jul 27, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review verdict

BLOCK — reverse/Decision-sourced invalidation still never reaches the lifecycle index, so overturned records can paint live under a Live/Partial pill (any BLOCKER rule; also consensus HIGH across four reviewers).

Latest sync (4386ad7) fixed several prior findings: edge-rejected drawer copy, Partial status, dangling-superseded_by retention test, and disconnected vs error coverage. The invalidation hole and wontfix Issue body contradiction remain.

Blocking / high (priority)

  1. RELATION_FIELDS is forward-only for invalidation — Findings select invalidates but never invalidated_by; Decisions never select invalidates even though Write* can emit them.
  2. buildLifecycleIndex only indexes forward invalidates — no invalidated_by mirror of supersession dual-indexing.
  3. Normalize / GraphEdgeKind omit invalidated_by — reverse edges dropped before lifecycle even if queried.
  4. Wontfix rename Issues still lead with pending Crumb implementation while frontmatter says cancelled.
  5. Superseded Crumb Decision gained superseded_by but still reads as current brand (state: accepted + body).

Coverage plan (must-ship)

  • query.test.ts — Finding/Decision invalidated_by + Decision invalidates when schema lists them (pos/neg).
  • lifecycle.test.ts — reverse-only invalidated_by retires (incl. Decision still accepted).
  • normalize.test.ts — dangling invalidated_by / rejected_by retained (parity with superseded_by).

Disputed (not blocking this round)

Sticky last-good schema → Live after probe failure: document in README; do not require a Partial code flip unless product wants it.

Perspectives

correctness-and-contracts, test-coverage-robustness, effort-viz-legibility (HIGH / grok-4.5), dx-and-examples, docs-and-positioning (LOW / composer-2.5) → judge.

Open in Web View Automation 

Sent by Cursor Automation: Flatbread PR Review

Comment on lines +22 to +30
allFindings: [
'derives_from',
'invalidates',
'supersedes',
'superseded_by',
'evidence',
],
allDecisions: [
'derives_from',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER (test-coverage-robustness; consensus HIGH: correctness-and-contracts, effort-viz-legibility, dx-and-examples) — Invalidation is forward-only here: Findings select invalidates but never invalidated_by, and Decisions never select invalidates even though Write* paths can emit them. A victim whose only on-disk signal is invalidated_by (or whose invalidator is a non-Finding) never enters the edge set, so canvas/drawer stay live.

Minimal fix: Mirror supersession — add invalidated_by to Finding and Decision catalogs; add invalidates to every Write*-capable collection that can carry it (at least allDecisions); keep probe-gating. Do not put invalidated_by under REF_SUBSELECTION (polymorphic id list like invalidates). Also restore invalidated_by?: … on RelationFields (~154–166).

Comment on lines +80 to +84
case 'invalidates':
invalidated.add(edge.target);
break;
case 'rejected_by':
rejected.add(edge.source);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (correctness-and-contracts, test-coverage-robustness, effort-viz-legibility) — Supersession dual-indexes (superseded_by source + supersedes target), but invalidation only indexes forward invalidates→target. Reverse-only / dangling invalidated_by cannot retire a node even after the query catalog fix.

Minimal fix: case 'invalidated_by': invalidated.add(edge.source) with supersession-style reverse dedupe when both directions exist. Add tests for reverse-only retirement (Decision still declaring accepted).

Comment on lines +154 to +175
/**
* Drop reverse projections that duplicate an authoritative forward edge.
*
* The writer materializes both sides of a supersession: the newer record gets
* `supersedes` and the older one gets `superseded_by`. Rendering both draws two
* opposing arrows between the same pair and lists the relation twice in the
* drawer, which reads as a mutual link when the datamodel has exactly one
* authoritative direction.
*/
function dropRedundantReverseEdges(edges: Map<string, GraphEdge>): void {
for (const edge of [...edges.values()]) {
if (edge.kind !== 'superseded_by') continue;
if (edges.has(edgeId('supersedes', edge.target, edge.source))) {
edges.delete(edge.id);
}
}
}

/*
* Edges pointing at records the query didn't return are deliberately kept.
* They render nothing (the renderer bails on a path shorter than two points),
* but a `superseded_by` whose superseder is missing is still the only signal

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (test-coverage-robustness; same hole cross-cutting) — Dangling superseded_by is kept as the sole retirement signal when the superseder is absent, but invalidated_by is not in POLYMORPHIC_RELATIONS / GraphEdgeKind, so reverse invalidation is dropped before lifecycle even if queried.

Minimal fix: Add invalidated_by to GraphEdgeKind + POLYMORPHIC_RELATIONS (and relation-legend meta); retain dangling reverses. Add dangling invalidated_by / rejected_by normalize tests for parity.

Comment on lines +21 to +22

Resolution: cancelled as `wontfix`; product name is Effort Graph; resolved by `dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13`; do not reopen rename work.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (docs-and-positioning) — Frontmatter/resolved_by say wontfix, but the body still leads with Crumb Trail branding and pending implementation, inviting agents to reopen cancelled rename work. Same pattern on the Crumb Graph twin Issue.

Minimal fix: Rewrite the body lead to past-tense cancellation (Effort Graph retained per dec-keep-effort-graph…); demote stale Crumb copy to historical context.

Comment on lines +12 to +13
superseded_by:
- dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (docs-and-positioning) — This PR adds superseded_by but leaves state: accepted and a body that still commits to Crumb Trail as the product name. Frontmatter, edges, and prose disagree.

Minimal fix: Repair via reindexer (state: superseded) per iss-writedecision-with-supersedes-…, or add a top-of-body supersession banner pointing at dec-keep-effort-graph… until repaired.

Comment on lines +82 to +85
updates the canvas. The status pill shows connecting / live / partial /
disconnected / error and the current generation. **Partial** means records
loaded but relationship fields could not be confirmed yet — retirement links
may be missing until the next successful schema probe.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MED (docs-and-positioning; disputed with dx-and-examples) — README defines Partial as “relationship fields could not be confirmed yet,” but sticky last-good after a failed probe still reports Live. Operators can trust Live while newly grown relation fields stay unselected.

Minimal fix (this round): One sentence that sticky cached schema keeps Live; only a successful re-probe picks up new relation fields. Do not require a Partial code flip unless product wants it.

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.

2 participants