diff --git a/.changeset/io-quality-report.md b/.changeset/io-quality-report.md new file mode 100644 index 000000000..7be277aba --- /dev/null +++ b/.changeset/io-quality-report.md @@ -0,0 +1,19 @@ +--- +"catalog": minor +--- + +Add IO data-quality triage: `pnpm io-quality` report and warning W128. + +Follow-up to the IO positioning work (#212). Adds tooling to find and prevent +bad hardware I/O data at scale: + +- New `pnpm io-quality` report scores every hardware entry and prints a + prioritized worklist: correctness smells (combine candidates, collapsed + stereo/numbered-pair names, uniform-position imports), connectivity-category + devices missing I/O entirely, and entries lacking column/row layout (densest + first). Supports `--json` and `--limit`. +- New advisory validation warning **W128** flags `maxConnections > 1` on + single-jack connections (e.g. two jacks collapsed into one entry), excluding + intentional aggregates. Non-blocking. +- Shared heuristic in `scripts/lib/io-heuristics.ts` keeps the report and the + validator in lockstep. diff --git a/docs/VALIDATION_ERRORS.md b/docs/VALIDATION_ERRORS.md index 79ef320ce..5e8ceb781 100644 --- a/docs/VALIDATION_ERRORS.md +++ b/docs/VALIDATION_ERRORS.md @@ -498,6 +498,16 @@ The `connection` field in an IO entry is not in the known connections list. --- +### W128: IO Entry May Combine Multiple Physical Jacks + +An IO entry sets `maxConnections` greater than 1 on a single-jack connection type (e.g., `1/4-inch`, `xlr`, `rca`, `5-pin din`). These connectors carry one physical link each, so a value above 1 usually means several jacks were collapsed into one entry during import. + +**Fix:** Model each physical jack as its own `io` entry with `maxConnections: 1` (see the Eventide H90 for an example). If the entry really is an intentional aggregate for a connector that carries multiple links (e.g., a D-sub snake), use the correct `connection` type instead. Names containing words like "all", "slots", or "bank" are treated as aggregates and not flagged. + +**Triage tip:** Run `pnpm io-quality` to see the full I/O data-quality worklist (combine candidates, collapsed-pair names, missing I/O, and column/row coverage gaps). + +--- + ## Getting Help If you encounter an error not listed here, or need clarification: diff --git a/package.json b/package.json index 8e9d22ccb..03c7fcb37 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "discontinued:report": "tsx scripts/discontinued-candidates.ts", "discontinued:apply": "tsx scripts/apply-discontinued-tags.ts", "identifier-coverage": "tsx scripts/identifier-coverage.ts", + "io-quality": "tsx scripts/io-quality.ts", "benchmark": "tsx scripts/benchmark.ts", "lint": "biome check scripts/ vitest.config.ts", "lint:fix": "biome check --write scripts/ vitest.config.ts", diff --git a/scripts/io-quality.ts b/scripts/io-quality.ts new file mode 100644 index 000000000..8a692b393 --- /dev/null +++ b/scripts/io-quality.ts @@ -0,0 +1,279 @@ +#!/usr/bin/env tsx +/** + * IO Quality & Coverage Report + * + * Scores every hardware entry's I/O data and emits a prioritized worklist for + * enrichment (issue #212 follow-up). Surfaces three kinds of gap: + * + * 1. Correctness smells — likely-wrong I/O that needs fixing: + * - combine candidates: maxConnections > 1 on a single-jack connection + * (each physical jack should be its own entry with maxConnections: 1) + * - collapsed-pair names: one entry named like a stereo/numbered pair + * ("Outputs 1/2", "Analog In L/R") that likely represents two jacks + * - uniform position: many jacks all sharing one `position` (a guessed + * import default, like the H90's original "all Bottom") + * 2. Missing I/O — connectivity-heavy devices with no `io` at all. + * 3. Spatial gap — entries with I/O but no columnPosition/rowPosition, the + * raw material the setup graph needs (densest devices first). + * + * The report is advisory triage: heuristics favor recall, so a human reviews + * the worklist. The high-precision subset is enforced separately as warning + * W128 in `pnpm validate`. + * + * Usage: + * pnpm io-quality # console report + * pnpm io-quality --json # machine-readable + * pnpm io-quality --limit 40 # rows per worklist (default 20) + */ + +import path from "node:path"; +import { isIoCombineCandidate } from "./lib/io-heuristics.js"; +import type { Hardware } from "./lib/types.js"; +import { DATA_DIR, getYamlFiles, loadYamlFile, SCHEMA_DIR } from "./lib/utils.js"; + +// ============================================================================= +// HEURISTIC CONFIG +// ============================================================================= + +/** + * Names that look like a collapsed stereo/numbered pair ("Outputs 1/2", + * "Analog In L/R"). The numbered form excludes connector sizes like "1/4-inch" + * or "1/8 inch" so a port that merely names its jack size isn't flagged. + */ +const COLLAPSED_PAIR_NAME = + /\bl\s*\/\s*r\b|\bl\s*\+\s*r\b|\b\d+\s*\/\s*\d+\b(?!\s*-?\s*(inch|mm|"|ā€))/i; + +/** Minimum jack count before "everything on one edge" is treated as a smell. */ +const UNIFORM_POSITION_MIN_IO = 4; + +/** + * Categories where a device almost always has meaningful connectivity, so a + * missing `io` array is a real gap. Weight drives worklist ordering. + */ +const CONNECTIVITY_WEIGHTS: Record = { + "audio-interface": 10, + mixer: 10, + "control-surface": 8, + synthesizer: 8, + sampler: 8, + groovebox: 8, + "drum-machine": 8, + "multi-effect": 7, + sequencer: 7, + preamp: 6, + compressor: 6, + equalizer: 6, + "midi-controller": 6, + "guitar-amplifier": 5, + "power-amp": 5, + pedal: 5, + "di-box": 5, + "studio-monitor": 4, + subwoofer: 4, + speaker: 3, +}; + +// ============================================================================= +// ANALYSIS +// ============================================================================= + +interface HardwareIoEntry { + file: string; + name: string; + category: string; + ioCount: number; + hasIo: boolean; + combineCandidates: number; + collapsedNames: number; + uniformPosition: boolean; + spatialCoverage: number; // 0..1 of io entries with column/row + spatialGap: boolean; // has io but zero column/row + missingIo: boolean; // connectivity category with no io + correctnessScore: number; +} + +function analyze(filePath: string, data: Hardware, positionExempt: Set): HardwareIoEntry { + const io = Array.isArray(data.io) ? data.io : []; + const category = data.primaryCategory ?? ""; + const exempt = positionExempt.has(category); + + const combineCandidates = io.filter(isIoCombineCandidate).length; + const collapsedNames = io.filter((p) => p.name && COLLAPSED_PAIR_NAME.test(p.name)).length; + + const positions = io.map((p) => p.position).filter(Boolean); + const uniformPosition = + !exempt && + io.length >= UNIFORM_POSITION_MIN_IO && + positions.length === io.length && + new Set(positions).size === 1; + + const withColRow = io.filter( + (p) => p.columnPosition !== undefined || p.rowPosition !== undefined + ).length; + const spatialCoverage = io.length > 0 ? withColRow / io.length : 0; + + const correctnessScore = combineCandidates * 5 + collapsedNames * 3 + (uniformPosition ? 2 : 0); + + return { + file: path.relative(DATA_DIR, filePath), + name: data.name, + category, + ioCount: io.length, + hasIo: io.length > 0, + combineCandidates, + collapsedNames, + uniformPosition, + spatialCoverage, + spatialGap: io.length > 0 && withColRow === 0 && !exempt, + missingIo: io.length === 0 && category in CONNECTIVITY_WEIGHTS, + correctnessScore, + }; +} + +interface IoQualityReport { + generatedAt: string; + summary: { + totalHardware: number; + withIo: number; + withIoPercent: number; + withSpatial: number; + withSpatialPercent: number; + combineCandidates: number; + collapsedNameEntries: number; + uniformPositionEntries: number; + missingIoInConnectivityCategories: number; + }; + worklists: { + correctness: HardwareIoEntry[]; + missingIo: HardwareIoEntry[]; + spatial: HardwareIoEntry[]; + }; +} + +function generateReport(limit: number): IoQualityReport { + const groups = loadYamlFile<{ groups: Record }>( + path.join(SCHEMA_DIR, "category-groups.yaml") + ); + const positionExempt = new Set(groups.groups.Instruments ?? []); + + const files = getYamlFiles(path.join(DATA_DIR, "hardware")); + const entries = files.map((f) => analyze(f, loadYamlFile(f), positionExempt)); + + const withIo = entries.filter((e) => e.hasIo).length; + const withSpatial = entries.filter((e) => e.spatialCoverage > 0).length; + const pct = (n: number) => (entries.length > 0 ? Math.round((n / entries.length) * 100) : 0); + + const correctness = entries + .filter((e) => e.correctnessScore > 0) + .sort((a, b) => b.correctnessScore - a.correctnessScore || b.ioCount - a.ioCount) + .slice(0, limit); + + const missingIo = entries + .filter((e) => e.missingIo) + .sort( + (a, b) => + (CONNECTIVITY_WEIGHTS[b.category] ?? 0) - (CONNECTIVITY_WEIGHTS[a.category] ?? 0) || + a.name.localeCompare(b.name) + ) + .slice(0, limit); + + const spatial = entries + .filter((e) => e.spatialGap) + .sort((a, b) => b.ioCount - a.ioCount || a.name.localeCompare(b.name)) + .slice(0, limit); + + return { + generatedAt: new Date().toISOString(), + summary: { + totalHardware: entries.length, + withIo, + withIoPercent: pct(withIo), + withSpatial, + withSpatialPercent: pct(withSpatial), + combineCandidates: entries.reduce((s, e) => s + e.combineCandidates, 0), + collapsedNameEntries: entries.filter((e) => e.collapsedNames > 0).length, + uniformPositionEntries: entries.filter((e) => e.uniformPosition).length, + missingIoInConnectivityCategories: entries.filter((e) => e.missingIo).length, + }, + worklists: { correctness, missingIo, spatial }, + }; +} + +// ============================================================================= +// OUTPUT +// ============================================================================= + +function printReport(report: IoQualityReport, limit: number): void { + const s = report.summary; + console.log("\nšŸ”Œ IO Quality & Coverage Report"); + console.log("═".repeat(64)); + console.log(`Generated: ${report.generatedAt}\n`); + + console.log("šŸ“ˆ Summary"); + console.log("─".repeat(48)); + console.log(` Hardware entries: ${s.totalHardware}`); + console.log(` With I/O data: ${s.withIo} (${s.withIoPercent}%)`); + console.log(` With column/row layout: ${s.withSpatial} (${s.withSpatialPercent}%)`); + console.log(` Combine candidates: ${s.combineCandidates}`); + console.log(` Collapsed-pair-name entries:${s.collapsedNameEntries}`); + console.log(` Uniform-position entries: ${s.uniformPositionEntries}`); + console.log(` Missing I/O (connectivity):${s.missingIoInConnectivityCategories}`); + console.log(); + + const section = ( + title: string, + rows: HardwareIoEntry[], + detail: (e: HardwareIoEntry) => string + ) => { + console.log(title); + console.log("─".repeat(48)); + if (rows.length === 0) { + console.log(" (none)\n"); + return; + } + for (const e of rows) { + console.log(` ${e.file}`); + console.log(` ${e.name} — ${detail(e)}`); + } + console.log(); + }; + + section( + `šŸ”“ Correctness worklist (likely-wrong I/O, top ${limit})`, + report.worklists.correctness, + (e) => { + const bits: string[] = []; + if (e.combineCandidates) bits.push(`${e.combineCandidates} combine`); + if (e.collapsedNames) bits.push(`${e.collapsedNames} collapsed-name`); + if (e.uniformPosition) bits.push("uniform position"); + return `${bits.join(", ")} (${e.ioCount} io)`; + } + ); + + section( + `🟠 Missing I/O in connectivity categories (top ${limit})`, + report.worklists.missingIo, + (e) => e.category + ); + + section( + `🟔 Spatial enrichment — has I/O, no column/row (densest first, top ${limit})`, + report.worklists.spatial, + (e) => `${e.ioCount} ports, ${e.category}` + ); + + console.log("═".repeat(64)); + console.log("Enrich column/row with: pnpm enrich-io "); +} + +const args = process.argv.slice(2); +const jsonOutput = args.includes("--json"); +const limitArg = args.indexOf("--limit"); +const limit = limitArg >= 0 ? Math.max(1, Number.parseInt(args[limitArg + 1] ?? "", 10) || 20) : 20; + +const report = generateReport(limit); +if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); +} else { + printReport(report, limit); +} diff --git a/scripts/lib/error-codes.ts b/scripts/lib/error-codes.ts index ac9541933..e80ba3f81 100644 --- a/scripts/lib/error-codes.ts +++ b/scripts/lib/error-codes.ts @@ -64,6 +64,7 @@ export enum ValidationErrorCode { W125_MANUFACTURER_URL_IN_LINKS = "W125", W126_SPECS_OVERLAP = "W126", W127_MISSING_SEARCH_TERMS = "W127", + W128_IO_COMBINE_CANDIDATE = "W128", } // ============================================================================= @@ -270,6 +271,10 @@ const ERROR_INFO: Record = { title: "Entry would benefit from searchTerms", anchor: "w127-missing-search-terms", }, + [ValidationErrorCode.W128_IO_COMBINE_CANDIDATE]: { + title: "IO entry may combine multiple physical jacks", + anchor: "w128-io-entry-may-combine-multiple-physical-jacks", + }, }; // ============================================================================= diff --git a/scripts/lib/io-heuristics.ts b/scripts/lib/io-heuristics.ts new file mode 100644 index 000000000..f9ec59a8c --- /dev/null +++ b/scripts/lib/io-heuristics.ts @@ -0,0 +1,41 @@ +/** + * Shared IO data-quality heuristics. + * + * Used by both `validate.ts` (warning W128) and the `io-quality` report so the + * CI signal and the triage report stay in lockstep. + */ + +/** Connections that carry exactly one physical link; maxConnections>1 is a smell. */ +export const SINGLE_JACK_CONNECTIONS = new Set([ + "1/4-inch", + "1/8-inch", + "2.5mm", + "xlr", + "mini-xlr", + "rca", + "5-pin din", + "7-pin din", +]); + +/** Names that describe an intentional aggregate, not a single jack. */ +const AGGREGATE_NAME = /\b(all|slots?|bank|banks|combined|total|each)\b/i; + +/** Minimal shape needed to evaluate the combine heuristic. */ +export interface IoLike { + name?: string; + connection?: string; + maxConnections?: number; +} + +/** + * True when an io entry represents a single-jack connection but claims more than + * one connection — i.e. several physical jacks likely collapsed into one entry. + * Intentional aggregates ("All Slots", "Bank A") are excluded. + */ +export function isIoCombineCandidate(io: IoLike): boolean { + const conn = io.connection?.toLowerCase(); + if (!conn || !SINGLE_JACK_CONNECTIONS.has(conn)) return false; + if ((io.maxConnections ?? 1) <= 1) return false; + if (io.name && AGGREGATE_NAME.test(io.name)) return false; + return true; +} diff --git a/scripts/validate.ts b/scripts/validate.ts index 1d30e41ed..43fcb643d 100644 --- a/scripts/validate.ts +++ b/scripts/validate.ts @@ -12,6 +12,7 @@ import { parse as parseYaml } from "yaml"; import { z } from "zod"; import { looksLikeAcronymName } from "./lib/acronym-exclusions.js"; import { getDocsUrl, ValidationErrorCode } from "./lib/error-codes.js"; +import { isIoCombineCandidate } from "./lib/io-heuristics.js"; import type { CategoriesSchema, CategoryAliasesSchema, @@ -1040,7 +1041,7 @@ function detectSupersedeCycle( interface WarningContext { name?: string; - io?: Array<{ name: string; type: string; connection: string }>; + io?: Array<{ name: string; type: string; connection: string; maxConnections?: number }>; compatibleWith?: string[]; url?: string; links?: Array<{ url: string }>; @@ -1091,6 +1092,19 @@ function collectWarnings( line: line ?? undefined, }); } + + // A single-jack connection carries one physical link; maxConnections>1 + // usually means several jacks were collapsed into one entry (aggregates + // like "All Slots" are excluded). + if (isIoCombineCandidate(io)) { + const line = getLineForPath(document, lineCounter, ["io", i, "maxConnections"]); + warnings.push({ + code: ValidationErrorCode.W128_IO_COMBINE_CANDIDATE, + message: `IO '${io.name}' sets maxConnections ${io.maxConnections} on a single-jack connection ('${io.connection}'). If this is several physical jacks, give each its own io entry with maxConnections: 1.`, + path: `io[${i}].maxConnections`, + line: line ?? undefined, + }); + } } }