PROCESS17 May 2026 · 4 min read

Same words, opposite axes — a name-collision cleanup

A user asked us where a widget went. We thought it was a render bug. It wasn't. The widget was working exactly as designed — correctly hidden because no cluster had fired that day. The confusion was that two completely different things in our dashboard both happened to be called "comparable setups," and from outside the code you couldn't tell which one the user meant. The fix took 20 minutes. The lesson took a little longer.

The report

"The comparable setup widget is not there. Is that because there aren't any?"

Reasonable hypothesis. Empty-state hidden is a common UX pattern: if a panel has nothing to show, get it out of the way. We dug into the code expecting to confirm that yes, the widget was correctly empty-state-hidden, end of investigation. Instead we found something more interesting: there were two widgets called "comparable setups," and they answered completely different questions.

Two features, one name

The first one is a chip rendered inline on every ticker card:

${_comparableSetupsChip(t.comparable_setups)}

The data behind it is server-emitted. The framework's audit cycle calls _fetch_comparable_setups_for_audit() for each ticker. It walks that ticker's audit history (every snapshot we've taken of it in similar score / regime / bias state) and returns a summary: "when this ticker last looked like this, the dominant outcome was X." Per-ticker. Looking backward.

The second one is a banner that appears (or doesn't) at the top of the dashboard:

<div id="comparable-setups-banner" style="display:none"></div>

The data behind that is client-side. A function called v5DetectComparableSetups() walks the current audit snapshot across all tickers right now and looks for portfolio-wide patterns: 2+ tickers with OBV rising and volume confirmed at the same time, 3+ tickers simultaneously in a framework EXIT state, news+flow divergence clusters. When one of those fires, the banner appears with a verdict like "3 tickers · OBV-rising + vol-confirmed (price+vol agreement)." Portfolio-wide. Looking sideways across tickers, right now.

Same words. Opposite axes. One is one ticker looking at its own past; the other is many tickers looking at each other in the present.

Why both names made sense in isolation

The per-card chip got the name comparable_setups first — it's a literal description of what the data is. "Show me setups from this ticker's history comparable to its current state." Honest.

The portfolio-wide banner got the name later, building on the same vocabulary. "Setups that are comparable across tickers right now." Also honest in isolation. The two names were each defensible against the other in isolation but became misleading the moment they shared a screen.

The user was right to be confused, and they were right that something was missing — just not the thing we initially looked for. The cluster banner was correctly hidden (nothing met the threshold that day), and the per-card chips were rendering on the 8/10 tickers that had history matches. Both were doing exactly what they were supposed to do. The bug was in the name.

The rename

The banner concept got renamed to pattern cluster, which matched the suffix-naming already living in the code's own cluster types (obv_vol_rising_cluster, state_exit_cluster, news_tape_divergence). Three changes:

#comparable-setups-banner   →  #pattern-cluster-banner
v5DetectComparableSetups    →  v5DetectPatternClusters
v5RenderComparableSetups    →  v5RenderPatternClusters

The per-card concept stayed as comparable_setups. Two reasons. First, it's a server-emitted contract consumed by multiple readers (audit card, candidate card, modal, JSON history exports) — renaming would ripple far beyond the bug. Second, "historical analogs of this ticker in similar state" is exactly what comparable setups describes accurately when read in isolation. The original name was never wrong; it just stopped being unique.

Disambiguation, not deletion.

Bonus: thresholds out of the code body

While in there, we noticed something else worth fixing. The cluster detector had hardcoded thresholds scattered through its body:

if (divergent.length >= 2)   { ... }
if (accum.length >= 2)       { ... }
if (dist.length >= 2)        { ... }
if (exitList.length >= 3)    { ... }
if (tightenList.length >= 3) { ... }

A small portfolio of 10 tickers has a hard time hitting "2+ simultaneously" or "3+ in EXIT state" on a quiet day — which is part of why no banner ever fired in the first place. We lifted the gates to named constants at the top of the function:

const PATTERN_CLUSTER_THRESHOLD = 2;  // news / OBV+vol clusters
const STATE_CLUSTER_THRESHOLD   = 3;  // framework state EXIT/TIGHTEN

One-place tuning instead of search-and-replace across the body. A user running a 10-name portfolio can drop STATE_CLUSTER_THRESHOLD to 2 if they want the banner to fire on smaller clusters — without having to read the whole function to understand which threshold lives where.

The lesson

Naming is the cheapest documentation you'll ever ship. When two surfaces share a name but answer different questions, both surfaces eventually confuse someone — usually the person who built one of them and forgot the other existed. The fix isn't to delete one of the features. It's to give them honest, distinct names that describe what each one actually answers.

Pinned in the dashboard lint suite via AST introspection: the new names must exist, the old names must not appear in JS code (comments mentioning them as historical context are fine — the test strips // lines before searching), the threshold constants must be present, and the per-card comparable_setups data field must still be in use (proves we disambiguated rather than scorched-earth renamed). All seven checks live in tests/test_dashboard_lint.py::test_pattern_cluster_banner_disambiguated_from_comparable_setups. Defends against a future "tidy up" that would revert the rename or quietly drop a constant.

Twenty minutes of code, one rename, one extraction, seven pin tests. Worth more than the line count suggests, because the user reading the dashboard six months from now will see two clearly-different surfaces with two clearly-different names — instead of two surfaces that share a vocabulary and force them to figure out the difference from context.

The lazy version of this work is to live with the collision — both surfaces "kind of work," the user can usually figure it out, who has time. The disciplined version is to fix the name the first time you see the collision actually confuse a real human. The cost is twenty minutes; the saved cost is every future user who would have hit the same confusion.

Claim a Founding Slot — $14.50/mo

Part of the v7.8 cleanup pass — see the v7.8 release post for the full architectural arc.


Disclosure: Swing Deck is built and operated by one person. The product is local-first; positions, broker tokens, and journal entries never leave your machine. AI features use your own API keys (BYOK). We don't proxy your data through our servers. Pricing as of 2026-05-17. Past performance is not indicative of future results. Nothing in this post is investment advice; it's a description of what software does.