From 1960cda8f9dc032f26c67f48695396c40955dbe9 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 18:48:31 +0100 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20#760=20polish=20=E2=80=94=20Disco?= =?UTF-8?q?veredPort=20metadata,=20ranked=20alternatives,=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. #740: Add metadata field to DiscoveredPort struct - MIDI ports: display_name (stripped instance suffix) - HID ports: display_name, manufacturer, vendor_id, product_id 2. P18: Return ranked alternatives in suggest_binding - Primary suggestion + up to 2 alternatives with lower confidence - build_alternatives() generates fallback categories 3. #742: Remove dead DeviceStatusPills wrapper + test - Was a thin shim around BindingPills, not imported anywhere Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 38 +- conductor-gui/src-tauri/src/commands.rs | 29 ++ .../lib/components/DeviceStatusPills.svelte | 18 - .../lib/components/DeviceStatusPills.test.ts | 393 ------------------ 4 files changed, 66 insertions(+), 412 deletions(-) delete mode 100644 conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte delete mode 100644 conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 75a2e498..7b4e0d94 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -13,7 +13,7 @@ use super::mcp_types::{ToolCallResult, ToolDefinition, ToolRiskTier}; use conductor_core::config::{ActionConfig, Config, Trigger}; use conductor_core::device_intelligence::fingerprint::{ - EventStats, suggest_binding as compute_suggestion, + DeviceCategory, EventStats, suggest_binding as compute_suggestion, }; use dashmap::DashMap; use serde_json::{Value, json}; @@ -795,6 +795,36 @@ impl McpToolExecutor { Self {} } + /// Build ranked alternative suggestions (P18). + /// Returns up to 2 alternatives with lower confidence. + fn build_alternatives( + primary_category: &DeviceCategory, + primary_confidence: f64, + ) -> Vec { + use conductor_core::device_intelligence::fingerprint::DeviceCategory::*; + let all = [ + (PadController, "pads", "midi"), + (Keyboard, "keys", "midi"), + (FaderController, "faders", "midi"), + (EncoderController, "encoders", "midi"), + (GameController, "gamepad", "hid"), + ]; + all.iter() + .filter(|(cat, _, _)| cat != primary_category) + .take(2) + .map(|(cat, alias, protocol)| { + let cat_value = serde_json::to_value(cat) + .unwrap_or(serde_json::Value::String("Unknown".to_string())); + json!({ + "category": cat_value, + "suggested_alias": alias, + "suggested_protocol": protocol, + "confidence": (primary_confidence * 0.3).min(0.2), + }) + }) + .collect() + } + /// Look up last event timestamp for a port from the fingerprint stats. fn lookup_last_event_ms( event_stats: Option<&DashMap>, @@ -1689,6 +1719,8 @@ impl McpToolExecutor { let suggestion = compute_suggestion(stats, port_name); let category_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); + let alternatives = + Self::build_alternatives(&suggestion.category, suggestion.confidence); Some(ToolCallResult::json(&json!({ "port_name": port_name, "category": category_value, @@ -1698,6 +1730,7 @@ impl McpToolExecutor { "reasoning": suggestion.reasoning, "method": "event_fingerprint", "event_count": stats.note_count + stats.cc_count + stats.gamepad_count, + "alternatives": alternatives, "note": "Classification based on observed event patterns" }))) } else { @@ -1795,6 +1828,8 @@ impl McpToolExecutor { let category_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); + let alternatives = Self::build_alternatives(&suggestion.category, heuristic_confidence); + ToolCallResult::json(&json!({ "port_name": port_name, "category": category_value, @@ -1803,6 +1838,7 @@ impl McpToolExecutor { "suggested_protocol": suggestion.suggested_protocol, "reasoning": suggestion.reasoning, "method": "port_name_heuristic", + "alternatives": alternatives, "note": "Based on port name heuristics. Confidence is capped at 0.5 without real event data." })) } diff --git a/conductor-gui/src-tauri/src/commands.rs b/conductor-gui/src-tauri/src/commands.rs index 44856d9b..95ce5750 100644 --- a/conductor-gui/src-tauri/src/commands.rs +++ b/conductor-gui/src-tauri/src/commands.rs @@ -2248,6 +2248,9 @@ pub struct DiscoveredPort { pub direction: String, pub binding: Option, pub connected: bool, + /// Optional metadata (manufacturer, model, etc.) extracted from port name or USB info + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub metadata: std::collections::HashMap, } /// Get all discovered ports across protocols with binding status (ADR-022 Phase 2A) @@ -2264,6 +2267,20 @@ pub fn get_discovered_ports() -> Result, String> { let mut ports = Vec::new(); + /// Extract metadata from a MIDI port name (best-effort heuristic). + fn port_metadata(name: &str) -> std::collections::HashMap { + let mut meta = std::collections::HashMap::new(); + // Strip trailing instance suffix (" #2", " Port 1") + let base = name + .trim_end_matches(|c: char| c.is_ascii_digit() || c == '#' || c == ' ') + .trim_end_matches("Port") + .trim(); + if !base.is_empty() { + meta.insert("display_name".to_string(), base.to_string()); + } + meta + } + // MIDI input (receive) ports if let Ok(midi_in) = midir::MidiInput::new("conductor-discovery") { for port in midi_in.ports() { @@ -2281,12 +2298,14 @@ pub fn get_discovered_ports() -> Result, String> { }) .map(|d| d.alias.clone()) }); + let metadata = port_metadata(&name); ports.push(DiscoveredPort { name, protocol: "midi".to_string(), direction: "receive".to_string(), binding, connected: true, + metadata, }); } } @@ -2308,12 +2327,14 @@ pub fn get_discovered_ports() -> Result, String> { }) .map(|d| d.alias.clone()) }); + let metadata = port_metadata(&name); ports.push(DiscoveredPort { name, protocol: "midi".to_string(), direction: "send".to_string(), binding, connected: true, + metadata, }); } } @@ -2348,12 +2369,20 @@ pub fn get_discovered_ports() -> Result, String> { }) .map(|d| d.alias.clone()) }); + let mut metadata = std::collections::HashMap::new(); + metadata.insert("display_name".to_string(), name.to_string()); + if let Some(mfg) = device_info.manufacturer_string() { + metadata.insert("manufacturer".to_string(), mfg.to_string()); + } + metadata.insert("vendor_id".to_string(), format!("0x{:04X}", vid)); + metadata.insert("product_id".to_string(), format!("0x{:04X}", pid)); ports.push(DiscoveredPort { name: name.to_string(), protocol: "hid".to_string(), direction: "receive".to_string(), binding, connected: true, + metadata, }); } } diff --git a/conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte b/conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte deleted file mode 100644 index 8b2e7d2c..00000000 --- a/conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - diff --git a/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts b/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts deleted file mode 100644 index 1fc5e723..00000000 --- a/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * DeviceStatusPills tests (GUI v2 Phase 4) - * Updated for ADR-019 Phase 1B: multi-select activeDeviceFilter from workspaceFilters.js - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; -import { get } from 'svelte/store'; - -const mockDeviceBindings = vi.hoisted(() => { - const { writable } = require('svelte/store'); - return writable({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true }, - { device_id: 'nanok', alias: 'nanoK', connected: true, enabled: false }, - ], - loading: false, - error: null, - }); -}); - -const mockToggleMute = vi.hoisted(() => vi.fn()); - -const mockActiveDeviceFilter = vi.hoisted(() => { - const { writable } = require('svelte/store'); - return writable(new Set()); -}); - -vi.mock('$lib/stores.js', () => ({ - deviceBindingsStore: { - subscribe: mockDeviceBindings.subscribe, - toggleMute: mockToggleMute, - }, -})); - -vi.mock('$lib/stores/workspaceFilters.js', () => ({ - activeDeviceFilter: mockActiveDeviceFilter, -})); - -vi.mock('$lib/stores/workspace.js', () => { - const { writable } = require('svelte/store'); - return { - workspaceView: writable('mappings'), - WORKSPACE_VIEWS: { - MAPPINGS: 'mappings', - SIGNAL_FLOW: 'signal-flow', - }, - }; -}); - -vi.mock('$lib/stores/events.js', async () => { - const { writable, derived, readable } = await vi.importActual('svelte/store'); - return { - eventBuffer: writable([]), - eventTypeFilter: writable('all'), - eventChannelFilter: writable('all'), - eventFiredFilter: writable(true), - toastsEnabled: writable(true), - toastsContinuous: writable(false), - eventStreamVisible: writable(true), - learnSessionActive: writable(false), - autoScroll: writable(true), - nowTick: readable(Date.now()), - filteredEvents: derived(writable([]), ($e) => $e), - pushEvent: vi.fn(), - pushEvents: vi.fn(), - clearEvents: vi.fn(), - initEventListener: vi.fn().mockResolvedValue(null), - mappingFireState: writable({}), - mappingFireCount: writable({}), - isMappingFired: () => false, - getMappingFireCount: () => 0, - }; -}); - -vi.mock('$lib/utils/event-helpers', () => ({ - normalizeEventType: (e) => e.event_type, -})); - -describe('DeviceStatusPills', () => { - afterEach(() => { - cleanup(); - mockActiveDeviceFilter.set(new Set()); - mockToggleMute.mockClear(); - // Reset bindings to default for tests that modify them - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true }, - { device_id: 'nanok', alias: 'nanoK', connected: true, enabled: false }, - ], - loading: false, - error: null, - }); - }); - - it('renders device pills', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - expect(screen.getByText('Mikro')).toBeTruthy(); - expect(screen.getByText('nanoK')).toBeTruthy(); - }); - - it('clicking a pill adds device to filter', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(true); - }); - - it('clicking selected pill removes it from filter', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.size).toBe(0); - }); - - it('multi-select: clicking second device adds to Set', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('nanoK')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(true); - expect(filter.has('nanok')).toBe(true); - expect(filter.size).toBe(2); - }); - - it('multi-select: clicking selected device removes from Set', async () => { - mockActiveDeviceFilter.set(new Set(['mikro', 'nanok'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(false); - expect(filter.has('nanok')).toBe(true); - expect(filter.size).toBe(1); - }); - - it('deselecting last device resets to empty Set (all)', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.size).toBe(0); - }); - - it('all pills active when filter is empty Set', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pills = container.querySelectorAll('.device-pill'); - expect(pills[0].classList.contains('active')).toBe(true); - expect(pills[1].classList.contains('active')).toBe(true); - }); - - it('only selected pill active when filter has one device', async () => { - mockActiveDeviceFilter.set(new Set(['mikro'])); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pills = container.querySelectorAll('.device-pill'); - expect(pills[0].classList.contains('active')).toBe(true); // Mikro - expect(pills[1].classList.contains('active')).toBe(false); // nanoK - }); - - it('muted device has muted class', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pills = container.querySelectorAll('.device-pill'); - // nanoK is muted (enabled: false) - expect(pills[1].classList.contains('muted')).toBe(true); - // Mikro is not muted - expect(pills[0].classList.contains('muted')).toBe(false); - }); - - it('right-click shows context menu', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.contextMenu(screen.getByText('Mikro')); - expect(screen.getByText('🔇 Mute Binding')).toBeTruthy(); - expect(screen.getByText('⚙ Binding Settings')).toBeTruthy(); - }); - - it('right-click on muted device shows Unmute', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.contextMenu(screen.getByText('nanoK')); - expect(screen.getByText('🔊 Unmute Binding')).toBeTruthy(); - }); - - it('clicking mute in context menu calls toggleMute', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.contextMenu(screen.getByText('Mikro')); - await fireEvent.click(screen.getByText('🔇 Mute Binding')); - expect(mockToggleMute).toHaveBeenCalledWith('mikro', false); - }); - - it('renders nothing when no bindings', async () => { - mockDeviceBindings.set({ bindings: [], loading: false, error: null }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - expect(container.querySelector('.device-pills')).toBeNull(); - }); - - it('compact prop truncates long names', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'longdevice', alias: 'VeryLongDeviceName', connected: true, enabled: true }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills, { props: { compact: true } }); - expect(screen.getByText('VeryLong\u2026')).toBeTruthy(); - }); - - it('compact prop does not truncate short names', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills, { props: { compact: true } }); - expect(screen.getByText('Mikro')).toBeTruthy(); - expect(screen.getByText('nanoK')).toBeTruthy(); - }); - - it('linked prop shows link indicator', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills, { props: { linked: true } }); - const linkIndicator = container.querySelector('.link-indicator'); - expect(linkIndicator).toBeTruthy(); - }); - - it('linked prop absent hides link indicator', async () => { - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const linkIndicator = container.querySelector('.link-indicator'); - expect(linkIndicator).toBeNull(); - }); - - // ADR-021 Phase 3B — DirectionBadge and output-only pills (#673) - describe('DirectionBadge integration (#673)', () => { - it('renders DirectionBadge with arrow for input device', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Input' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const badge = container.querySelector('.direction-badge'); - expect(badge).toBeTruthy(); - expect(badge?.textContent?.trim()).toBe('\u2190'); // ← - }); - - it('renders DirectionBadge with arrow for output device', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const badge = container.querySelector('.direction-badge'); - expect(badge?.textContent?.trim()).toBe('\u2192'); // → - }); - - it('renders DirectionBadge with arrow for bidirectional device', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Bidirectional' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const badge = container.querySelector('.direction-badge'); - expect(badge?.textContent?.trim()).toBe('\u2194'); // ↔ - }); - - it('output-only pill has .output-only class', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.classList.contains('output-only')).toBe(true); - }); - - it('clicking output-only pill does NOT add to filter', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Synth')); - const filter = get(mockActiveDeviceFilter); - expect(filter.size).toBe(0); - }); - - it('output-only pill aria-label includes "no event filtering"', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.getAttribute('aria-label')).toContain('no event filtering'); - }); - - it('input pill aria-label includes device name and protocol', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Input' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.getAttribute('aria-label')).toBe('Mikro, MIDI'); - }); - - it('muted output-only pill has both .output-only and .muted classes', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: false, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - const { container } = render(DeviceStatusPills); - const pill = container.querySelector('.device-pill'); - expect(pill?.classList.contains('output-only')).toBe(true); - expect(pill?.classList.contains('muted')).toBe(true); - }); - - it('prunes output-only device from activeDeviceFilter when bindings update', async () => { - // Simulate: device was Input and in the filter, then switched to Output - mockActiveDeviceFilter.set(new Set(['synth'])); - mockDeviceBindings.set({ - bindings: [ - { device_id: 'synth', alias: 'Synth', connected: true, enabled: true, direction: 'Output' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - // The reactive pruning should remove 'synth' from the filter - await vi.waitFor(() => { - const filter = get(mockActiveDeviceFilter); - expect(filter.has('synth')).toBe(false); - }); - }); - - it('input devices still toggle filter normally (regression)', async () => { - mockDeviceBindings.set({ - bindings: [ - { device_id: 'mikro', alias: 'Mikro', connected: true, enabled: true, direction: 'Input' }, - ], - loading: false, - error: null, - }); - const DeviceStatusPills = (await import('./DeviceStatusPills.svelte')).default; - render(DeviceStatusPills); - await fireEvent.click(screen.getByText('Mikro')); - const filter = get(mockActiveDeviceFilter); - expect(filter.has('mikro')).toBe(true); - }); - }); -}); From f2e21ebf3be1c8d9d8b96f3df6b8de5782cbb174 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 18:58:54 +0100 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20review=20feedback=20=E2=80=94=20po?= =?UTF-8?q?rt=5Fmetadata,=20core-based=20alternatives,=20test=20+=20descri?= =?UTF-8?q?ption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix port_metadata to only strip " #N" / " Port N" suffixes (preserve model numbers) - build_alternatives uses core's compute_suggestion (no duplicated mapping) - Update tool description to mention alternatives array - Add test assertions for alternatives (present, lower confidence) Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 89 +++++++++++++++++++----- conductor-gui/src-tauri/src/commands.rs | 26 ++++--- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 7b4e0d94..7e840ab2 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -688,7 +688,7 @@ pub fn get_tool_definitions() -> Vec { // Event fingerprinting (ADR-022 Phase 5D, #755) ToolDefinition { name: "conductor_suggest_binding".to_string(), - description: "Suggest a binding configuration for a port. Uses live event fingerprinting when events have been observed (method: event_fingerprint, confidence based on observed event volume), and falls back to port-name heuristics otherwise (method: port_name_heuristic, confidence capped at 0.5). Returns device category, confidence score, suggested alias, and reasoning.".to_string(), + description: "Suggest a binding configuration for a port. Uses live event fingerprinting when events have been observed (method: event_fingerprint), falls back to port-name heuristics (method: port_name_heuristic, confidence capped at 0.5). Returns primary suggestion (category, confidence, alias, protocol, reasoning) plus ranked alternatives array with lower-confidence fallback categories.".to_string(), input_schema: json!({ "type": "object", "properties": { @@ -795,30 +795,63 @@ impl McpToolExecutor { Self {} } - /// Build ranked alternative suggestions (P18). - /// Returns up to 2 alternatives with lower confidence. + /// Build ranked alternative suggestions using core's suggest_binding (P18). + /// Returns up to 2 alternatives with lower confidence than the primary. fn build_alternatives( primary_category: &DeviceCategory, primary_confidence: f64, + port_name: &str, ) -> Vec { - use conductor_core::device_intelligence::fingerprint::DeviceCategory::*; - let all = [ - (PadController, "pads", "midi"), - (Keyboard, "keys", "midi"), - (FaderController, "faders", "midi"), - (EncoderController, "encoders", "midi"), - (GameController, "gamepad", "hid"), + // Synthetic EventStats that produce each category via classify() + #[allow(clippy::type_complexity)] + let category_seeds: &[(fn(&mut EventStats), DeviceCategory)] = &[ + ( + |s| { + for n in 36..52 { + s.record_note(n, 100); + } + }, + DeviceCategory::PadController, + ), + ( + |s| { + for n in 21..109 { + s.record_note(n, 80); + } + }, + DeviceCategory::Keyboard, + ), + ( + |s| { + for cc in 0..8 { + s.record_cc(cc); + } + }, + DeviceCategory::FaderController, + ), + ( + |s| { + for _ in 0..20 { + s.record_gamepad(); + } + }, + DeviceCategory::GameController, + ), ]; - all.iter() - .filter(|(cat, _, _)| cat != primary_category) + category_seeds + .iter() + .filter(|(_, cat)| cat != primary_category) .take(2) - .map(|(cat, alias, protocol)| { - let cat_value = serde_json::to_value(cat) + .map(|(seed_fn, _)| { + let mut stats = EventStats::new(); + seed_fn(&mut stats); + let suggestion = compute_suggestion(&stats, port_name); + let cat_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); json!({ "category": cat_value, - "suggested_alias": alias, - "suggested_protocol": protocol, + "suggested_alias": suggestion.suggested_alias, + "suggested_protocol": suggestion.suggested_protocol, "confidence": (primary_confidence * 0.3).min(0.2), }) }) @@ -1719,8 +1752,11 @@ impl McpToolExecutor { let suggestion = compute_suggestion(stats, port_name); let category_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); - let alternatives = - Self::build_alternatives(&suggestion.category, suggestion.confidence); + let alternatives = Self::build_alternatives( + &suggestion.category, + suggestion.confidence, + port_name, + ); Some(ToolCallResult::json(&json!({ "port_name": port_name, "category": category_value, @@ -1828,7 +1864,8 @@ impl McpToolExecutor { let category_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); - let alternatives = Self::build_alternatives(&suggestion.category, heuristic_confidence); + let alternatives = + Self::build_alternatives(&suggestion.category, heuristic_confidence, port_name); ToolCallResult::json(&json!({ "port_name": port_name, @@ -2235,6 +2272,20 @@ mod tests { assert_eq!(parsed["category"], "PadController"); assert!(parsed["confidence"].as_f64().unwrap() <= 0.5); assert_eq!(parsed["method"], "port_name_heuristic"); + // P18: ranked alternatives + let alts = parsed["alternatives"] + .as_array() + .expect("should have alternatives"); + assert!(alts.len() >= 1, "should have at least 1 alternative"); + for alt in alts { + assert!(alt["category"].is_string()); + assert!(alt["suggested_alias"].is_string()); + let alt_conf = alt["confidence"].as_f64().unwrap(); + assert!( + alt_conf < parsed["confidence"].as_f64().unwrap(), + "alternative confidence should be lower than primary" + ); + } } else { panic!("Expected text content"); } diff --git a/conductor-gui/src-tauri/src/commands.rs b/conductor-gui/src-tauri/src/commands.rs index 95ce5750..a7b48709 100644 --- a/conductor-gui/src-tauri/src/commands.rs +++ b/conductor-gui/src-tauri/src/commands.rs @@ -2270,14 +2270,24 @@ pub fn get_discovered_ports() -> Result, String> { /// Extract metadata from a MIDI port name (best-effort heuristic). fn port_metadata(name: &str) -> std::collections::HashMap { let mut meta = std::collections::HashMap::new(); - // Strip trailing instance suffix (" #2", " Port 1") - let base = name - .trim_end_matches(|c: char| c.is_ascii_digit() || c == '#' || c == ' ') - .trim_end_matches("Port") - .trim(); - if !base.is_empty() { - meta.insert("display_name".to_string(), base.to_string()); - } + // Strip known instance suffixes: " #2", " Port 3" (OS-appended) + // Preserve model numbers that are part of the name (e.g., "nanoKONTROL2") + let base = if let Some((prefix, suffix)) = name.rsplit_once(" #") { + if suffix.chars().all(|c| c.is_ascii_digit()) { + prefix + } else { + name + } + } else if let Some((prefix, suffix)) = name.rsplit_once(" Port ") { + if suffix.chars().all(|c| c.is_ascii_digit()) { + prefix + } else { + name + } + } else { + name + }; + meta.insert("display_name".to_string(), base.to_string()); meta } From b48a573c0494285a62c05aef3c384e7c689aa931 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 19:07:23 +0100 Subject: [PATCH 03/11] fix: decreasing confidence for ranked alternatives, test assertions - Alternatives now have rank-based decay (1st: 30%, 2nd: 15% of primary) - Min confidence 0.01 to avoid zero-confidence alternatives - Test verifies decreasing confidence across alternatives Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 7e840ab2..4ee69d91 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -842,17 +842,21 @@ impl McpToolExecutor { .iter() .filter(|(_, cat)| cat != primary_category) .take(2) - .map(|(seed_fn, _)| { + .enumerate() + .map(|(rank, (seed_fn, _))| { let mut stats = EventStats::new(); seed_fn(&mut stats); let suggestion = compute_suggestion(&stats, port_name); let cat_value = serde_json::to_value(&suggestion.category) .unwrap_or(serde_json::Value::String("Unknown".to_string())); + // Decreasing confidence: 1st alt ~30%, 2nd alt ~15% of primary (min 0.01) + let decay = if rank == 0 { 0.3 } else { 0.15 }; + let alt_conf = (primary_confidence * decay).max(0.01).min(0.2); json!({ "category": cat_value, "suggested_alias": suggestion.suggested_alias, "suggested_protocol": suggestion.suggested_protocol, - "confidence": (primary_confidence * 0.3).min(0.2), + "confidence": alt_conf, }) }) .collect() @@ -2277,14 +2281,17 @@ mod tests { .as_array() .expect("should have alternatives"); assert!(alts.len() >= 1, "should have at least 1 alternative"); + let mut prev_conf = parsed["confidence"].as_f64().unwrap(); for alt in alts { assert!(alt["category"].is_string()); assert!(alt["suggested_alias"].is_string()); let alt_conf = alt["confidence"].as_f64().unwrap(); + assert!(alt_conf > 0.0, "alternative confidence should be positive"); assert!( - alt_conf < parsed["confidence"].as_f64().unwrap(), - "alternative confidence should be lower than primary" + alt_conf <= prev_conf, + "alternatives should have decreasing confidence" ); + prev_conf = alt_conf; } } else { panic!("Expected text content"); From 5e18f4185e7ed350ceecaccb8536f94f7abc4bb5 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 19:13:37 +0100 Subject: [PATCH 04/11] fix: use clamp() instead of max().min() (clippy manual_clamp) Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 4ee69d91..63886d06 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -851,7 +851,7 @@ impl McpToolExecutor { .unwrap_or(serde_json::Value::String("Unknown".to_string())); // Decreasing confidence: 1st alt ~30%, 2nd alt ~15% of primary (min 0.01) let decay = if rank == 0 { 0.3 } else { 0.15 }; - let alt_conf = (primary_confidence * decay).max(0.01).min(0.2); + let alt_conf = (primary_confidence * decay).clamp(0.01, 0.2); json!({ "category": cat_value, "suggested_alias": suggestion.suggested_alias, From ab20fcbd50b2d433c9e1a201867f3ad982e8367e Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 19:38:19 +0100 Subject: [PATCH 05/11] fix: metadata doc, unknown port alternatives test - Fix DiscoveredPort.metadata doc: always present (not optional) - Remove skip_serializing_if since metadata always has display_name - Add test: unknown port name produces positive-confidence alternatives Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 30 ++++++++++++++++++++++++ conductor-gui/src-tauri/src/commands.rs | 4 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 63886d06..6aba62c3 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -2380,6 +2380,36 @@ mod tests { } } + #[tokio::test] + async fn test_suggest_binding_unknown_port_has_positive_alternatives() { + let executor = McpToolExecutor::new(); + // Unknown port name that matches no heuristic seeds + let result = executor + .execute( + "conductor_suggest_binding", + Some(json!({"port_name": "Totally Unknown Device XYZ"})), + None, + None, + None, + None, + ) + .await; + assert!(result.is_error.is_none()); + let content = &result.content[0]; + if let ToolContent::Text { text } = content { + let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed["category"], "Unknown"); + let alts = parsed["alternatives"].as_array().expect("should have alternatives"); + for alt in alts { + let conf = alt["confidence"].as_f64().unwrap(); + assert!(conf > 0.0, "alternatives should have positive confidence even for unknown primary"); + assert!(conf <= 0.2, "alternatives capped at 0.2"); + } + } else { + panic!("Expected text content"); + } + } + #[tokio::test] async fn test_suggest_binding_falls_back_with_empty_stats() { let executor = McpToolExecutor::new(); diff --git a/conductor-gui/src-tauri/src/commands.rs b/conductor-gui/src-tauri/src/commands.rs index a7b48709..6b6f769c 100644 --- a/conductor-gui/src-tauri/src/commands.rs +++ b/conductor-gui/src-tauri/src/commands.rs @@ -2248,8 +2248,8 @@ pub struct DiscoveredPort { pub direction: String, pub binding: Option, pub connected: bool, - /// Optional metadata (manufacturer, model, etc.) extracted from port name or USB info - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + /// Port metadata (always includes display_name; HID ports add manufacturer, vendor_id, product_id) + #[serde(default)] pub metadata: std::collections::HashMap, } From b33c84585f8d6563afce1b1c7be51ac348fc49e2 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 19:40:33 +0100 Subject: [PATCH 06/11] fix: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 6aba62c3..35626aee 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -2399,10 +2399,15 @@ mod tests { if let ToolContent::Text { text } = content { let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); assert_eq!(parsed["category"], "Unknown"); - let alts = parsed["alternatives"].as_array().expect("should have alternatives"); + let alts = parsed["alternatives"] + .as_array() + .expect("should have alternatives"); for alt in alts { let conf = alt["confidence"].as_f64().unwrap(); - assert!(conf > 0.0, "alternatives should have positive confidence even for unknown primary"); + assert!( + conf > 0.0, + "alternatives should have positive confidence even for unknown primary" + ); assert!(conf <= 0.2, "alternatives capped at 0.2"); } } else { From fd51763af67bb0e66d043dc4080fa0106f791c94 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 19:47:17 +0100 Subject: [PATCH 07/11] fix: extract port_metadata to module level + 5 unit tests - Move port_metadata out of nested function for testability - 5 tests: model number preservation (nanoKONTROL2, APC40), instance suffix stripping (#2, Port 3), plain name passthrough Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-gui/src-tauri/src/commands.rs | 77 +++++++++++++++++-------- 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/conductor-gui/src-tauri/src/commands.rs b/conductor-gui/src-tauri/src/commands.rs index 6b6f769c..7d12585d 100644 --- a/conductor-gui/src-tauri/src/commands.rs +++ b/conductor-gui/src-tauri/src/commands.rs @@ -2253,6 +2253,29 @@ pub struct DiscoveredPort { pub metadata: std::collections::HashMap, } +/// Extract metadata from a port name. Strips OS-appended instance suffixes +/// (" #2", " Port 3") while preserving model numbers (e.g., "nanoKONTROL2"). +fn port_metadata(name: &str) -> std::collections::HashMap { + let mut meta = std::collections::HashMap::new(); + let base = if let Some((prefix, suffix)) = name.rsplit_once(" #") { + if suffix.chars().all(|c| c.is_ascii_digit()) { + prefix + } else { + name + } + } else if let Some((prefix, suffix)) = name.rsplit_once(" Port ") { + if suffix.chars().all(|c| c.is_ascii_digit()) { + prefix + } else { + name + } + } else { + name + }; + meta.insert("display_name".to_string(), base.to_string()); + meta +} + /// Get all discovered ports across protocols with binding status (ADR-022 Phase 2A) /// /// Enumerates MIDI input/output ports and returns them with the binding alias @@ -2267,30 +2290,6 @@ pub fn get_discovered_ports() -> Result, String> { let mut ports = Vec::new(); - /// Extract metadata from a MIDI port name (best-effort heuristic). - fn port_metadata(name: &str) -> std::collections::HashMap { - let mut meta = std::collections::HashMap::new(); - // Strip known instance suffixes: " #2", " Port 3" (OS-appended) - // Preserve model numbers that are part of the name (e.g., "nanoKONTROL2") - let base = if let Some((prefix, suffix)) = name.rsplit_once(" #") { - if suffix.chars().all(|c| c.is_ascii_digit()) { - prefix - } else { - name - } - } else if let Some((prefix, suffix)) = name.rsplit_once(" Port ") { - if suffix.chars().all(|c| c.is_ascii_digit()) { - prefix - } else { - name - } - } else { - name - }; - meta.insert("display_name".to_string(), base.to_string()); - meta - } - // MIDI input (receive) ports if let Ok(midi_in) = midir::MidiInput::new("conductor-discovery") { for port in midi_in.ports() { @@ -2789,4 +2788,34 @@ mod tests { .contains("must be within the midimon config directory") ); } + + #[test] + fn test_port_metadata_preserves_model_numbers() { + let meta = port_metadata("nanoKONTROL2"); + assert_eq!(meta["display_name"], "nanoKONTROL2"); + } + + #[test] + fn test_port_metadata_strips_instance_suffix() { + let meta = port_metadata("Maschine Mikro MK3 #2"); + assert_eq!(meta["display_name"], "Maschine Mikro MK3"); + } + + #[test] + fn test_port_metadata_strips_port_suffix() { + let meta = port_metadata("USB MIDI Port 3"); + assert_eq!(meta["display_name"], "USB MIDI"); + } + + #[test] + fn test_port_metadata_no_suffix() { + let meta = port_metadata("Launchpad Mini MK3 MIDI"); + assert_eq!(meta["display_name"], "Launchpad Mini MK3 MIDI"); + } + + #[test] + fn test_port_metadata_preserves_apc40() { + let meta = port_metadata("APC40"); + assert_eq!(meta["display_name"], "APC40"); + } } From a3ce2ed420af54223b1d8ed90e1628c0921edc2d Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 19:54:47 +0100 Subject: [PATCH 08/11] fix: skip alternatives when primary confidence is 0, minimal seeds - Empty alternatives for Unknown ports (avoids higher-than-primary confidence) - Use minimal synthetic events (2 per category) instead of large loops - Update test to verify empty alternatives for unknown ports Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 121 ++++++++++------------- 1 file changed, 53 insertions(+), 68 deletions(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 35626aee..2685b4bc 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -802,64 +802,53 @@ impl McpToolExecutor { primary_confidence: f64, port_name: &str, ) -> Vec { - // Synthetic EventStats that produce each category via classify() - #[allow(clippy::type_complexity)] - let category_seeds: &[(fn(&mut EventStats), DeviceCategory)] = &[ - ( - |s| { - for n in 36..52 { - s.record_note(n, 100); - } - }, - DeviceCategory::PadController, - ), - ( - |s| { - for n in 21..109 { - s.record_note(n, 80); - } - }, - DeviceCategory::Keyboard, - ), - ( - |s| { - for cc in 0..8 { - s.record_cc(cc); - } - }, - DeviceCategory::FaderController, - ), - ( - |s| { - for _ in 0..20 { - s.record_gamepad(); - } - }, - DeviceCategory::GameController, - ), + // No alternatives when primary has no confidence (Unknown/no data) + if primary_confidence <= 0.0 { + return Vec::new(); + } + + // Minimal synthetic EventStats to trigger each category. + // Uses core's compute_suggestion to keep alias/protocol in sync. + let categories = [ + (DeviceCategory::PadController, 36u8, 40u8, false), + (DeviceCategory::Keyboard, 21, 100, false), + (DeviceCategory::FaderController, 0, 1, true), + (DeviceCategory::GameController, 0, 0, false), ]; - category_seeds - .iter() - .filter(|(_, cat)| cat != primary_category) - .take(2) - .enumerate() - .map(|(rank, (seed_fn, _))| { - let mut stats = EventStats::new(); - seed_fn(&mut stats); - let suggestion = compute_suggestion(&stats, port_name); - let cat_value = serde_json::to_value(&suggestion.category) - .unwrap_or(serde_json::Value::String("Unknown".to_string())); - // Decreasing confidence: 1st alt ~30%, 2nd alt ~15% of primary (min 0.01) - let decay = if rank == 0 { 0.3 } else { 0.15 }; - let alt_conf = (primary_confidence * decay).clamp(0.01, 0.2); - json!({ - "category": cat_value, - "suggested_alias": suggestion.suggested_alias, - "suggested_protocol": suggestion.suggested_protocol, - "confidence": alt_conf, - }) - }) - .collect() + let mut alts = Vec::new(); + let mut rank = 0usize; + for (cat, a, b, is_cc) in &categories { + if cat == primary_category { + continue; + } + if rank >= 2 { + break; + } + let mut stats = EventStats::new(); + if *cat == DeviceCategory::GameController { + stats.record_gamepad(); + stats.record_gamepad(); + } else if *is_cc { + stats.record_cc(*a); + stats.record_cc(*b); + } else { + stats.record_note(*a, 80); + stats.record_note(*b, 80); + } + let suggestion = compute_suggestion(&stats, port_name); + let cat_value = serde_json::to_value(cat) + .unwrap_or(serde_json::Value::String("Unknown".to_string())); + let decay = if rank == 0 { 0.3 } else { 0.15 }; + let alt_conf = (primary_confidence * decay).min(0.2); + alts.push(json!({ + "category": cat_value, + "suggested_alias": suggestion.suggested_alias, + "suggested_protocol": suggestion.suggested_protocol, + "confidence": alt_conf, + })); + rank += 1; + } + alts } /// Look up last event timestamp for a port from the fingerprint stats. @@ -2381,9 +2370,9 @@ mod tests { } #[tokio::test] - async fn test_suggest_binding_unknown_port_has_positive_alternatives() { + async fn test_suggest_binding_unknown_port_omits_alternatives() { let executor = McpToolExecutor::new(); - // Unknown port name that matches no heuristic seeds + // Unknown port name — primary confidence is 0, so alternatives should be empty let result = executor .execute( "conductor_suggest_binding", @@ -2401,15 +2390,11 @@ mod tests { assert_eq!(parsed["category"], "Unknown"); let alts = parsed["alternatives"] .as_array() - .expect("should have alternatives"); - for alt in alts { - let conf = alt["confidence"].as_f64().unwrap(); - assert!( - conf > 0.0, - "alternatives should have positive confidence even for unknown primary" - ); - assert!(conf <= 0.2, "alternatives capped at 0.2"); - } + .expect("should have alternatives array"); + assert!( + alts.is_empty(), + "Unknown port (0 confidence) should have no alternatives" + ); } else { panic!("Expected text content"); } From 72685b1ff9751c764a4d076f49dcf23e30080e31 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 20:01:25 +0100 Subject: [PATCH 09/11] fix: include EncoderController in alternatives seeds Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 2685b4bc..3b678514 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -810,14 +810,15 @@ impl McpToolExecutor { // Minimal synthetic EventStats to trigger each category. // Uses core's compute_suggestion to keep alias/protocol in sync. let categories = [ - (DeviceCategory::PadController, 36u8, 40u8, false), - (DeviceCategory::Keyboard, 21, 100, false), - (DeviceCategory::FaderController, 0, 1, true), - (DeviceCategory::GameController, 0, 0, false), + (DeviceCategory::PadController, 36u8, 40u8, false, false), + (DeviceCategory::Keyboard, 21, 100, false, false), + (DeviceCategory::FaderController, 0, 1, true, false), + (DeviceCategory::EncoderController, 0, 0, true, true), + (DeviceCategory::GameController, 0, 0, false, false), ]; let mut alts = Vec::new(); let mut rank = 0usize; - for (cat, a, b, is_cc) in &categories { + for (cat, a, b, is_cc, is_encoder) in &categories { if cat == primary_category { continue; } @@ -828,6 +829,11 @@ impl McpToolExecutor { if *cat == DeviceCategory::GameController { stats.record_gamepad(); stats.record_gamepad(); + } else if *is_encoder { + // Encoder: high event density on few CCs (>10 hits, <=4 unique CCs) + for _ in 0..12 { + stats.record_cc(0); + } } else if *is_cc { stats.record_cc(*a); stats.record_cc(*b); From 0d83917d562adc506af8aad42d6d8b9ca4003b32 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 20:07:20 +0100 Subject: [PATCH 10/11] fix: HID metadata reuses port_metadata() for consistent display_name Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-gui/src-tauri/src/commands.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/conductor-gui/src-tauri/src/commands.rs b/conductor-gui/src-tauri/src/commands.rs index 7d12585d..c95c159b 100644 --- a/conductor-gui/src-tauri/src/commands.rs +++ b/conductor-gui/src-tauri/src/commands.rs @@ -2378,8 +2378,7 @@ pub fn get_discovered_ports() -> Result, String> { }) .map(|d| d.alias.clone()) }); - let mut metadata = std::collections::HashMap::new(); - metadata.insert("display_name".to_string(), name.to_string()); + let mut metadata = port_metadata(name); if let Some(mfg) = device_info.manufacturer_string() { metadata.insert("manufacturer".to_string(), mfg.to_string()); } From 1273f4eb2e56f40e9a27e04ea06726aae53b8c09 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 20:12:25 +0100 Subject: [PATCH 11/11] fix: rename test to match behavior (empty alternatives, not omitted) Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-daemon/src/daemon/mcp_tools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conductor-daemon/src/daemon/mcp_tools.rs b/conductor-daemon/src/daemon/mcp_tools.rs index 3b678514..bdbe4cf7 100644 --- a/conductor-daemon/src/daemon/mcp_tools.rs +++ b/conductor-daemon/src/daemon/mcp_tools.rs @@ -2376,7 +2376,7 @@ mod tests { } #[tokio::test] - async fn test_suggest_binding_unknown_port_omits_alternatives() { + async fn test_suggest_binding_unknown_port_returns_empty_alternatives() { let executor = McpToolExecutor::new(); // Unknown port name — primary confidence is 0, so alternatives should be empty let result = executor