Re-aligned heuristic, updated readme, added code comment #3

Merged
kjannette merged 1 commits from BUG-cohesion-scoreUI-display into master 2026-08-01 08:13:29 +00:00
3 changed files with 119 additions and 111 deletions
Showing only changes of commit 6f09b6ecdc - Show all commits

View File

@@ -30,14 +30,18 @@ Average silhouette width is a widely-used measure of clustering quality. Higher
2. How well-separated each cluster is from its nearest neighboring cluster. 2. How well-separated each cluster is from its nearest neighboring cluster.
Although the coefficient is mathematically bounded by [−1, 1], cosine distance between high-dimensional text embeddings is compressed: unrelated notes sit close to orthogonal, so both the within-cluster and nearest-cluster distances land near 0.8. Because silhouette divides the gap between them by the larger of the two, the practical range on embedding data is roughly [−0.05, 0.10] rather than the full interval.
The bands below are therefore calibrated against that observed range. On the seed board, the five ideal thematic clusters score 0.09; swapping a few notes between clusters drops it to 0.06; a scrambled assignment falls below zero.
The score appears above the results with a plain-language band: The score appears above the results with a plain-language band:
- **0.70 and above** — Strong - **0.07 and above** — Strong
- **0.40 to 0.69** — Moderate - **0.04 to 0.06** — Moderate
- **0.10 to 0.39** — Weak - **0.01 to 0.03** — Weak
- **Below 0.10** — Poor - **Below 0.01** — Poor
Silhouette values are archetypically bounded below 1.0 for real-world data, so the number is best read as a relative measure. See Hugo Sträng, Tai Dinh. An upper bound on the silhouette evaluation metric for clustering. Pattern Recognition, Volume 178, 2026, 113402, ISSN 0031-3203. A score near 0.00 means the grouping is no better than chance. Bands are specific to `voyage-3` cosine distance and would need recalibration behind a different embedding model. See Hugo Sträng, Tai Dinh. An upper bound on the silhouette evaluation metric for clustering. Pattern Recognition, Volume 178, 2026, 113402, ISSN 0031-3203.
## Organizing clusters, exporting to workflow software ## Organizing clusters, exporting to workflow software

View File

@@ -6,132 +6,136 @@ import Sticky from './sticky';
import Button from './button'; import Button from './button';
import '../styles/stickies.css'; import '../styles/stickies.css';
// In practice, silhouette on cosine distance between text embeddings occupies roughly
// [-0.05, 0.10], not strict theoretical [-1, 1]: near-orthogonal vectors put both the within- and
// nearest-cluster distances close to 0.8, and the coefficient divides their gap
// by the larger. These bands are calibrated to that range for voyage-3. see README, Reading the cohesion score
const scoreLabel = (score: number): string => { const scoreLabel = (score: number): string => {
if (score >= 0.7) return 'Strong'; if (score >= 0.07) return 'Strong';
if (score >= 0.4) return 'Moderate'; if (score >= 0.04) return 'Moderate';
if (score >= 0.1) return 'Weak'; if (score >= 0.01) return 'Weak';
return 'Poor'; return 'Poor';
}; };
const Stickies = () => { const Stickies = () => {
const { data: stickies, isLoading, error } = useGetStickies(); const { data: stickies, isLoading, error } = useGetStickies();
const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies(); const { mutate: cluster, data: clusterResponse, isPending } = useClusterStickies();
const [rankedClusters, setRankedClusters] = useState<RankedCluster[]>([]); const [rankedClusters, setRankedClusters] = useState<RankedCluster[]>([]);
const dragIndex = useRef<number | null>(null); const dragIndex = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
if (clusterResponse?.clusters) { if (clusterResponse?.clusters) {
setRankedClusters( setRankedClusters(
clusterResponse.clusters.map((c, i) => ({ ...c, rank: i + 1 })) clusterResponse.clusters.map((c, i) => ({ ...c, rank: i + 1 }))
); );
} }
}, [clusterResponse]); }, [clusterResponse]);
if (isLoading) return <div>Loading...</div>; if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>; if (error) return <div>Error: {error.message}</div>;
const score = clusterResponse?.score; const score = clusterResponse?.score;
const handleCluster = () => { const handleCluster = () => {
cluster(); cluster();
}; };
const buildStickyMap = (): Map<string, StickyType> => { const buildStickyMap = (): Map<string, StickyType> => {
const map = new Map<string, StickyType>(); const map = new Map<string, StickyType>();
stickies?.forEach((s) => map.set(s.id, s)); stickies?.forEach((s) => map.set(s.id, s));
return map; return map;
}; };
const stickyMap = buildStickyMap(); const stickyMap = buildStickyMap();
const renderStickies = (items: StickyType[]) => const renderStickies = (items: StickyType[]) =>
items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />); items?.map((sticky) => <Sticky key={sticky.id} sticky={sticky} />);
const handleDragStart = (index: number) => { const handleDragStart = (index: number) => {
dragIndex.current = index; dragIndex.current = index;
}; };
const handleDragOver = (e: DragEvent, index: number) => { const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault(); e.preventDefault();
setDragOverIndex(index); setDragOverIndex(index);
}; };
const handleDragLeave = () => { const handleDragLeave = () => {
setDragOverIndex(null); setDragOverIndex(null);
}; };
const handleDrop = (targetIndex: number) => { const handleDrop = (targetIndex: number) => {
const sourceIndex = dragIndex.current; const sourceIndex = dragIndex.current;
if (sourceIndex === null || sourceIndex === targetIndex) { if (sourceIndex === null || sourceIndex === targetIndex) {
dragIndex.current = null; dragIndex.current = null;
setDragOverIndex(null); setDragOverIndex(null);
return; return;
} }
const reordered = [...rankedClusters]; const reordered = [...rankedClusters];
const [moved] = reordered.splice(sourceIndex, 1); const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, moved); reordered.splice(targetIndex, 0, moved);
setRankedClusters(reordered.map((c, i) => ({ ...c, rank: i + 1 }))); setRankedClusters(reordered.map((c, i) => ({ ...c, rank: i + 1 })));
dragIndex.current = null; dragIndex.current = null;
setDragOverIndex(null); setDragOverIndex(null);
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
dragIndex.current = null; dragIndex.current = null;
setDragOverIndex(null); setDragOverIndex(null);
}; };
return ( return (
<div className="stickies-container"> <div className="stickies-container">
<Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" /> <Button onClick={handleCluster} isLoading={isPending} label="Group Stickies By Topic" />
{rankedClusters.length > 0 ? ( {rankedClusters.length > 0 ? (
<div className="clusters-container"> <div className="clusters-container">
{score != null && ( {score != null && (
<div className="cohesion-score"> <div className="cohesion-score">
Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)} Cluster cohesion: <strong>{score.toFixed(2)}</strong> — {scoreLabel(score)}
</div> </div>
)} )}
{rankedClusters.map((group, index) => ( {rankedClusters.map((group, index) => (
<div <div
key={group.label} key={group.label}
className={`cluster-group cluster-draggable${dragOverIndex === index ? ' cluster-drag-over' : ''}`} className={`cluster-group cluster-draggable${dragOverIndex === index ? ' cluster-drag-over' : ''}`}
draggable draggable
onDragStart={() => handleDragStart(index)} onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={() => handleDrop(index)} onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="cluster-header"> <div className="cluster-header">
<span className="cluster-rank" aria-label={`Priority ${group.rank}`}> <span className="cluster-rank" aria-label={`Priority ${group.rank}`}>
{group.rank} {group.rank}
</span> </span>
{group.rank === 1 && ( {group.rank === 1 && (
<span className="cluster-reorder-hint">Drag and drop to reorganize cluster priority</span> <span className="cluster-reorder-hint">Drag and drop to reorganize cluster priority</span>
)} )}
<h3 className="cluster-label">{group.label}</h3> <h3 className="cluster-label">{group.label}</h3>
<span className="cluster-drag-handle" aria-hidden="true">⠿</span> <span className="cluster-drag-handle" aria-hidden="true">⠿</span>
</div> </div>
<div className="stickies-grid"> <div className="stickies-grid">
{renderStickies( {renderStickies(
group?.noteIds group?.noteIds
.map((id) => stickyMap?.get(id)) .map((id) => stickyMap?.get(id))
.filter((s): s is StickyType => !!s) .filter((s): s is StickyType => !!s)
)} )}
</div> </div>
</div> </div>
))} ))}
</div>
) : (
<div className="stickies-grid">
{renderStickies(stickies ?? [])}
</div>
)}
</div> </div>
) : ( );
<div className="stickies-grid">
{renderStickies(stickies ?? [])}
</div>
)}
</div>
);
}; };
export default Stickies; export default Stickies;

View File

@@ -14,7 +14,7 @@ const MOCK_CLUSTER_RESPONSE = {
{ label: 'Auth Issues', noteIds: ['note_001'] }, { label: 'Auth Issues', noteIds: ['note_001'] },
{ label: 'Export Issues', noteIds: ['note_002'] }, { label: 'Export Issues', noteIds: ['note_002'] },
], ],
score: 0.74, score: 0.09,
}; };
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;