feat: #760 polish — metadata, ranked alternatives, dead code cleanup#791
feat: #760 polish — metadata, ranked alternatives, dead code cleanup#791amiable-dev merged 11 commits intomainfrom
Conversation
…d code 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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR polishes ADR-022 discovery and binding-suggestion UX by enriching discovered port data with metadata, returning ranked alternative binding suggestions for suggest_binding, and removing an unused legacy UI wrapper.
Changes:
- Add a
metadatafield toDiscoveredPort(best-effortdisplay_namefor MIDI; USB info for HID). - Extend
conductor_suggest_bindingMCP tool responses to include analternativesarray (up to 2). - Remove dead
DeviceStatusPillswrapper component and its test.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts | Deleted unused tests for the removed wrapper component. |
| conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte | Deleted unused wrapper around BindingPills. |
| conductor-gui/src-tauri/src/commands.rs | Added DiscoveredPort.metadata and populated it for MIDI/HID discovered ports. |
| conductor-daemon/src/daemon/mcp_tools.rs | Added alternatives to conductor_suggest_binding responses via a helper builder. |
| @@ -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" | |||
There was a problem hiding this comment.
conductor_suggest_binding now returns an alternatives array, but the existing suggest-binding tests in this file only assert category/method/confidence and never validate alternatives is present/structured (or that confidences are lower than the primary). Please extend the current tests to cover the new alternatives field for both event_fingerprint and port_name_heuristic paths.
| 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(); |
There was a problem hiding this comment.
port_metadata trims all trailing ASCII digits unconditionally. This will incorrectly strip model numbers from real device names that legitimately end with digits (e.g., "nanoKONTROL2", "APC40"), producing a wrong display_name. Consider only stripping digits when they match known instance-suffix patterns (e.g. " #" or " Port ") rather than any trailing digit.
| 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(); | |
| fn strip_instance_suffix(s: &str) -> &str { | |
| let s = s.trim_end(); | |
| // Pattern 1: trailing " #<digits>", e.g. "APC MINI #2" | |
| if let Some(pos) = s.rfind(" #") { | |
| let suffix = &s[pos + 2..]; | |
| if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { | |
| return s[..pos].trim_end(); | |
| } | |
| } | |
| // Pattern 2: trailing " Port <digits>", e.g. "APC MINI Port 1" | |
| if let Some(pos) = s.rfind(" Port ") { | |
| let suffix = &s[pos + " Port ".len()..]; | |
| if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { | |
| return s[..pos].trim_end(); | |
| } | |
| } | |
| // No known instance suffix found; return the original (trimmed) string. | |
| s | |
| } | |
| let mut meta = std::collections::HashMap::new(); | |
| // Strip trailing instance suffix (" #2", " Port 1") but keep model numbers. | |
| let base = strip_instance_suffix(name); |
| (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, |
There was a problem hiding this comment.
build_alternatives re-encodes the category→(alias,protocol) mapping that already exists in conductor_core::device_intelligence::fingerprint::suggest_binding. This duplication can drift over time (e.g., if new categories are added or aliases change) and would make alternatives inconsistent with primary suggestions. Consider centralizing the mapping in conductor-core (e.g., a helper on DeviceCategory) and reusing it here.
| (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, | |
| PadController, | |
| Keyboard, | |
| FaderController, | |
| EncoderController, | |
| GameController, | |
| ]; | |
| all.iter() | |
| .filter(|cat| *cat != primary_category) | |
| .take(2) | |
| .map(|cat| { | |
| let cat_value = serde_json::to_value(cat) | |
| .unwrap_or(serde_json::Value::String("Unknown".to_string())); | |
| json!({ | |
| "category": cat_value, |
| /// Build ranked alternative suggestions (P18). | ||
| /// Returns up to 2 alternatives with lower confidence. | ||
| fn build_alternatives( | ||
| primary_category: &DeviceCategory, | ||
| primary_confidence: f64, | ||
| ) -> Vec<serde_json::Value> { |
There was a problem hiding this comment.
The tool definition for conductor_suggest_binding (in get_tool_definitions) still describes the response as only containing category/confidence/alias/protocol/reasoning. Since this change adds an alternatives field to the JSON output, please update the tool description so MCP/LLM consumers know it exists and how to use it.
… description - 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) <noreply@anthropic.com>
| category_seeds | ||
| .iter() | ||
| .filter(|(_, cat)| cat != primary_category) | ||
| .take(2) | ||
| .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": suggestion.suggested_alias, | ||
| "suggested_protocol": suggestion.suggested_protocol, | ||
| "confidence": (primary_confidence * 0.3).min(0.2), |
There was a problem hiding this comment.
In build_alternatives, the .take(2) happens before any ranking/selection, so the last seed (GameController) can never be returned as an alternative (it’s always after PadController/Keyboard/FaderController, and only one category is filtered out). If the intent is “ranked alternatives”, consider generating all non-primary candidates, sorting them by a score (e.g., their own suggestion.confidence scaled below the primary), then taking the top 2.
| category_seeds | |
| .iter() | |
| .filter(|(_, cat)| cat != primary_category) | |
| .take(2) | |
| .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": suggestion.suggested_alias, | |
| "suggested_protocol": suggestion.suggested_protocol, | |
| "confidence": (primary_confidence * 0.3).min(0.2), | |
| // Build suggestions for all non-primary categories first. | |
| let mut candidates = Vec::new(); | |
| for (seed_fn, cat) in category_seeds.iter() { | |
| if cat == primary_category { | |
| continue; | |
| } | |
| let mut stats = EventStats::new(); | |
| seed_fn(&mut stats); | |
| let suggestion = compute_suggestion(&stats, port_name); | |
| candidates.push(suggestion); | |
| } | |
| // Sort candidates by their own confidence (highest first), then take top 2. | |
| candidates.sort_by(|a, b| { | |
| b.confidence | |
| .partial_cmp(&a.confidence) | |
| .unwrap_or(std::cmp::Ordering::Equal) | |
| }); | |
| candidates | |
| .into_iter() | |
| .take(2) | |
| .map(|suggestion| { | |
| let cat_value = serde_json::to_value(&suggestion.category) | |
| .unwrap_or(serde_json::Value::String("Unknown".to_string())); | |
| let alt_confidence = (suggestion.confidence * primary_confidence) | |
| .min(primary_confidence * 0.9); | |
| json!({ | |
| "category": cat_value, | |
| "suggested_alias": suggestion.suggested_alias, | |
| "suggested_protocol": suggestion.suggested_protocol, | |
| "confidence": alt_confidence, |
| json!({ | ||
| "category": cat_value, | ||
| "suggested_alias": suggestion.suggested_alias, | ||
| "suggested_protocol": suggestion.suggested_protocol, | ||
| "confidence": (primary_confidence * 0.3).min(0.2), | ||
| }) |
There was a problem hiding this comment.
Alternative confidence is currently derived only from primary_confidence and is identical for every alternative. This also produces alternatives with confidence == 0.0 when the primary heuristic classification yields 0.0 (Unknown/no events), violating the “lower confidence than primary” expectation. Consider either omitting alternatives when primary_confidence is 0.0, or computing per-alternative confidence from each candidate’s own stats and clamping it to be strictly less than the primary.
- 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // P18: ranked alternatives | ||
| let alts = parsed["alternatives"] | ||
| .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 <= prev_conf, | ||
| "alternatives should have decreasing confidence" | ||
| ); | ||
| prev_conf = alt_conf; | ||
| } |
There was a problem hiding this comment.
The new alternatives assertions only cover cases where the primary confidence is > 0 (e.g., names that match the heuristic seeds). It would be good to add a regression test for an “unknown/insufficient data” port name (primary confidence 0.0) to ensure alternatives are either omitted or never exceed the primary confidence.
| /// Optional metadata (manufacturer, model, etc.) extracted from port name or USB info | ||
| #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] |
There was a problem hiding this comment.
metadata is described as “Optional” and has skip_serializing_if = is_empty, but get_discovered_ports always inserts at least display_name, so the map is never empty and the field will always be serialized. Either adjust the doc comment to reflect that metadata is always present (at least display_name), or change construction/typing so it can actually be omitted when empty.
| /// Optional metadata (manufacturer, model, etc.) extracted from port name or USB info | |
| #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] | |
| /// Metadata (always includes at least `display_name`, and may contain manufacturer, model, etc.) | |
| #[serde(default)] |
- 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| /// Port metadata (always includes display_name; HID ports add manufacturer, vendor_id, product_id) | ||
| #[serde(default)] | ||
| pub metadata: std::collections::HashMap<String, String>, |
There was a problem hiding this comment.
The PR description/test plan says DiscoveredPort serialization includes metadata when non-empty, but metadata currently lacks skip_serializing_if, so an empty map would still serialize as {}. If the intent is conditional inclusion, add #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] (or adjust the description if metadata is always expected to be present).
| /// Extract metadata from a MIDI port name (best-effort heuristic). | ||
| fn port_metadata(name: &str) -> std::collections::HashMap<String, String> { | ||
| 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 |
There was a problem hiding this comment.
port_metadata introduces non-trivial name normalization logic (stripping " #N" / " Port N") and is part of the new API payload (metadata.display_name), but there are no unit tests covering these cases. Since this file already has a test module, consider extracting the normalization into a testable helper and adding cases like "Foo #2" → "Foo" and "Foo Port 3" → "Foo", plus a serialization test asserting metadata.display_name is present.
- 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) <noreply@anthropic.com>
| // 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, | ||
| }) | ||
| }) |
There was a problem hiding this comment.
build_alternatives clamps alternative confidence to a minimum of 0.01. When the primary suggestion has confidence 0.0 (e.g., unknown port name with no heuristic seed events), this produces alternatives with higher confidence than the primary, contradicting the tool description/comments that alternatives are lower-confidence fallbacks. Consider either (a) allowing 0.0 alternative confidence when primary is 0.0, or (b) special-casing the unknown/zero-confidence case so alternatives remain strictly <= primary (and updating tests accordingly).
| // 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, | ||
| ), |
There was a problem hiding this comment.
build_alternatives seeds synthetic EventStats using large loops (e.g., 88 note events for keyboard) even though classification can be triggered with far fewer events (e.g., 2 notes far apart for keyboard; 1 CC for fader; 1 gamepad event). Since this runs on every conductor_suggest_binding call, consider reducing the seeded events to the minimum needed to hit each category’s classify() thresholds to cut unnecessary work.
- 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) <noreply@anthropic.com>
| let categories = [ | ||
| (DeviceCategory::PadController, 36u8, 40u8, false), | ||
| (DeviceCategory::Keyboard, 21, 100, false), | ||
| (DeviceCategory::FaderController, 0, 1, true), | ||
| (DeviceCategory::GameController, 0, 0, false), | ||
| ]; |
There was a problem hiding this comment.
build_alternatives never considers DeviceCategory::EncoderController, even though it exists in conductor_core::device_intelligence::fingerprint::DeviceCategory. This makes it impossible for the MCP response to ever return an encoder alternative (and the current synthetic CC stats can only classify as FaderController). Consider adding EncoderController to the candidate list and generating stats that satisfy core’s encoder heuristic (high hit count on <=4 CCs).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| let mut metadata = std::collections::HashMap::new(); | ||
| metadata.insert("display_name".to_string(), name.to_string()); |
There was a problem hiding this comment.
HID metadata construction duplicates the display_name logic and bypasses port_metadata(), so any instance-suffix stripping behavior applied to MIDI ports won’t be applied to HID ports. Consider starting with let mut metadata = port_metadata(name); here and then inserting manufacturer/vendor_id/product_id to keep display_name normalization consistent across protocols and avoid drift.
| let mut metadata = std::collections::HashMap::new(); | |
| metadata.insert("display_name".to_string(), name.to_string()); | |
| let mut metadata = port_metadata(name); |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| #[tokio::test] | ||
| async fn test_suggest_binding_unknown_port_omits_alternatives() { | ||
| let executor = McpToolExecutor::new(); | ||
| // Unknown port name — primary confidence is 0, so alternatives should be empty | ||
| let result = executor | ||
| .execute( |
There was a problem hiding this comment.
Test name says the tool "omits" alternatives, but the assertions expect an alternatives array to be present (empty). Rename the test to reflect the actual contract (e.g., "returns empty alternatives"), or change the response to truly omit the field if that's the intended behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
metadatafield toDiscoveredPort(manufacturer, vendor_id, product_id for HID; display_name for all)suggest_bindingnow returns rankedalternativesarray (up to 2 fallback categories with lower confidence)DeviceStatusPills.sveltewrapper + test (was unused shim around BindingPills)Test plan
cargo clippy --workspace -- -D warningspassescargo test --workspacepasses🤖 Generated with Claude Code