Repository navigation
Conversation
Adverse impact ratios per protected attribute, computed on the validation split inside creditboost-train, stamped into ModelMetadata, and gated at the four-fifths threshold of 0.80. A probe against the shipped model drove the central design decision. With adverse defined as the high band, an applicant under 62 is denied at 9.19x the rate of one aged 62 or over -- and the four-fifths test returns 0.974, a comfortable pass. At a 97% approval rate the arithmetic compresses every ratio toward 1.0, so the conventional test cannot detect anything, and a gate built on it would manufacture documented assurance it had not established. Defining adverse as "not banded low" restores sensitivity and keeps both the literal rule and its 0.80 threshold, grounded in Reg B's definition of adverse action as covering credit on substantially different terms. Under that definition the shipped model measures 0.868 on sex, 0.818 on marital status, and 0.810 on age -- a one-point margin, which is the point: a gate that cannot fail proves nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six tasks: the fairness schema, the measurement module, making protected attributes available to training, the metadata field and training gate, the retrain and release, and docs. Two things the spec left implicit and the plan pins down. fit() does not return validation predictions, so main recomputes them rather than changing a signature tests depend on. And making ModelMetadata.fairness required breaks five ModelMetadata construction sites across the tests, which take a shared conftest helper rather than five copies of a literal. Task 4 leaves docker build red until Task 5 releases model-v0.3.0; that window is documented up front rather than discovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An attribute is either measured or carries a reason it was not, never both and never neither: 'not measured' reading as 'passed' is the failure that would make the whole report worthless. The ratio is bounded to [0, 1] because min/max over favourable rates cannot exceed 1 -- a value above it means the ratio was computed upside down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adverse impact ratio per protected attribute, min/max over favourable rates, with adverse defined as not-banded-low rather than banded-high. On approvals the four-fifths test cannot discriminate at a 97% approval rate: an applicant under 62 is denied at 9.19x the rate of one aged 62 or over and the test returns 0.974. Reg B treats credit on substantially different terms as adverse action, so a medium band is adverse. Age buckets at the ECOA line of 62, and an unknown age is excluded rather than filed as young -- NaN >= 62 is False, which would report a rate for applicants whose age is not known. Measurement and enforcement are separate functions so the gate can be tested against constructed reports without training anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The import-linter contract's source_modules list was missing three runtime-side modules: creditboost.lockfile, creditboost.artifact_cli, and creditboost.reasons. Earlier milestones' edits to this list silently no-opped -- a string replace whose anchor never matched -- and lint-imports kept passing because a narrower contract is still a valid one to check; the intended registrations only ever landed on an abandoned branch. This mattered most for artifact_cli: it runs inside the Docker builder stage, which installs the base package only and has no scikit-learn, and the Milestone 2 spec claims this contract is what guarantees the module can be imported there. That guarantee was false -- an import of data.py or train.py from artifact_cli would not have been caught until the Docker build crashed. Verified the gap is closed, not just quieted: a guarded `from .data import split` temporarily appended to artifact_cli.py made lint-imports report the contract BROKEN (exit 1, chain: creditboost.artifact_cli -> creditboost.data), then KEPT again (exit 0) once reverted, with no leftover diff. A check that has never failed proves nothing, which is exactly how this gap survived two milestones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CODE_GENDER is required in training data and still never accepted from a caller. The fixture gains a third column category alongside model features and monitoring-only request fields: analysis-only columns, present so outcomes can be measured across groups. The gender split is deterministic and exactly balanced rather than drawn. 200 rows split evenly gives exactly the default minimum group size, so a random 95/105 would make fairness measurement on the fixture flaky; alternating by index also consumes no rng, so no existing column moved -- verified against the previous fixture rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Training measures the adverse impact ratio on the validation split after the AUC gate and refuses to write a model failing any measured attribute, logging each group's rate and size so the refusal is actionable. There is no override flag, for the same reason the AUC floor has none. ModelMetadata.fairness is required rather than optional: optional would make 'every shipped model has measured disparate impact' a hope rather than a guarantee. Five test construction sites take a shared conftest helper. NOTE: docker build fails from this commit until model-v0.3.0 is released. That is artifact.load correctly refusing a model-v0.2.0 artifact that carries no fairness report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adverse impact ratios, adverse defined as not-banded-low, all clear of the 0.80 floor: sex 0.86841, marital status 0.81785, age 0.80996. Age clears by 0.00996 -- a one-point margin, which is the point. A gate that cannot fail proves nothing. roc_auc 0.75307, unchanged. Nothing affecting training changed this milestone -- same features, hyperparameters and seeded split -- so this is in substance a metadata refresh. That is now proven rather than argued: model.json's sha256 is byte-identical to model-v0.2.0's (019cb131...), and only model_meta.json's digest moved. Closes the build-red window opened by the previous commit. model-v0.2.0 remains released but can no longer be loaded by this code, which is the skew gate working as intended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents why the four-fifths rule is applied to not-banded-low rather than to approvals: at a 97% approval rate the conventional form passes a 9.19x age disparity at 0.974. Records the band-policy rule as an explicit documentation-only invariant rather than leaving the gap implicit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a spy test proving evaluate() receives the aligned validation frame and its own predictions -- the one property the fixture's small split lets every other test miss. Corrects the false "enables measurement" rationale on the fixture's deterministic gender split, warns (without gating) when an attribute comes back unmeasured, derives the fixture's analysis-only columns from config.FAIRNESS_ATTRIBUTES instead of a hardcoded tuple, guards FAIRNESS_ATTRIBUTES against FEATURE_ORDER the same way PROTECTED_ATTRIBUTES already is, notes size-excluded groups in the gate's failure log, fixes a stale dict annotation and a one-sided ratio-bound test, and corrects the stale spec/plan count in CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
creditboost-trainnow measures the adverse impact ratio of every model across protected groups, stamps the result into the artifact, and refuses to write one that falls below the four-fifths threshold of 0.80.Implements
docs/superpowers/specs/2026-09-02-creditboost-disparate-impact-design.mdtask-by-task, test-first, one commit per task.Why the conventional four-fifths test would have been theatre
Milestone 3 established that no protected attribute is a model feature. That is a statement about inputs, and it is not fairness — neutral features act as proxies, which is why disparate impact is a distinct doctrine from disparate treatment.
The obvious way to measure it does not work here. The four-fifths rule comes from UGESP, written for employment selection where selection rates are moderate. This model approves 97.3% of applicants, and at that rate the arithmetic compresses every ratio toward 1.0. Measured against the shipped model with adverse defined as the
highband:An applicant under 62 is denied at more than nine times the rate of one aged 62 or over, and the test returns 0.974. A gate built that way would pass essentially any model — worse than no gate, because it manufactures documented assurance in exactly the artifact a compliance reader would trust.
Defining the adverse outcome as not banded low — the applicant is not auto-approved — restores sensitivity while keeping both the literal rule and its 0.80 threshold. It is grounded rather than convenient: Regulation B defines adverse action to include credit granted "on substantially the terms requested," so a
mediumband is adverse action.Results
model-v0.3.0, measured on the 61,503-row validation split:CODE_GENDERNAME_FAMILY_STATUSDAYS_BIRTH(62+ vs under 62)roc_auc0.75307, unchanged. Age clears by one point, which is the design working: a gate that cannot fail proves nothing, and the response to a future failure is investigation, not adjusting the constant.What changed
fairness.py— computes the ratio asmin/maxover favourable rates. Inverted, a failing model reads as passing and the milestone is decorative, so one test exists solely to catch that direction.NaN >= 62is False, which would otherwise report a rate for applicants whose age is not known.ModelMetadata.fairnessis required, not optional. Optional would make "every shipped model has measured disparate impact" a hope rather than a guarantee. Enforced at three boundaries: training,artifact.load, andverify_artifactinside the Docker builder.adverse_impact_ratioandunmeasured_reasonis set, andfailing_attributesnever returns an unmeasured one.CODE_GENDERis now required in training data and still never accepted from a caller.FAIRNESS_ATTRIBUTESis a requirement,PROTECTED_ATTRIBUTESa prohibition; they overlap and are deliberately not merged.FEATURE_ORDERstays at 20,REQUEST_FIELDSat 19./metadataexposes the report with no endpoint change.Also fixes a defect predating this milestone:
lockfile,artifact_cliandreasonswere never registered in the import-linter contract, because earlier string-replace edits silently no-opped and a narrower contract still passes. M2's spec claims that contract is what guaranteesartifact_cliimports cleanly in the Docker builder where scikit-learn is absent — that claim was false until this branch. Verified by injecting a guarded forbidden import and confirming the contract now reports BROKEN.Verification
The final whole-branch review independently reproduced the stamped ratios from the released
model.jsonagainst the real validation split with its own positional numpy implementation — identical to the last digit. Andmodel.json's sha256 is byte-identical tomodel-v0.2.0's, so this is provably a metadata refresh rather than a new model.The load-bearing test was mutation-checked: swapping
valid_framefortrain_frameintrain.pymakestest_evaluate_is_called_with_the_aligned_validation_frame_and_predictionsfail, and it passes again on restore. That seam matters because the fixture's 40-row validation split leaves every group under the minimum, so nothing else would have caught a misalignment — it would have stamped plausible, wrong ratios with no symptom.194 tests,
ruff,mypyandlint-importsall clean.Deliberately deferred
verify_artifact. Real, but the spec scopes enforcement to training time; expanding the ship boundary belongs in its own milestone.NAME_INCOME_TYPEcarriesMaternity leave(a sex proxy) andPensioner(an age proxy) as levels. Recorded since Milestone 3, now measurable but unresolved.A note on what this does and does not claim
An adverse impact ratio below 0.80 establishes a prima facie case a creditor may rebut with business necessity. It is evidence, not proof, and this gate is a conservative engineering control rather than a legal conclusion. The disparities above are observed outcome differences: some part of the age gap is genuine repayment behaviour, and the model uses no age feature at all — any age effect arrives through proxies, which is exactly what this analysis exists to surface.
🤖 Generated with Claude Code