Skip to content

Commit d86c3cf

Browse files
committed
fix: matrix view honors per-series baseline override + correct blended label (#2179)
Codex review-gate findings on PR #2247: - getVoiceFingerprint resolved only the GLOBAL style.voice-drift config, so a per-series editorialCheckConfig override (baselineMode/threshold/wells) drove the editor's findings but NOT the matrix view — they disagreed. resolveVoiceDriftConfig now takes the series' editorialCheckConfig and overlays the override on the global stored config, mirroring applySeriesCheckConfig's merge. - The Voice Fingerprint intro copy described blended mode as 'the chosen voice (its voice exemplars)', but blended centers on the MIDPOINT of the drafted mean and the chosen voice. Intro copy + footer row now label blended distinctly. Refs #2179
1 parent b1023e8 commit d86c3cf

4 files changed

Lines changed: 89 additions & 14 deletions

File tree

client/src/pages/PipelineVoiceFingerprint.jsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ export default function PipelineVoiceFingerprint() {
7373
// and returns `series: {}`, so the footer row + intro copy must fall back to the
7474
// plain description rather than promising a baseline that never ran.
7575
const usesChosenVoice = data?.exemplarBaselineUsed === true && !data?.gatedOff;
76+
// The baseline phrase for the intro copy — blended is the midpoint, NOT the
77+
// exemplars alone, so it must not be described as "the chosen voice."
78+
const baselineNoun = !usesChosenVoice
79+
? 'series mean'
80+
: (baselineMode === 'blended'
81+
? "blend of the series mean and the style guide's chosen voice"
82+
: "style guide's chosen voice (its voice exemplars)");
7683

7784
return (
7885
<div className="h-full overflow-y-auto p-4 md:p-6">
@@ -101,11 +108,9 @@ export default function PipelineVoiceFingerprint() {
101108
Every drafted issue's prose fingerprint. Each column is a metric; a cell
102109
highlighted in <span className="text-port-warning">amber</span> is a
103110
statistical outlier — more than {data?.threshold ?? 1.5}σ from the{' '}
104-
{usesChosenVoice
105-
? "style guide's chosen voice (its voice exemplars)"
106-
: 'series mean'}{' '}
111+
{baselineNoun}{' '}
107112
on that metric. The bottom rows are the series mean and standard
108-
deviation (σ){usesChosenVoice ? ', plus the chosen-voice baseline the outliers are measured against' : ''}.
113+
deviation (σ){usesChosenVoice ? `, plus the ${baselineMode === 'blended' ? 'blended' : 'chosen-voice'} baseline the outliers are measured against` : ''}.
109114
This measures the same vectors the deterministic{' '}
110115
<code className="text-gray-400">style.voice-drift</code> editorial check
111116
flags; it just shows the whole matrix, not only the flagged outliers.

client/src/pages/PipelineVoiceFingerprint.test.jsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,24 @@ describe('PipelineVoiceFingerprint', () => {
106106
expect(screen.getByText('5 words')).toBeInTheDocument();
107107
});
108108

109+
it('labels blended mode as a blend, not the chosen voice alone (#2179)', async () => {
110+
getVoiceFingerprint.mockResolvedValue({
111+
...MATRIX_PAYLOAD,
112+
baselineMode: 'blended',
113+
exemplarBaselineUsed: true,
114+
series: {
115+
sentenceLenMean: { mean: 10.5, std: 3, center: 7.75 },
116+
dialogueRatio: { mean: 15, std: 8, center: 8.5 },
117+
},
118+
});
119+
renderAt();
120+
await waitFor(() => expect(screen.getByText('Voice Fingerprint')).toBeInTheDocument());
121+
// The intro copy calls it a blend, and the footer row is labeled "blend".
122+
expect(screen.getByText(/blend of the series mean/)).toBeInTheDocument();
123+
expect(screen.getByText('blend')).toBeInTheDocument();
124+
expect(screen.queryByText('voice')).not.toBeInTheDocument();
125+
});
126+
109127
it('does not claim the chosen-voice baseline on a gated-off run (#2179)', async () => {
110128
// A gated-off run (< minIssues) still reports the configured mode, but the
111129
// baseline was never applied and series is empty — the UI must fall back.

server/services/pipeline/voiceFingerprint.js

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,37 @@ export const VOICE_DRIFT_CHECK_ID = 'style.voice-drift';
3737

3838
/**
3939
* Resolve the effective `style.voice-drift` config for a series from the persisted
40-
* per-check settings (threshold / minIssues / vocabularyWells), tolerant of a
41-
* hand-edited or absent slice. `resolveCheckConfig` validates the stored slice
42-
* through the check's Zod `configSchema`, so every key comes back populated with
43-
* its schema default when unset — the caller can trust the fields are present.
44-
* (`style.voice-drift` is statically registered, so `getCheckById` never misses.)
40+
* per-check settings (threshold / minIssues / vocabularyWells / baselineMode),
41+
* tolerant of a hand-edited or absent slice. `resolveCheckConfig` validates the
42+
* stored slice through the check's Zod `configSchema`, so every key comes back
43+
* populated with its schema default when unset — the caller can trust the fields
44+
* are present. (`style.voice-drift` is statically registered, so `getCheckById`
45+
* never misses.)
46+
*
47+
* `seriesOverrides` is the series' `editorialCheckConfig` map (#1591): when it
48+
* carries an override for this check, it is overlaid on the global stored config
49+
* exactly as `applySeriesCheckConfig` does for the finding-emitting run — so the
50+
* matrix view and the editor's findings resolve the SAME effective config (a
51+
* per-series `baselineMode`/threshold override drives both).
4552
*
4653
* @param {object} [settings] pre-loaded settings (optional; fetched when omitted)
54+
* @param {object} [seriesOverrides] the series' editorialCheckConfig map (optional)
4755
* @returns {Promise<{ sigmaThreshold: number, minIssues: number, vocabularyWells: string, maxFindings: number, baselineMode: string }>}
4856
*/
49-
export async function resolveVoiceDriftConfig(settings) {
57+
export async function resolveVoiceDriftConfig(settings, seriesOverrides) {
5058
const s = settings || await getSettings();
5159
const check = getCheckById(s, VOICE_DRIFT_CHECK_ID);
52-
const stored = readChecksSlice(s)[VOICE_DRIFT_CHECK_ID] || {};
53-
return resolveCheckConfig(check, stored.config);
60+
const stored = readChecksSlice(s)[VOICE_DRIFT_CHECK_ID]?.config;
61+
const override = seriesOverrides && typeof seriesOverrides === 'object' && !Array.isArray(seriesOverrides)
62+
? seriesOverrides[VOICE_DRIFT_CHECK_ID]
63+
: null;
64+
// Overlay the per-series override on the global stored config before validating,
65+
// mirroring applySeriesCheckConfig's `{ ...config, ...override }` merge. A
66+
// non-object override is ignored (the spread of a non-object is a no-op).
67+
const merged = (override && typeof override === 'object' && !Array.isArray(override))
68+
? { ...(stored || {}), ...override }
69+
: stored;
70+
return resolveCheckConfig(check, merged);
5471
}
5572

5673
/**
@@ -83,7 +100,9 @@ export async function getVoiceFingerprint(seriesId) {
83100

84101
const [sections, cfg] = await Promise.all([
85102
collectManuscriptSections(seriesId),
86-
resolveVoiceDriftConfig(),
103+
// Overlay this series' per-check overrides (#1591) so the matrix view resolves
104+
// the same effective config the finding-emitting run does.
105+
resolveVoiceDriftConfig(undefined, series?.editorialCheckConfig),
87106
]);
88107

89108
// `cfg` is schema-validated, so sigmaThreshold/minIssues/vocabularyWells/baselineMode

server/services/pipeline/voiceFingerprint.test.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ let settingsStore = {};
2020
let manuscriptCorpus = buildManuscript();
2121

2222
let seriesStyleGuide = null;
23+
let seriesEditorialCheckConfig = null;
2324
vi.mock('./series.js', () => ({
2425
getSeries: vi.fn(async (id) => {
2526
if (id === 'missing') throw Object.assign(new Error('nope'), { code: 'PIPELINE_SERIES_NOT_FOUND' });
26-
return { id, name: 'Test Series', styleGuide: seriesStyleGuide };
27+
return { id, name: 'Test Series', styleGuide: seriesStyleGuide, editorialCheckConfig: seriesEditorialCheckConfig };
2728
}),
2829
}));
2930

@@ -42,6 +43,7 @@ beforeEach(() => {
4243
settingsStore = {};
4344
manuscriptCorpus = buildManuscript();
4445
seriesStyleGuide = null;
46+
seriesEditorialCheckConfig = null;
4547
});
4648

4749
describe('resolveVoiceDriftConfig', () => {
@@ -66,6 +68,26 @@ describe('resolveVoiceDriftConfig', () => {
6668
expect(cfg.minIssues).toBe(3);
6769
expect(cfg.vocabularyWells).toBe('trade: forge, anvil');
6870
});
71+
72+
it('overlays a per-series editorialCheckConfig override on the global config (#1591)', async () => {
73+
settingsStore = {
74+
pipelineEditorialChecks: {
75+
checks: { [VOICE_DRIFT_CHECK_ID]: { config: { sigmaThreshold: 2, baselineMode: 'drafted' } } },
76+
},
77+
};
78+
// The series override flips baselineMode and threshold; the un-overridden
79+
// global sigmaThreshold is replaced, other globals persist.
80+
const cfg = await resolveVoiceDriftConfig(undefined, {
81+
[VOICE_DRIFT_CHECK_ID]: { baselineMode: 'blended', sigmaThreshold: 1.2 },
82+
});
83+
expect(cfg.baselineMode).toBe('blended');
84+
expect(cfg.sigmaThreshold).toBe(1.2);
85+
});
86+
87+
it('ignores a non-object per-series override', async () => {
88+
const cfg = await resolveVoiceDriftConfig(undefined, { [VOICE_DRIFT_CHECK_ID]: 'nope' });
89+
expect(cfg.baselineMode).toBe('drafted');
90+
});
6991
});
7092

7193
describe('getVoiceFingerprint', () => {
@@ -156,6 +178,17 @@ describe('getVoiceFingerprint', () => {
156178
expect(shifted).toBe(true);
157179
});
158180

181+
it('applies a per-series editorialCheckConfig override for the baseline (#2179/#1591)', async () => {
182+
// Global config leaves baselineMode at the default 'drafted'; the SERIES
183+
// override flips it to exemplars — the matrix must honor the override, exactly
184+
// as the finding-emitting run does via applySeriesCheckConfig.
185+
seriesStyleGuide = { voiceExemplars: [{ passage: METRONOMIC }, { passage: METRONOMIC }] };
186+
seriesEditorialCheckConfig = { [VOICE_DRIFT_CHECK_ID]: { baselineMode: 'exemplars' } };
187+
const res = await getVoiceFingerprint('ser-1');
188+
expect(res.baselineMode).toBe('exemplars');
189+
expect(res.exemplarBaselineUsed).toBe(true);
190+
});
191+
159192
it('falls back to drafted when configured for exemplars but the style guide has none (#2179)', async () => {
160193
settingsStore = {
161194
pipelineEditorialChecks: {

0 commit comments

Comments
 (0)