Skip to content

feat: #760 polish — metadata, ranked alternatives, dead code cleanup#791

Merged
amiable-dev merged 11 commits intomainfrom
feat/760-polish
Mar 29, 2026
Merged

feat: #760 polish — metadata, ranked alternatives, dead code cleanup#791
amiable-dev merged 11 commits intomainfrom
feat/760-polish

Conversation

@amiable-dev
Copy link
Copy Markdown
Owner

Summary

Test plan

  • cargo clippy --workspace -- -D warnings passes
  • cargo test --workspace passes
  • 5 suggest_binding tests pass with alternatives in response
  • DiscoveredPort serialization includes metadata when non-empty

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings March 29, 2026 17:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 metadata field to DiscoveredPort (best-effort display_name for MIDI; USB info for HID).
  • Extend conductor_suggest_binding MCP tool responses to include an alternatives array (up to 2).
  • Remove dead DeviceStatusPills wrapper 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.

Comment on lines 1722 to 1734
@@ -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"
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +2272 to +2277
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();
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +806 to +821
(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,
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
(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,

Copilot uses AI. Check for mistakes.
Comment on lines +798 to +803
/// 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> {
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
… 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +841 to +855
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),
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines +851 to +856
json!({
"category": cat_value,
"suggested_alias": suggestion.suggested_alias,
"suggested_protocol": suggestion.suggested_protocol,
"confidence": (primary_confidence * 0.3).min(0.2),
})
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
amiable-dev and others added 2 commits March 29, 2026 19:07
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +2279 to +2295
// 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;
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +2251 to +2252
/// Optional metadata (manufacturer, model, etc.) extracted from port name or USB info
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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)]

Copilot uses AI. Check for mistakes.
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +2251 to +2253
/// Port metadata (always includes display_name; HID ports add manufacturer, vendor_id, product_id)
#[serde(default)]
pub metadata: std::collections::HashMap<String, String>,
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +2270 to +2291
/// 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
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +852 to +861
// 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,
})
})
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +805 to +839
// 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,
),
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment on lines +812 to +817
let categories = [
(DeviceCategory::PadController, 36u8, 40u8, false),
(DeviceCategory::Keyboard, 21, 100, false),
(DeviceCategory::FaderController, 0, 1, true),
(DeviceCategory::GameController, 0, 0, false),
];
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment on lines +2381 to +2382
let mut metadata = std::collections::HashMap::new();
metadata.insert("display_name".to_string(), name.to_string());
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
let mut metadata = std::collections::HashMap::new();
metadata.insert("display_name".to_string(), name.to_string());
let mut metadata = port_metadata(name);

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment on lines +2378 to +2383
#[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(
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@amiable-dev amiable-dev merged commit d293d85 into main Mar 29, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants