Skip to content

fix: recalculate stats picks wrong leaf on duplicate node names - #14

Merged
aar0npal merged 8 commits into
mainfrom
fix/recalculate-path-outcome
Apr 24, 2026
Merged

fix: recalculate stats picks wrong leaf on duplicate node names#14
aar0npal merged 8 commits into
mainfrom
fix/recalculate-path-outcome

Conversation

@aar0npal

Copy link
Copy Markdown
Owner

Problem / Intent

Addresses #13.

Study owners clicking Recalculate Stats sometimes saw previously-correct task results flip to failures (and occasionally the reverse). The problem was worst on trees with duplicate leaf names, or when participants backtracked between branches before making a final selection.

Root cause: a participant's final leaf selection was never persisted — only a concatenated path_taken string. Recalculate had to reverse-engineer the selection from that string, and its fallback matched the first link whose terminal segment matched, regardless of which branch the link actually lived in. When multiple leaves shared a name (e.g. two contact-us nodes under different parents), matching was effectively arbitrary.

This button has been live since January 2026, so any historical results that were previously recalculated on affected studies may be inaccurate.

Approach

Replaced the last-segment fallback with progressive multi-segment suffix matching: the resolver builds progressively longer trailing suffixes from path_taken and keeps growing the window until exactly one valid link matches. This uses the full navigation context instead of just the last path segment, so the correct branch is identified unambiguously even when multiple leaves share a terminal name.

Owners who ran Recalculate before this fix are prompted via the changelog to re-run it and refresh their results.

Also includes internal design notes (docs/tree-testing-notes.md) capturing:

  • the root-cause analysis for future reference,
  • a cleaner short-term schema fix (persist selected_link so Recalculate doesn't have to guess from a string),
  • a proposed V2 event-sourced model (stable node IDs, versioned trees, outcomes-as-derived-views) that would eliminate this bug class entirely.

The fallback in findLastValidPath used validLinks.find() to return the
first tree node whose link ended with the last path segment. This was
ambiguous when a participant backtracked through multiple branches and
two or more sibling leaves shared the same name — the wrong node could
be returned, flipping successful from true to false and producing
incorrect Task Path Outcomes (Indirect/Direct Success → Fail) after
Recalculate Stats.

Replace the single-segment heuristic with a progressive multi-segment
suffix match: build suffixes of increasing length from the tail of
pathTaken until exactly one valid link matches, giving the most
context-aware and unambiguous result.

Closes #13
Adds a changelog entry prompting owners to re-run Recalculate Stats on
previously-recalculated studies, bumps LATEST_UPDATE_DATE so the unread
indicator lights up, and drops internal design notes covering the
root-cause analysis, a short-term schema fix, and a proposed V2
event-sourced overhaul for tree testing.
@vercel

vercel Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
usabilitree Ready Ready Preview, Comment Apr 24, 2026 0:27am

@aar0npal
aar0npal marked this pull request as ready for review April 23, 2026 22:23
Copilot AI review requested due to automatic review settings April 23, 2026 22:23
@greptile-apps

greptile-apps Bot commented Apr 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the single-segment fallback in resolveSelectedLink (previously findLastValidPath) with a progressive multi-segment suffix matcher, fixing incorrect leaf selection when duplicate node names exist or participants backtracked between branches. It also hoists the collectValidLinks tree walk out of per-result loops in both saveStudyData and recalculateStudyResults, and adds a user-facing changelog entry prompting study owners to re-run Recalculate Stats.

Confidence Score: 5/5

Safe to merge — the core algorithm is correct and well-tested against the documented edge cases; only pre-existing P2 gaps remain.

No P0 or P1 issues introduced. The progressive suffix algorithm correctly handles duplicate leaf names and backtracking paths. The early-break optimisation is provably safe (if no link ends with suffix S, no link ends with any longer prefix of S). The lastResortMatch tracking (previously flagged and already addressed in 85ac53b) now correctly retains the most-specific ambiguous candidate. Hoisting collectValidLinks out of per-result loops is a clean performance improvement with no correctness trade-offs. The only outstanding gap — direct_path_taken not updated during recalculation — is a pre-existing P2 acknowledged in the docs shipped with this PR.

No files require special attention.

Important Files Changed

Filename Overview
src/lib/treetest/actions.ts Core logic refactored: findLastValidPath split into collectValidLinks + resolveSelectedLink; fallback upgraded from last-segment to progressive multi-segment suffix; lastResortMatch correctly tracks the smallest (most-specific) ambiguous set; tree walk correctly hoisted out of hot loops.
src/app/(main)/dashboard/updates/updates-list.tsx Appends update entry id "28" dated 2026-04-24 describing the fix and prompting owners to re-run Recalculate Stats; no logic issues.
src/app/(main)/dashboard/_components/dashboard-nav.tsx Bumps LATEST_UPDATE_DATE constant to match the new changelog entry; cosmetic change only.
docs/tree-testing-notes.md New internal design doc covering root-cause analysis, a short-term selected_link schema fix, and a proposed V2 event-sourced model; documentation only, no runtime impact.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["resolveSelectedLink(validLinks, pathTaken)"] --> B["Sort links by length ↓ (sortedLinks)"]
    B --> C{"Step 1: pathTaken.endsWith(link)\nfor each sortedLink"}
    C -->|"Exact suffix match"| R1["Return link (longest wins)"]
    C -->|"No match"| D{"Step 2: repeat last segment\npathTaken + '/' + lastSegment"}
    D -->|"Repeated suffix match"| R2["Return link"]
    D -->|"No match"| E["Step 3: progressive suffix loop\nnumSegments = 1 → N"]
    E --> F{"suffix = last numSegments\nof pathTaken"}
    F --> G["matches = sortedLinks\n.filter(endsWith suffix)"]
    G -->|"matches.length === 1"| R3["Return matches[0]"]
    G -->|"matches.length === 0"| H["break — longer suffix\nwon't match either"]
    G -->|"matches.length > 1"| I{"Is this a smaller\nambiguous set?"}
    I -->|"Yes"| J["Update lastResortMatch\n& currentBestMatchCount"]
    J --> F
    I -->|"No"| F
    H --> R4["Return lastResortMatch\n(null if nothing found)"]
Loading

Reviews (3): Last reviewed commit: "refactor(treetest): split findLastValidP..." | Re-trigger Greptile

Copilot AI 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.

Pull request overview

Fixes incorrect task outcome flips caused by Recalculate Stats mis-resolving a participant’s selected leaf when path_taken contains backtracking segments and/or duplicate leaf names exist in the tree.

Changes:

  • Updates findLastValidPath to use progressive multi-segment suffix matching to better infer the intended selected link from pathTaken.
  • Adds a dashboard update/changelog entry notifying owners to re-run Recalculate Stats, and bumps the “latest update” date used for unread-badge logic.
  • Adds internal design notes documenting the root cause and proposing schema/model improvements.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/lib/treetest/actions.ts Improves path resolution heuristic used during stats recalculation.
src/app/(main)/dashboard/updates/updates-list.tsx Adds an updates feed entry about the fix.
src/app/(main)/dashboard/_components/dashboard-nav.tsx Updates the constant used to detect unread updates.
docs/tree-testing-notes.md Adds internal notes on the issue, fix, and longer-term redesign options.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/tree-testing-notes.md Outdated
Comment thread src/lib/treetest/actions.ts Outdated
Comment thread src/lib/treetest/actions.ts
PR review pointed out that lastResortMatch was only captured on the
first ambiguous iteration (numSegments=1), so when a later, more
specific suffix narrowed the candidate set and then hit zero matches,
the fallback returned a candidate from the least-specific window.

Track currentBestMatchCount alongside lastResortMatch and update the
fallback whenever a smaller ambiguous set is found, so the returned
candidate always reflects the longest suffix that still matched.

Also updates tree-testing-notes.md to reflect the actual
findLastValidPath(tree, pathTaken) signature and document all four
matching steps (the doc previously only mentioned the old broken
fallback).
@aar0npal

Copy link
Copy Markdown
Owner Author

Addressing the direct_path_taken drift flagged in the Greptile review (outside-diff comments section):

This one is deliberately out of scope for this PR — the same reasoning is captured in docs/tree-testing-notes.md (§1 "The drift this causes" and §2 short-term fix).

Quick rationale for why I don't want to patch it here: computing direct_path_taken retroactively has the same ambiguity as successful did before this fix. The current path_taken is a concatenated string of visited-and-selected node names mashed together, so any retroactive directness calc still has to disambiguate "visited then backtracked" vs "the final selection" vs "folder expand" — exactly the class of guess we're trying to get rid of. Using the new findLastValidPath output to overwrite direct_path_taken would just move the drift to a different axis (you'd get cases where successful = true but direct_path_taken = false because the directness heuristic disagrees with the matcher's chosen leaf).

The right fix ships with the short-term schema work documented in §2 of the notes: add selected_link as a persisted column and upgrade path_taken from a concatenated string to structured JSON with node IDs and action types. Once selected_link is the source of truth, Recalculate can update both successful and direct_path_taken deterministically — same logic as live completion, zero heuristics. I'll open a follow-up issue for that schema change so it doesn't get lost.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/treetest/actions.ts Outdated
PR review noted that the inline comment still described the old
single-segment heuristic as the ambiguous-case fallback, which no
longer exists — the current code returns the first candidate from
the smallest ambiguous suffix set instead. Rewrites the comment to
describe what the code actually does.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/treetest/actions.ts
Comment thread src/lib/treetest/actions.ts
…stic

PR review raised two points on the recalculate path:

1. Performance — findLastValidPath rebuilt validLinks via a full tree
   walk on every call, and the ambiguous-set filter allocated a fresh
   array per suffix length. In recalculateStudyResults (and the
   answer-changed branch of saveStudyData) this ran once per result
   row, so the tree walk was redundant for every row after the first.

2. Determinism — the lastResortMatch fallback picked matches[0] from
   validLinks in DFS order, which meant sibling reordering in the tree
   could flip a result on re-recalculation even though nothing
   substantive changed.

Splits the resolver into collectValidLinks(tree) +
resolveSelectedLink(validLinks, pathTaken), with findLastValidPath as
a thin wrapper for single-shot callers. Both hot callers now collect
the link list once and pass it into the per-row loop. The suffix-match
loop now filters from the length-sorted list so ambiguous-set picks
are stable under tree reorganization and consistent with the
"longest/most-specific wins" heuristic used by step 1.

Also updates the design notes to describe the new two-function shape.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/treetest/actions.ts Outdated
Comment thread src/lib/treetest/actions.ts Outdated
Follow-up to the previous refactor, addressing two PR review findings:

1. The length-desc sort had no secondary tie-breaker, so same-length
   links kept their Array.sort-stable input order — i.e., DFS
   traversal order. Reordering siblings in the tree could still flip
   the ambiguous-fallback winner. Adds a localeCompare tie-breaker so
   sort order is fully independent of tree traversal.

2. resolveSelectedLink still cloned+sorted validLinks on every call,
   wasting O(leaves log leaves) per result row in both hot callers.
   Extracts sortValidLinks as its own helper so callers can hoist it
   once outside their per-row loop; resolveSelectedLink now takes the
   pre-sorted list directly. findLastValidPath keeps working as a
   single-shot wrapper.

Both hot callers (saveStudyData's answer-changed branch, and
recalculateStudyResults) now compute sortedLinks once per study.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/treetest/actions.ts Outdated
Comment thread docs/tree-testing-notes.md Outdated
…h_taken

Two follow-ups from PR review:

1. findLastValidPath was dead code after both hot callers were moved
   to use the three-helper API directly. Dropped the wrapper rather
   than pinning it with a speculative single-shot call site; adding
   it back later is trivial if a real caller appears.

2. The design notes wrongly framed recalculateStudyResults not
   updating direct_path_taken as a "drift out of sync" bug. It isn't
   one: direct_path_taken is derived from (selectedLink === pathTaken)
   at submission — it's a directness measure, independent of answer
   correctness. Editing expected_answer has no bearing on whether the
   participant backtracked, so recalc shouldn't touch it. The notes
   now say so, and push the direct_path_taken re-derivation concern
   into the short-term selected_link backfill section where it
   actually applies.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Tightens the #28 update entry to the two things users actually need
(what happened and what to do), and removes the internal design notes
from version control — keeping them as a local-only working doc.
@aar0npal
aar0npal merged commit 32de20d into main Apr 24, 2026
4 checks passed
@aar0npal
aar0npal deleted the fix/recalculate-path-outcome branch April 24, 2026 13:20
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