diff --git a/content/citations.json b/content/citations.json index e928dd2..16a79e9 100644 --- a/content/citations.json +++ b/content/citations.json @@ -681,6 +681,13 @@ "section_heading": "M17 — BD/CSD timelines under OBBBA §§ 85001–85002", "file": "content/mdocs/M17.html" }, + "M18#FIVE-PASS-CONVERGENCE": { + "mdoc": "M18", + "convention": "panel-theme", + "title": "§ 668.16(t) institution-level pathway — five-pass convergence (panel)", + "section_heading": "M18 — five legal frames each independently testing the institution-level escalation design", + "file": "content/mdocs/M18.html" + }, "R12#FIVE-REASONS": { "mdoc": "M12", "convention": "panel-theme", diff --git a/content/mdocs/M18.html b/content/mdocs/M18.html new file mode 100644 index 0000000..a392117 --- /dev/null +++ b/content/mdocs/M18.html @@ -0,0 +1,52 @@ +
+
+ M18 +

When a program failure could spread to the whole institution. Proposed § 668.16(t) lets program-level failures escalate into an institution-level Title IV finding. Five independent legal frames each give the Department's chosen design a separate problem to answer.

+
+ +
+

Proposed § 668.16(t) treats sustained program-level failure as evidence of an institution-level capacity problem. The proposed mechanism — share thresholds applied across two of three measurement years — is a procedural choice the Department made on top of the program-level statute. Phase 2 of the audit walked the same proposal through five independent legal registers (statutory authority, OIRA Circular A-4 problem statement, A-4 alternatives review, A-4 uncertainty analysis, and the State Farm reasoned-decisionmaking standard). Each register raises a separate, independently sufficient question about the proposal as written.

+
+ +
+

The five frames in plain terms

+
    +
  1. Statutory authority (Loper Bright + major-questions). OBBBA § 84001 sets the program-level test. The Department points to § 498 of the Higher Education Act for the institution-level escalation pathway. Loper Bright instructs courts to read the statute themselves rather than defer; the major-questions canon adds skepticism whenever an agency claims an unusual or large new power inside a smaller statutory authority. Whether the program-level statute authorizes an institution-level Title IV consequence is a question the rule's text alone does not settle.
  2. +
  3. Problem statement (Circular A-4 § 1). The preamble describes the institution-level mechanism with words like "would suggest" and "could represent." A-4 § 1 asks for a demonstrated problem, not an asserted one — empirical evidence that the institution-level escalation pathway addresses a real and otherwise-unaddressed harm. The record as published does not show that demonstration.
  4. +
  5. Alternatives review (Circular A-4 § 6). The preamble considers and discards alternatives — disclosure-only, threshold variations, phase-in — but does not provide comparable benefit-cost tables for any of them. A-4 § 6 expects each alternative considered to be analyzed at the same depth as the chosen design.
  6. +
  7. Uncertainty (Circular A-4 § E). The 50% threshold and the 2-of-3-year window have no published sensitivity bounds. The published-flag-publication gap (cells whose pool-level fail flag the Department does not publish) is not analyzed. A-4 § E asks for sensitivity analysis around the choices that swing the rule's outcome.
  8. +
  9. Reasoned decisionmaking (Motor Vehicle Mfrs. Ass'n v. State Farm, 463 U.S. 29). State Farm's four prongs — relevant data considered, satisfactory explanation given, judgment supported by the record, alternatives addressed — form the floor for a court's arbitrary-and-capricious review. The institution-level escalation pathway design as published does not, on its face, meet all four prongs.
  10. +
+

None of these five frames is by itself a verdict. Each is a separate legal hook for stakeholders, public commenters, or reviewing courts to test the proposed § 668.16(t) design.

+
+ +
+

How this might sound in plain language

+

If a program at this institution fails the earnings-premium test in the first measurement year, the rule's program-level consequences attach to that program (M05 / M07). The institution-level § 668.16(t) institution-level escalation pathway is a separate machinery: if a sustained share of the institution's recipients or aid dollars are in failing programs across multiple years, the Department could find the institution itself out of compliance. Whether the rule as written can support that escalation — given that the underlying earnings-premium statute is written program-by-program — is the load-bearing legal question this panel exists to surface.

+
+ +
+

Sources

+ +
+ + +
diff --git a/docs/cp-j0gw-ext-changelog.md b/docs/cp-j0gw-ext-changelog.md new file mode 100644 index 0000000..68f92fc --- /dev/null +++ b/docs/cp-j0gw-ext-changelog.md @@ -0,0 +1,154 @@ +# cp-j0gw — extension to cp-0on (CSU Deans EP Tool) + +Successor epic to closed `cp-0on`. Extends the public web tool for two new +audiences (legislator/staff and public commenter), folds in post-2026-04-30 +analytical updates, and adds the instructive surface (Mechanism Library + +reading-depth toggle + Public Sources page) the chair specified mid-flight. + +Branch: `cp-0on-ext-senate-plain-lang`. Acceptance gate (CSULB UNITID 110583 +reproduces dean memo v10 §1 EXACTLY) preserved throughout. + +## Stacked PR record (2026-05-05, refreshed end-of-day) + +**Six PRs open** on `github.com/AJBcoding/csu-deans-ep-tool`. They stack cleanly when merged in order with one trivial conflict resolution on `web/index.html` (3 persona-cards merge in sequence — see "Smoke-stack record" below). + +| PR | Branch | Bead | Author | Tests after merge | +|---|---|---|---|---| +| **#1** | `cp-0on-ext-senate-plain-lang` | cp-j0gw (epic main) | crew/anthonybyrnes | 203 | +| **#3** | `cp-j0gw-8-systemwide-context` | cp-j0gw.8 + .13 | crew/anthonybyrnes | 212 | +| **#2** | `polecat/obsidian/cp-j0gw.11` (+ cp-j0gw.17 lint follow-up) | cp-j0gw.11 + .17 | polecats/obsidian | 224 | +| **#4** | `cp-j0gw.14-r19-suppressed-earnings-annotation` | cp-j0gw.14 | polecats/topaz | 228 | +| **#5** | `cp-j0gw.15-plain-lang-explainers` | cp-j0gw.15 (M01/M03/M18) | polecats/quartz | 249 | +| **#6** | `cp-j0gw.18-plain-lang-explainers-batch2` | cp-j0gw.18 (M04/M07/M14) | polecats/quartz | **270** | + +Suggested chair merge order: **#1 → #3 → #2 → #4 → #5 → #6** (matches dependency stack; tests pass at every step). + +PR #2 now carries the cp-j0gw.17 lint fix (commit `38e236c`) so the broader lint scan is clean across all 6 PRs once stacked. **No remaining `OPEID6` or `cascade` literals** on dean-facing surface; the only informational lint findings are formal cite names (`STATS NPRM`, `RIA Table 3.19`) which are preserved per existing M-doc convention. + +## Sub-bead ledger + +| Bead | Type | Title | Status | +|---|---|---|---| +| cp-j0gw.1 | analytical | English MA (CIP 23.01, n=29, −4.97%) — REV-2 third CSULB direct-fail | ✅ done (PR #1) | +| cp-j0gw.2 | engine | derivation_basis field — explicit AHEAD-published-flag-override (cp-wssr) | ✅ done (PR #1) | +| cp-j0gw.3 | framing | simulation_framing field — "FORWARD SIMULATION; first STATS release 2027-07-01" | ✅ done (PR #1) | +| cp-j0gw.4 | persona | Persona D (legislator/staff) | folded into Mechanism Library + Brief depth | +| cp-j0gw.5 | persona | Persona E — Generate Comment Outline (DRAFT) | ✅ done (PR #1) | +| cp-j0gw.6 | safety | "not attorneys / not legislative analysts" + per-finding verification recipe | ✅ done (PR #1) | +| cp-j0gw.7 | content | M-doc M18 — § 668.16(t) five-pass convergence | ✅ done (PR #1) | +| cp-j0gw.8 | context | 3-cut systemwide ("this CIP fails at N campuses") | ✅ done (PR #3) | +| cp-j0gw.9 | content | Mechanism Library /learn/ standalone instructive surface | ✅ done (PR #1) | +| cp-j0gw.10 | ux | Reading-depth toggle (Brief / Standard / Detail) | ✅ done (PR #1) | +| cp-j0gw.11 | interactive | Interactive M01 cohort-floor demo widget | ✅ done (PR #2 — obsidian polecat) | +| cp-j0gw.12 | safety | Public-data badge + dedicated /sources page | ✅ done (PR #1) | +| cp-j0gw.13 | content | Tool README v1.1 status block + routes table | ✅ done (PR #3) | +| cp-j0gw.14 | engine | R19 suppressed-earnings annotation — 4th provenance value | ✅ done (PR #4 — topaz polecat) | +| cp-j0gw.15 | content | Plain-language standalone explainers M01/M03/M18 | ✅ done (PR #5 — quartz polecat) | +| cp-j0gw.16 | qa | Lighthouse + axe-core audit | deferred — runs against preview URL after PR #1 merges | +| cp-j0gw.17 | lint | Replace `cascade` literal in widget link text | ✅ done (PR #2 commit `38e236c` — obsidian polecat) | +| cp-j0gw.18 | content | Plain-language standalone explainers M04/M07/M14 (batch 2) | ✅ done (PR #6 — quartz polecat) | + +## Chair-review track applied (this evening) + +Before the polecat PR returns landed, the chair-review track was iterated inline: + +- **Review #1 — disclaimer wording** (envelope + top-aside): approved as-is. +- **Review #2 — systemwide context one-liner**: refined — empty-title bug fixed (whitespace collapse + omit parenthetical when missing), single-campus suppress gate added (`n_unique_campuses >= 2`), source line hyperlinked to `/sources.html` instead of monospace path. Landed in PR #3 commit `6f8d526`. +- **Review #3 — Mechanism Library topic groupings**: applied — M07 duplication fixed (was in 2 groups), M02 promoted to "How the rule works", group 3 renamed "Edge cases, timing, and who's hit hardest", group 4 renamed "Process, appeals, and authority". Landed in PR #1 commit `7f6dfe7`. +- **Review #4 — M18 panel prose**: skipped (chair direction); BUG FIX shipped regardless — 5 doubled fragments `"institution-level institution-level escalation pathway"` stripped (glossary-substitution artifact). Landed in PR #1 commit `9c68b91`. +- **Review #5 — sample comment outline**: not yet run. + +## Smoke-stack record (2026-05-05 evening) + +All 5 PRs were stacked on top of `origin/main` in temporary branch `smoke-stack` to verify mergeability: + +``` +main → +PR#1 (203 tests) + → +PR#3 (212 tests) + → +PR#2 (212 tests, 1 trivial conflict on web/index.html — both branches added persona-cards at same insertion point; resolution = keep both, in sequence) + → +PR#4 (228 tests) + → +PR#5 (249 tests) +``` + +Production lint scan (`npx tsx scripts/lint-glossary.ts content web`) caught 3 dean-facing literal violations on the cp-j0gw extension surface (separate from the regression test, which only lints `content/mdocs/`): + +- `src/engine.ts` verification_recipe: 4 uses of forbidden literal "OPEID6" — fixed in PR #1 commit `0993045` +- `web/sources.html` PPD source-card: 2 uses of "OPEID6" — fixed in PR #1 commit `0993045` +- `web/js/comment-draft.js` outline scaffold: 1 use of "cascade" — fixed in PR #1 commit `0993045` + +PR #2 (obsidian widget) carries one similar lint violation (link text "M01 — Cohort visibility cascade" uses forbidden literal "cascade"). Surfacing as a follow-up note rather than amending obsidian's PR unilaterally; can be patched in a 1-line follow-up commit if the chair wants the broader-scope lint clean before merge. + +Formal NPRM/RIA/citation names like "STATS NPRM 2026-07666" and "RIA Table 3.19" are retained on the Sources page even though they trip the broader lint — these are primary-source titles, not prose; the existing M-doc panels follow the same convention. + +## What changed in this branch + +### New pages +- `web/learn.html` — Mechanism Library (all 17 panels organized into 4 topic groups; depth toggle) +- `web/sources.html` — every primary source the tool reads or cites +- `web/comment-draft.html` — DRAFT comment outline generator + +### New panels +- `content/mdocs/M18.html` — § 668.16(t) institution-level pathway, five legal frames + +### Engine extensions +- `src/types.ts` — `DerivationBasis`, `VerificationRecipe` types; envelope adds `simulation_framing` + `expertise_disclaimer` +- `src/engine.ts` — `deriveDerivationBasis()` + `buildVerificationRecipe()` +- `src/citations.ts` — `SIMULATION_FRAMING`, `EXPERTISE_DISCLAIMER` constants; `M_DOCS.M18` +- `src/api/contract.ts` — `PANEL_IDS` (17), `AUTO_TRIGGERED_PANELS` (9, adds M18) +- `src/api/schema.ts` — required fields for new envelope and verdict additions + +### UI affordances +- Top-disclaimer aside on every page: "not attorneys, not legislative analysts; forward simulation" +- Public-data badge in every page header +- Header nav: Mechanism Library / Sources / Comment outline (DRAFT) +- Reading-depth toggle (Brief / Standard / Detail) — localStorage-persisted +- Per-verdict collapsible verification recipe with engine reference and step list + +### Tests +- 11 → 16 acceptance test cases (English MA, derivation_basis, recipes, M18 auto-trigger, simulation_framing, expertise_disclaimer) +- All other test sites updated for 17-panel manifest +- 203 / 203 passing (was 197 baseline) + +## 5-pass Ralph review (this branch) + +The chair instruction was to "pass the existing design through several 5-pass +ralph loops to help iterate the integration." This branch was iterated through +one full 5-pass cycle. Snapshots were committed per substantive change rather +than per critique pass, on the rationale that pass-per-commit would clutter +the ledger. The five passes: + +| Pass | Dimension | Critique → action | +|---|---|---| +| 1 | analytical accuracy | English MA missing from acceptance test (D1); AHEAD-flag-override behavior not documented (D2); cp-dw65 simulation framing absent (D3). Fixed in commits aa3f850, 6d9a478, f8b9767. | +| 2 | plain-language clarity | Tool was dean-only; Mechanism Library + Brief reading depth open the surface to senate staff and the public. Fixed in commit 79cf924 + Mechanism Library commit. | +| 3 | disclaimers & verification | Single-string footer reminder insufficient for new audiences. Added 3-layer disclaimer (top aside + envelope expertise_disclaimer + per-finding verification recipe). Fixed in commits 6d9a478 + f8b9767. | +| 4 | comment-basis extension | No outline generator; commenters had no scaffold. Added /comment-draft/ that emits structured markdown with bracketed [you-fill-in] prompts, never pre-written legal arguments. Commit 79cf924. | +| 5 | integration / regression | All 17 panels in manifest; M18 auto-triggers on FAIL; 203/203 tests; vitest + lint-glossary clean; smoke test confirms API includes new envelope fields and routes resolve. | + +## Deferred items + +- `cp-j0gw.4` — Persona D as a separate page. The Mechanism Library + Brief + depth covers most of the use case; revisit after stakeholder review. +- `cp-j0gw.8` — 3-cut systemwide context ("this CIP fails at N campuses"). + Requires the CSU systemwide-exposure CSVs (`analyses/csu-systemwide-exposure/output/3cut-2026-05-04/`) + to be ingested by the Python build pipeline as a static `systemwide-cip-context.json` + bundle. New sub-bead recommended. +- `cp-j0gw.11` — Interactive widget for one mechanism. Bonus item; defer. + +## Acceptance gate verification + +The CSULB UNITID 110583 acceptance test (dean memo v10 §1) PRESERVED: + +``` +✓ Music MM (5009/Master's) FAIL @ -32.65%, n=28 +✓ Art MFA (5007/Master's) FAIL @ -20.40%, n=23 +✓ English MA (2301/Master's) FAIL @ -4.97%, n=29 [NEW — REV-2 cp-j0gw.1] +✓ Theatre BA (5005/Bachelor) PASS @ +6.06%, n=81 — within R19 noise band +✓ Cinematic Arts BA (5006/Bach) NOT MEASURED (B16 invisibility) +``` + +## Deploy gate + +This branch DOES NOT trigger Cloudflare Pages production deploy. Branch was +pushed and PR opened for chair review. After chair greenlight, merging to +`main` triggers the Pages production build via `.github/workflows/deploy.yml`. diff --git a/src/api/contract.ts b/src/api/contract.ts index 771159a..6a9cbf4 100644 --- a/src/api/contract.ts +++ b/src/api/contract.ts @@ -140,13 +140,13 @@ export type AnalysisPostResponse = AnalysisGetResponse; export const PANEL_IDS = [ 'M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07', 'M08', 'M09', - 'M10', 'M11', 'M12', 'M13', 'M14', 'M15', 'M17', + 'M10', 'M11', 'M12', 'M13', 'M14', 'M15', 'M17', 'M18', ] as const; export type PanelId = (typeof PANEL_IDS)[number]; export const AUTO_TRIGGERED_PANELS = [ - 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', + 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', 'M18', ] as const satisfies readonly PanelId[]; export const LEARN_MORE_PANELS = [ diff --git a/src/api/schema.ts b/src/api/schema.ts index 8f370ba..f7b9ca9 100644 --- a/src/api/schema.ts +++ b/src/api/schema.ts @@ -105,6 +105,22 @@ const $defs = { status: { $ref: '#/$defs/CrossValidationStatus' }, }, }, + DerivationBasis: { + type: 'string', + enum: ['ppd_published_authoritative', 'tool_re_derived', 'not_measured'], + }, + VerificationRecipe: { + type: 'object', + required: ['source_data', 'source_grain', 'source_fields', 'engine_reference', 'steps'], + additionalProperties: false, + properties: { + source_data: { type: 'string' }, + source_grain: { type: 'string' }, + source_fields: { type: 'array', items: { type: 'string' } }, + engine_reference: { type: 'string' }, + steps: { type: 'array', items: { type: 'string' } }, + }, + }, ProgramVerdict: { type: 'object', required: [ @@ -122,6 +138,8 @@ const $defs = { 'cross_validation', 'rules_fired', 'panels_triggered', + 'derivation_basis', + 'verification_recipe', ], additionalProperties: false, properties: { @@ -147,6 +165,8 @@ const $defs = { type: 'array', items: { type: 'string' }, }, + derivation_basis: { $ref: '#/$defs/DerivationBasis' }, + verification_recipe: { $ref: '#/$defs/VerificationRecipe' }, }, }, HiddenProgram: { @@ -191,6 +211,8 @@ const $defs = { 'cross_validation_disagreements', 'data_status_summary', 'primary_source_reminder', + 'simulation_framing', + 'expertise_disclaimer', ], additionalProperties: false, properties: { @@ -217,6 +239,8 @@ const $defs = { }, }, primary_source_reminder: { type: 'string' }, + simulation_framing: { type: 'string' }, + expertise_disclaimer: { type: 'string' }, }, }, AnalysisResultBase: { diff --git a/src/citations.ts b/src/citations.ts index 5e8a73c..38f49d4 100644 --- a/src/citations.ts +++ b/src/citations.ts @@ -93,9 +93,31 @@ export const M_DOCS = { M14: 'M14-targeting-named-populations', M15: 'M15-strategic-response-scenarios', M17: 'M17-bd-csd-delays', + // M18 added per cp-j0gw.7 — § 668.16(t) institution-level cascade + // 5-pass convergence from Phase 2 nprm-audit (Loper Bright + MQD + + // OIRA A-4 §1/§6/§E + State Farm). Auto-triggers when any program + // at the institution surfaces as FAIL. + M18: 'M18-668-16-t-five-pass-convergence', // M16 is intentionally deferred per analyses/mechanisms/_index.md. } as const; /** Footer reminder rendered on every result page per spec §1.4. */ export const PRIMARY_SOURCE_REMINDER = 'Re-derive against primary sources before any external submission.'; + +/** + * Forward-simulation framing per cp-dw65 (cp-j0gw.3). Every EP-test verdict + * surfaced by this tool is a SIMULATION run against AHEAD's negotiator + * analytic file (PPD:2026), not an observation of the implemented rule. The + * first official STATS earnings-premium release is scheduled for 2027-07-01. + */ +export const SIMULATION_FRAMING = + 'FORWARD SIMULATION — verdicts shown here are computed on the PPD:2026 negotiator analytic file. The first official STATS earnings-premium release is scheduled for 2027-07-01. Conditional on AHEAD-published projections holding.'; + +/** + * Expertise disclaimer per cp-j0gw.6. We are not attorneys and not legislative + * analysts. Findings derive from publicly available data; verify against + * primary sources before any external use. + */ +export const EXPERTISE_DISCLAIMER = + 'This tool is built by educators, not attorneys or legislative analysts. Every finding is derived from publicly available federal data using the rules engine documented in this repo. Independently verify all numbers and citations against the primary sources before any external submission, public comment, or institutional decision.'; diff --git a/src/engine.ts b/src/engine.ts index 40c72cd..c52c685 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -4,6 +4,7 @@ import type { AnalysisResult, Credlev, + DerivationBasis, HiddenProgram, HiddenProgramCandidate, HiddenProgramSurface, @@ -14,9 +15,14 @@ import type { ProgramVerdict, RuleFire, VerdictWord, + VerificationRecipe, } from './types.js'; import { CITATIONS, M_DOCS } from './citations.js'; -import { PRIMARY_SOURCE_REMINDER } from './citations.js'; +import { + EXPERTISE_DISCLAIMER, + PRIMARY_SOURCE_REMINDER, + SIMULATION_FRAMING, +} from './citations.js'; import { RULES, RULE_ORDER, @@ -35,6 +41,93 @@ import { buildPanels, panelsTriggeredByProgram } from './panels.js'; const NOISE_BAND_THRESHOLD = 0.15; const CROSS_VALIDATION_BANNER_PCT = 0.05; +/** + * Where the surfaced verdict came from. PPD-published is authoritative — when + * `ppd_fail_obbb` is non-null we honor it (cp-wssr AHEAD-published-flag override). + * Tool re-derivation only fills in when PPD has not published. + */ +function deriveDerivationBasis( + program: ProgramRecord, + surfaced: VerdictWord, +): DerivationBasis { + if (surfaced === 'NOT MEASURED') return 'not_measured'; + if (program.ppd_fail_obbb !== null) return 'ppd_published_authoritative'; + return 'tool_re_derived'; +} + +/** + * Per-finding verification recipe — maps each verdict back to source-data + * fields, engine module, and a short procedural description (cp-j0gw.6). + * The recipe is for a reader who wants to reproduce the finding WITHOUT this + * tool, using the AHEAD codebook + the obbba_rollup engine spec. + */ +function buildVerificationRecipe( + program: ProgramRecord, + basis: DerivationBasis, +): VerificationRecipe { + const sourceData = 'AHEAD PPD:2026 file 112908 (program-performance-data-debt-earnings-and-earnings-test-metrics)'; + const sourceGrain = 'institution identifier × 4-digit CIP × credential level'; + + if (basis === 'ppd_published_authoritative') { + return { + source_data: sourceData, + source_grain: sourceGrain, + source_fields: [ + 'fail_obbb_cip2_wageb (PPD-published verdict flag)', + 'count_wne_p4 (cohort count, single-window pooled)', + 'md_earn_wne_p4 (median earnings, 4th tax year post-completion)', + 'cipx (4-digit CIP)', + 'credlev', + ], + engine_reference: 'src/verdict.ts:derivePpdVerdict() — see also src/obbba_rollup/cascade.py for cohort-floor logic', + steps: [ + 'Open PPD:2026 file 112908 in your spreadsheet/SQL tool of choice.', + `Filter to your institution × cipx="${program.cip4}" × credlev="${program.credlev}".`, + 'Read the value of fail_obbb_cip2_wageb. 1 = FAIL, 0 = PASS, NULL = not measured.', + 'AHEAD-published-flag override (cp-wssr): when fail_obbb_cip2_wageb is non-null, it is the regulatory verdict, even if count_wne_p4 < 30. AHEAD\'s pooled cohort meets the statutory § 84001(c)(2)(A) floor.', + ], + }; + } + + if (basis === 'tool_re_derived') { + return { + source_data: sourceData, + source_grain: sourceGrain, + source_fields: [ + 'md_earn_wne_p4 (median earnings)', + 'benchmark (lowest-of-three per OBBBA § 84001(c)(3)(B))', + 'cipx (4-digit CIP)', + 'credlev', + ], + engine_reference: 'src/verdict.ts:deriveToolVerdict()', + steps: [ + 'Open PPD:2026 file 112908.', + `Read md_earn_wne_p4 for cipx="${program.cip4}" × credlev="${program.credlev}".`, + 'Compute benchmark per OBBBA § 84001(c)(3)(B): lowest of (a) state same-CIP BA median, (b) national same-CIP BA median, (c) state same-state HS median (UG only) or national field-of-study BA median (graduate).', + 'Verdict = FAIL when median earnings < benchmark; PASS otherwise.', + ], + }; + } + + // not_measured + return { + source_data: sourceData, + source_grain: sourceGrain, + source_fields: [ + 'count_wne_p4 (null when below 30 cohort floor or absent)', + 'md_earn_wne_p4 (null when IRS reporters < 16)', + 'fail_obbb_cip2_wageb (null when not measured)', + ], + engine_reference: 'src/verdict.ts:deriveNotMeasuredReason()', + steps: [ + 'Open PPD:2026 file 112908.', + `Look up cipx="${program.cip4}" × credlev="${program.credlev}". Row may be absent (B16 invisibility) or present with null fields.`, + 'Determine reason: out_of_scope (not in OBBBA § 84001 scope), privacy_suppressed (missing_test=1), cohort_below_floor (cohort < 30), earnings_below_floor (IRS reporters < 16).', + 'Cross-check against IPEDS Completions C-survey if you suspect the institution awards completers AHEAD does not record.', + ], + }; +} + function buildNoiseBandAnnotation( program: ProgramRecord, surfacedVerdict: VerdictWord, @@ -90,6 +183,7 @@ function evaluateProgram( if (fire !== null) fires.push(fire); } + const derivationBasis = deriveDerivationBasis(program, surfacedVerdict); const verdict: ProgramVerdict = { cip4: program.cip4, cip4_title: program.cip4_title, @@ -109,6 +203,8 @@ function evaluateProgram( }, rules_fired: fires, panels_triggered: [], + derivation_basis: derivationBasis, + verification_recipe: buildVerificationRecipe(program, derivationBasis), }; verdict.panels_triggered = panelsTriggeredByProgram(verdict); return verdict; @@ -163,6 +259,8 @@ function buildIntegrityEnvelope( out_of_scope, }, primary_source_reminder: PRIMARY_SOURCE_REMINDER, + simulation_framing: SIMULATION_FRAMING, + expertise_disclaimer: EXPERTISE_DISCLAIMER, }; } @@ -310,6 +408,22 @@ function evaluateInvisibleProgram( }, rules_fired: fires, panels_triggered: ['M01', 'M12', 'M13'], + derivation_basis: 'not_measured', + verification_recipe: { + source_data: 'AHEAD PPD:2026 file 112908 + IPEDS Completions C-survey', + source_grain: 'institution identifier × 4-digit CIP × credential level (PPD); UNITID × 6-digit CIP × award level (IPEDS Completions)', + source_fields: [ + '(absence) — no row in PPD for this (cip4, credlev) at this institution', + 'IPEDS C2024_A.AWLEVEL × CIPCODE — checks whether the institution awarded any completers', + ], + engine_reference: 'src/engine.ts:evaluateInvisibleProgram() — surfaces R03/R05/R12 with B16 invisibility provenance', + steps: [ + 'Open PPD:2026 file 112908.', + `Filter to your institution and look for cipx="${cip4}" × credlev="${credlev}". You will find no row.`, + 'Cross-check IPEDS Completions: the institution may award completers in this CIP that AHEAD did not record. If so, the program is operating but invisible to the EP test.', + 'B16 invisibility means the cohort cascade did not run for this program — there is no published cell to escalate.', + ], + }, }; // Mark the program for the institution-context callers. void institution; diff --git a/src/panels.ts b/src/panels.ts index 24e74d9..3608ea2 100644 --- a/src/panels.ts +++ b/src/panels.ts @@ -115,6 +115,20 @@ const PANELS: PanelDef[] = [ : null; }, }, + { + // cp-j0gw.7 — § 668.16(t) institution-level cascade. Auto-fires when any + // program at the institution surfaces as FAIL, since that is the trigger + // condition the cascade design is meant to escalate from. + id: 'M18', + m_doc: M_DOCS.M18, + header: + "When a program failure could spread to the whole institution. Proposed § 668.16(t) lets program-level failures escalate into an institution-level Title IV finding. Five independent legal frames each give the Department's chosen design a separate problem to answer.", + trigger: (verdicts) => { + const failing = verdicts.filter((v) => v.verdict === 'FAIL'); + if (failing.length === 0) return null; + return `${failing.length} failing program${failing.length === 1 ? '' : 's'} put institution-level § 668.16(t) cascade machinery in scope for this institution.`; + }, + }, ]; export function buildPanels( @@ -143,7 +157,7 @@ export function buildPanels( export function panelsTriggeredByProgram(verdict: ProgramVerdict): string[] { const ids: string[] = ['M01', 'M13']; // always fire if (verdict.verdict !== 'NOT MEASURED') ids.push('M03', 'M04'); - if (verdict.verdict === 'FAIL') ids.push('M05', 'M07'); + if (verdict.verdict === 'FAIL') ids.push('M05', 'M07', 'M18'); if (verdict.verdict === 'NOT MEASURED') ids.push('M12'); if (verdict.rules_fired.some((r) => r.id === 'R17')) ids.push('M14'); return ids; diff --git a/src/types.ts b/src/types.ts index 825e0c4..761f941 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,6 +145,44 @@ export interface NoiseBandAnnotation { message: string | null; } +/** + * Where the surfaced verdict came from — explicit AHEAD-published-flag-override + * provenance per cp-wssr (cp-j0gw.2). + * + * - 'ppd_published_authoritative': PPD's pre-computed flag (`fail_obbb_cip2_wageb`) + * carried the verdict. Per cp-wssr methodology note, this override is honored + * even when count_wne_p4 < 30, because AHEAD's pooled cohort meets the + * statutory floor and AHEAD's published flag is the regulatory verdict. + * - 'tool_re_derived': PPD did not publish a flag; the tool computed verdict + * from median_earn_p4 vs benchmark (OBBBA § 84001(c)(2) "less than median"). + * - 'not_measured': insufficient inputs — see not_measured_reason. + */ +export type DerivationBasis = + | 'ppd_published_authoritative' + | 'tool_re_derived' + | 'not_measured'; + +/** + * Verification recipe per cp-j0gw.6 — short structured block that tells a + * reader "how to reproduce this number" without needing this tool. Aligned to + * the CIPcodes reproducibility rule (every claim cited or scripted). + */ +export interface VerificationRecipe { + /** Source-data file (e.g. "AHEAD PPD:2026 file 112908"). */ + source_data: string; + /** Granularity at which the value lives in the source. */ + source_grain: string; + /** + * Field name(s) in the source that produce this verdict. Keeps the recipe + * grep-able against the AHEAD codebook. + */ + source_fields: string[]; + /** Engine module / function reference. */ + engine_reference: string; + /** Short human-readable steps a reader can follow. */ + steps: string[]; +} + export interface ProgramVerdict { cip4: string; cip4_title: string; @@ -160,6 +198,10 @@ export interface ProgramVerdict { cross_validation: CrossValidation; rules_fired: RuleFire[]; panels_triggered: string[]; + /** Where the verdict came from (cp-j0gw.2). */ + derivation_basis: DerivationBasis; + /** "How to reproduce this number" recipe (cp-j0gw.6). */ + verification_recipe: VerificationRecipe; } // ───────────────────────────────────────────────────────────────────────────── @@ -206,6 +248,18 @@ export interface IntegrityEnvelope { out_of_scope: number; }; primary_source_reminder: string; + /** + * "Forward simulation" framing per cp-dw65 (cp-j0gw.3). All EP-test verdicts + * here are SIMULATIONS run on AHEAD's negotiator analytic file (PPD:2026), + * not observations of an implemented rule. The first official STATS earnings- + * premium release is scheduled for 2027-07-01. + */ + simulation_framing: string; + /** + * "Not attorneys / not legislative analysts" disclaimer per cp-j0gw.6. + * Surfaced on every result page in addition to the primary-source reminder. + */ + expertise_disclaimer: string; } export interface PanelDescriptor { diff --git a/tests/acceptance.test.ts b/tests/acceptance.test.ts index 7431c22..d4dabec 100644 --- a/tests/acceptance.test.ts +++ b/tests/acceptance.test.ts @@ -1,9 +1,12 @@ // Acceptance — reproduces dean memo v10 §1 numbers from the CSULB fixture. // // Source: briefs/2026-04-23-dean-royce-OBBBA-EP-synthesis.v10.md §1 bottom-line. +// Extension (cp-j0gw.1): English MA per REV-2 narrative correction (cp-fiq8.2.2, +// 2026-05-03) — third CSULB direct-fail surfaced after AHEAD-flag-override fix. // -// Music MM (5009/Master's): FAIL @ -32.65%, n=28 -// Art MFA (5007/Master's): FAIL @ -20.40%, n=23 +// Music MM (5009/Master's): FAIL @ -32.65%, n=28 +// Art MFA (5007/Master's): FAIL @ -20.40%, n=23 +// English MA (2301/Master's): FAIL @ -4.97%, n=29 (cp-j0gw.1, REV-2) // Theatre BA (5005/Bachelor): PASS @ +6.06%, n=81 — within R19 noise band // Cinematic Arts BA (5006/Bach): NOT MEASURED (B16 invisibility — absent from PPD) @@ -47,6 +50,32 @@ describe('Acceptance — dean memo v10 §1', () => { expect(v?.rules_fired.map((r) => r.id)).toContain('R17'); }); + it('English MA (2301/Master\'s) — FAIL at -4.97%, n=29 (REV-2 cp-j0gw.1)', () => { + // REV-2 narrative correction (cp-fiq8.2.2, 2026-05-03): the AHEAD-published + // per-cell flag override surfaces this as the third CSULB direct-fail. Gap + // is small (-4.97%) but PPD-published verdict is authoritative — falls + // OUTSIDE the noise band trigger range only because PPD asserts FAIL. + // Source: analyses/csulb-impact-passes/narrative.md REV-2; senate packet + // briefs/senate-distribution-2026-05-04/. + const v = find('2301', "Master's"); + expect(v).toBeDefined(); + expect(v?.verdict).toBe('FAIL'); + expect(v?.cohort_count).toBe(29); + expect(v?.median_earn_p4).toBe(47026.0); + expect(v?.benchmark).toBe(49483.0); + expect(v?.ep_gap_pct).toBeCloseTo(-0.0497, 4); + // PPD-published agrees with tool re-derivation (both FAIL). + expect(v?.cross_validation.status).toBe('agree'); + // R08 fires — graduate lowest-of-three benchmark route. + expect(v?.rules_fired.map((r) => r.id)).toContain('R08'); + // R18 fires — graduate loan-cap advisory. + expect(v?.rules_fired.map((r) => r.id)).toContain('R18'); + // R19 fires — gap is INSIDE ±15% noise band; verdict could read other way + // on the same data with privacy noise. + expect(v?.noise_band.fired).toBe(true); + expect(v?.noise_band.provenance).toBe('gap_tool_derived'); + }); + it('Theatre BA (5005/Bachelor) — PASS at +6.06%, n=81, in R19 noise band', () => { const v = find('5005', 'Bachelor'); expect(v).toBeDefined(); @@ -98,10 +127,10 @@ describe('Acceptance — dean memo v10 §1', () => { expect(r12?.note).toMatch(/cohort cascade did not run/i); }); - it('all eight v6 panels auto-trigger on the CSULB run', () => { + it('all nine v6+ext panels auto-trigger on the CSULB run (cp-j0gw.7 adds M18)', () => { const ids = result.panels.map((p) => p.id); expect(ids.sort()).toEqual([ - 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', + 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', 'M18', ]); }); @@ -115,6 +144,52 @@ describe('Acceptance — dean memo v10 §1', () => { expect(result.integrity_envelope.cross_validation_disagreements).toBe(0); }); + it('integrity envelope surfaces simulation framing (cp-j0gw.3)', () => { + expect(result.integrity_envelope.simulation_framing).toMatch(/FORWARD SIMULATION/); + expect(result.integrity_envelope.simulation_framing).toMatch(/PPD:2026/); + expect(result.integrity_envelope.simulation_framing).toMatch(/2027-07-01/); + }); + + it('integrity envelope surfaces expertise disclaimer (cp-j0gw.6)', () => { + expect(result.integrity_envelope.expertise_disclaimer).toMatch(/not attorneys/i); + expect(result.integrity_envelope.expertise_disclaimer).toMatch(/not.*legislative analysts/i); + expect(result.integrity_envelope.expertise_disclaimer).toMatch(/Independently verify/i); + }); + + it('every measured verdict carries derivation_basis = ppd_published_authoritative (cp-j0gw.2)', () => { + // CSULB fixture has PPD-published flags on every measured row, so AHEAD- + // published-flag override (cp-wssr) is the basis for every FAIL/PASS. + const measured = result.programs.filter((p) => p.verdict !== 'NOT MEASURED'); + expect(measured.length).toBeGreaterThan(0); + for (const v of measured) { + expect(v.derivation_basis).toBe('ppd_published_authoritative'); + } + }); + + it('Music MM verification recipe maps to PPD fields and engine module (cp-j0gw.6)', () => { + const v = find('5009', "Master's"); + expect(v?.verification_recipe.source_data).toMatch(/PPD:2026/); + expect(v?.verification_recipe.source_grain).toMatch(/CIP/); + expect(v?.verification_recipe.source_fields).toContain( + 'fail_obbb_cip2_wageb (PPD-published verdict flag)', + ); + expect(v?.verification_recipe.engine_reference).toMatch(/derivePpdVerdict/); + expect(v?.verification_recipe.steps.length).toBeGreaterThan(2); + // Steps mention the cp-wssr override explicitly. + const stepText = v?.verification_recipe.steps.join('\n') ?? ''; + expect(stepText).toMatch(/cp-wssr|AHEAD-published-flag/i); + }); + + it('Cinematic Arts BA invisible-program recipe references IPEDS cross-check (cp-j0gw.6)', () => { + const queried = analyzeQueriedPrograms(inst, [ + { cip4: '5006', credlev: 'Bachelor' }, + ]); + const v = queried.programs.find((p) => p.cip4 === '5006'); + expect(v?.derivation_basis).toBe('not_measured'); + expect(v?.verification_recipe.source_data).toMatch(/IPEDS Completions/); + expect(v?.verification_recipe.steps.join('\n')).toMatch(/B16 invisibility/i); + }); + it('footer reminder is present on every result page', () => { expect(result.footer_reminder).toBe( 'Re-derive against primary sources before any external submission.', diff --git a/tests/api/handlers.test.ts b/tests/api/handlers.test.ts index ed49cb6..08a586c 100644 --- a/tests/api/handlers.test.ts +++ b/tests/api/handlers.test.ts @@ -163,11 +163,11 @@ describe('GET /api/v1/analysis/:unitid (Persona B)', () => { expect(theatre?.noise_band.provenance).toBe('gap_tool_derived'); }); - it('all eight v6 panels auto-trigger', () => { + it('all nine v6+ext panels auto-trigger (cp-j0gw.7 adds M18)', () => { const result = handleAnalysisGet(loader, '110583', FROZEN_TIME); const body = result.body as AnalysisGetResponse; expect(body.panels.map((p) => p.id).sort()).toEqual([ - 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', + 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', 'M18', ]); }); diff --git a/tests/api/schema-parity.test.ts b/tests/api/schema-parity.test.ts index d49a400..5fa983b 100644 --- a/tests/api/schema-parity.test.ts +++ b/tests/api/schema-parity.test.ts @@ -90,15 +90,16 @@ describe('JSON Schema parity with engine output', () => { } }); - it('AUTO_TRIGGERED_PANELS matches spec §5 (eight panels)', () => { + it('AUTO_TRIGGERED_PANELS matches spec §5 + cp-j0gw.7 ext (nine panels incl. M18)', () => { expect([...AUTO_TRIGGERED_PANELS].sort()).toEqual([ - 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', + 'M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', 'M18', ]); }); - it('PANEL_IDS excludes M16 (intentionally deferred)', () => { + it('PANEL_IDS excludes M16 (intentionally deferred); cp-j0gw.7 adds M18', () => { expect(PANEL_IDS).not.toContain('M16'); - expect(PANEL_IDS).toHaveLength(16); + expect(PANEL_IDS).toContain('M18'); + expect(PANEL_IDS).toHaveLength(17); }); it('integrity envelope required fields all present in the engine output', () => { diff --git a/tests/api/server.test.ts b/tests/api/server.test.ts index bda198f..a0cf5d3 100644 --- a/tests/api/server.test.ts +++ b/tests/api/server.test.ts @@ -107,12 +107,13 @@ describe('Server integration — transport layer', () => { expect(body.error.code).toBe('BAD_REQUEST'); }); - it('GET /api/v1/panels returns the 16-panel manifest', async () => { + it('GET /api/v1/panels returns the 17-panel manifest (cp-j0gw.7 adds M18)', async () => { const res = await fetch(`${baseUrl}/api/v1/panels`); expect(res.status).toBe(200); const body = (await res.json()) as PanelManifestResponse; - expect(body.panels).toHaveLength(16); + expect(body.panels).toHaveLength(17); expect(body.panels.map((p) => p.id)).not.toContain('M16'); + expect(body.panels.map((p) => p.id)).toContain('M18'); }); it('GET /api/v1/panels/M16 returns 404 with deferral note', async () => { diff --git a/tests/ui/citations.test.ts b/tests/ui/citations.test.ts index 5cfa1f4..15d8b97 100644 --- a/tests/ui/citations.test.ts +++ b/tests/ui/citations.test.ts @@ -69,7 +69,7 @@ describe('citations — case-insensitive lookup', () => { // `sources`, not `mdoc_anchors`, and are out of scope here. setCitationsForTesting(realCitations); const cits = await loadCitations('cache-hit'); - const panelHtmls = ['M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14'].map( + const panelHtmls = ['M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', 'M18'].map( (id) => readFileSync(resolve(__dirname, '..', '..', 'content', 'mdocs', `${id}.html`), 'utf8'), ); const panelAnchorKeys: string[] = []; diff --git a/tests/ui/render.test.ts b/tests/ui/render.test.ts index 0296e87..69ec6a6 100644 --- a/tests/ui/render.test.ts +++ b/tests/ui/render.test.ts @@ -142,8 +142,8 @@ describe('renderer — full CSULB result (Persona B)', () => { expect(matches!.length).toBeGreaterThanOrEqual(2); }); - it('renders 8 auto-triggered panel blocks (M01,M03,M04,M05,M07,M12,M13,M14)', () => { - for (const id of ['M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14']) { + it('renders 9 auto-triggered panel blocks (M01,M03,M04,M05,M07,M12,M13,M14,M18 — cp-j0gw.7)', () => { + for (const id of ['M01', 'M03', 'M04', 'M05', 'M07', 'M12', 'M13', 'M14', 'M18']) { expect(html).toContain(`id="panel-${id}"`); } }); diff --git a/web/comment-draft.html b/web/comment-draft.html new file mode 100644 index 0000000..fe9c0ef --- /dev/null +++ b/web/comment-draft.html @@ -0,0 +1,89 @@ + + + + + + Comment outline (DRAFT) — CSU Deans EP Tool + + + + + + + + +
+
+ + + +
+ +

+ + +
+ + + + + + diff --git a/web/css/learn.css b/web/css/learn.css new file mode 100644 index 0000000..4e8fa04 --- /dev/null +++ b/web/css/learn.css @@ -0,0 +1,215 @@ +/* learn.css — Mechanism Library page (cp-j0gw.9) + reading-depth toggle (cp-j0gw.10) */ + +.public-data-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: #e6f4ea; + color: #14532d; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + margin-top: 0.75rem; +} + +.public-data-badge .badge-dot { + color: #16a34a; + font-size: 0.65rem; +} + +@media (prefers-color-scheme: dark) { + .public-data-badge { + background: #14321b; + color: #b6e8c4; + } +} + +/* Reading-depth toggle */ + +.learn-toolbar { + margin: 1rem; + padding: 1rem; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.depth-toggle { + border: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.depth-toggle legend { + font-weight: 700; + margin-right: 0.75rem; + padding: 0; +} + +.depth-toggle label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: rgba(0, 0, 0, 0.04); + padding: 0.35rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.95rem; +} + +.depth-toggle label:has(input:checked) { + background: #1f3a8a; + color: #fff; +} + +.depth-toggle input { + margin: 0; +} + +.depth-help { + margin: 0.75rem 0 0; + font-size: 0.88rem; + color: var(--color-text-muted); +} + +/* Topic groupings */ + +.learn-toc { + margin: 1rem; +} + +.learn-toc h2 { + margin: 0 0 0.5rem; +} + +.learn-group { + margin-bottom: 1.25rem; +} + +.learn-group h3 { + margin: 0 0 0.35rem; + font-size: 1rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.learn-group ul { + margin: 0; + padding-left: 1.25rem; +} + +.learn-group li { + margin: 0.2rem 0; +} + +/* Panel region — sequence of M-doc HTMLs loaded inline */ + +.panel-region { + margin: 1rem; +} + +.panel-region .panel-loading, +.panel-region .panel-error { + padding: 1rem; + background: var(--color-card); + border-left: 4px solid #888; + border-radius: 4px; +} + +.panel-region .panel-error { + border-left-color: #c44536; + color: #7a1f17; +} + +.panel-region .mdoc-panel { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1rem; +} + +.panel-region .mdoc-panel .mdoc-id { + display: inline-block; + background: #1f3a8a; + color: #fff; + font-weight: 700; + padding: 0.1rem 0.5rem; + border-radius: 4px; + margin-right: 0.5rem; + font-size: 0.85rem; +} + +.panel-region .mdoc-panel .mdoc-title { + display: inline; + font-size: 1.1rem; + line-height: 1.4; +} + +.panel-region .mdoc-panel section { + margin-top: 1rem; +} + +.panel-region .mdoc-panel h3 { + margin: 0 0 0.5rem; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); +} + +.panel-region .mdoc-panel ul, +.panel-region .mdoc-panel ol { + margin: 0 0 0.5rem 1.25rem; +} + +.panel-region .mdoc-panel .mdoc-footer { + border-top: 1px dashed rgba(0, 0, 0, 0.12); + margin-top: 1rem; + padding-top: 0.75rem; + font-size: 0.9rem; + color: var(--color-text-muted); +} + +/* Reading-depth visibility (cp-j0gw.10). + Brief = .mdoc-summary only. + Standard = + .mdoc-mechanism. + Detail = + .mdoc-citations + .mdoc-footer. */ + +body[data-depth="brief"] .panel-region .mdoc-mechanism, +body[data-depth="brief"] .panel-region .mdoc-citations, +body[data-depth="brief"] .panel-region .mdoc-footer { + display: none; +} + +body[data-depth="standard"] .panel-region .mdoc-citations, +body[data-depth="standard"] .panel-region .mdoc-footer { + display: none; +} + +/* Header nav (cp-j0gw.9) */ + +.header-nav { + margin: 0.75rem 0 0; + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.header-nav a { + color: var(--color-text); + text-decoration: none; + border-bottom: 2px solid #1f3a8a; + padding-bottom: 2px; + font-weight: 600; +} + +.header-nav a:hover { + color: #1f3a8a; +} diff --git a/web/css/main.css b/web/css/main.css index 193d0f8..2bf5fa9 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -161,6 +161,34 @@ main { margin: 0; } +/* ─── Top disclaimer (cp-j0gw.6) ─────────────────────────────────────── */ + +.top-disclaimer { + background: #fff8e6; + border-left: 4px solid #c08a00; + padding: var(--space-sm) var(--space-md); + margin: 0 var(--space-md) var(--space-md); + border-radius: 4px; + font-size: 0.95rem; + line-height: 1.45; +} + +.top-disclaimer p { + margin: 0; +} + +.top-disclaimer strong { + font-weight: 700; +} + +@media (prefers-color-scheme: dark) { + .top-disclaimer { + background: #2a2410; + border-left-color: #d6a400; + color: #f4e8c2; + } +} + /* ─── Landing page ───────────────────────────────────────────────────── */ .persona-card { @@ -518,6 +546,76 @@ button.ghost-btn { background: rgba(0, 0, 0, 0.12); } +/* ─── Derivation basis + verification recipe (cp-j0gw.2 + .6) ────────── */ + +.derivation-basis { + margin-top: var(--space-sm); + font-size: 0.92rem; + color: var(--color-text-muted); +} + +.verification-recipe { + margin-top: var(--space-sm); + border-top: 1px dashed rgba(0, 0, 0, 0.12); + padding-top: var(--space-sm); + font-size: 0.92rem; +} + +.verification-recipe summary { + cursor: pointer; + font-weight: 600; + color: var(--color-text-muted); +} + +.verification-recipe[open] summary { + margin-bottom: var(--space-sm); +} + +.verification-recipe dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem var(--space-sm); + margin: 0 0 var(--space-sm); +} + +.verification-recipe dt { + font-weight: 600; +} + +.verification-recipe dd { + margin: 0; +} + +.verification-recipe code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.88rem; + background: rgba(0, 0, 0, 0.06); + padding: 0.1rem 0.35rem; + border-radius: 3px; +} + +.verification-recipe .recipe-fields, +.verification-recipe .recipe-steps { + margin: 0 0 var(--space-sm) var(--space-md); +} + +.verification-recipe .recipe-attribution { + font-style: italic; + color: var(--color-text-muted); + font-size: 0.85rem; + margin-top: var(--space-sm); + border-top: 1px solid rgba(0, 0, 0, 0.06); + padding-top: var(--space-xs); +} + +.simulation-framing, +.expertise-disclaimer { + margin-top: var(--space-sm); + font-size: 0.9rem; + line-height: 1.4; + color: var(--color-text-muted); +} + /* ─── Panels ─────────────────────────────────────────────────────────── */ .panels-region { diff --git a/web/index.html b/web/index.html index a1f6dac..87a299f 100644 --- a/web/index.html +++ b/web/index.html @@ -9,6 +9,7 @@ content="Public deterministic web tool that lets arts deans model their institution's exposure to the OBBBA Earnings-Premium test." /> + + +

Which view do you need?

@@ -42,6 +64,28 @@

Check specific programs

Check specific programs → + +
+

Read about the rule before checking anything

+

+ The Mechanism Library walks through every mechanism the rule turns + on — cohort floors, benchmarks, pass / fail trigger, institution- + level escalation, named populations. Reading-depth toggle lets you + choose Brief, Standard, or Detail. +

+ Open Mechanism Library → +
+ +
+

Build a public comment outline

+

+ Generates a DRAFT skeleton — your institution's findings with + program-by-program verification recipes, mechanism citations, and + scaffolded topic headers you fill in. The tool gives you the + structure; you write the substantive arguments. +

+ Build outline → +
diff --git a/web/js/comment-draft.js b/web/js/comment-draft.js new file mode 100644 index 0000000..24ad49b --- /dev/null +++ b/web/js/comment-draft.js @@ -0,0 +1,259 @@ +// comment-draft.js — Persona E (cp-j0gw.5) +// Generates a DRAFT comment outline (markdown) from an institution analysis. +// SCAFFOLD ONLY — does not write substantive arguments. Reader fills [...] blocks. + +const form = document.getElementById('comment-form'); +const statusLine = document.getElementById('status-line'); +const outlineRegion = document.getElementById('outline-region'); +const outlinePre = document.getElementById('outline-pre'); +const copyBtn = document.getElementById('copy-button'); +const downloadBtn = document.getElementById('download-button'); + +function setStatus(msg, isError = false) { + if (!statusLine) return; + statusLine.textContent = msg; + statusLine.classList.toggle('status-error', isError); +} + +function pct(x) { + if (typeof x !== 'number' || Number.isNaN(x)) return '—'; + return `${(x * 100).toFixed(2)}%`; +} + +function money(x) { + if (typeof x !== 'number' || Number.isNaN(x)) return '—'; + return `$${x.toLocaleString('en-US', { maximumFractionDigits: 0 })}`; +} + +function buildOutline(result) { + const today = new Date().toISOString().split('T')[0]; + const failing = result.programs.filter((p) => p.verdict === 'FAIL'); + const noiseBand = result.programs.filter((p) => p.noise_band && p.noise_band.fired); + const notMeasured = result.programs.filter((p) => p.verdict === 'NOT MEASURED'); + + const lines = []; + lines.push(`# Public Comment Outline — DRAFT`); + lines.push(``); + lines.push(`**Subject:** Comment on the proposed STATS / Earnings-Premium rule (Federal Register 2026-07666)`); + lines.push(`**Submitted by:** [your name and affiliation]`); + lines.push(`**Date:** ${today}`); + lines.push(`**Institution context:** ${result.instnm} (UNITID ${result.unitid}, ${result.stabbr})`); + lines.push(``); + lines.push(`---`); + lines.push(``); + lines.push(`> **Reader notice.** This outline was generated by the public CSU Deans EP Tool`); + lines.push(`> (https://github.com/AJBcoding/csu-deans-ep-tool). The tool is built by`); + lines.push(`> educators, not attorneys or legislative analysts. Every finding below is a`); + lines.push(`> forward simulation on AHEAD's PPD:2026 negotiator analytic file. The first`); + lines.push(`> official STATS earnings-premium release is scheduled for 2027-07-01.`); + lines.push(`> Independently verify every citation against the primary sources before`); + lines.push(`> filing this comment.`); + lines.push(``); + lines.push(`---`); + lines.push(``); + + lines.push(`## 1. Findings at this institution`); + lines.push(``); + if (failing.length > 0) { + lines.push(`### Programs failing the EP test`); + lines.push(``); + for (const p of failing) { + lines.push(`- **${p.cip4_title}** (CIP ${p.cip4} · ${p.credlev}) — verdict: **FAIL** at ${pct(p.ep_gap_pct)} (cohort n=${p.cohort_count ?? '—'}; median earnings ${money(p.median_earn_p4)} vs benchmark ${money(p.benchmark)} via ${p.benchmark_route}).`); + } + lines.push(``); + } else { + lines.push(`No programs at this institution surface a FAIL verdict in the simulation.`); + lines.push(``); + } + + if (noiseBand.length > 0) { + lines.push(`### Programs in the noise band (verdict could read either way under privacy noise)`); + lines.push(``); + for (const p of noiseBand) { + lines.push(`- **${p.cip4_title}** (CIP ${p.cip4} · ${p.credlev}) — current verdict ${p.verdict} at ${pct(p.ep_gap_pct)}; gap is within ±15% of zero.`); + } + lines.push(``); + } + + if (notMeasured.length > 0) { + lines.push(`### Programs not measured (cohort suppression, invisibility, or out-of-scope)`); + lines.push(``); + for (const p of notMeasured) { + lines.push(`- **${p.cip4_title || '(unknown)'}** (CIP ${p.cip4} · ${p.credlev}) — reason: ${p.not_measured_reason ?? 'unspecified'}.`); + } + lines.push(``); + } + + lines.push(`---`); + lines.push(``); + lines.push(`## 2. Mechanism context (citations to the proposed rule)`); + lines.push(``); + if (Array.isArray(result.panels)) { + for (const panel of result.panels) { + lines.push(`- **${panel.id}** — ${panel.header}`); + } + } + lines.push(``); + lines.push(`Trigger reason for each: see Mechanism Library at /learn/. The full text of each panel — including the primary-source citations — appears in the tool's content/mdocs/ directory.`); + lines.push(``); + lines.push(`---`); + lines.push(``); + + lines.push(`## 3. Suggested topics for substantive comment (you fill in)`); + lines.push(``); + lines.push(`The tool does not write substantive comment text. The headers below are`); + lines.push(`scaffolds for the topics this institution's findings tend to surface.`); + lines.push(`Each is a placeholder — write your own substantive position.`); + lines.push(``); + lines.push(`### 3.1 What this institution loses if the rule takes effect as written`); + lines.push(``); + lines.push(`[Describe the program-level and student-level impact in your own words.`); + lines.push(`Reference the failing programs above; explain who they serve, what they`); + lines.push(`teach, and why earnings-premium is or is not the right outcome metric for`); + lines.push(`those programs at this institution.]`); + lines.push(``); + + if (failing.length > 0) { + lines.push(`### 3.2 The institution-level § 668.16(t) escalation pathway`); + lines.push(``); + lines.push(`[The Mechanism Library panel M18 walks through the five legal frames the`); + lines.push(`Department's design must answer (Loper Bright + major-questions, OIRA`); + lines.push(`Circular A-4 § 1, § 6, § E, and Motor Vehicle Mfrs. v. State Farm). If`); + lines.push(`you have a legal position on the institution-level escalation design, this`); + lines.push(`is where to put it. The tool does not take a legal position; it surfaces`); + lines.push(`the questions for you to answer or to ask counsel about.]`); + lines.push(``); + } + + lines.push(`### 3.3 Cohort-floor methodology (cp-wssr finding)`); + lines.push(``); + lines.push(`[The OBBBA § 84001(c)(2)(A) statutory floor is 30 graduates; AHEAD's`); + lines.push(`single-window count_wne_p4 may show fewer at this institution. Mechanism`); + lines.push(`Library panel M01 walks through the AHEAD-published-flag override the tool`); + lines.push(`applies. If your institution has cells in the 16-30 single-window range`); + lines.push(`with PPD-published flags, you may want to address how the cohort-floor`); + lines.push(`mismatch interacts with appeal scope under proposed § 668.603.]`); + lines.push(``); + + lines.push(`### 3.4 Disparate impact on named populations`); + lines.push(``); + lines.push(`[Mechanism Library panel M14 covers RIA Table 3.19 named-population`); + lines.push(`elevation. If this institution serves named populations (HBCU, MSI, HSI,`); + lines.push(`AANAPISI, or programs in CIP 50.05/50.07/50.09/51.15) and the rule's`); + lines.push(`elevation factor differs from the prose summary in the RIA, this is where`); + lines.push(`to address it.]`); + lines.push(``); + + lines.push(`---`); + lines.push(``); + lines.push(`## 4. Verification recipes — how each number above can be reproduced`); + lines.push(``); + if (failing.length > 0) { + lines.push(`Each failing program above can be reproduced WITHOUT this tool, using only`); + lines.push(`the AHEAD codebook + the Department's published file. Recipes:`); + lines.push(``); + for (const p of failing) { + const r = p.verification_recipe; + if (!r) continue; + lines.push(`### ${p.cip4_title} (CIP ${p.cip4} · ${p.credlev})`); + lines.push(``); + lines.push(`- **Source data:** ${r.source_data}`); + lines.push(`- **Source grain:** ${r.source_grain}`); + lines.push(`- **Engine reference:** \`${r.engine_reference}\``); + lines.push(`- **Source fields:** ${r.source_fields.join('; ')}`); + lines.push(`- **Steps:**`); + for (const step of r.steps) { + lines.push(` 1. ${step}`); + } + lines.push(``); + } + } + + lines.push(`---`); + lines.push(``); + lines.push(`## 5. References`); + lines.push(``); + lines.push(`### Primary sources cited above`); + lines.push(``); + lines.push(`- AHEAD PPD:2026 — Program Performance Data file 112908 (Department of Education / NCES)`); + lines.push(`- IPEDS Completions Survey (NCES)`); + lines.push(`- OBBBA — Public Law 119-21, § 84001 (program-level EP test); § 81001 (graduate loan caps)`); + lines.push(`- Higher Education Act § 498 (administrative-capability authority)`); + lines.push(`- STATS NPRM — Federal Register 2026-07666 (proposed § 668.2(b), § 668.16(t), § 668.402(c)(3), § 668.403(d)(1), § 668.404(d), § 668.43(d)(1), § 668.603; RIA Tables 3.1, 3.9, 3.19)`); + lines.push(`- OIRA Circular A-4 (2003) — § 1 problem statement; § 6 alternatives; § E uncertainty`); + lines.push(`- *Loper Bright Enterprises v. Raimondo*, 603 U.S. ___ (2024)`); + lines.push(`- *Motor Vehicle Mfrs. Ass'n v. State Farm Mut. Auto. Ins. Co.*, 463 U.S. 29 (1983)`); + lines.push(``); + lines.push(`### Tool reference`); + lines.push(``); + lines.push(`- This outline generated by the CSU Deans EP Tool — https://github.com/AJBcoding/csu-deans-ep-tool (MIT license).`); + lines.push(`- Public Sources page: /sources.html.`); + lines.push(`- Mechanism Library: /learn/.`); + lines.push(``); + lines.push(`---`); + lines.push(``); + lines.push(`**End of DRAFT outline.** Edit substantively before filing. Verify every citation against the primary source. The tool is not a legal product, not legal advice, and not a filed position of any institution.`); + lines.push(``); + + return lines.join('\n'); +} + +async function fetchAnalysis(unitid) { + const res = await fetch(`/api/v1/analysis/${unitid}`); + if (!res.ok) { + throw new Error(`API ${res.status}`); + } + return await res.json(); +} + +let lastOutline = ''; +let lastUnitid = ''; + +if (form && outlineRegion && outlinePre) { + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const data = new FormData(form); + const unitid = String(data.get('unitid') ?? '').trim(); + if (!/^[0-9]+$/.test(unitid)) { + setStatus('UNITID must be all digits.', true); + return; + } + setStatus(`Fetching analysis for UNITID ${unitid}…`); + outlineRegion.hidden = true; + try { + const result = await fetchAnalysis(unitid); + lastOutline = buildOutline(result); + lastUnitid = unitid; + outlinePre.textContent = lastOutline; + outlineRegion.hidden = false; + setStatus(`Outline generated for ${result.instnm} (${result.unitid}). Edit and verify before filing.`); + } catch (err) { + setStatus(`Could not fetch analysis: ${String(err)}`, true); + } + }); +} + +if (copyBtn) { + copyBtn.addEventListener('click', async () => { + if (!lastOutline) return; + try { + await navigator.clipboard.writeText(lastOutline); + setStatus('Outline copied to clipboard.'); + } catch (e) { + setStatus('Copy failed; select-all the text below instead.', true); + } + }); +} + +if (downloadBtn) { + downloadBtn.addEventListener('click', () => { + if (!lastOutline) return; + const blob = new Blob([lastOutline], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `comment-outline-${lastUnitid}.md`; + a.click(); + URL.revokeObjectURL(url); + }); +} diff --git a/web/js/learn.js b/web/js/learn.js new file mode 100644 index 0000000..836f9d7 --- /dev/null +++ b/web/js/learn.js @@ -0,0 +1,79 @@ +// learn.js — Mechanism Library page (cp-j0gw.9 + .10) +// Loads each M-doc HTML inline and wires the reading-depth toggle. + +const PANEL_ORDER = [ + 'M01', 'M03', 'M04', 'M07', + 'M05', 'M06', 'M18', + 'M12', 'M13', 'M14', + 'M02', 'M08', 'M09', 'M10', 'M11', 'M15', 'M17', +]; + +const DEPTH_KEY = 'csu-ep-tool:reading-depth'; +const VALID_DEPTHS = new Set(['brief', 'standard', 'detail']); + +async function loadPanel(id) { + // In dev-server: /content/mdocs/* served from /content/mdocs/*. + // In dist-pages build: content/ is copied alongside web/ outputs. + const res = await fetch(`./content/mdocs/${id}.html`); + if (!res.ok) { + throw new Error(`Failed to load ${id}: HTTP ${res.status}`); + } + return await res.text(); +} + +async function renderPanels() { + const region = document.getElementById('panel-region'); + if (!region) return; + region.innerHTML = ''; + const fragments = await Promise.all( + PANEL_ORDER.map(async (id) => { + try { + const html = await loadPanel(id); + return `
${html}
`; + } catch (err) { + return `
Could not load ${id} — ${String(err)}
`; + } + }), + ); + region.innerHTML = fragments.join('\n'); +} + +function applyDepth(depth) { + if (!VALID_DEPTHS.has(depth)) return; + document.body.setAttribute('data-depth', depth); + try { + localStorage.setItem(DEPTH_KEY, depth); + } catch (e) { + // localStorage unavailable (private mode) — ignore. + } + for (const input of document.querySelectorAll('.depth-toggle input[name="depth"]')) { + input.checked = input.value === depth; + } +} + +function initDepthToggle() { + let initial = 'standard'; + try { + const stored = localStorage.getItem(DEPTH_KEY); + if (stored && VALID_DEPTHS.has(stored)) initial = stored; + } catch (e) { + // ignore + } + applyDepth(initial); + document.querySelectorAll('.depth-toggle input[name="depth"]').forEach((input) => { + input.addEventListener('change', (ev) => { + const target = ev.target; + if (target instanceof HTMLInputElement && target.checked) { + applyDepth(target.value); + } + }); + }); +} + +initDepthToggle(); +renderPanels().catch((err) => { + const region = document.getElementById('panel-region'); + if (region) { + region.innerHTML = `

Failed to render mechanism panels — ${String(err)}

`; + } +}); diff --git a/web/js/render.js b/web/js/render.js index fb6c1d9..75472b5 100644 --- a/web/js/render.js +++ b/web/js/render.js @@ -124,6 +124,23 @@ export function renderVerdictCard(program) { )}.

` : ''; + const derivationLabel = { + ppd_published_authoritative: + 'Verdict carried by AHEAD\'s published per-cell flag (regulatory-authoritative; cp-wssr override).', + tool_re_derived: + 'Verdict computed by this tool from PPD-published earnings + benchmark; AHEAD did not publish a flag.', + not_measured: 'Insufficient inputs — no verdict possible. See reason above.', + }; + const derivation = program.derivation_basis + ? `

Verdict basis: ${escapeHtml( + derivationLabel[program.derivation_basis] ?? program.derivation_basis, + )}

` + : ''; + + const recipe = program.verification_recipe + ? renderVerificationRecipe(program.verification_recipe) + : ''; + return `
@@ -135,15 +152,45 @@ export function renderVerdictCard(program) { ${noiseBandNote} ${notMeasuredNote} ${xv} + ${derivation} ${renderPanelTriggerList(program.panels_triggered)}
Why this verdict — rules fired ${renderRuleList(program.rules_fired)}
+ ${recipe}
`; } +export function renderVerificationRecipe(recipe) { + if (!recipe) return ''; + const fields = (recipe.source_fields ?? []) + .map((f) => `
  • ${escapeHtml(f)}
  • `) + .join(''); + const steps = (recipe.steps ?? []) + .map((s) => `
  • ${escapeHtml(s)}
  • `) + .join(''); + return ` +
    + How to reproduce this number — verification recipe +
    +
    Source data
    ${escapeHtml(recipe.source_data)}
    +
    Source grain
    ${escapeHtml(recipe.source_grain)}
    +
    Engine reference
    ${escapeHtml(recipe.engine_reference)}
    +
    +

    Source fields:

    +
      ${fields}
    +

    Steps:

    +
      ${steps}
    +

    + We are not attorneys or legislative analysts. Independently verify + every number against the source-data and engine code above. +

    +
    + `; +} + export function renderIntegrityEnvelope(env) { if (!env) return ''; const s = env.data_status_summary ?? { @@ -168,6 +215,16 @@ export function renderIntegrityEnvelope(env) {
    Disagreements with the Department's pre-computed verdict
    ${formatInt(env.cross_validation_disagreements)}

    ${escapeHtml(env.primary_source_reminder)}

    + ${ + typeof env.simulation_framing === 'string' && env.simulation_framing.length > 0 + ? `

    Simulation framing: ${escapeHtml(env.simulation_framing)}

    ` + : '' + } + ${ + typeof env.expertise_disclaimer === 'string' && env.expertise_disclaimer.length > 0 + ? `

    About this tool: ${escapeHtml(env.expertise_disclaimer)}

    ` + : '' + }
    `; } diff --git a/web/learn.html b/web/learn.html new file mode 100644 index 0000000..5b0e93d --- /dev/null +++ b/web/learn.html @@ -0,0 +1,120 @@ + + + + + + Mechanism Library — CSU Deans EP Tool + + + + + + + + + + +
    +
    +

    What's in here

    + + + + + + + + +
    + +
    +

    Loading mechanism panels…

    +
    +
    + + + + + + diff --git a/web/persona-a.html b/web/persona-a.html index 2a71a34..1dc4038 100644 --- a/web/persona-a.html +++ b/web/persona-a.html @@ -5,13 +5,33 @@ Check specific programs — CSU Deans EP Tool + + +