Compare commits
12 Commits
FEAT-updat
...
e547dee979
| Author | SHA1 | Date | |
|---|---|---|---|
| e547dee979 | |||
|
|
362a47f88a | ||
| 274e846909 | |||
|
|
c6b07ccb56 | ||
|
|
8e79e78006 | ||
| 19d4a82c90 | |||
| edeadd547a | |||
| 3b707a150b | |||
| c2d40fa409 | |||
|
|
6f09b6ecdc | ||
| ac75a30b61 | |||
| f1f2a93e4a |
26
README.md
26
README.md
@@ -1,4 +1,4 @@
|
|||||||
# kongruity
|
# kongruity: Signal from noise
|
||||||
|
|
||||||
“...All those moments will be lost in time, like tears in rain.”
|
“...All those moments will be lost in time, like tears in rain.”
|
||||||
|
|
||||||
@@ -8,15 +8,19 @@ In kongruity, the artifacts become "sticky notes." A board full of them looks ch
|
|||||||
|
|
||||||
With a click, they are semantically evaluated, grouped into thematic clusters with descriptive headers, rankable and exportable to project planning and execution tools.
|
With a click, they are semantically evaluated, grouped into thematic clusters with descriptive headers, rankable and exportable to project planning and execution tools.
|
||||||
|
|
||||||
|
## Voyage AI voyage-3.5
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Clustering and evaluation: methodology
|
## Clustering and evaluation: methodology
|
||||||
|
|
||||||
Two models run in parallel, and neither sees the other's work. Anthropic's `claude-sonnet-5` (`backend/services/clustering.service.js`) reads the raw text of every note and groups them into labeled thematic clusters.
|
Two models run in parallel, and neither sees the other's work. Anthropic's `claude-sonnet-5` (`backend/services/clustering.service.js`) reads the raw text of every note and groups them into labeled thematic clusters.
|
||||||
|
|
||||||
At the same time, Voyage AI's voyage-3 model (`backend/services/embedding.service.js`) converts each note's text into a numeric representation of its semantic meaning aka vector.
|
At the same time, Voyage AI's voyage-3.5 model (`backend/services/embedding.service.js`) converts each note's text into a numeric representation of its semantic meaning aka vector.
|
||||||
|
|
||||||
Once the LLM returns, kongruity scores that grouping (`backend/services/validation.service.js`) using an established silhouette coefficient, with cosine distance rather than Euclidean as the distance metric.
|
Once the LLM returns, kongruity scores that grouping (`backend/services/validation.service.js`) using an established silhouette coefficient, with cosine distance rather than Euclidean as the proximity metric.
|
||||||
|
|
||||||
For each note, it weighs the average distance to the other notes in its own cluster against the average distance to the notes in the nearest neighboring cluster. Averaged across every note, this yields a single cohesion score in the range [−1, 1], displayed at the top of the results.
|
For each note, it weighs the average distance to the other notes in its own cluster against the average distance to the notes in the nearest neighboring cluster. Averaged across every note, this yields a single numeric cohesion score, displayed at the top of the results, along with plaintext: Strong, Moderate, Weak, Poor.
|
||||||
|
|
||||||
This yields an empirical groundedness evaluation. One model proposes the grouping; an independent model evaluates grouping accuracy.
|
This yields an empirical groundedness evaluation. One model proposes the grouping; an independent model evaluates grouping accuracy.
|
||||||
|
|
||||||
@@ -30,14 +34,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
|
||||||
|
|
||||||
|
|||||||
BIN
Voyage.jpg
Normal file
BIN
Voyage.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -27,7 +27,7 @@ export const embedNotes = async (notes) => {
|
|||||||
for await (const chunk of batches) {
|
for await (const chunk of batches) {
|
||||||
const response = await client.embed({
|
const response = await client.embed({
|
||||||
input: chunk.map((n) => n.text),
|
input: chunk.map((n) => n.text),
|
||||||
model: "voyage-3",
|
model: "voyage-3.5",
|
||||||
});
|
});
|
||||||
|
|
||||||
response.data.forEach((item, i) => {
|
response.data.forEach((item, i) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
console.log(score?.toFixed(2))
|
||||||
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>{scoreLabel(score)}</strong>
|
||||||
</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;
|
||||||
|
|||||||
@@ -1,100 +1,107 @@
|
|||||||
.stickies-grid {
|
.stickies-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stickies-container {
|
.stickies-container {
|
||||||
margin: 36px 0px 36px 0px;
|
margin: 36px 0px 36px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clusters-container {
|
.clusters-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-group {
|
.cluster-group {
|
||||||
border: 1px solid #6dd6f4;
|
border: 1px solid #6dd6f4;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-draggable {
|
.cluster-draggable {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
|
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-draggable:active {
|
.cluster-draggable:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-drag-over {
|
.cluster-drag-over {
|
||||||
border-color: #ffb7ce;
|
border-color: #ffb7ce;
|
||||||
box-shadow: 0 0 12px rgba(255, 183, 206, 0.4);
|
box-shadow: 0 0 12px rgba(255, 183, 206, 0.4);
|
||||||
transform: scale(1.01);
|
transform: scale(1.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-header {
|
.cluster-header {
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
display: flex;
|
||||||
gap: 12px;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-rank {
|
.cluster-rank {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #6dd6f4;
|
background: #6dd6f4;
|
||||||
color: #1a1a2e;
|
color: #1a1a2e;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-reorder-hint {
|
.cluster-reorder-hint {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-label {
|
.cluster-label {
|
||||||
margin: 0;
|
position: absolute;
|
||||||
font-size: 1.2em;
|
left: 50%;
|
||||||
font-weight: 600;
|
transform: translateX(-50%);
|
||||||
flex: 1;
|
max-width: 50%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-drag-handle {
|
.cluster-drag-handle {
|
||||||
font-size: 1.4em;
|
margin-left: auto;
|
||||||
color: #6dd6f4;
|
font-size: 1.4em;
|
||||||
opacity: 0.4;
|
color: #6dd6f4;
|
||||||
user-select: none;
|
opacity: 0.4;
|
||||||
transition: opacity 0.2s ease;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
transition: opacity 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-draggable:hover .cluster-drag-handle {
|
.cluster-draggable:hover .cluster-drag-handle {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cohesion-score {
|
.cohesion-score {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: rgba(109, 214, 244, 0.1);
|
background: rgba(109, 214, 244, 0.1);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user