Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ All notable changes to Fair Code are documented here, newest first.
- **Change-aware CI: `profiler`/`benchmark-harness` skip on docs-only changes** - extends the same idea to `.github/workflows/audits.yml`. A new `changes` job diffs the push/PR against its base commit and gates `profiler` (the full pytest suite) and `benchmark-harness` (the fairlearn end-to-end smoke test) behind whether anything test-relevant changed - same path set as the pre-push hook above, kept in sync deliberately. `run-audits` is left untouched and always runs: it's the one job in this workflow that's a required status check (#160), and a job skipped via `if:` can leave a required check permanently "waiting to be reported" instead of green, which would block merging every docs-only PR. `profiler`/`benchmark-harness` aren't required checks, so skipping them is risk-free.
- **Theme-toggle button now exposes its state to assistive tech** (closes #250) - `#themeToggle`/`#explainerThemeToggle` had a dynamic `aria-label` ("Switch to light/dark mode") but nothing a screen reader announces as state, unlike the visible ☀/☾ glyph swap. Added `aria-pressed="true"/"false"` (`true` = dark mode active), kept in sync everywhere the label/glyph already update - initial load and on click - across `index.html`, `profiler.html`, and the explainer-page template in `scripts/build_explainers.py` (`make build-explainers` re-run to regenerate all 39 explainer pages).
- **`scripts/check_broken_links.py`** (closes #253) - checks every tracked `.md` file for a `[text](#anchor)` that doesn't match any heading on that page, or a relative link/anchor to another file that doesn't exist. Wired into `make lint`/`lint.yml` alongside the em-dash check (that job is renamed `lint` accordingly). The anchor slugifier was reverse-engineered against all 71 real anchor links already in the repo (70 matched cleanly; the one holdout is a `#link-to-section` placeholder inside a CONTRIBUTING.md code-fence example, correctly ignored) rather than assumed, since GitHub's exact algorithm isn't public. Running it against the current tree found five real, previously-unnoticed broken links in the raw markdown - three explainers linked a misspelled/nonexistent `Ai Fair Recrutment Dataset` folder instead of `AI Fair Recruitment`, and `proxy-entanglement.md` linked a notebook path missing its `../` prefix - fixed in `explainers/neural-networks.md`, `proxy-variables.md`, `shap-values.md`, and `proxy-entanglement.md`.
- **`faircode/report.py`'s HTML report tables get proper headers** (closes #254) - the per-column breakdown table and the drift-comparison table had no `<th>` header row at all (only the smaller reference-deviation table did, and even that had no `scope`); a screen reader had nothing to announce when reading a data cell. Added a `<thead>`/`<th scope="col">` row plus a `<caption>` to all three tables (in both `to_html` and `compare_to_html`), with matching `th`/`caption` CSS in each report's embedded stylesheet - the caption text stands on its own since the report is often shared outside the page it was generated on (CI output, email, a PR comment).
### Fixed
- **`scripts/build_explainers.py`'s `resolve_link_target()` silently broke every generic cross-repo-root link in an explainer** - found while fixing #253 above: rebuilding after the notebook-link fix showed the generated `.html` still pointing at the old broken path. Its fallback branch stripped a leading `../` from any relative link that wasn't a known explainer `.md` or a recognized project folder, even though `explainers/*.md` and the `.html` generated from it live in the same directory, so a plain relative link needs no rewriting at all. This wasn't just the notebook link - `disparate-impact.md`'s links to `unfair.py`/`fair.py` in `AI Fair Recruitment/` had the exact same bug, already live on the deployed site. Also removed a `PROJECT_ANCHORS` entry that hardcoded the misspelled `"Ai Fair Recrutment Dataset"` folder name as a redirect target - a band-aid for the exact typo fixed at the source above, now dead code.

Expand Down
26 changes: 22 additions & 4 deletions faircode/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,21 @@ def esc(s) -> str:
reference_html = (
f'<div class="reference"><h3>Reference '
f'<span class="kind">deviation {ref["deviation"] * 100:.1f}%</span></h3>'
f'<table><tr><th></th><th class="num">Expected</th>'
f'<th class="num">Actual</th><th class="num">Delta</th></tr>'
f'<table><caption>Expected vs. actual share - {esc(d["name"])}</caption>'
f'<tr><th scope="col"></th><th scope="col" class="num">Expected</th>'
f'<th scope="col" class="num">Actual</th><th scope="col" class="num">Delta</th></tr>'
f'{ref_rows}</table></div>'
)

dim_blocks.append(
f'<section class="dim"><h2>{esc(d["name"])} '
f'<span class="kind">{esc(d["kind"])}</span> '
f'<span class="score">{d["dimension_score"]}/100</span></h2>'
f'<table>{"".join(rows)}</table>{reference_html}</section>'
f'<table><caption>Group breakdown - {esc(d["name"])}</caption>'
f'<thead><tr><th scope="col">Group</th><th scope="col" class="num">Share</th>'
f'<th scope="col" class="num">95% CI</th><th scope="col" class="num">Count</th>'
f'<th scope="col" class="bar"></th></tr></thead>'
f'<tbody>{"".join(rows)}</tbody></table>{reference_html}</section>'
)

flag_html = ""
Expand Down Expand Up @@ -236,6 +241,11 @@ def esc(s) -> str:
.dim {{ background:var(--surface); border:1px solid var(--border); border-radius:8px;
padding:16px 20px; margin:16px 0; }}
table {{ width:100%; border-collapse:collapse; }}
caption {{ text-align:left; font-size:11px; color:var(--muted); text-transform:uppercase;
letter-spacing:.04em; margin-bottom:4px; }}
th {{ padding:4px 8px; font-size:14px; font-weight:600; text-align:left;
border-bottom:2px solid var(--border); }}
th.num {{ text-align:right; }}
td {{ padding:4px 8px; font-size:14px; border-bottom:1px solid var(--border); }}
td.num {{ text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }}
td.ci {{ color:var(--muted); font-size:12px; }}
Expand Down Expand Up @@ -348,7 +358,10 @@ def signed(val: float | int, dp: int = 1) -> str:
f'<span class="drift-badge {cd["drift_level"]}">{esc(cd["drift_level"])} drift</span></h2>'
f'<div class="drift-metrics">PSI {cd["psi"]:.3f} · TVD {cd["tvd"]:.3f} · score {cd["dimension_score_a"]}→{cd["dimension_score_b"]} ({signed(cd["dimension_score_delta"], 0)})</div>'
'</div>'
f'<table>{"".join(rows)}</table>'
f'<table><caption>Group-level share drift - {esc(cd["name"])}</caption>'
f'<thead><tr><th scope="col">Group</th><th scope="col" class="num">Share A → B</th>'
f'<th scope="col" class="num">Δ</th><th scope="col" class="bar"></th></tr></thead>'
f'<tbody>{"".join(rows)}</tbody></table>'
f'{more_html}'
'</section>'
)
Expand Down Expand Up @@ -390,6 +403,11 @@ def signed(val: float | int, dp: int = 1) -> str:
".drift-card-head h2 { margin:0; font-size:18px; } "
".drift-metrics { font-size:12px; color:var(--muted); } "
"table { width:100%; border-collapse:collapse; } "
"caption { text-align:left; font-size:11px; color:var(--muted); text-transform:uppercase; "
"letter-spacing:.04em; margin-bottom:4px; } "
"th { padding:6px 8px; font-size:14px; font-weight:600; text-align:left; "
"border-bottom:2px solid var(--border); } "
"th.num { text-align:right; } "
"td { padding:6px 8px; font-size:14px; border-bottom:1px solid var(--border); } "
"td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; font-size:13px; } "
"td.label { width:25%; } "
Expand Down