diff --git a/apps/ui/src/components/AnalysisResults.tsx b/apps/ui/src/components/AnalysisResults.tsx index 5042eac9..c8558b96 100644 --- a/apps/ui/src/components/AnalysisResults.tsx +++ b/apps/ui/src/components/AnalysisResults.tsx @@ -54,6 +54,11 @@ import { ConfidenceBandBadge } from './sessionMusician/ConfidenceBandBadge'; import { RecommendationVerificationBadge } from './RecommendationVerificationBadge'; import { toConfidenceBand } from '../services/sessionMusician/confidenceBand'; import { loadAppliedIds, toggleAppliedId } from '../services/appliedRecommendations'; +import { + formatContractRange, + formatContractValue, +} from '../services/recommendationsContract'; +import type { RecommendationContractEntry } from '../types'; import { buildArrangementViewModel, buildMixChainGroups, @@ -238,6 +243,64 @@ function AppliedCheckbox({ ); } +/** + * "Validated" chip for cards backed by the recommendations.v1 contract + * (ADR 0003): the backend admitted this card to the schema-validated, + * citation-gated envelope. Cards without it are exactly the ones the + * projection refused (typically: no Phase 1 citation), so the badge is the + * citation gate made visible. Renders nothing when no entries match. + */ +function ContractValidatedBadge({ + entries, + testId, +}: { + entries: RecommendationContractEntry[]; + testId: string; +}) { + if (entries.length === 0) return null; + return ( + + ✓ Validated + + ); +} + +/** + * Expanded-card block listing the contract's normalized view of each backing + * entry: parsed value + unit and the published working range. The range is + * net-new information (the per-unit tolerance neighborhood the contract + * derives) — the raw value string already renders in the parameter grid. + */ +function ContractEntriesBlock({ + entries, + testId, +}: { + entries: RecommendationContractEntry[]; + testId: string; +}) { + if (entries.length === 0) return null; + return ( +
+

+ Validated · recommendations.v1 +

+ {entries.map((entry, idx) => { + const range = formatContractRange(entry); + return ( +

+ {entry.parameter}: {formatContractValue(entry)} + {range ? ` · working range ${range}` : ''} +

+ ); + })} +
+ ); +} + function Collapsible({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) { return (
+
{/* Audit Finding #3: primary citation visible in the collapsed header so the chain-of-custody @@ -2253,6 +2325,11 @@ export function AnalysisResults({ ))} + +

PRO TIP

@@ -2353,6 +2430,10 @@ export function AnalysisResults({ trackContext={patch.trackContext} category={patch.category} /> +

{/* Audit Finding #3: primary citation in the collapsed header so the chain-of-custody @@ -2423,6 +2504,11 @@ export function AnalysisResults({ ))} + +

PRO TIP

diff --git a/apps/ui/src/components/analysisResultsViewModel.ts b/apps/ui/src/components/analysisResultsViewModel.ts index 9d31753b..fca1b0f7 100644 --- a/apps/ui/src/components/analysisResultsViewModel.ts +++ b/apps/ui/src/components/analysisResultsViewModel.ts @@ -1,9 +1,20 @@ -import { AbletonRecommendation, Phase1Result, Phase2Result } from "../types"; +import { + AbletonRecommendation, + Phase1Result, + Phase2Result, + RecommendationContractEntry, + RecommendationsContract, +} from "../types"; import { type ConfidenceBand, getConfidenceBand, toConfidenceBand, } from "../services/sessionMusician/confidenceBand"; +import { + buildRecommendationsContractIndex, + findContractEntries, + type RecommendationsContractIndex, +} from "../services/recommendationsContract"; export interface ConfidenceBadgeViewModel { label: string; @@ -144,6 +155,15 @@ export interface MixChainCardViewModel { * don't render an empty block. */ phase1Fields: string[]; + /** + * recommendations.v1 (ADR 0003) entries backing this card — the + * schema-validated, citation-gated projection of the same raw device card. + * Empty when the card was not admitted to the contract (no Phase 1 + * citation, no usable value) or when the envelope is absent (older stored + * results, stem_summary). Synthetic frontend-derived cards are always + * empty: they never passed through the backend projection. + */ + contractEntries: RecommendationContractEntry[]; } export interface MixChainGroupViewModel { @@ -173,6 +193,8 @@ export interface PatchCardViewModel { * across the items that group into this single patch card. Deduplicated. */ phase1Fields: string[]; + /** See MixChainCardViewModel.contractEntries — merged across the grouped items. */ + contractEntries: RecommendationContractEntry[]; } /** @@ -925,6 +947,8 @@ function makeLimiterFallbackCard(phase1: Phase1Result, nextOrder: number) { // it consumes to derive its parameters, so the CitationBlock shows what // makes this fallback specific to *this* track. phase1Fields: ["truePeak", "lufsIntegrated", "stereoWidth"], + // Frontend-derived, never passed through the backend projection. + contractEntries: [], }; } @@ -990,9 +1014,11 @@ export function buildMixChainGroups( phase1: Phase1Result, chain: Phase2Result["mixAndMasterChain"] | undefined, sonicElements?: Phase2Result["sonicElements"], + recommendations?: RecommendationsContract | null, ): MixChainGroupViewModel[] { if (!Array.isArray(chain) || chain.length === 0) return []; + const contractIndex = buildRecommendationsContractIndex(recommendations); const total = chain.length; const cards = chain.map((item, index) => { @@ -1031,6 +1057,7 @@ export function buildMixChainGroups( // backend Phase 2 schema, enforced by server_phase2.py + phase2Validator) // so the CitationBlock above each card has structured citations. phase1Fields: Array.isArray(item.phase1Fields) ? [...item.phase1Fields] : [], + contractEntries: findContractEntries(contractIndex, item), group, highEndCues: highEnd.cues, } satisfies MixChainCardViewModel & { group: ProcessingGroup; highEndCues: string[] }; @@ -1159,6 +1186,8 @@ function buildStereoWidthPatchCard( // Audit Finding #2: synthetic stereo-width card cites the fields it // consumes when generating its parameter recommendations. phase1Fields: ['stereoWidth', 'stereoCorrelation', 'truePeak'], + // Frontend-derived, never passed through the backend projection. + contractEntries: [], }; } @@ -1173,6 +1202,7 @@ export function buildPatchCards( const melodyInsights = buildMelodyInsights(phase1); const recommendations = Array.isArray(phase2.abletonRecommendations) ? phase2.abletonRecommendations : []; const grouped = groupRecommendationsByDevice(recommendations); + const contractIndex = buildRecommendationsContractIndex(phase2.recommendations); // Audit Finding #1B (overlap): Gemini sometimes emits the same device in // both `mixAndMasterChain` (processing) and `abletonRecommendations` @@ -1241,6 +1271,13 @@ export function buildPatchCards( ), ); + // Contract entries merged across the grouped items, mirroring the + // phase1Fields merge above. Set-dedupe works on entry object identity: + // the index hands back the same envelope objects for identical cards. + const mergedContractEntries = Array.from( + new Set(group.items.flatMap((item) => findContractEntries(contractIndex, item))), + ); + return { id: `patch-${index}-${group.device}`, device: group.device, @@ -1256,6 +1293,7 @@ export function buildPatchCards( ), transcriptionDerived: midiFocused, phase1Fields: mergedPhase1Fields, + contractEntries: mergedContractEntries, }; }); @@ -1288,6 +1326,8 @@ export function buildPatchCards( melodyInsights.source === 'transcription' ? ['transcriptionDetail.averageConfidence', 'key', 'bpm'] : ['melodyDetail.pitchConfidence', 'key', 'bpm'], + // Frontend-derived, never passed through the backend projection. + contractEntries: [], }); } diff --git a/apps/ui/src/services/recommendationsContract.ts b/apps/ui/src/services/recommendationsContract.ts new file mode 100644 index 00000000..7bdfd795 --- /dev/null +++ b/apps/ui/src/services/recommendationsContract.ts @@ -0,0 +1,185 @@ +/** + * recommendations.v1 consumer (ADR 0003). The backend projects the three + * Phase 2 device-card arrays into a frozen, schema-validated, citation-gated + * envelope (`apps/backend/recommendations_contract.py`) and attaches it to + * the producer_summary interpretation as `Phase2Result.recommendations`. + * This service is the UI-side reader: it indexes that envelope so render + * surfaces can pair each raw device card with its validated contract entry — + * a card with a match is provably schema-checked AND cites at least one + * Phase 1 measurement; a card without one is exactly an uncited card the + * contract refused to admit. + * + * Matching is by (device, parameter, normalized value). Device + parameter + * alone is ambiguous when Phase 2 emits the same pair twice with different + * values, and pairing the wrong entry would surface a confidently-wrong + * working range — the failure mode the warn-only catalogue gate exists to + * prevent. The value parser below is therefore a deliberate TS mirror of the + * backend's `parse_value` (same cross-boundary duplication pattern as + * audio_mime.py / audioFile.ts): both sides must normalize "4 kHz" to the + * same (4000, "Hz") or the pairing silently misses. Keep the two in sync. + */ + +import type { + RecommendationContractEntry, + RecommendationsContract, + RecommendationUnit, +} from '../types'; + +/** + * A numeric magnitude extracted from a free-text value string. `unit` is the + * backend's internal lowercase token ("hz", "db", "ms", "s", "ratio", "pct", + * "st", or "" for unitless) — not the display unit. + */ +export interface ParsedRecommendationValue { + number: number; + unit: string; +} + +// Internal token -> contract display unit. "" (unitless) is absent on +// purpose: the contract emits `unit: null` there. +const DISPLAY_UNIT: Record = { + hz: 'Hz', + db: 'dB', + ms: 'ms', + s: 's', + ratio: 'ratio', + pct: '%', + st: 'st', +}; + +// Longer unit tokens must precede their prefixes in the alternation (`st` +// before `s`, `sec`/`semitones` before `s`, `ms` before `s`) so first-match +// wins — same ordering constraint as the backend `_VALUE_RE`. +const VALUE_RE = /(-?\d+(?:\.\d+)?)\s*(khz|hz|db|ms|sec|semitones?|st|s|%|:1|x)?/i; + +const RATIO_RE = /(-?\d+(?:\.\d+)?)\s*:\s*1\b/; + +function normalizeUnit(raw: string): string { + if (raw === 'khz' || raw === 'hz') return 'hz'; + if (raw === 'db') return 'db'; + if (raw === 'ms') return 'ms'; + if (raw === 's' || raw === 'sec') return 's'; + if (raw === '%') return 'pct'; + if (raw === 'st' || raw === 'semitone' || raw === 'semitones') return 'st'; + if (raw === ':1' || raw === 'x') return 'ratio'; + return ''; +} + +/** + * Extract a numeric magnitude + normalized unit token from a free-text value + * string. Handles "4 kHz", "-15 dB", "200 ms", "3:1", "30%", "0.6", "+12st". + * Returns null for non-numeric values ("Sine", "Auto"). Mirror of the + * backend `parse_value` — see module docstring. + */ +export function parseRecommendationValue( + text: string | number | null | undefined, +): ParsedRecommendationValue | null { + if (text === null || text === undefined) return null; + if (typeof text === 'number') { + return Number.isFinite(text) ? { number: text, unit: '' } : null; + } + const s = text.trim(); + if (!s) return null; + const ratioMatch = s.match(RATIO_RE); + if (ratioMatch) { + return { number: Number.parseFloat(ratioMatch[1]), unit: 'ratio' }; + } + const match = s.match(VALUE_RE); + if (!match) return null; + let number = Number.parseFloat(match[1]); + const rawUnit = (match[2] ?? '').toLowerCase(); + const unit = normalizeUnit(rawUnit); + if (unit === 'hz' && rawUnit.startsWith('k')) { + number *= 1000; + } + return { number, unit }; +} + +/** + * Pairing key for one device card / contract entry. Numeric values key on + * the parsed magnitude + display unit so "4 kHz" (raw card) and + * `{value: 4000, unit: "Hz"}` (contract entry) collide; non-numeric values + * key on the lowercased string. + */ +function pairingKey(device: string, parameter: string, valueKey: string): string { + return `${device.trim().toLowerCase()}\u0000${parameter.trim().toLowerCase()}\u0000${valueKey}`; +} + +function entryValueKey(entry: RecommendationContractEntry): string { + if (typeof entry.value === 'number') { + return `${entry.value}|${entry.unit ?? ''}`; + } + return entry.value.trim().toLowerCase(); +} + +function rawValueKey(value: string | number | null | undefined): string { + const parsed = parseRecommendationValue(value); + if (parsed !== null) { + return `${parsed.number}|${DISPLAY_UNIT[parsed.unit] ?? ''}`; + } + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +export type RecommendationsContractIndex = Map; + +/** + * Index a recommendations.v1 envelope for card pairing. Returns an empty + * index for null/undefined (older stored results and the stem_summary + * profile carry no envelope) so callers can pass `phase2.recommendations` + * straight through. + */ +export function buildRecommendationsContractIndex( + contract: RecommendationsContract | null | undefined, +): RecommendationsContractIndex { + const index: RecommendationsContractIndex = new Map(); + if (!contract || !Array.isArray(contract.recommendations)) return index; + for (const entry of contract.recommendations) { + const key = pairingKey(entry.device, entry.parameter, entryValueKey(entry)); + const bucket = index.get(key); + if (bucket) { + bucket.push(entry); + } else { + index.set(key, [entry]); + } + } + return index; +} + +/** + * Find the contract entries backing one raw Phase 2 device card. Empty array + * means the card was not admitted to the contract (typically: no Phase 1 + * citation) — render sites use presence as the "validated" signal. + */ +export function findContractEntries( + index: RecommendationsContractIndex, + card: { device: string; parameter: string; value: string }, +): RecommendationContractEntry[] { + if (!card.device || !card.parameter) return []; + const key = pairingKey(card.device, card.parameter, rawValueKey(card.value)); + return index.get(key) ?? []; +} + +/** Strip float noise from contract numbers ("4699.999999999999" → "4700"). */ +function formatContractNumber(value: number): string { + return String(Number(value.toFixed(4))); +} + +/** Display string for an entry's normalized value: "250 Hz", "0.6", "Sine". */ +export function formatContractValue(entry: RecommendationContractEntry): string { + if (typeof entry.value === 'number') { + const unitSuffix = entry.unit ? ` ${entry.unit}` : ''; + return `${formatContractNumber(entry.value)}${unitSuffix}`; + } + return entry.value; +} + +/** + * Display string for an entry's working range ("200–300 Hz"), or null when + * the contract published no range (non-numeric value or unknown unit). + */ +export function formatContractRange(entry: RecommendationContractEntry): string | null { + if (!entry.range) return null; + const [low, high] = entry.range; + const unitSuffix = entry.unit ? ` ${entry.unit}` : ''; + return `${formatContractNumber(low)}–${formatContractNumber(high)}${unitSuffix}`; +} diff --git a/apps/ui/src/utils/exportUtils.ts b/apps/ui/src/utils/exportUtils.ts index 2b42de7e..d8e44a09 100644 --- a/apps/ui/src/utils/exportUtils.ts +++ b/apps/ui/src/utils/exportUtils.ts @@ -1,4 +1,8 @@ import { Phase1Result, Phase2Result } from '../types'; +import { + formatContractRange, + formatContractValue, +} from '../services/recommendationsContract'; export function downloadFile(content: string, fileName: string, contentType: string) { const a = document.createElement('a'); @@ -124,6 +128,22 @@ export function generateMarkdown( phase2.abletonRecommendations.forEach((rec) => { md += `| ${rec.device} | ${rec.category} | ${rec.parameter} | ${rec.value} | ${rec.reason} |\n`; }); + md += '\n'; + } + + // recommendations.v1 (ADR 0003): the schema-validated, citation-gated + // projection the backend attaches to the interpretation. Every entry here + // cites the Phase 1 measurement(s) that justify it — the machine-checkable + // half of the report, alongside the raw cards above. + if (phase2.recommendations && phase2.recommendations.recommendations.length > 0) { + md += `### Validated Recommendations (${phase2.recommendations.version})\n`; + md += 'Schema-validated projection of the device cards above. Entries are admitted only when they cite at least one Phase 1 measurement.\n'; + md += '| Device | Parameter | Value | Working Range | Cited Measurements |\n'; + md += '| :--- | :--- | :--- | :--- | :--- |\n'; + phase2.recommendations.recommendations.forEach((entry) => { + const range = formatContractRange(entry) ?? '—'; + md += `| ${entry.device} | ${entry.parameter} | ${formatContractValue(entry)} | ${range} | ${entry.cited_measurements.join(', ')} |\n`; + }); } return md; diff --git a/apps/ui/tests/services/analysisResultsViewModel.test.ts b/apps/ui/tests/services/analysisResultsViewModel.test.ts index aa1b9b0c..104076d1 100644 --- a/apps/ui/tests/services/analysisResultsViewModel.test.ts +++ b/apps/ui/tests/services/analysisResultsViewModel.test.ts @@ -823,4 +823,129 @@ describe('analysisResultsViewModel helpers', () => { expect(masterCeilingDb(0)).toBeCloseTo(-0.3, 5); expect(masterCeilingDb(null)).toBeCloseTo(-0.3, 5); }); + + // recommendations.v1 wiring (ADR 0003): cards backed by the validated + // envelope expose their contract entries; cards the projection refused + // (uncited) and synthetic frontend-derived cards expose none. + it('buildMixChainGroups attaches contract entries to the matching card only', () => { + const groups = buildMixChainGroups( + measurement, + [ + { + order: 1, + device: 'Drum Buss', + parameter: 'Drive', + value: '6 dB', + reason: 'Adds drum bite and transient character.', + phase1Fields: ['spectralBalance.lowBass'], + }, + { + order: 2, + device: 'EQ Eight', + parameter: 'Low Cut', + value: '30 Hz', + reason: 'Cleans up sub energy in bass layers.', + // No phase1Fields — the backend projection excludes uncited cards, + // so this card has no envelope entry to pair with. + }, + ], + undefined, + { + version: 'recommendations.v1', + recommendations: [ + { + device: 'Drum Buss', + parameter: 'Drive', + value: 6, + unit: 'dB', + range: [3, 9], + cited_measurements: ['spectralBalance.lowBass'], + }, + ], + }, + ); + + const cards = groups.flatMap((group) => group.cards); + const drumBuss = cards.find((card) => card.device === 'Drum Buss'); + const eqEight = cards.find((card) => card.device === 'EQ Eight'); + const limiter = cards.find((card) => card.device === 'Limiter'); + + expect(drumBuss?.contractEntries).toHaveLength(1); + expect(drumBuss?.contractEntries[0]).toMatchObject({ value: 6, unit: 'dB', range: [3, 9] }); + expect(eqEight?.contractEntries).toEqual([]); + // The synthetic limiter fallback is frontend-derived and never entered + // the backend projection. + expect(limiter?.contractEntries).toEqual([]); + }); + + it('buildPatchCards merges contract entries across grouped items and leaves synthetic cards empty', () => { + const phase2: Phase2Result = { + trackCharacter: 'Character.', + detectedCharacteristics: [], + arrangementOverview: { summary: 'Summary', segments: [] }, + sonicElements: { + kick: 'Kick.', + bass: 'Bass.', + melodicArp: 'Arp.', + grooveAndTiming: 'Groove.', + effectsAndTexture: 'FX.', + }, + mixAndMasterChain: [], + secretSauce: { title: 'Sauce', explanation: 'Explain', implementationSteps: ['Step'] }, + confidenceNotes: [], + abletonRecommendations: [ + { + device: 'Wavetable', + category: 'SYNTHESIS', + parameter: 'Position', + value: '0.5', + reason: 'Builds the lead supersaw character.', + phase1Fields: ['key'], + }, + { + device: 'Wavetable', + category: 'SYNTHESIS', + parameter: 'Unison Voices', + value: '5', + reason: 'Thickens the supersaw stack.', + phase1Fields: ['stereoWidth'], + }, + ], + recommendations: { + version: 'recommendations.v1', + recommendations: [ + { + device: 'Wavetable', + parameter: 'Position', + value: 0.5, + unit: null, + range: null, + cited_measurements: ['key'], + }, + { + device: 'Wavetable', + parameter: 'Unison Voices', + value: 5, + unit: null, + range: null, + cited_measurements: ['stereoWidth'], + }, + ], + }, + } as Phase2Result; + + const cards = buildPatchCards(measurement, phase2); + const wavetable = cards.find((card) => card.device === 'Wavetable'); + expect(wavetable?.contractEntries).toHaveLength(2); + expect(wavetable?.contractEntries.map((entry) => entry.parameter)).toEqual([ + 'Position', + 'Unison Voices', + ]); + + // Synthetic cards (stereo width fallback, MIDI Clip Guide) are + // frontend-derived and carry no contract entries. + const synthetic = cards.filter((card) => card.device !== 'Wavetable'); + expect(synthetic.length).toBeGreaterThan(0); + expect(synthetic.every((card) => card.contractEntries.length === 0)).toBe(true); + }); }); diff --git a/apps/ui/tests/services/exportUtils.test.ts b/apps/ui/tests/services/exportUtils.test.ts index ad0cafe4..417ade91 100644 --- a/apps/ui/tests/services/exportUtils.test.ts +++ b/apps/ui/tests/services/exportUtils.test.ts @@ -139,4 +139,47 @@ describe('generateMarkdown', () => { expect(markdown).not.toContain('- **Harmonic Content**:'); expect(markdown).not.toContain('undefined'); }); + + // recommendations.v1 wiring (ADR 0003): the markdown report carries the + // schema-validated, citation-gated projection alongside the raw cards. + it('renders the validated recommendations.v1 table when the envelope is present', () => { + const markdown = generateMarkdown(basePhase1, { + ...basePhase2, + recommendations: { + version: 'recommendations.v1', + recommendations: [ + { + device: 'Drum Buss', + parameter: 'Drive', + value: 5, + unit: 'dB', + range: [2, 8], + cited_measurements: ['spectralBalance.lowBass', 'truePeak'], + }, + { + device: 'Operator', + parameter: 'Waveform', + value: 'Sine', + unit: null, + range: null, + cited_measurements: ['key'], + }, + ], + }, + }); + + expect(markdown).toContain('### Validated Recommendations (recommendations.v1)'); + expect(markdown).toContain('| Drum Buss | Drive | 5 dB | 2–8 dB | spectralBalance.lowBass, truePeak |'); + expect(markdown).toContain('| Operator | Waveform | Sine | — | key |'); + }); + + it('omits the validated recommendations section when the envelope is absent or empty', () => { + expect(generateMarkdown(basePhase1, basePhase2)).not.toContain('Validated Recommendations'); + expect( + generateMarkdown(basePhase1, { + ...basePhase2, + recommendations: { version: 'recommendations.v1', recommendations: [] }, + }), + ).not.toContain('Validated Recommendations'); + }); }); diff --git a/apps/ui/tests/services/recommendationsContract.test.ts b/apps/ui/tests/services/recommendationsContract.test.ts new file mode 100644 index 00000000..28aba6ec --- /dev/null +++ b/apps/ui/tests/services/recommendationsContract.test.ts @@ -0,0 +1,137 @@ +import { + buildRecommendationsContractIndex, + findContractEntries, + formatContractRange, + formatContractValue, + parseRecommendationValue, +} from '../../src/services/recommendationsContract'; +import { RecommendationContractEntry, RecommendationsContract } from '../../src/types'; + +function entry(partial: Partial): RecommendationContractEntry { + return { + device: 'EQ Eight', + parameter: 'Low Cut', + value: 30, + unit: 'Hz', + range: [24, 36], + cited_measurements: ['spectralBalance.subBass'], + ...partial, + }; +} + +function contract(entries: RecommendationContractEntry[]): RecommendationsContract { + return { version: 'recommendations.v1', recommendations: entries }; +} + +describe('parseRecommendationValue', () => { + // Mirror of apps/backend/recommendations_contract.py parse_value — the + // case table below matches the backend's documented examples so a drift + // in either side surfaces here. + it('parses the backend parser case table identically', () => { + expect(parseRecommendationValue('4 kHz')).toEqual({ number: 4000, unit: 'hz' }); + expect(parseRecommendationValue('-15 dB')).toEqual({ number: -15, unit: 'db' }); + expect(parseRecommendationValue('200 ms')).toEqual({ number: 200, unit: 'ms' }); + expect(parseRecommendationValue('3:1')).toEqual({ number: 3, unit: 'ratio' }); + expect(parseRecommendationValue('30%')).toEqual({ number: 30, unit: 'pct' }); + expect(parseRecommendationValue('0.6')).toEqual({ number: 0.6, unit: '' }); + expect(parseRecommendationValue('+12st')).toEqual({ number: 12, unit: 'st' }); + expect(parseRecommendationValue('2 semitones')).toEqual({ number: 2, unit: 'st' }); + expect(parseRecommendationValue('1.5 s')).toEqual({ number: 1.5, unit: 's' }); + }); + + it('returns null for non-numeric, empty, and missing values', () => { + expect(parseRecommendationValue('Sine')).toBeNull(); + expect(parseRecommendationValue('')).toBeNull(); + expect(parseRecommendationValue(' ')).toBeNull(); + expect(parseRecommendationValue(null)).toBeNull(); + expect(parseRecommendationValue(undefined)).toBeNull(); + }); + + it('passes through finite numbers as unitless', () => { + expect(parseRecommendationValue(0.5)).toEqual({ number: 0.5, unit: '' }); + expect(parseRecommendationValue(Number.NaN)).toBeNull(); + }); +}); + +describe('buildRecommendationsContractIndex + findContractEntries', () => { + it('pairs a raw card with its contract entry across the value normalization boundary', () => { + // Raw card says "4 kHz"; the contract normalized it to {4000, "Hz"}. + const index = buildRecommendationsContractIndex( + contract([entry({ device: 'Auto Filter', parameter: 'Cutoff', value: 4000, unit: 'Hz', range: [3200, 4800] })]), + ); + + const matches = findContractEntries(index, { + device: 'Auto Filter', + parameter: 'Cutoff', + value: '4 kHz', + }); + expect(matches).toHaveLength(1); + expect(matches[0].value).toBe(4000); + }); + + it('is case- and whitespace-insensitive on device and parameter', () => { + const index = buildRecommendationsContractIndex(contract([entry({})])); + + expect( + findContractEntries(index, { device: ' eq eight ', parameter: 'LOW CUT', value: '30 Hz' }), + ).toHaveLength(1); + }); + + it('disambiguates duplicate device+parameter pairs by value', () => { + // Same device+parameter twice with different values (e.g. one in + // mixAndMasterChain, one in abletonRecommendations). Pairing must be + // value-aware or the wrong working range would render — the + // confidently-wrong failure mode this surface exists to prevent. + const lowCut30 = entry({ value: 30, range: [27, 33] }); + const lowCut100 = entry({ value: 100, range: [97, 103] }); + const index = buildRecommendationsContractIndex(contract([lowCut30, lowCut100])); + + const matches = findContractEntries(index, { + device: 'EQ Eight', + parameter: 'Low Cut', + value: '100 Hz', + }); + expect(matches).toEqual([lowCut100]); + }); + + it('matches non-numeric values as strings', () => { + const sine = entry({ device: 'Operator', parameter: 'Waveform', value: 'Sine', unit: null, range: null }); + const index = buildRecommendationsContractIndex(contract([sine])); + + expect( + findContractEntries(index, { device: 'Operator', parameter: 'Waveform', value: 'sine' }), + ).toEqual([sine]); + }); + + it('returns no entries for uncited cards (absent from the envelope) and for absent envelopes', () => { + const index = buildRecommendationsContractIndex(contract([entry({})])); + expect( + findContractEntries(index, { device: 'Glue Compressor', parameter: 'Ratio', value: '2:1' }), + ).toEqual([]); + + const emptyIndex = buildRecommendationsContractIndex(undefined); + expect( + findContractEntries(emptyIndex, { device: 'EQ Eight', parameter: 'Low Cut', value: '30 Hz' }), + ).toEqual([]); + }); +}); + +describe('formatContractValue + formatContractRange', () => { + it('formats numeric entries with their display unit', () => { + expect(formatContractValue(entry({ value: 30, unit: 'Hz' }))).toBe('30 Hz'); + expect(formatContractValue(entry({ value: 0.6, unit: null }))).toBe('0.6'); + expect(formatContractValue(entry({ value: 'Sine', unit: null }))).toBe('Sine'); + }); + + it('formats ranges and returns null when the contract published none', () => { + expect(formatContractRange(entry({ value: 30, unit: 'Hz', range: [24, 36] }))).toBe('24–36 Hz'); + expect(formatContractRange(entry({ value: 3, unit: 'ratio', range: [2, 4] }))).toBe('2–4 ratio'); + expect(formatContractRange(entry({ value: 'Sine', unit: null, range: null }))).toBeNull(); + }); + + it('strips float noise from kHz-multiplied magnitudes', () => { + // 4.7 kHz parses to 4.7 * 1000 on both sides; display must not leak + // IEEE754 artifacts like "4700.000000000001". + expect(formatContractValue(entry({ value: 4.7 * 1000, unit: 'Hz' }))).toBe('4700 Hz'); + }); +});