UCT ← Back to the explorer's documentation page  ·  source: docs/REPLICATION.md

Replication Log — 2025 Baseline

This document records, in full detail, how the 2025 dataset was produced, so the process can be repeated for future handbook years and audited later.

Baseline statement. The 2025 handbooks represent the initial state of UCT's academic offerings before further credit-load re-think editions are introduced. The git tag baseline-2025 marks the commit that produced this state. All later handbook years are loaded alongside (every table has a year column) and compared against this baseline.


1. Environment

2. Inputs

Raw PDFs live in faculty-handbooks-undergraduate/ and are immutable:

File Content
2025-com-ug.pdf Faculty of Commerce UG handbook (303 pp)
2025-_fees.pdf Student Fees handbook (154 pp)
YYYY-{com,ebe,fhs,hum,law,sci}-ug.pdf, YYYY-_fees.pdf all six faculties + fees, loaded for 2021–2026 (§7–§9)

Convention for future years: YYYY-<fac>-ug.pdf, YYYY-_fees.pdf. Drop the files in, never rename or edit existing ones.

3. Pipeline (exact commands, in order)

python -m extractors.fees.extract --year 2025
python -m extractors.com.extract --year 2025
python build_main_dataset.py --year 2025
python validation/validate.py --year 2025
python build_final_dataset.py --year 2025      # step 5: final-clean layer
python validation/validate_final.py --year 2025  # step 6: final-layer assertions

Steps 1–4 build and check the as-printed layer (exactly what the handbooks print, defects preserved). Steps 5–6 build and check the final-clean layer (main_dataset_final.csv, ideal_student_summary_final.csv): discrepancies resolved by the ordered rule set R0/R3/R1/R2/R4 with the curated per-faculty registers (resolutions/<fac>.csv) — full method and justification in FINAL-DATASET-METHOD.md. The batch runner executes steps 5–6 for every loaded year after the per-year loop (cross-edition rules make the final layer a whole-dataset computation).

Each step is deterministic and re-runnable; interim page dumps go to data/interim/ (gitignored), outputs to data/processed/ (committed), and exception reports to validation/ (committed). --skip-dump reuses an existing page dump during development.

Step 1 — fees extractor (extractors/fees/extract.py)

Section boundaries are found by full-line heading matches ("11. UCT ACADEMIC FEES", "12. UCT ACADEMIC COURSES", "13. RESIDENCES") — full-line, because the table of contents contains the same text with dotted leaders.

Step 2 — Commerce extractor (extractors/com/extract.py)

Pages are classified by their running-header line ("RULES FOR ADVANCED DIPLOMAS" / "PROGRAMMES OF STUDY" → programme parsing; "DEPARTMENTS IN THE FACULTY..." / "FACULTIES AND DEPARTMENTS..." → course catalogue).

Programme blocks. A specialisation starts at a [PLANCODE] line (or an inline Advanced-Diploma heading). The 1–2 title lines above it are reconstructed by walking back to a line starting "Bachelor of…"/"Advanced Diploma…". Umbrella lines ("Bachelor of Commerce Augmented", "… Extended Academic Development") set the variant for subsequent blocks. Plan codes are normalised (O→0, zero-padding) — see Hazards H1.

Curriculum tables. Inside a block, "(First…Fifth) Year Core Modules" headings open year tables; repeated headings for the same year get table_index 2+ (kept, never ideal). Row grammar and structures handled:

Structure Example Encoding in curriculum.csv
Core row ACC1006F Financial Accounting … 18 5 requirement=core
OR pair (line or trailing "OR") INF1002F … OR / CSC1015F … shared choice_group, choice_member 1..k, choice_pick_n=1
AND bundle inside a choice CSC2001F AND CSC2002S OR … same choice_member for bundled rows
Named option blocks Mathematical Statistics Option: vs Applied Statistics Option: one group, member per block, pick 1
Pick-n menu Plus 2 courses from: + rows member per row, choice_pick_n=n
4th-year sub-blocks Core courses (…): / Elective Courses: + "take two options" core rows + menu with instructed pick
Elective slot with credits Four electives at NQF level 6 … 72 6 requirement=elective
Elective slot, credits elsewhere PLUS one elective at 1st year level … credits blank → inferred in assembly
Open elective minimum Any NQF level 7 electives … minimum of 120 credits is_minimum=True
Stated total Total credits per year … 159 (also +168, 144+, >=126, prose "The total credits for year 2 equals 186.") curriculum_totals.csv; prose form never overwrites an inline total

2025 yield: 73 specialisations (33 BBusSc, 34 BCom, 3 AdvDip; 27 regular, 23 augmented, 23 extended), 2,323 curriculum rows, 268 stated totals.

Course catalogue. Entries recognised as CODE TITLE (≥60% uppercase title) confirmed by an NN NQF credits at NQF level N line within 4 lines; labelled fields (Convener / Course entry requirements / Course outline / Lecture times / DP requirements / Assessment) accumulate until the next entry. 2025 yield: 307 courses.

Step 3 — assembly (build_main_dataset.py)

Produces main_dataset.csv — the single source of truth: one row per specialisation × study-year × course-slot carrying degree, credit, course, fee and provenance columns plus the ideal_student boolean.

Ideal-student rules (deterministic):

  1. core rows → taken.
  2. option rows → taken iff choice_member <= choice_pick_n.
  3. elective slots → taken; blank credits inferred as stated_year_total − sum(other ideal rows) when exactly one blank slot exists (credits_inferred=True).
  4. alternative rows and table_index > 1 rows → never taken.
  5. Fees: exact course-code match first; then suffix-variant fallback (MAM1010MAM1010F, BUS4050WBUS4050H), recorded in fee_source; elective slots get median same-level fee × slot_credits/18, flagged estimated_median.

Published-fee matching: labels are parsed to (degree, AD?, normalised specialisation) with an alias table for renames ("Analytics" → "Statistics and Data Sciences", "Finance (Non CA Option)" → "Finance, Investment and Banking", …); postgraduate labels are excluded. Academic-Development fees are published once per specialisation — the block's year-count decides which AD variant it prices (5 year-rows → the 5-year extended BBusSc), method recorded as ad_duration/ad_shared.

Also produces ideal_student_summary.csv: per specialisation-year credits and cost vs the handbook's stated total and the published fee.

Step 4 — validation (validation/validate.py)

Writes credit_check_2025.csv, fee_check_2025.csv, missing_fees_2025.csv.

2025 baseline results:

Check Result
Credit reconciliation 217 OK / 33 MISMATCH / 16 UNRESOLVED_SLOTS (of 266)
Fee reconciliation (5% tolerance) 131 OK / 49 MISMATCH (of 180 matched)
Ideal-student courses without a fee 0
Median fee delta vs published 0.0%

The MISMATCH rows are findings, not failures: most are the handbook's own arithmetic (totals that count both OR branches, exclude a listed row, or a typo like the 382-for-182 stated total on CB025BUS09 year 1) or final-year tables whose choice rule is printed nowhere (the CSC 4th-year menu). They are preserved verbatim in the dataset and listed in the exception reports for the analysis team; decisions to pin them are made in per-extractor overrides files with written rationale, never by editing outputs.

4. Hazard catalogue (all confirmed in 2025 sources)

# Hazard Example Handling
H1 Plan-code typos in TOC CBO18BUS01 (O for 0), CB25BUS09 (9 chars) trust body heading; normalise O→0, zero-pad
H2 Split words from PDF extraction C omputer Science match on codes, not titles
H3 Stray symbols in totals +168, 144+, >=126 tolerated; +/>= sets is_minimum
H4 Composite course codes STA2020F/S, CML1001F/1004S, ECO1011FS resolve to first-listed variant
H5 Suffixless codes MAM1010 fee fallback tries F/S/W/H/Z variants
H6 Credits glued to title/dots …Research** ……142 8, …Report60 8 separator [\s.…]* before credits
H7 Wrapped elective lines "Plus ECO2008S and 1 NQF level 6 course …" / "… 18+ 7" credits continuation line fills the slot
H8 Zero-credit courses CSC2004Z Programming Assessment 0 6 allowed
H9 Prose totals as remnants "The total credits for year 2 equals 186." after an unrelated table fill-only, never overwrite
H10 Handbook arithmetic quirks total counts both OR branches (CB004INF01 y1) surfaced in credit_check, not "fixed"
H11 Stated-total misprint CB025BUS09 y1 "Total … 382" adjudicated in resolutions/com.csv (COM-2025-008), resolved in the final layer
H12 Un-labelled choice menus CSC 4th-year module list adjudicated in resolutions/com.csv (accept_stated), resolved in the final layer
H13 Character-interleaved fee rows p.82 of fees book, two rows merged de-interleaved via extractors/fees/overrides.py
H14 Fees-book code variants ACC1011N (R600 exam-only rows), X codes kept in course_fees; costing joins exact codes
H15 PG fee rows bleeding into UG labels part-time year rows after dotted fees dotted-label fallback; PG labels excluded from matching
H16 AD fees published once for two variants 5-year block vs 4-year augmented duration-aware matching (ad_duration)
H17 Specialisation renames between books fees "Analytics" vs handbook "Statistics and Data Sciences" alias table in build_main_dataset.py

5. Adding a new handbook year — batch processing

Drop the year's PDFs (YYYY-<fac>-ug.pdf for each faculty, plus YYYY-_fees.pdf) into faculty-handbooks-undergraduate/, then:

python run_pipeline.py --years all        # or --years 2027, or --years 2021-2027

The runner executes fees → every faculty extractor whose PDF is present → assemble → validate per year, then the final-clean layer for all loaded years, and prints a yield summary. Writers are merge-by-year: re-running a year replaces exactly that year's rows in the processed CSVs and leaves every other year byte-identical (verified for the 2025 baseline on every change). Then:

  1. Compare the new year's yields to the table in §6 — a large drop means the layout changed; check the header families first (Hazards H18-H20).
  2. New specialisation names/renames → extend SPEC_ALIASES; print defects → the relevant overrides file with reason and source page.
  3. Commit the updated CSVs and validation reports; existing years' rows must show no diff.

6. Commerce yield reference (per-edition, as-printed layer)

Per-edition COM yields for regression comparison when parsers change: 73/74/75/75/73/71 specialisations and 2,434/2,468/2,491/2,318/2,323/2,248 curriculum rows for 2021→2026. Current cross-faculty status lives in §8. The credit re-think is visible directly, e.g. CB019BUS01 (BCom Actuarial Science) year 1: 185 credits (2021-2023) → 180 credits (2024-2026), with computed ideal fees equal to the published fee to the rand in 2021-2025.

Layout drift encountered and handled across editions:

# Hazard Edition Handling
H18 Title-Case running headers; catalogue section renamed "Departments offering courses to the Faculty of Commerce" 2024 case-insensitive page classification + extra header family
H19 Plan codes printed inline at the end of the title line (Title Case and UPPERCASE) 2024 generalised inline-heading rule; case-insensitive degree parsing
H20 Per-degree/per-department running headers ("BACHELOR OF COMMERCE AUGMENTED 15"), no "Programmes of Study" header 2026 degree/department header families; variant read from the page header
H21 "NQF credits at HEQSF level N" wording 2026 credits-line accepts NQF and HEQSF
H22 Trailing brackets on plan-code lines [CB003BUS01][SAQA ID:4411] (2021/2022) unified heading rule tolerates extra brackets
H23 Wrapped heading tails "…specialising in Quantitative ⏎ Finance [CB025BUS09]" (2024) pre-bracket tail joined to buffered title lines
H24 Markers/misprints inside the code bracket [CB011ECO03#] (2022/2023), [CB0015ECO03] extra zero (2024) bracket grammar tolerates #/*; extra-zero normalisation
H25 Unseparated / comma-format published fees R 84690 (2023), R 68,900 (2024) sec-11 amount grammar accepts all three formats
H26 Mixed year-heading nouns within one edition EBE 2021: "Second Year Core Modules" inside a "Core Courses" book EBE heading grammar accepts both nouns
H27 Year headings with parenthetical suffixes EBE: "First Year Core Courses (from 2020)", "… (EE)" optional trailing parenthetical in the heading grammar
H28 Pages with no running header (bare page number) EBE 2023 in-faculty departments section (107 pages) content-signature classification + sandwich fill for bare-number pages
H29 Level-based totals with two wordings LAW: "Total credits for Preliminary Level … 144" vs "… for first (Preliminary) year … 144"; stream grand totals must NOT match faculty total_line override; grand totals excluded by pattern
H30 Slot credits on a continuation line LAW: "Two semester courses in a single language, …" + "…… 36 5" extra_elective_nocred + credits-continuation merge
H31 Section running header mislabelled by the book LAW 2026 prints "COURSE OUTLINES (LLB)" over the rules-section pages content reclassification (content_reclassify): programme signatures override the header
H32 Multi-code programme blocks with shared curricula FHS: "[MB014, MB020]", "[BSc Audiology MB011/MB019 & BSc Speech-Language Pathology MB010/MB018]" bespoke FHS parser: primary code carries the curriculum, siblings noted; per-segment degree pairing
H33 Trailing totals with slashed variant values FHS: rows print BEFORE "Total NQF credits for year 2 … 162/168" rows buffer until the next total/heading stamps their year; slash → credits + stated_total_max
H34 Multi-line code brackets and programme-vs-plan code mixes FHS 2021-2023: "[Programme code: MB003 or MB016 … Plan code: MB003AHS09." closing bracket optional; 10-char plan codes take precedence over 5-char programme codes they subsume
H35 Rule-code families re-assigned wholesale between editions COM 2023→2024: FBE = BBusSc-ActSci-AD becomes BCom, FBF = BCom becomes BCom-ActSci, FBG/FBH/FBI likewise; EBE 2025 renumbers FB8.2-FB8.14 rules layer keys on the degree heading text, never the rule code (degree_rules.csv carries the code as provenance only)
H36 Rule sentences wrap mid-clause; three surface forms COM "…528 NQF credits of ⏎ which 120 NQF credits…"; "of which"/"with a minimum of"; "will be"/"must be"; HEQSF (2021); "120NQF" missing space (2024) whitespace-collapsed multi-line windows + tolerant grammar in common/degree_rules.py
H37 Year headings with trailing footnote markers EBE 2025/2026: "First Year Core Courses (EE)*†" marker-tolerant year-heading grammar; unrecognised, the whole year-table is silently lost (EE/EC 2025-2026 year 1-2)
H38 In-year elective menus and optional courses counted as core EBE "(Ordinal) Year (Further) Elective Core Courses (EE/EC/ME)", "Optional Courses"; totals "Total credits per year (minimum) … 138" elective_core_heading/optional_heading → alternative rows; select-instructions → min-credit slot or pick-n menu; "(minimum)" totals accepted
H39 Elective slot lines in five shapes, incl. level-range tails "Electives … 18", "Elective … 18 5", "F/S/P/L *Approved Complementary Studies Elective … 18 7", "…Open electives …Totalling at least … 24 5-8" widened EBE extra_elective with a dotted-leader guard so prose never matches
H40 Course rows wrapped before their credits; third totals wording LAW LB003: "PVL1006W … (No longer on" / "offer after 2019) … 36 5"; "Total credits for first year … 90" course_row_nocred + course_cred_cont continuation; total grammar accepts the bare-ordinal wording
H41 UG majors and PG rules share one running header SCI: Honours/Masters rules and curricula print under "DEGREES OFFERED IN THE FACULTY"; the last major's open year-3 table swallowed the whole PG section (3.3-4.0k phantom credits on SB001BIO13 2021-2024 / SB001STA13 2025-2026) majors parser stops at "Rules for the degree of Bachelor of Science Honours" (wraps after "(BSc" in 2023 — match the prefix); found via a >250-credit spec-year scan

Failing to recognise a heading is the costliest hazard class: the previous block silently swallows the next programme's tables (doubling its row sums) — exactly what detector R2b in the final layer flags as check_extraction. H22–H24, H26 and the EB015CON04 case were all found this way; an exactly-2× row-sum is the diagnostic signature.

Variant assignment precedence (robust across all layouts): page-header hint (2026) → known programme-code family map (VARIANT_BY_PROGCODE) → umbrella- line tracking (2021-2025).

7. The shared engine and the EBE faculty (added 2026-08-06)

The COM parser was promoted to a faculty-configurable engine, common/handbook_parser.py, once EBE proved to use the same publisher template. Each faculty supplies a FacultyConfig (extractors/<fac>/extract.py): plan-code grammar, degree parser, page classification, heading grammar, variant resolution, department map, and optional extras. The promotion was verified value-identical for all six COM editions (curriculum and courses byte-equal; one deliberate improvement — range-total support now captures a 2026 AdvDip total that previously sat in notes).

EBE config deltas (stable across 2021-2026 — EBE shows no cross-edition layout drift, unlike COM):

Tables shared by several faculties are written merge-by-faculty-within- year (write_year_rows(..., keep=...)): an extractor re-run replaces only its own faculty's rows.

LAW config deltas (stable across 2021-2026 except H31 in 2026):

FHS (bespoke parser, extractors/fhs/extract.py). FHS does not fit the block engine: programme blocks are bracket lines carrying one or more MB codes with shared curricula (H32), totals trail their tables with several wordings and slashed variant values (H33), the MBChB runs SIX years with rule-prefixed headings ("FBA3.8 Sixth Year"), and 2022-2023 use per-degree running headers while 2021-2023 print multi-line brackets (H34). The parser reuses the shared grammar (course rows, OR handling) and the engine's catalogue parser/classifier. Conventions: primary code carries the curriculum; second code of a pair is the intervention/extended variant; known residual — the combined Audiology/Speech-Language block interleaves both degrees' sub-tables before shared totals and needs a dedicated splitter (its spec-years are flagged unresolved; see DEV-TODO.md).

8. Multi-year status (all six faculties, final layer 2026-08-06,

after the H37-H40 engine fixes)

Consistent / resolved / unresolved per faculty (SCI and HUM spec-years are majors and carry no_anchor — no per-year totals exist for them, §9):

Year COM EBE LAW FHS SCI (majors) HUM (majors)
2021 227 / 17 / 30 85 / 0 / 5 11 / 0 / 1 7 / 0 / 12 66 89
2022 240 / 11 / 24 86 / 0 / 4 11 / 0 / 1 8 / 0 / 11 66 88
2023 225 / 15 / 36 88 / 0 / 2 11 / 0 / 1 14 / 0 / 11 60 88
2024 203 / 13 / 55 86 / 0 / 4 10 / 0 / 2 14 / 0 / 11 63 91
2025 219 / 13 / 36 84 / 0 / 6 10 / 0 / 2 11 / 0 / 12 66 91
2026 202 / 11 / 48 80 / 0 / 10 7 / 0 / 0 10 / 0 / 13 66 87

Main dataset: 23,592 rows (COM 14,282 + EBE 4,729 + SCI 1,571 + HUM 1,372 + FHS 1,187 + LAW 451); 3,287 specialisation-years (1,949 consistent, 80 resolved, 337 unresolved pending adjudication, 921 no_anchor major-years). The H37-H40 fixes resolved what DEV-TODO had queued as adjudication clusters: EBE went from 344/185 consistent/unresolved to 509/31, LAW from 42/25 to 60/7 (the remaining LAW 7 are LB003 2024/2025 years whose printed tables drop discontinued courses — genuine print drift, listed in the pending reports). Median computed-vs-published fee delta is 0.0% for COM, EBE and FHS (the MBChB's years 1-3 match to the rand); LAW's flat-annual and SCI/HUM's degree-flat published fees make per-year/per-major deltas structurally divergent (LP001 year 1 matches to the rand).

9. SCI and HUM — majors as the curriculum unit (added 2026-08-06)

Science and Humanities complete the six-faculty load, on bespoke parsers in the FHS mould (extractors/sci/, extractors/hum/). Their unit of curriculum is the major, not the specialisation:

Majors print no per-year credit totals — by design. The credit anchors for these faculties are degree-level (the rules layer, §10): SCI FB7.1 (≥ 360 NQF credits from 2025; "nine full-year courses" before), HUM's award minima (20 semester courses / 10 senior / 2 majors / 3 years, extended programme 4 years). The finaliser therefore gives SCI/HUM spec-years the status no_anchor instead of unresolved — there is nothing to adjudicate against per year — and keeps them out of the pending reports. Published fees are per degree, not per major: the fees book's "Bachelor of Science" block prices every SCI major and "Bachelor of Arts and Bachelor of Social Science" every HUM major (match method degree_flat, like LAW's flat_annual); the specialised Humanities programmes' own blocks (Fine Art, BMus, BSW, PPE, Film & Media) have no major rows to price and surface in the unmatched report by design.

10. The rules layer (degree_rules.csv, added 2026-08-06)

The faculty-rules sections — never previously parsed — print degree-level facts the curriculum tables cannot supply: minimum total credits for the degree, level-specific credit requirements, durations, and (LAW) whole-stream grand totals. common/degree_rules.py extracts them into data/processed/degree_rules.csv (one row per printed rule statement, with source_page and a verbatim quote; merged by year and faculty like every shared table). Each faculty extractor runs it after its main parse.

Why it matters — the credit re-think is written into the rules:

Degree (COM, rule FBx2) 2021 2022 2023 2024 2025 2026
BBusSc 623 (96@L8) 623 623 623 528 (120@L8) 528
BBusSc Actuarial Science 681 (96@L8) 681 681 681 528 (120@L8) 528
BCom 450 440 440 440 440 440
BCom Actuarial Science 528 528 528 528 528 528

EBE's blanket rule FB3.2 (4-year degrees ≥ 576 / 3-year ≥ 432) drops to 560 in 2026 — after the departments moved first (Electrical prints cohort-split minima from 2025, "registers in 2025 … 560 / registered before 2025 … 576"; Mechanical follows in 2026; Civil prints "576 (or 560 if admitted … from 2025)"; Geomatics phases in 519/511 totals in 2026). LAW prints stream grand totals every edition: graduate LLB 504 (flat), 4-year undergraduate LLB 660 → 637 in 2026, legacy LB003 660. FHS professional degrees are duration-ruled, not credit-ruled (the one credit rule is FBC3.1, intercalated BSc(Med) ≥ 360).

SCI re-based its composition rules at the 2025 edition — from full-year-course counts to credits: FB7.1 "at least nine full-year courses" (2021-2024) becomes "at least 360 NQF credits of which at least 180 must be Science credits" (2025-2026); FB7.2 "four full-year senior courses" becomes "120 credits at level 7". The majors rule renumbered FB7.6 → FB7.5 at the same moment (H35 again). HUM's award minima are stable across all six editions: BA/BSocSc = 3 years, 20 semester courses, 10 senior, 2 majors (≥ 1 from Humanities departments, FB3); the extended programme (HB061/HB062) the same over 4 years; specialised programmes (Fine Art) print "a minimum of 27 semester courses". Because SCI (pre-2025) and HUM count in courses, degree_rules.csv carries min_total_courses / min_senior_courses / min_majors with a course_unit column ("full-year courses" vs "semester courses") — the numbers are recorded as printed, never converted.

Notable negative finding: the rules sections do not print the missing selection rules for the COM unlabelled choice menus (CSC 4th year, PPE year 2, senior Economics) — but sibling editions' Programmes of Study do ("required to take two options", 2022/2024/2026), which is now cited as evidence in the affected resolutions/com.csv entries.

Validation. validate_final.py step 5 reconciles each specialisation's whole-degree credit sum against the applicable rule (validation/degree_check_<year>.csv; statuses OK / BELOW_MIN / ELECTIVE_GAP / NO_RULE — see the docstring). It also checks the EBE 5-year-ECP invariant (EB8xx totals must equal their EB0xx twin's). Results are findings, never failures. Highlights of the first run: LP001 sums to its printed 504 exactly in all six editions; the 2026 BAS curriculum (376) sits 56 credits below FB3.2's 3-year minimum, and the 2026 Property Studies stated minimum (452) exceeds its own table sums (411) — both genuine handbook inconsistencies now surfaced mechanically.

Parsing contract (hazards H35/H36): key on the degree-heading text, never the rule code; whitespace-collapse before matching; expect HEQSF wording in 2021 and cohort-split sentences from 2025.