diff --git a/src/api/schema.ts b/src/api/schema.ts index 8f370ba..4d145df 100644 --- a/src/api/schema.ts +++ b/src/api/schema.ts @@ -88,7 +88,14 @@ const $defs = { fired: { type: 'boolean' }, provenance: { oneOf: [ - { type: 'string', enum: ['gap_tool_derived', 'earnings_suppressed_ppd_published'] }, + { + type: 'string', + enum: [ + 'gap_tool_derived', + 'earnings_suppressed_ppd_published', + 'cohort_suppressed_ppd_published', + ], + }, { type: 'null' }, ], }, diff --git a/src/engine.ts b/src/engine.ts index 40c72cd..6165d2c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -42,7 +42,11 @@ function buildNoiseBandAnnotation( if (surfacedVerdict === 'NOT MEASURED') { return { fired: false, provenance: null, message: null }; } - if (program.suppression.earn_suppressed || program.median_earn_p4 === null) { + // Privacy-suppressed earnings cell — the federal privacy rule withheld + // the cell-level value. Verdict still surfaces from the published OBBBA + // flag, but the noise-band test cannot run because there is no gap to + // threshold. Distinct from cohort-level suppression below. + if (program.suppression.earn_suppressed) { return { fired: true, provenance: 'earnings_suppressed_ppd_published', @@ -50,6 +54,19 @@ function buildNoiseBandAnnotation( 'Verdict is PPD-published; underlying earnings cell suppressed under federal privacy rule. Noise-band test is structurally inapplicable.', }; } + // Cohort-level suppression — earnings not published at the 4-digit grain + // (typically because the cell's cohort is below the floor or the cell + // was not published at 4-digit despite the verdict being published via + // 2-digit pool flag). Per SPEC-DELTA §2.3 four-layer suppression, this + // is a structurally different cause from the privacy-rule case above. + if (program.median_earn_p4 === null) { + return { + fired: true, + provenance: 'cohort_suppressed_ppd_published', + message: + 'Verdict is PPD-published; underlying earnings not published at 4-digit grain (cohort below floor or otherwise unavailable). Noise-band test is structurally inapplicable.', + }; + } if (program.ep_gap_pct === null) { return { fired: false, provenance: null, message: null }; } diff --git a/src/types.ts b/src/types.ts index 825e0c4..d6593e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,10 +137,31 @@ export interface CrossValidation { export interface NoiseBandAnnotation { fired: boolean; - /** Provenance string per chair greenlight (SPEC-DELTA §2.2). */ + /** + * Provenance string per chair greenlight (SPEC-DELTA §2.2). + * + * - `gap_tool_derived` — verdict's noise-band gap was computed from + * PPD-published earnings + benchmark; the percentage gap itself + * is NOT a published PPD field. + * - `earnings_suppressed_ppd_published` — verdict surfaces from the + * PPD-published OBBBA flag, but the underlying earnings cell is + * suppressed under the federal privacy rule (`earn_suppressed=true`). + * Noise-band test is structurally inapplicable. + * - `cohort_suppressed_ppd_published` — verdict surfaces from the + * PPD-published OBBBA flag, but the underlying earnings cell is + * not published because the cohort is below the floor or otherwise + * unavailable at 4-digit grain (`median_earn_p4 IS NULL` while the + * privacy-suppression flag is NOT the cause). Noise-band test is + * structurally inapplicable for a cohort-level reason rather than a + * privacy-rule reason; the distinction matters for explainability + * per SPEC-DELTA §2.3 four-layer suppression model. + * - `null` — annotation not fired (verdict NOT MEASURED, gap outside + * threshold, or no published gap to evaluate). + */ provenance: | 'gap_tool_derived' | 'earnings_suppressed_ppd_published' + | 'cohort_suppressed_ppd_published' | null; message: string | null; } diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 364bc82..4eb2ddb 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -249,6 +249,87 @@ describe('Engine — noise-band suppressed-earnings annotation', () => { expect(v.noise_band.fired).toBe(true); expect(v.noise_band.provenance).toBe('earnings_suppressed_ppd_published'); }); + + // cp-j0gw.14 / R19 fourth annotation case (SPEC-DELTA §2.2). + // When the published OBBBA verdict surfaces but the underlying earnings + // are not published at the 4-digit grain for a cohort-level reason + // (median_earn_p4 IS NULL with earn_suppressed=false — typically cohort + // below floor or pool-inherited verdict), the noise-band test is still + // structurally inapplicable but for a different reason than the + // privacy-rule case above. The annotation must distinguish so the UI + // can explain WHY the noise band did not run, per SPEC-DELTA §2.3 + // four-layer suppression model. + describe('fourth annotation case — cohort_suppressed_ppd_published', () => { + it('fires when verdict published but earnings null without privacy-rule suppression', () => { + const inst = makeInstitution([ + makeProgram({ + median_earn_p4: null, + ep_gap_pct: null, + ppd_fail_obbb: 1, + suppression: { cohort_suppressed: true, earn_suppressed: false }, + }), + ]); + const r = analyzeInstitution(inst); + const v = r.programs[0]!; + expect(v.verdict).toBe('FAIL'); + expect(v.noise_band.fired).toBe(true); + expect(v.noise_band.provenance).toBe('cohort_suppressed_ppd_published'); + expect(v.noise_band.message).toMatch(/cohort below floor|not published at 4-digit/i); + }); + + it('keeps earnings_suppressed_ppd_published when earn_suppressed=true even if median_earn_p4 is null', () => { + // earn_suppressed wins over the cohort branch — the privacy-rule + // explanation is the more specific and user-relevant reason. + const inst = makeInstitution([ + makeProgram({ + median_earn_p4: null, + ep_gap_pct: null, + ppd_fail_obbb: 1, + suppression: { earn_suppressed: true, cohort_suppressed: true }, + }), + ]); + const r = analyzeInstitution(inst); + const v = r.programs[0]!; + expect(v.noise_band.provenance).toBe('earnings_suppressed_ppd_published'); + }); + + it('does not fire when surfaced verdict is NOT MEASURED', () => { + // When the cell never surfaces a verdict, no annotation fires — + // regardless of which suppression flag is set. + const inst = makeInstitution([ + makeProgram({ + median_earn_p4: null, + ep_gap_pct: null, + ppd_fail_obbb: null, + benchmark: null, + suppression: { cohort_suppressed: true }, + }), + ]); + const r = analyzeInstitution(inst); + const v = r.programs[0]!; + expect(v.verdict).toBe('NOT MEASURED'); + expect(v.noise_band.fired).toBe(false); + expect(v.noise_band.provenance).toBeNull(); + }); + + it('does not fire when earnings are present (preserves gap_tool_derived path)', () => { + // Backward-compat sanity: the existing measurable-with-noise-band path + // still produces gap_tool_derived; the new fourth case only branches + // on median_earn_p4 IS NULL. + const inst = makeInstitution([ + makeProgram({ + median_earn_p4: 38270.0, + ep_gap_pct: 0.0606, + ppd_fail_obbb: 0, + suppression: { cohort_suppressed: false, earn_suppressed: false }, + }), + ]); + const r = analyzeInstitution(inst); + const v = r.programs[0]!; + expect(v.noise_band.fired).toBe(true); + expect(v.noise_band.provenance).toBe('gap_tool_derived'); + }); + }); }); // ─────────────────────────────────────────────────────────────────────────────