Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/io-quality-report.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/VALIDATION_ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Getting Help

If you encounter an error not listed here, or need clarification:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
279 changes: 279 additions & 0 deletions scripts/io-quality.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
"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<string>): 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<string, string[]> }>(
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<Hardware>(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 <slug>");
}

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);
}
5 changes: 5 additions & 0 deletions scripts/lib/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

// =============================================================================
Expand Down Expand Up @@ -270,6 +271,10 @@ const ERROR_INFO: Record<ValidationErrorCode, ErrorInfoEntry> = {
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",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

// =============================================================================
Expand Down
41 changes: 41 additions & 0 deletions scripts/lib/io-heuristics.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading