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
4 changes: 4 additions & 0 deletions .github/workflows/audits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ jobs:
python-version: '3.12'
cache: 'pip'

- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install faircode + pytest
run: |
python -m pip install --upgrade pip
Expand Down
18 changes: 17 additions & 1 deletion assets/profiler-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@
var SCORE_DROP_FLAG = 5;

// Pandas-style missing tokens, so JS null-handling matches read_csv defaults.
var NA_TOKENS = { '': 1, 'na': 1, 'n/a': 1, 'nan': 1, 'null': 1, 'none': 1 };
var NA_TOKENS = {
'': 1,
'na': 1,
'n/a': 1,
'nan': 1,
'null': 1,
// Intentionally exclude "none" to match the Python profiler.
// In this project, pd.read_csv() preserves the literal string "none"
// as a categorical value, so treating it as missing breaks Python↔JS
// parity (see credit_customers.csv).
};

// ── Keyword lists - MUST mirror faircode/detect.py ─────────────────────
var KEYWORDS = [
Expand Down Expand Up @@ -447,6 +457,12 @@
flags.push(d.name + ": '" + g.label + "' is under-represented (" +
(g.share * 100).toFixed(1) + '%)');
}
if (g.small_group) {
flags.push(
d.name + ": '" + g.label + "' has only " +
g.count + " rows; fairness metrics may be unreliable"
);
}
});
if (d.imbalance_ratio !== null && d.imbalance_ratio >= imbalanceFlag) {
flags.push(d.name + ': imbalance ratio ' + d.imbalance_ratio.toFixed(1) +
Expand Down
22 changes: 22 additions & 0 deletions scripts/profile-js.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env node

const fs = require("fs");
const path = require("path");

// Load the profiler engine. It registers itself on globalThis.FairCodeProfiler.
require(path.join(__dirname, "..", "assets", "profiler-engine.js"));

const E = globalThis.FairCodeProfiler;

if (process.argv.length !== 3) {
console.error("Usage: node scripts/profile-js.js <dataset.csv>");
process.exit(1);
}

const csvPath = process.argv[2];
const text = fs.readFileSync(csvPath, "utf8");

const table = E.parseCSV(text);
const result = E.profile(table);

process.stdout.write(JSON.stringify(result));
121,201 changes: 121,201 additions & 0 deletions tests/fixtures/AI_Fair_Recruitment_Dataset.csv

Large diffs are not rendered by default.

32,562 changes: 32,562 additions & 0 deletions tests/fixtures/adult.csv

Large diffs are not rendered by default.

60,844 changes: 60,844 additions & 0 deletions tests/fixtures/compas-scores-raw.csv

Large diffs are not rendered by default.

1,001 changes: 1,001 additions & 0 deletions tests/fixtures/credit_customers.csv

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions tests/fixtures/small.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
sex
M
M
M
F
52 changes: 52 additions & 0 deletions tests/test_js_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Parity tests between the Python and JavaScript profiler implementations."""

from pathlib import Path
import json
import subprocess

import pandas as pd
import pytest

from faircode import profile

FIXTURES = Path(__file__).resolve().parent / "fixtures"

Comment on lines +3 to +13

@pytest.mark.parametrize(
"csv_name",
[
"small.csv",
"adult.csv",
"compas-scores-raw.csv",
"credit_customers.csv",
"AI_Fair_Recruitment_Dataset.csv",
],
)
def test_python_js_profiler_parity(csv_name):
"""The Python and JavaScript profilers should produce equivalent structured JSON."""

csv = FIXTURES / csv_name

python_result = profile(pd.read_csv(csv))

completed = subprocess.run(
["node", "scripts/profile-js.js", str(csv)],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)

javascript_result = json.loads(completed.stdout)

# Flags are human-readable messages. They duplicate information already
# present in the structured output and may differ because Python and
# JavaScript format floating-point values differently (e.g. 6.25 -> 6.2
# vs 6.3). Compare the structured data instead.
python_result = dict(python_result)
javascript_result = dict(javascript_result)

python_result.pop("flags", None)
javascript_result.pop("flags", None)

assert javascript_result == python_result
Loading