Skip to content

ADR-0001: Collapse duplicate course_ids to one representative row

Date: 2026-06-02 Status: Accepted

Context

The plan (recommender_plan.md §0) specifies course_id = f"{Subject} {Course Number}" and treats it as a stable, unique key — it is the recommendation target, the index, and the key used to match cross-listing ground truth. In practice the catalog violates this: 16 ids appear on 34 rows. Some are byte-identical, others differ in description, title, units, or cross_listed. A non-unique key would silently break index lookups, eval matching, and any recommend_similar seed exclusion.

Decision

Deduplicate on course_id in the loader, keeping one representative per id: the row with the longest text field (most descriptive signal), ties broken by first occurrence for determinism. Before dropping, coalesce cross_listed across the group (forward/back-fill) so no ground-truth cross-listing edge is lost when the kept row happens to be the one missing it. This reduces the catalog from 11,091 raw rows to 11,073 unique courses.

Alternatives considered

Option Pros Cons Why rejected
Keep duplicates, accept non-unique index No data loss Breaks index lookups, eval matching, seed exclusion; non-deterministic .loc Unique key is load-bearing
Disambiguate ids (append a suffix) Lossless Invents ids that don't exist in the catalog; pollutes ground-truth matching Cross-listings reference the real id
Drop only exact duplicates Simple Leaves 9 ids still colliding (differ in text/units) Doesn't actually make the key unique
Keep first occurrence blindly Trivial Order-dependent; may keep the emptier row and lose a cross-listing Determinism + ground-truth preservation matter

Consequences

Positive: course_id is a genuine unique key — index lookups, eval ground-truth matching, and seed exclusion are all safe. Ground-truth edges are preserved via coalescing. Deterministic (no seed needed). Negative: 18 rows are discarded; a true distinct-section course sharing an id (e.g. cross-campus MBA 258 vs EWMBA 258 variants) loses its alternate text. This is acceptable at 0.16% of the catalog. Neutral: Downstream row count is 11,073, not the plan's 11,091; tests and docs assert the deduplicated figure.

Implementation notes

src/courserec/data.py::_deduplicate, called from load_raw after set_index. Coalesce uses groupby(level=0)["cross_listed"].transform(ffill().bfill()); representative selection sorts by text length descending and keeps first. tests/test_data.py::test_course_ids_unique and test_row_count lock the invariant.