From 1b2788f2067251a92707230003cd5ee3924aeb7b Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Thu, 18 Jun 2026 09:31:15 -0700 Subject: [PATCH 1/4] =?UTF-8?q?EDGE-LOD-2:=20wire=20edgeLOD=20into=20rende?= =?UTF-8?q?rer=20=E2=80=94=20zoom-aware=20arrows/labels=20+=20parallel-edg?= =?UTF-8?q?e=20offset=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 83 ++++++++++++++++--- .../src/lib/__tests__/parallelEdges.test.ts | 65 +++++++++++++++ packages/web/src/lib/parallelEdges.ts | 53 ++++++++++++ 3 files changed, 188 insertions(+), 13 deletions(-) create mode 100644 packages/web/src/lib/__tests__/parallelEdges.test.ts create mode 100644 packages/web/src/lib/parallelEdges.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 31ca615a..cff1ff36 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -49,6 +49,8 @@ import { edgeLabelPlacement, clearSegment, slideTFromPointer, chooseLabelT } fro import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; +import { directionStrategy, arrowVisibility, labelVisibility, perpendicularOffset } from '../lib/edgeLOD'; +import { assignParallelEdgeIndices } from '../lib/parallelEdges'; import { spawnCelebration } from '../lib/celebration'; import { buildNeighborhood } from '../lib/graphAdjacency'; import { UndoStack } from '../lib/undoStack'; @@ -2038,9 +2040,24 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Filter edges based on visible nodes for performance // Temporarily show ALL edges for debugging const visibleEdges = validatedEdges; - + + // Parallel-edge bundling (#22): tag each edge with its index + sibling count + // within its node-pair group so parallels can be fanned apart perpendicular + // to the edge line (perpendicularOffset) and dimmed as they crowd. + const parallelInfo = assignParallelEdgeIndices(validatedEdges as any[]); + (validatedEdges as any[]).forEach((e: any) => { + const info = parallelInfo.get(e.id); + e._pIndex = info ? info.index : 0; + e._pTotal = info ? info.total : 1; + }); + const liveScale = svg.node() ? d3.zoomTransform(svg.node() as Element).k : 1; + // Arrow opacity routed through the edge LOD module: hidden direction → 0, + // otherwise the zoom-faded arrow opacity (smaller/dimmer when edges crowd). + const edgeArrowOpacity = (k: number, count: number) => + directionStrategy(k) === 'hidden' ? 0 : arrowVisibility(k, count).opacity; + // Debug: Log edge visibility - + // Create edges FIRST (so they render under nodes) const edgesGroup = g.append('g').attr('class', 'edges-group'); @@ -3165,7 +3182,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return config.hexColor; }) .attr('stroke-width', 1) - .attr('opacity', 1); + .attr('opacity', (d: WorkItemEdge) => edgeArrowOpacity(liveScale, (d as any)._pTotal || 1)); // Create edge label groups with rounded rectangles and text (only for visible edges) // NOTE: this group is appended after the nodes group, so nodes-group is @@ -3213,12 +3230,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const config = getRelationshipConfig(d.type as RelationshipType); return config.label; }) - .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.CLOSE ? 1 : 0); + .style('opacity', (d: WorkItemEdge) => labelVisibility(liveScale, (d as any)._pTotal || 1)); // Add icons positioned to the left of text edgeLabelGroups .append('foreignObject') - .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.CLOSE ? 1 : 0) + .style('opacity', (d: WorkItemEdge) => labelVisibility(liveScale, (d as any)._pTotal || 1)) .attr('class', 'edge-label-icon') .attr('width', 14) .attr('height', 14) @@ -3586,15 +3603,34 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (dotModeRef.current && !forceAvoid) { return; } + // Live zoom scale drives the LOD-aware parallel-edge fan-out (collapses to + // the centre line when zoomed out) and arrow sizing. + const liveK = svg.node() ? d3.zoomTransform(svg.node() as Element).k : 1; + // Border-to-border anchors: the edge starts/ends where the center line // crosses each card's border, not at the buried center. Computed once per // edge per tick (shared datum) so line, hitbox and arrow agree. The anchor - // slides around the border as the nodes move — shortest border path. + // slides around the border as the nodes move — shortest border path. For + // parallel edges, shift the whole anchored line perpendicular to itself by + // perpendicularOffset so siblings don't overlap. linkElements.each(function (d: any) { - d._ep = edgeBorderEndpoints( + const ep = edgeBorderEndpoints( { x: d.source.x || 0, y: d.source.y || 0 }, getNodeDimensions(d.source), { x: d.target.x || 0, y: d.target.y || 0 }, getNodeDimensions(d.target) ); + if ((d._pTotal || 1) > 1) { + const off = perpendicularOffset(d._pIndex || 0, d._pTotal, liveK); + if (off !== 0) { + const lx = ep.x2 - ep.x1; + const ly = ep.y2 - ep.y1; + const len = Math.hypot(lx, ly) || 1; + const px = -ly / len; + const py = lx / len; + ep.x1 += px * off; ep.y1 += py * off; + ep.x2 += px * off; ep.y2 += py * off; + } + } + d._ep = ep; }); // Update visible edge positions @@ -3620,12 +3656,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return; } - // Arrow sits at the TARGET border, pointing into the node. + // Arrow sits at the TARGET border, pointing into the node. Parallel edges + // get a smaller arrow head (arrowVisibility.scale) so crowded siblings stay + // legible; lone edges keep the default size. arrowElements .attr('transform', (d: any) => { const ep = d._ep; const angle = Math.atan2(ep.y2 - ep.y1, ep.x2 - ep.x1) * 180 / Math.PI; - return `translate(${ep.x2},${ep.y2}) rotate(${angle})`; + const size = arrowVisibility(liveK, d._pTotal || 1).scale; + return `translate(${ep.x2},${ep.y2}) rotate(${angle}) scale(${size})`; }); // Edge labels: auto-centered in the clear span between the two node @@ -3669,8 +3708,22 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } const placement = edgeLabelPlacement({ source, target, sourceDims, targetDims, t: d.labelT }); + // Fan parallel-edge labels off the centre line to match their shifted + // paths (same perpendicular offset as the line above). + let labelX = placement.x; + let labelY = placement.y; + if ((d._pTotal || 1) > 1) { + const off = perpendicularOffset(d._pIndex || 0, d._pTotal, liveK); + if (off !== 0) { + const lx = target.x - source.x; + const ly = target.y - source.y; + const len = Math.hypot(lx, ly) || 1; + labelX += (-ly / len) * off; + labelY += (lx / len) * off; + } + } if (obstacles) { - placedLabels.push({ x: placement.x, y: placement.y, width: labelW + 8, height: labelH + 8 }); + placedLabels.push({ x: labelX, y: labelY, width: labelW + 8, height: labelH + 8 }); } // edgeLabelPlacement keeps text upright by stripping 180° when the edge // points "backward" — which leaves the directional label icon pointing @@ -3690,7 +3743,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } else { iconSel.attr('transform', null); } - return `translate(${placement.x},${placement.y}) rotate(${placement.rotation})`; + return `translate(${labelX},${labelY}) rotate(${placement.rotation})`; }); }; @@ -3929,10 +3982,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } g.selectAll('.node-description-text' as any) .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE)); + // Edge labels + arrows follow the edge LOD module (zoom-aware, crowd-aware). g.selectAll('.edge-label' as any) - .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE)); + .style('opacity', (d: any) => labelVisibility(scale, d?._pTotal || 1)); g.selectAll('.edge-label-icon' as any) - .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE)); + .style('opacity', (d: any) => labelVisibility(scale, d?._pTotal || 1)); + g.selectAll('.arrow' as any) + .style('opacity', (d: any) => + directionStrategy(scale) === 'hidden' ? 0 : arrowVisibility(scale, d?._pTotal || 1).opacity); g.selectAll('.edge' as any) .style('opacity', Math.max(0.1, getSmoothedOpacity(LOD_THRESHOLDS.VERY_FAR, 0.1))) .attr('stroke-width', function(d: any) { diff --git a/packages/web/src/lib/__tests__/parallelEdges.test.ts b/packages/web/src/lib/__tests__/parallelEdges.test.ts new file mode 100644 index 00000000..bab71600 --- /dev/null +++ b/packages/web/src/lib/__tests__/parallelEdges.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { nodePairKey, assignParallelEdgeIndices } from '../parallelEdges'; + +describe('nodePairKey', () => { + it('is order-independent (A→B === B→A)', () => { + expect(nodePairKey('a', 'b')).toBe(nodePairKey('b', 'a')); + }); + it('reads .id from object endpoints (post force-link binding)', () => { + expect(nodePairKey({ id: 'a' }, { id: 'b' })).toBe(nodePairKey('a', 'b')); + }); + it('distinguishes different pairs', () => { + expect(nodePairKey('a', 'b')).not.toBe(nodePairKey('a', 'c')); + }); +}); + +describe('assignParallelEdgeIndices', () => { + it('marks a lone edge as index 0 of total 1', () => { + const m = assignParallelEdgeIndices([{ id: 'e1', source: 'a', target: 'b' }]); + expect(m.get('e1')).toEqual({ index: 0, total: 1 }); + }); + + it('groups edges between the same pair regardless of direction', () => { + const m = assignParallelEdgeIndices([ + { id: 'e1', source: 'a', target: 'b' }, + { id: 'e2', source: 'b', target: 'a' }, + { id: 'e3', source: 'a', target: 'b' }, + ]); + expect(m.get('e1')!.total).toBe(3); + expect(m.get('e2')!.total).toBe(3); + expect(m.get('e3')!.total).toBe(3); + const indices = ['e1', 'e2', 'e3'].map((id) => m.get(id)!.index).sort(); + expect(indices).toEqual([0, 1, 2]); + }); + + it('keeps separate pairs independent', () => { + const m = assignParallelEdgeIndices([ + { id: 'e1', source: 'a', target: 'b' }, + { id: 'e2', source: 'a', target: 'b' }, + { id: 'e3', source: 'c', target: 'd' }, + ]); + expect(m.get('e1')!.total).toBe(2); + expect(m.get('e2')!.total).toBe(2); + expect(m.get('e3')).toEqual({ index: 0, total: 1 }); + }); + + it('assigns indices in input order (stable)', () => { + const m = assignParallelEdgeIndices([ + { id: 'first', source: 'a', target: 'b' }, + { id: 'second', source: 'a', target: 'b' }, + ]); + expect(m.get('first')!.index).toBe(0); + expect(m.get('second')!.index).toBe(1); + }); + + it('works with object endpoints', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const m = assignParallelEdgeIndices([ + { id: 'e1', source: a, target: b }, + { id: 'e2', source: b, target: a }, + ]); + expect(m.get('e1')!.total).toBe(2); + expect(m.get('e2')!.total).toBe(2); + }); +}); diff --git a/packages/web/src/lib/parallelEdges.ts b/packages/web/src/lib/parallelEdges.ts new file mode 100644 index 00000000..361cc69a --- /dev/null +++ b/packages/web/src/lib/parallelEdges.ts @@ -0,0 +1,53 @@ +/** + * Parallel-edge grouping (#22): assign each edge an index within its undirected + * node-pair group plus the group size, so the renderer can offset siblings + * perpendicular to the edge line (edgeLOD.perpendicularOffset) and keep multiple + * edges between the same two nodes legible. Pure: no D3/DOM here. + */ + +export interface ParallelEdgeInfo { + index: number; + total: number; +} + +const endpointId = (end: unknown): string => { + if (end && typeof end === 'object') { + return String((end as { id?: unknown }).id ?? ''); + } + return String(end ?? ''); +}; + +/** Stable, order-independent key for the pair of nodes an edge connects. */ +export function nodePairKey(source: unknown, target: unknown): string { + const a = endpointId(source); + const b = endpointId(target); + return a < b ? `${a}__${b}` : `${b}__${a}`; +} + +/** + * Map each edge id → its index within its node-pair group and the group total. + * Edges sharing the same unordered {source,target} pair are siblings; the index + * is assigned in input order (stable) starting at 0, and `total` is the count of + * siblings in that pair (1 for a lone edge). + */ +export function assignParallelEdgeIndices( + edges: Array<{ id: string; source: unknown; target: unknown }> +): Map { + const groups = new Map(); + for (const e of edges) { + const key = nodePairKey(e.source, e.target); + const list = groups.get(key); + if (list) { + list.push(e.id); + } else { + groups.set(key, [e.id]); + } + } + + const result = new Map(); + for (const ids of groups.values()) { + const total = ids.length; + ids.forEach((id, index) => result.set(id, { index, total })); + } + return result; +} From cbbc96511b6501ebbd714300b450754ca2d949c3 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Thu, 18 Jun 2026 09:32:25 -0700 Subject: [PATCH 2/4] LAYOUT-5: hierarchical layout toggle wired into the sim (#30) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 65 +++++++++++++++++++ .../lib/__tests__/layoutAlgorithms.test.ts | 40 ++++++++++++ packages/web/src/lib/layoutAlgorithms.ts | 33 ++++++++++ packages/web/src/pages/Workspace.tsx | 26 ++++++++ 4 files changed, 164 insertions(+) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index cff1ff36..27b7376f 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -48,6 +48,7 @@ import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerg import { edgeLabelPlacement, clearSegment, slideTFromPointer, chooseLabelT } from '../lib/edgeLabelLayout'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; +import { computeNodeLayerY, isHierarchicalLayout, parseLayoutMode, LAYOUT_MODE_STORAGE_KEY, type LayoutMode } from '../lib/layoutAlgorithms'; import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; import { directionStrategy, arrowVisibility, labelVisibility, perpendicularOffset } from '../lib/edgeLOD'; import { assignParallelEdgeIndices } from '../lib/parallelEdges'; @@ -4257,6 +4258,48 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }, 2500); }, [nodes, initializeVisualization, fitViewToNodes, persistAllPositions]); + // Layout mode (#30): 'force' = organic spring layout (default); 'hierarchical' + // pins each node's fy to its dependency-layer Y (computed by the pure module) + // so the graph settles into prerequisite layers while X stays force-arranged. + // Non-destructive + reversible: switching back to 'force' restores the fy each + // node had before we pinned it (saved-snapshot pins survive the round-trip). + const layoutModeRef = useRef( + parseLayoutMode(typeof window !== 'undefined' ? window.localStorage?.getItem(LAYOUT_MODE_STORAGE_KEY) : null) + ); + const applyLayoutMode = useCallback((mode: LayoutMode) => { + layoutModeRef.current = mode; + try { window.localStorage?.setItem(LAYOUT_MODE_STORAGE_KEY, mode); } catch { /* storage unavailable */ } + const sim = simulationRef.current; + const container = containerRef.current; + if (!sim || !container) return; + const simNodes = sim.nodes() as any[]; + if (isHierarchicalLayout(mode)) { + const ys = computeNodeLayerY( + simNodes, validatedEdges as any[], + { width: container.clientWidth, height: container.clientHeight }, + ); + simNodes.forEach((node: any) => { + const y = ys.get(node.id); + if (y == null) return; + if (!node.__layerPinned) node.__preLayerFy = node.fy ?? null; + node.__layerPinned = true; + node.fy = y; + }); + } else { + simNodes.forEach((node: any) => { + if (!node.__layerPinned) return; + node.fy = node.__preLayerFy ?? null; + node.__layerPinned = false; + delete node.__preLayerFy; + }); + } + sim.alpha(DEFAULT_PHYSICS.alpha.loadEnergy).restart(); + }, [validatedEdges]); + // Stable handle so the window hook + load effect don't churn when the edge + // poll re-creates applyLayoutMode (which would reheat the sim every poll). + const applyLayoutModeRef = useRef(applyLayoutMode); + applyLayoutModeRef.current = applyLayoutMode; + // Comprehensive layout metrics for studying the physics behaviour skeptically: // is the simulation actually idle (not silently reheating), do node cards // overlap, do edge labels overlap, how long did the last layout take to @@ -4543,6 +4586,28 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; }, []); + // Expose the layout-mode toggle to the parent (Workspace control), mirroring + // the triggerGraphReset hook above. getLayoutMode lets the control reflect the + // persisted mode without prop drilling through SafeGraphVisualization. + useEffect(() => { + (window as any).triggerLayoutMode = (mode: string) => applyLayoutModeRef.current(parseLayoutMode(mode)); + (window as any).getLayoutMode = () => layoutModeRef.current; + return () => { + if ((window as any).triggerLayoutMode) delete (window as any).triggerLayoutMode; + if ((window as any).getLayoutMode) delete (window as any).getLayoutMode; + }; + }, []); + + // Re-apply a persisted hierarchical layout once a graph's nodes have settled + // (graph load / switch / drill-in). Force mode needs no action on load. + useEffect(() => { + if (!hasNodes) return undefined; + if (!isHierarchicalLayout(layoutModeRef.current)) return undefined; + const timer = setTimeout(() => applyLayoutModeRef.current('hierarchical'), 1600); + return () => clearTimeout(timer); + }, [hasNodes, currentGraphId]); + + // Force reinitialization trigger - incremented when view needs refresh const [reinitTrigger, setReinitTrigger] = useState(0); diff --git a/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts b/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts index 0552dc9e..f3e15cff 100644 --- a/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts +++ b/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { assignLayersByDependencyDirection, computeLayerYPositions, isHierarchicalLayout, + parseLayoutMode, computeNodeLayerY, LAYOUT_MODE_STORAGE_KEY, } from '../layoutAlgorithms'; const n = (id: string) => ({ id }); @@ -74,3 +75,42 @@ describe('isHierarchicalLayout', () => { expect(isHierarchicalLayout(undefined)).toBe(false); }); }); + +describe('parseLayoutMode', () => { + it('keeps "hierarchical"', () => { + expect(parseLayoutMode('hierarchical')).toBe('hierarchical'); + }); + + it('defaults anything else to "force"', () => { + expect(parseLayoutMode('force')).toBe('force'); + expect(parseLayoutMode('garbage')).toBe('force'); + expect(parseLayoutMode(null)).toBe('force'); + expect(parseLayoutMode(undefined)).toBe('force'); + }); + + it('exposes a stable storage key', () => { + expect(LAYOUT_MODE_STORAGE_KEY).toBe('graphdone:layoutMode'); + }); +}); + +describe('computeNodeLayerY', () => { + it('gives every node the Y of its dependency layer (prerequisite higher)', () => { + // A depends on B → B layer 0 (top, smaller Y), A layer 1 (lower, larger Y). + const ys = computeNodeLayerY([n('A'), n('B')], [dep('A', 'B')], { width: 1000, height: 900 }); + expect(ys.get('B')!).toBeLessThan(ys.get('A')!); + }); + + it('places nodes on the same layer at the same Y', () => { + const ys = computeNodeLayerY( + [n('A'), n('B'), n('C')], [dep('A', 'C'), dep('B', 'C')], { width: 800, height: 600 }, + ); + expect(ys.get('A')!).toBe(ys.get('B')!); + expect(ys.get('C')!).toBeLessThan(ys.get('A')!); + }); + + it('returns a finite Y for every node in a single-layer graph', () => { + const ys = computeNodeLayerY([n('A'), n('B')], [], { width: 500, height: 500 }); + expect(Number.isFinite(ys.get('A')!)).toBe(true); + expect(Number.isFinite(ys.get('B')!)).toBe(true); + }); +}); diff --git a/packages/web/src/lib/layoutAlgorithms.ts b/packages/web/src/lib/layoutAlgorithms.ts index ac6e1184..743667ac 100644 --- a/packages/web/src/lib/layoutAlgorithms.ts +++ b/packages/web/src/lib/layoutAlgorithms.ts @@ -68,3 +68,36 @@ export function computeLayerYPositions( export function isHierarchicalLayout(mode: string | undefined | null): boolean { return mode === 'hierarchical'; } + +/** The two graph layout modes. 'force' is the organic default. */ +export type LayoutMode = 'force' | 'hierarchical'; + +/** localStorage key the graph view persists the chosen layout mode under. */ +export const LAYOUT_MODE_STORAGE_KEY = 'graphdone:layoutMode'; + +/** Coerce any stored/raw value to a valid LayoutMode (defaults to 'force'). */ +export function parseLayoutMode(raw: string | undefined | null): LayoutMode { + return raw === 'hierarchical' ? 'hierarchical' : 'force'; +} + +/** + * Per-node target Y for the hierarchical layout: assign dependency layers, map + * each layer to a Y, then resolve every node to its layer's Y. The simulation + * pins each node's fy to this so it settles into dependency layers (X stays free + * to arrange organically). Pure glue over the two layering primitives. + */ +export function computeNodeLayerY( + nodes: NodeLike[], + edges: EdgeLike[], + viewport: { width: number; height: number }, + verticalPadding = 60, +): Map { + const layers = assignLayersByDependencyDirection(nodes, edges); + const layerY = computeLayerYPositions(layers, viewport, verticalPadding); + const out = new Map(); + for (const node of nodes) { + const layer = layers.get(node.id) ?? 0; + out.set(node.id, layerY.get(layer) ?? verticalPadding); + } + return out; +} diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 1725632e..2d0524ce 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -18,6 +18,7 @@ import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries'; import { APP_VERSION } from '../utils/version'; import { useHealthStatus } from '../hooks/useHealthStatus'; import { useViewMode } from '../contexts/ViewModeContext'; +import { parseLayoutMode, LAYOUT_MODE_STORAGE_KEY, type LayoutMode } from '../lib/layoutAlgorithms'; export function Workspace() { const [showCreateModal, setShowCreateModal] = useState(false); @@ -36,6 +37,15 @@ export function Workspace() { typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches ); const [showMiniMap, setShowMiniMap] = useState(true); + const [layoutMode, setLayoutMode] = useState(() => + parseLayoutMode(typeof window !== 'undefined' ? window.localStorage.getItem(LAYOUT_MODE_STORAGE_KEY) : null) + ); + const toggleLayoutMode = () => { + const next: LayoutMode = layoutMode === 'hierarchical' ? 'force' : 'hierarchical'; + setLayoutMode(next); + try { window.localStorage.setItem(LAYOUT_MODE_STORAGE_KEY, next); } catch { /* storage unavailable */ } + (window as any).triggerLayoutMode?.(next); + }; const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); const breadcrumb = getBreadcrumb(); const [inspectorNode, setInspectorNode] = useState(null); @@ -504,6 +514,22 @@ export function Workspace() { document.body )} + {/* Layout Mode Toggle - force (organic) vs hierarchical (dependency layers) */} + {viewMode === 'graph' && currentGraph && !isMobile && createPortal( + , + document.body + )} + {/* Mini-Map Toggle Button - Shows when mini-map is hidden (desktop only) */} {viewMode === 'graph' && currentGraph && !showMiniMap && !isMobile && createPortal( + @@ -372,6 +407,82 @@ export function Ontology() { )} + + {activeTab === 'coverage' && ( +
+ {requirementCoverage.length === 0 ? ( +
+ +

No requirements yet

+

+ Create work items of type Requirement, then connect tasks to them with a + Satisfies relationship to track coverage here. +

+
+ ) : ( + <> +
+
+

Requirements Coverage

+

Tasks that satisfy each requirement

+
+
+ {requirementCoverage.length} requirements +
+
+ +
+ {requirementCoverage.map(({ requirement, satisfyingTasks, count }) => ( +
+
+
+
+ +
+
+

+ {requirement.title || 'Untitled requirement'} +

+ + Requirement + +
+
+ 0 ? 'bg-green-900 text-green-300' : 'bg-gray-700 text-gray-400' + }`}> + {count} satisfying {count === 1 ? 'task' : 'tasks'} + +
+ + {count === 0 ? ( +

+ No tasks satisfy this requirement yet. +

+ ) : ( +
    + {satisfyingTasks.map((task) => ( +
  • + {task.title || task.id} + {task.status && ( + + {task.status} + + )} +
  • + ))} +
+ )} +
+ ))} +
+ + )} +
+ )} {/* Work Item Type Detail Modal */} From 93a0918af28692ef167d30b802da9431333ce91f Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Thu, 18 Jun 2026 09:28:01 -0700 Subject: [PATCH 4/4] MCP-1: suggest_task_assignment tool with pure, tested synthesis (#28) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp-server/src/index.ts | 32 +++ .../src/services/assignment-synthesis.ts | 183 ++++++++++++++++++ .../mcp-server/src/services/graph-service.ts | 156 +++++++++++++++ .../tests/assignment-synthesis.test.ts | 168 ++++++++++++++++ 4 files changed, 539 insertions(+) create mode 100644 packages/mcp-server/src/services/assignment-synthesis.ts create mode 100644 packages/mcp-server/tests/assignment-synthesis.test.ts diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 1a49e03a..01c1d714 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -548,6 +548,35 @@ const tools: Tool[] = [ } }, + { + name: 'suggest_task_assignment', + description: 'Suggest the best contributor(s) for open tasks in a graph by combining each contributor\'s expertise (work-type history + completion rate) with their current availability (active workload). Returns ranked, rationale-bearing candidates per task to make AI-as-peer assignment workflows easier.', + inputSchema: { + type: 'object', + properties: { + graph_id: { type: 'string', description: 'Graph/project ID to draw open tasks and contributors from' }, + task_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Restrict suggestions to these specific task IDs (default: all open tasks in the graph)' + }, + open_statuses: { + type: 'array', + items: { type: 'string', enum: ['PROPOSED', 'ACTIVE', 'IN_PROGRESS', 'BLOCKED'] }, + description: 'Statuses considered "open" and eligible for assignment' + }, + include_assigned: { type: 'boolean', default: false, description: 'Include tasks that already have a contributor' }, + expertise_weight: { type: 'number', description: 'Relative weight of expertise match (default 0.6)' }, + availability_weight: { type: 'number', description: 'Relative weight of contributor availability (default 0.4)' }, + max_candidates_per_task: { type: 'number', default: 3, description: 'Maximum ranked candidates returned per task' }, + min_items_threshold: { type: 'number', default: 3, description: 'Minimum items of a work type before a contributor counts as more than a beginner' }, + task_limit: { type: 'number', default: 25, description: 'Maximum number of open tasks to rank' } + }, + required: ['graph_id'], + additionalProperties: false + } + }, + // Graph Management Tools { name: 'create_graph', @@ -789,6 +818,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case 'get_contributor_availability': return await graphService.getContributorAvailability(args as Record); + case 'suggest_task_assignment': + return await graphService.suggestTaskAssignment(args || {}); + // Graph Management Commands - Type assertions needed for MCP dynamic arguments case 'create_graph': return await graphService.createGraph((args || {}) as CreateGraphArgs); diff --git a/packages/mcp-server/src/services/assignment-synthesis.ts b/packages/mcp-server/src/services/assignment-synthesis.ts new file mode 100644 index 00000000..19d1d6d5 --- /dev/null +++ b/packages/mcp-server/src/services/assignment-synthesis.ts @@ -0,0 +1,183 @@ +// MCP-1 (#28): pure synthesis logic behind the suggest_task_assignment tool. +// Given contributor expertise + availability and a set of open tasks, rank the +// best contributor for each task. Kept free of Neo4j/IO so it can be unit-tested +// deterministically; the GraphService gathers the inputs and calls this. + +export type ExpertiseLevel = 'Expert' | 'Proficient' | 'Beginner'; + +export type CapacityStatus = 'available' | 'busy' | 'at_capacity' | 'overloaded'; + +export interface ContributorExpertise { + workType: string; + level: ExpertiseLevel; + completionRate?: number; +} + +export interface ContributorProfile { + id: string; + name: string; + activeItems: number; + capacityStatus?: CapacityStatus; + expertise: ContributorExpertise[]; +} + +export interface OpenTask { + id: string; + title: string; + type: string; + priority?: number; +} + +export interface AssignmentWeights { + expertiseWeight: number; + availabilityWeight: number; +} + +export interface RankTaskAssignmentsOptions { + weights?: Partial; + maxCandidatesPerTask?: number; + capacityCeiling?: number; +} + +export interface CandidateScore { + contributorId: string; + contributorName: string; + score: number; + expertiseScore: number; + availabilityScore: number; + matchedLevel: ExpertiseLevel | null; + rationale: string; +} + +export interface AssignmentSuggestion { + taskId: string; + taskTitle: string; + taskType: string; + bestCandidate: CandidateScore | null; + candidates: CandidateScore[]; +} + +export const DEFAULT_WEIGHTS: AssignmentWeights = { + expertiseWeight: 0.6, + availabilityWeight: 0.4 +}; + +export const DEFAULT_CAPACITY_CEILING = 15; +export const DEFAULT_MAX_CANDIDATES = 3; + +const LEVEL_BASE: Record = { + Expert: 1, + Proficient: 0.6, + Beginner: 0.3 +}; + +function clamp01(value: number): number { + if (Number.isNaN(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +function normalizeWeights(weights?: Partial): AssignmentWeights { + const expertiseWeight = weights?.expertiseWeight ?? DEFAULT_WEIGHTS.expertiseWeight; + const availabilityWeight = weights?.availabilityWeight ?? DEFAULT_WEIGHTS.availabilityWeight; + const sum = expertiseWeight + availabilityWeight; + if (sum <= 0) { + return { expertiseWeight: 0.5, availabilityWeight: 0.5 }; + } + return { + expertiseWeight: expertiseWeight / sum, + availabilityWeight: availabilityWeight / sum + }; +} + +function expertiseScoreFor( + task: OpenTask, + contributor: ContributorProfile +): { score: number; matched: ContributorExpertise | null } { + const matched = contributor.expertise.find(e => e.workType === task.type) ?? null; + if (!matched) { + return { score: 0, matched: null }; + } + const base = LEVEL_BASE[matched.level]; + const completion = matched.completionRate; + const completionFactor = + typeof completion === 'number' ? 0.85 + 0.15 * clamp01(completion) : 1; + return { score: clamp01(base * completionFactor), matched }; +} + +function availabilityScoreFor( + contributor: ContributorProfile, + capacityCeiling: number +): number { + if (contributor.capacityStatus === 'overloaded') { + return 0; + } + const ceiling = capacityCeiling > 0 ? capacityCeiling : DEFAULT_CAPACITY_CEILING; + return clamp01(1 - contributor.activeItems / ceiling); +} + +function buildRationale( + contributor: ContributorProfile, + matched: ContributorExpertise | null, + task: OpenTask +): string { + const expertisePart = matched + ? `${matched.level} in ${task.type}` + : `no direct ${task.type} expertise`; + const loadPart = + contributor.capacityStatus === 'overloaded' + ? 'currently overloaded' + : `${contributor.activeItems} active item(s)`; + return `${contributor.name}: ${expertisePart}, ${loadPart}.`; +} + +function compareCandidates(a: CandidateScore, b: CandidateScore): number { + if (b.score !== a.score) return b.score - a.score; + if (b.expertiseScore !== a.expertiseScore) return b.expertiseScore - a.expertiseScore; + if (b.availabilityScore !== a.availabilityScore) { + return b.availabilityScore - a.availabilityScore; + } + return a.contributorId.localeCompare(b.contributorId); +} + +export function rankTaskAssignments( + tasks: OpenTask[], + contributors: ContributorProfile[], + options: RankTaskAssignmentsOptions = {} +): AssignmentSuggestion[] { + const weights = normalizeWeights(options.weights); + const capacityCeiling = options.capacityCeiling ?? DEFAULT_CAPACITY_CEILING; + const maxCandidates = Math.max(1, options.maxCandidatesPerTask ?? DEFAULT_MAX_CANDIDATES); + + return tasks.map(task => { + const candidates = contributors + .map(contributor => { + const { score: expertiseScore, matched } = expertiseScoreFor(task, contributor); + const availabilityScore = availabilityScoreFor(contributor, capacityCeiling); + const score = clamp01( + weights.expertiseWeight * expertiseScore + + weights.availabilityWeight * availabilityScore + ); + return { + contributorId: contributor.id, + contributorName: contributor.name, + score: Number(score.toFixed(4)), + expertiseScore: Number(expertiseScore.toFixed(4)), + availabilityScore: Number(availabilityScore.toFixed(4)), + matchedLevel: matched ? matched.level : null, + rationale: buildRationale(contributor, matched, task) + }; + }) + .sort(compareCandidates) + .slice(0, maxCandidates); + + return { + taskId: task.id, + taskTitle: task.title, + taskType: task.type, + bestCandidate: candidates[0] ?? null, + candidates + }; + }); +} diff --git a/packages/mcp-server/src/services/graph-service.ts b/packages/mcp-server/src/services/graph-service.ts index 12bfcd33..355c85aa 100644 --- a/packages/mcp-server/src/services/graph-service.ts +++ b/packages/mcp-server/src/services/graph-service.ts @@ -40,6 +40,14 @@ import { electCoordinator } from '../utils/leader-election.js'; import { withCPUThrottling, isSystemUnderStress } from '../utils/cpu-monitor.js'; import { withConnectionPoolLimit } from '../utils/connection-pool.js'; import { withReadConsistency, withWriteConsistency } from '../utils/consistency-manager.js'; +import { + rankTaskAssignments, + ContributorProfile, + ContributorExpertise, + ExpertiseLevel, + CapacityStatus, + OpenTask +} from './assignment-synthesis.js'; export interface PaginationInfo { total_count: number; @@ -3745,4 +3753,152 @@ export class GraphService { await session.close(); } } + + async suggestTaskAssignment(args: { + graph_id?: string; + task_ids?: string[]; + open_statuses?: string[]; + include_assigned?: boolean; + expertise_weight?: number; + availability_weight?: number; + max_candidates_per_task?: number; + min_items_threshold?: number; + task_limit?: number; + }): Promise { + if (!args.graph_id) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ error: 'graph_id is required' }) + }], + isError: true + }; + } + + const session = this.driver.session(); + try { + const openStatuses = args.open_statuses?.length + ? args.open_statuses + : ['PROPOSED', 'ACTIVE', 'IN_PROGRESS', 'BLOCKED']; + const minThreshold = args.min_items_threshold ?? 3; + const taskLimit = args.task_limit ?? 25; + const includeAssigned = args.include_assigned ?? false; + + const tasksQuery = ` + MATCH (g:Graph {id: $graphId})<-[:BELONGS_TO]-(w:WorkItem) + WHERE w.status IN $openStatuses + AND ($taskIds IS NULL OR w.id IN $taskIds) + OPTIONAL MATCH (w)<-[:CONTRIBUTES_TO]-(assignee:Contributor) + WITH w, count(assignee) AS assigneeCount + WHERE $includeAssigned OR assigneeCount = 0 + RETURN w.id AS id, w.title AS title, w.type AS type, w.priorityComp AS priority + ORDER BY w.priorityComp DESC + LIMIT $taskLimit + `; + + const tasksResult = await session.run(tasksQuery, { + graphId: args.graph_id, + openStatuses, + taskIds: args.task_ids?.length ? args.task_ids : null, + includeAssigned, + taskLimit: int(taskLimit) + }); + + const tasks: OpenTask[] = tasksResult.records.map(record => ({ + id: String(record.get('id')), + title: record.get('title') ? String(record.get('title')) : '', + type: record.get('type') ? String(record.get('type')) : 'TASK', + priority: typeof record.get('priority') === 'number' + ? (record.get('priority') as number) + : undefined + })); + + const contributorsQuery = ` + MATCH (g:Graph {id: $graphId})<-[:BELONGS_TO]-(:WorkItem)<-[:CONTRIBUTES_TO]-(c:Contributor) + WITH DISTINCT c + OPTIONAL MATCH (c)-[:CONTRIBUTES_TO]->(active:WorkItem) + WHERE active.status IN ['ACTIVE', 'IN_PROGRESS', 'BLOCKED'] + WITH c, count(active) AS activeItems + OPTIONAL MATCH (c)-[:CONTRIBUTES_TO]->(hist:WorkItem) + WITH c, activeItems, hist.type AS workType, + count(hist) AS typeCount, + sum(CASE WHEN hist.status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed + WITH c, activeItems, + collect(CASE WHEN workType IS NULL THEN null + ELSE {workType: workType, count: typeCount, completed: completed} END) AS expertise + RETURN c.id AS contributorId, c.name AS contributorName, activeItems, expertise + `; + + const contributorsResult = await session.run(contributorsQuery, { + graphId: args.graph_id + }); + + const toNum = (v: unknown): number => + typeof (v as { toNumber?: () => number })?.toNumber === 'function' + ? (v as { toNumber: () => number }).toNumber() + : Number(v) || 0; + + const capacityFor = (activeItems: number): CapacityStatus => { + if (activeItems >= 15) return 'overloaded'; + if (activeItems >= 10) return 'at_capacity'; + if (activeItems >= 5) return 'busy'; + return 'available'; + }; + + const contributors: ContributorProfile[] = contributorsResult.records.map(record => { + const activeItems = toNum(record.get('activeItems')); + const rawExpertise = (record.get('expertise') || []) as Array<{ + workType: string; + count: unknown; + completed: unknown; + } | null>; + + const expertise: ContributorExpertise[] = rawExpertise + .filter((e): e is { workType: string; count: unknown; completed: unknown } => !!e && !!e.workType) + .map(e => { + const count = toNum(e.count); + const completed = toNum(e.completed); + const completionRate = count > 0 ? completed / count : 0; + let level: ExpertiseLevel = 'Beginner'; + if (count >= minThreshold) { + level = completionRate > 0.8 ? 'Expert' : 'Proficient'; + } + return { workType: e.workType, level, completionRate }; + }); + + return { + id: String(record.get('contributorId')), + name: record.get('contributorName') ? String(record.get('contributorName')) : '', + activeItems, + capacityStatus: capacityFor(activeItems), + expertise + }; + }); + + const suggestions = rankTaskAssignments(tasks, contributors, { + weights: { + expertiseWeight: args.expertise_weight, + availabilityWeight: args.availability_weight + }, + maxCandidatesPerTask: args.max_candidates_per_task + }); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + graph_id: args.graph_id, + summary: { + open_tasks: tasks.length, + candidate_contributors: contributors.length, + tasks_with_a_suggestion: suggestions.filter(s => s.bestCandidate).length + }, + suggestions + }, null, 2) + }] + }; + } finally { + await session.close(); + } + } } \ No newline at end of file diff --git a/packages/mcp-server/tests/assignment-synthesis.test.ts b/packages/mcp-server/tests/assignment-synthesis.test.ts new file mode 100644 index 00000000..5adbf9ef --- /dev/null +++ b/packages/mcp-server/tests/assignment-synthesis.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { + rankTaskAssignments, + DEFAULT_WEIGHTS, + type ContributorProfile, + type OpenTask +} from '../src/services/assignment-synthesis'; + +// MCP-1 (#28): pure synthesis behind suggest_task_assignment. Ranking is a pure +// function of (open tasks, contributor expertise + availability) so it can be +// asserted deterministically without Neo4j. + +const expert = (workType: string, level: 'Expert' | 'Proficient' | 'Beginner', completionRate?: number) => ({ + workType, + level, + completionRate +}); + +describe('rankTaskAssignments (MCP-1 / #28)', () => { + it('returns one suggestion per open task, preserving identity', () => { + const tasks: OpenTask[] = [ + { id: 't1', title: 'Fix login bug', type: 'BUG' }, + { id: 't2', title: 'Build dashboard', type: 'FEATURE' } + ]; + const contributors: ContributorProfile[] = [ + { id: 'c1', name: 'Ada', activeItems: 2, expertise: [expert('BUG', 'Expert')] } + ]; + + const result = rankTaskAssignments(tasks, contributors); + + expect(result).toHaveLength(2); + expect(result.map(r => r.taskId)).toEqual(['t1', 't2']); + expect(result[0].taskTitle).toBe('Fix login bug'); + expect(result[0].taskType).toBe('BUG'); + }); + + it('ranks the matching expert above a beginner for the same task', () => { + const tasks: OpenTask[] = [{ id: 't1', title: 'Fix bug', type: 'BUG' }]; + const contributors: ContributorProfile[] = [ + { id: 'beginner', name: 'Bea', activeItems: 0, expertise: [expert('BUG', 'Beginner')] }, + { id: 'expert', name: 'Eli', activeItems: 0, expertise: [expert('BUG', 'Expert')] } + ]; + + const [suggestion] = rankTaskAssignments(tasks, contributors); + + expect(suggestion.bestCandidate?.contributorId).toBe('expert'); + expect(suggestion.bestCandidate?.matchedLevel).toBe('Expert'); + expect(suggestion.candidates[0].score).toBeGreaterThan(suggestion.candidates[1].score); + }); + + it('penalizes overloaded contributors so a free proficient can win over a busy expert', () => { + const tasks: OpenTask[] = [{ id: 't1', title: 'Ship feature', type: 'FEATURE' }]; + const contributors: ContributorProfile[] = [ + { + id: 'busy-expert', + name: 'Max', + activeItems: 20, + capacityStatus: 'overloaded', + expertise: [expert('FEATURE', 'Expert')] + }, + { + id: 'free-proficient', + name: 'Nia', + activeItems: 0, + capacityStatus: 'available', + expertise: [expert('FEATURE', 'Proficient')] + } + ]; + + const [suggestion] = rankTaskAssignments(tasks, contributors, { + weights: { expertiseWeight: 0.5, availabilityWeight: 0.5 } + }); + + expect(suggestion.bestCandidate?.contributorId).toBe('free-proficient'); + const overloaded = suggestion.candidates.find(c => c.contributorId === 'busy-expert'); + expect(overloaded?.availabilityScore).toBe(0); + }); + + it('gives a contributor with no matching expertise an expertise score of zero', () => { + const tasks: OpenTask[] = [{ id: 't1', title: 'Design API', type: 'STORY' }]; + const contributors: ContributorProfile[] = [ + { id: 'c1', name: 'Sam', activeItems: 1, expertise: [expert('BUG', 'Expert')] } + ]; + + const [suggestion] = rankTaskAssignments(tasks, contributors); + + expect(suggestion.candidates[0].expertiseScore).toBe(0); + expect(suggestion.candidates[0].matchedLevel).toBeNull(); + }); + + it('honors maxCandidatesPerTask', () => { + const tasks: OpenTask[] = [{ id: 't1', title: 'Task', type: 'TASK' }]; + const contributors: ContributorProfile[] = Array.from({ length: 5 }, (_, i) => ({ + id: `c${i}`, + name: `Person ${i}`, + activeItems: i, + expertise: [expert('TASK', 'Proficient')] + })); + + const [suggestion] = rankTaskAssignments(tasks, contributors, { maxCandidatesPerTask: 2 }); + + expect(suggestion.candidates).toHaveLength(2); + }); + + it('produces a null bestCandidate and empty candidates when no contributors exist', () => { + const tasks: OpenTask[] = [{ id: 't1', title: 'Orphan task', type: 'TASK' }]; + + const [suggestion] = rankTaskAssignments(tasks, []); + + expect(suggestion.bestCandidate).toBeNull(); + expect(suggestion.candidates).toEqual([]); + }); + + it('returns no suggestions for an empty task list', () => { + const contributors: ContributorProfile[] = [ + { id: 'c1', name: 'Ada', activeItems: 0, expertise: [expert('BUG', 'Expert')] } + ]; + + expect(rankTaskAssignments([], contributors)).toEqual([]); + }); + + it('keeps all component scores within the normalized 0..1 range', () => { + const tasks: OpenTask[] = [{ id: 't1', title: 'Task', type: 'BUG' }]; + const contributors: ContributorProfile[] = [ + { id: 'c1', name: 'Over', activeItems: 999, expertise: [expert('BUG', 'Expert', 1)] }, + { id: 'c2', name: 'Neg', activeItems: -5, expertise: [expert('BUG', 'Expert', 5)] } + ]; + + const [suggestion] = rankTaskAssignments(tasks, contributors); + + for (const candidate of suggestion.candidates) { + expect(candidate.score).toBeGreaterThanOrEqual(0); + expect(candidate.score).toBeLessThanOrEqual(1); + expect(candidate.expertiseScore).toBeGreaterThanOrEqual(0); + expect(candidate.expertiseScore).toBeLessThanOrEqual(1); + expect(candidate.availabilityScore).toBeGreaterThanOrEqual(0); + expect(candidate.availabilityScore).toBeLessThanOrEqual(1); + } + }); + + it('breaks ties deterministically by contributor id', () => { + const tasks: OpenTask[] = [{ id: 't1', title: 'Task', type: 'TASK' }]; + const contributors: ContributorProfile[] = [ + { id: 'zeb', name: 'Zeb', activeItems: 3, expertise: [expert('TASK', 'Expert')] }, + { id: 'abe', name: 'Abe', activeItems: 3, expertise: [expert('TASK', 'Expert')] } + ]; + + const [suggestion] = rankTaskAssignments(tasks, contributors); + + expect(suggestion.candidates[0].contributorId).toBe('abe'); + expect(suggestion.candidates[0].score).toBe(suggestion.candidates[1].score); + }); + + it('normalizes weights so equal raw weights split influence evenly', () => { + expect(DEFAULT_WEIGHTS.expertiseWeight + DEFAULT_WEIGHTS.availabilityWeight).toBeCloseTo(1); + + const tasks: OpenTask[] = [{ id: 't1', title: 'Task', type: 'BUG' }]; + const contributors: ContributorProfile[] = [ + { id: 'c1', name: 'Ada', activeItems: 0, expertise: [expert('BUG', 'Expert')] } + ]; + + const [suggestion] = rankTaskAssignments(tasks, contributors, { + weights: { expertiseWeight: 10, availabilityWeight: 10 } + }); + + expect(suggestion.candidates[0].score).toBeCloseTo(1); + }); +});