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 (
+
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');
+ });
+});