feat(editor): resolve chords as one coherent grip, not a greedy spread - #245
Conversation
"Resolve positions in an anchor window" placed each note of a simultaneous
cluster greedily — one at a time, each taking its own lowest free fret. For
a chord that spreads the notes across the neck into a stretch no hand can
play, which the playability lint then scolds. The fix is to resolve the
cluster JOINTLY.
New _resolveChordGripPure (position.js): given each note's sounding pitch,
enumerate its playable positions and choose ONE {string, fret} per note —
all on distinct strings — that minimises the fretted span (open strings are
free), pulled toward the hand (prev note / anchor), accepting only a grip
whose span fits the lint's window+tolerance. It returns null when no coherent
grip exists.
_resolveWindowPure (anchor-resolve.js) now walks the window in time order,
groups simultaneous notes into chord clusters (LINT_CLUSTER_EPSILON), and
resolves each cluster of >=2 as one grip; a singleton — or a cluster with no
coherent grip — falls back to the per-note resolver, so behaviour never
regresses. Occupancy still honours non-cluster notes; the grip enforces
distinct strings within the cluster.
Tests: tests/chord_grip.test.mjs (11) — the grip search (min span, distinct
strings, open-free, anchor window, occupancy routing, too-wide + no-position
refusals, determinism) and the _resolveWindowPure integration (a chord that
greedy spreads to a 6-fret stretch now lands as a 2-fret grip; singleton and
no-grip fallback unchanged — fails on main). Live-verified in-app through the
real editorResolveAnchorWindow action: a spread chord snaps to s3f5+s4f3
(span 2), committed as one undoable command, undo restores the spread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
📝 WalkthroughWalkthroughChord resolution now groups simultaneous notes within anchor windows and selects a coherent playable fret-hand grip. The resolver tracks repicks and occupancy, falls back to per-note placement when needed, and adds grip and integration tests. ChangesChord resolution
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Resolver as _resolveWindowPure
participant Grip as _resolveChordGripPure
participant Fallback as _suggestPositionPure
Resolver->>Resolver: Group simultaneous notes
Resolver->>Grip: Resolve a coherent chord grip
Grip-->>Resolver: Return assignments or no grip
Resolver->>Fallback: Resolve notes individually when needed
Fallback-->>Resolver: Return suggested positions
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/chord_grip.test.mjs (1)
124-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMake the fallback test prove that fallback ran.
This all-refused case also passes if a future implementation skips
resolveOneand rejects the cluster outright. Use two pitch-48 notes with anchor window[3,4): both only fit ons1f3, so the joint grip fails; fallback should move the first note and refuse the second due to in-pass occupancy.Proposed test adjustment
- // Anchor window [10,12) width 2, maxSpan 3. Two notes sounding 48: candidates - // s1f3 (out of window) and s0f8 (out of window) → no in-window grip AND the - // per-note resolver also refuses (outside-anchor-window) — both left as-is. + // Anchor window [3,4): both notes can only use s1f3. The joint solver + // cannot assign distinct strings, so fallback moves one and refuses one. const nn = [N(2, 0, 8, 0.5), N(2, 0, 8, 0.5)]; - const anchors = [{ time: 0, fret: 10, width: 2 }]; + const anchors = [{ time: 0, fret: 3, width: 1 }]; const ctx = { ...CTX, prevFretAt: () => null }; const r = _resolveWindowPure(nn, { start: 0, end: Infinity }, anchors, ctx, () => true); - assert.strictEqual(r.moves.length, 0); - assert.strictEqual(r.refused.length, 2, 'both fall back and refuse — never guessed'); + assert.deepStrictEqual(r.moves, [ + { index: 0, oldString: 0, oldFret: 8, newString: 1, newFret: 3 }, + ]); + assert.strictEqual(r.refused.length, 1);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/chord_grip.test.mjs` around lines 124 - 134, Update the fallback test around _resolveWindowPure to use two pitch-48 notes and an anchor window at fret 3 with width 1, where both notes only resolve to s1f3. Assert the joint grip fails, then verify fallback moves the first note and refuses the second because the target is occupied during the same pass, proving resolveOne was executed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/chord_grip.test.mjs`:
- Around line 124-134: Update the fallback test around _resolveWindowPure to use
two pitch-48 notes and an anchor window at fret 3 with width 1, where both notes
only resolve to s1f3. Assert the joint grip fails, then verify fallback moves
the first note and refuses the second because the target is occupied during the
same pass, proving resolveOne was executed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4ec266de-3186-4a51-85d4-47eda89196b3
📒 Files selected for processing (4)
CHANGELOG.mdsrc/anchor-resolve.jssrc/position.jstests/chord_grip.test.mjs
… note
Two defects in the new joint grip resolver, both on the chart-data write path.
1. The grip silently made an articulation choice the rest of this module
REFUSES to make. `_suggestPositionPure` bails with `open-vs-fretted` when a
note is playable both open and fretted — the anchor-resolve header calls
that refusal absolute ("never guessed"), and `_acceptAllRefsPure` exists so
a bulk accept can't confirm one. The grip decided anyway, and worse: open
frets are FREE in its span metric, so the search actively PREFERRED the open
voicing. A D+G dyad collapsed to two open strings (span 0). Differentially
fuzzed against main, every single divergence was this: 1236 notes main
honestly refused that the grip re-voiced, and "Accept all" would have
written them. The grip now bails on an ambiguous note and the per-note path
refuses it, exactly as before. An open string that is a note's ONLY position
is not a choice, and still resolves (and is still free in the span).
2. A malformed cluster member was filtered out and the rest of the grip
returned — a partial grip. The dropped note was then neither moved nor
refused, so it fell outside the refused set and "Accept all" would confirm a
position nobody ever picked. Now all-or-nothing.
Also renames the `window` locals: shadowing the browser global for a whole
block is a trap for the next edit.
Divergence vs main is now only `string-occupied` / `outside-anchor-window` —
the corners greedy per-note ordering painted itself into and joint resolution
legitimately solves. Nothing that resolved before refuses now.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@node_modules`:
- Line 1: Remove the machine-local node_modules symbolic link from version
control, ensure node_modules is covered by the repository’s ignore
configuration, and rely on the project manifest and lockfile for dependency
installation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 19ab7a63-26d9-426d-bbaa-f1f1cb5b4ac7
📒 Files selected for processing (5)
CHANGELOG.mdnode_modulessrc/anchor-resolve.jssrc/position.jstests/chord_grip.test.mjs
🚧 Files skipped from review as they are similar to previous changes (4)
- CHANGELOG.md
- tests/chord_grip.test.mjs
- src/anchor-resolve.js
- src/position.js
| @@ -0,0 +1 @@ | |||
| /home/byron/Repositories/feedback-plugin-editor/node_modules No newline at end of file | |||
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Remove the machine-local node_modules entry.
This absolute path only resolves on the author’s machine, breaks dependency resolution in clean checkouts, and exposes local filesystem details. Remove it from version control and rely on the project’s package manifest/lockfile with node_modules ignored.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@node_modules` at line 1, Remove the machine-local node_modules symbolic link
from version control, ensure node_modules is covered by the repository’s ignore
configuration, and rely on the project manifest and lockfile for dependency
installation.
* feat(editor): lint finger conflicts in a grip Auto-fingering (#237) and the coherent-grip chord resolve (#245) assign fret-hand fingers, but a grip can still ask ONE finger to hold two different frets at the same instant — a shape no hand can play. Add a finger-conflict rule to the advisory playability lint. _lintFingerConflictPure (playability-lint.js): within a simultaneous cluster (the shared LINT_CLUSTER_EPSILON), group fretted notes by their fret_finger (1-4 only; open strings and the -1 unset sentinel carry no fretting finger), and flag any finger used on more than one distinct fret. A BARRE — the same finger across strings at the SAME fret — is one fret, so it never flags. Wired into _playabilityLintPure and the RULE_LABELS map, so it rides the existing surface: a yellow underline on the flagged notes, the count chip, and a popover row that seeks + selects. Advisory only — never blocks, never auto-fixes (the charter answers the physical question), like every rule. Tests: tests/finger_conflict_lint.test.mjs (10) — conflict vs barre vs different-fingers vs not-simultaneous, open/unset/out-of-range fingers ignored, barre-plus-stray, two conflicts in one cluster, aggregation into the full pass, junk-input degradation. Live-verified in-app: the conflict is named in the real _lintResults memo and both notes land in the flagged set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q * fix(editor): count the thumb as a fretting digit in the finger-conflict lint The rule guarded on `fret_finger` 1-4, so it never saw the THUMB. Per spec §6.2.2 (and routes.py's `_fret_finger_attr`) the fret-hand digit range is 0-4, where 0 is the thumb — the fretboard strip's `T`, cycled by right-click and clamped to -1..4 by the inspector. A thumb-over grip frets the low E, so a thumb assigned to two different frets at one instant is exactly as impossible as finger 1 in two places, and the lint went blind to it. Widen the guard to 0-4 and render the thumb as `T` in the popover detail (the strip's FINGER_LABELS encoding, inlined rather than importing that UI module into the @pure block). A thumb barre across E+A at one fret stays legal, as does a normal thumb-over grip — same physics as the other digits. Also drop the redundant `list.length >= 2` (implied by `frets.length >= 2`). --------- Co-authored-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
"Use opens, flag for review" (#245 follow-up): _resolveChordGripPure may voice an ambiguous note open, tagging the pick ambiguousOpen; _resolveWindowPure propagates the tag to its moves and lists flagged indices; the sweep's Accept-all excludes flagged refs via the existing refused-notes gate. Singleton open-vs-fretted refusal unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
"Use opens, flag for review" (#245 follow-up): _resolveChordGripPure may voice an ambiguous note open, tagging the pick ambiguousOpen; _resolveWindowPure propagates the tag to its moves and lists flagged indices; the sweep's Accept-all excludes flagged refs via the existing refused-notes gate. Singleton open-vs-fretted refusal unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q
…ual review (#257) * Allow flagged open voicings in chord grips "Use opens, flag for review" (#245 follow-up): _resolveChordGripPure may voice an ambiguous note open, tagging the pick ambiguousOpen; _resolveWindowPure propagates the tag to its moves and lists flagged indices; the sweep's Accept-all excludes flagged refs via the existing refused-notes gate. Singleton open-vs-fretted refusal unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q * Name reviewOnly in the sweep state comment The sweep object grew a reviewOnly set (refused + ambiguous-open refs); the state comment still described the old two-field shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: untrack the node_modules symlink An agent worktree symlinked node_modules; .gitignore only lists node_modules/ (trailing slash), which matches a directory but not a symlink, so git add -A tracked it. The symlink pointed at a local absolute path and would break any other checkout. --------- Co-authored-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
What & why
"Resolve positions" over an anchor window placed each note of a simultaneous cluster greedily — one at a time, each grabbing its own lowest free fret. For a chord that spreads the notes across the neck into a stretch no hand can play, and which the playability (stretch) lint then flags. Gap-audit #7: kill unplayable auto-chords at the source.
Approach
Resolve the cluster jointly.
_resolveChordGripPure(new,position.js) — given each note's sounding pitch, enumerate its playable positions and choose one{string, fret}per note, all on distinct strings, that minimises the fretted span (open strings, fret 0, are free), pulled toward the hand (previous note → anchor). It accepts only a grip whose span fits the lint's window + tolerance, and returnsnullwhen no coherent grip exists. Deterministic search (fewest-choice-first, pinned tie-breaks)._resolveWindowPure(anchor-resolve.js) now walks the window in time order, groups simultaneous notes into clusters (LINT_CLUSTER_EPSILON), and resolves each cluster of ≥2 as one grip. A singleton — or a cluster with no coherent grip — falls back to the per-note resolver, so nothing that resolved before stops resolving. Occupancy still honours non-cluster (overlapping) notes; distinct strings within the cluster are enforced by the grip search.The span ceiling and cluster epsilon are the same constants the stretch lint uses (
LINT_STRETCH_TOLERANCE,LINT_DEFAULT_WINDOW,LINT_CLUSTER_EPSILON), so the resolver targets exactly what the lint would otherwise scold.Tests
tests/chord_grip.test.mjs(11) — the grip search (min span, distinct strings, open-string-free, anchor-window eligibility, occupancy routing, the too-wide and no-eligible-position refusals, determinism) and the end-to-end_resolveWindowPureintegration (a chord greedy spreads to a 6-fret stretch now lands as a 2-fret grip; singleton + no-grip fallback unchanged — these fail on main's per-note path).routes.pyuntouched.editorResolveAnchorWindowaction: a spread chord snaps tos3f5 + s4f3(fretted span 2), committed as one undoable command, and undo restores the original spread.🤖 Generated with Claude Code
Summary by CodeRabbit
Improvements
Bug Fixes
Tests