feat: parse and apply cohort-year conditions in prerequisite trees#4429
Merged
Conversation
The scraper recognised COHORT_YEARS predicates in the ANTLR grammar but
had no visitor for them, so the gate was discarded and cohort-specific
requirements were flattened into unconditional ones (e.g. NGT2001E's
"cohorts from 2022" requirement applied to everyone).
This adds a `{ cohort, then }` variant to PrereqTree, implements the
missing visitCohort_years visitor (dropping bare predicates with no gated
requirement as before), and walks the new node in flattenTree.
On the website, checkPrerequisite now evaluates the gate against the
student's matriculation year (planner minYear), so the requirement is
enforced only for matching cohorts; the module page renders the condition
as a label since it has no user context.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
flattenTree walks nodes generically via Object.values, which recurses into the nOf tuple and passes the count (a number) back in. The new `'cohort' in tree` check then threw "Cannot use 'in' operator ... in 2" on any module with an nOf requirement. Bail on non-object values before the `in` check, restoring the previous tolerance for the leaked count. Adds nOf coverage (standalone and nested under a cohort gate) that the original tests missed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cohort THEN consequence bound only to the immediately-following clause, so a trailing "AND COURSES ..." escaped the gate and became an unconditional requirement. For NGT2001E this wrongly required NSW% of every cohort instead of only cohorts from 2022. Lift the cohort consequence to a new compound-level `cohort_conditional` rule (cohort_years THEN compound), placed above binop so the consequence greedily absorbs trailing AND/OR clauses. cohort_years is now a bare condition; the THEN handling moves to visitCohort_conditional, while a bare cohort (no THEN) still contributes nothing. program_types/programs/ plan_types are untouched. Regenerated the ANTLR parser/visitor from the updated grammar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #4429 +/- ##
==========================================
+ Coverage 54.52% 57.33% +2.80%
==========================================
Files 274 317 +43
Lines 6076 7169 +1093
Branches 1455 1747 +292
==========================================
+ Hits 3313 4110 +797
- Misses 2763 3059 +296 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
BT4103 ANDs two cohort gates (E:2021 and S:2022), each wrapping nested or/wildcard requirements, exercising cohort rendering that the existing snapshots did not cover. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`pnpm serve:data` serves the scraped data/ directory over HTTP with CORS, mapping the website's /v2/<acad-year>/... request prefix onto data/<acad-year>/... so files can stay where `pnpm build && pnpm scrape combine` writes them (no v2/ directory or copying needed). Zero-dependency Node server; defaults to port 1234 to match DATA_API_BASE_URL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Member
1 similar comment
Member
Contributor
leslieyip02
requested changes
Jun 21, 2026
Standardise the condition alternatives so all four are parser rules: introduce `if_in: IF_IN;` and `if_not_in: IF_NOT_IN;` to match the existing `must_be_in`/`must_not_be_in`, and use them everywhere the raw IF_IN/IF_NOT_IN tokens appeared (program_types, programs/plan_types/ special conditions, cohort_years, subject_years). visitCohort_conditional now reads cohort.if_in()/if_not_in(). No behavioural change; parser regenerated from the grammar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cohort predicate without a THEN (e.g. "... AND COHORT_YEARS MUST_BE_IN
S:2017" in DBA3702/MNO2707A) was silently dropped, losing the "must be in
cohort X" eligibility requirement.
Make `then` optional on the cohort node: with `then` it gates a
requirement (existing behaviour), without it is a bare eligibility
constraint.
- Scraper: visitCohort_years now emits `{ cohort }` for a bare predicate
(shared cohortCondition helper); flattenTree yields no module codes for
it.
- Planner: checkPrerequisite treats a bare constraint as unfulfilled when
the matriculation year doesn't match, satisfied when it does, and
conservatively satisfied when the cohort year is unknown.
- Display: conflictToText and ModuleTree describe it via a shared
formatCohortCondition helper; ModuleTree renders it as a childless
"For cohort X onwards" node and drops the dangling connector line for
childless conditionals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
leslieyip02
approved these changes
Jun 22, 2026
leslieyip02
left a comment
Member
There was a problem hiding this comment.
LGTM, thanks for the quick fixes!
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.
Closes #4258
Problem
The scraper's ANTLR grammar already recognises
COHORT_YEARSpredicates, butReqTreeVisitorhad no visitor for them, so the predicate fell through to the defaultvisitChildrenand was silently discarded. Cohort-specific requirements were therefore flattened into unconditional ones. For example, BT4103 has differing prerequiste logic, based on studens' matriculation cohorts.What this does
This carries the cohort condition through the whole pipeline instead of dropping it. The scraper now emits a
{ cohort, then }node, and the website both evaluates it (in the planner, where the student's matriculation year is known) and displays it (on the module page, which has no user context).Scraper (
scrapers/nus-v2)CohortRule/CohortConditiontypes and a{ cohort: CohortCondition; then: PrereqTree }variant toPrereqTree. Theyearsfield holds the rawYEARStokens (S:= from-inclusive,E:= until-inclusive; the year part may be a calendar year like2022or an academic year like2019/20; two tokens form a closed range).THENclause bound only to the immediately-following clause, so a trailingAND COURSES …escaped the gate and became an unconditional requirement (for NGT2001E this wrongly requiredNSW%of every cohort). The cohort consequence is now lifted to a newcompound-level rulecohort_conditional: cohort_years THEN compound, placed abovebinop, so the consequence greedily absorbs trailingAND/ORclauses.cohort_yearsis now a bare condition, theTHENhandling lives in the newvisitCohort_conditional, and a bare cohort (noTHEN) still contributes nothing.program_types/programs/plan_typesare deliberately left untouched to avoid regressions. The ANTLR parser/visitor were regenerated from the updated grammar.flattenTreeto descend only intothenso the condition metadata never leaks intofulfillRequirements, and to bail on non-object values —flattenTreewalks nodes viaObject.values, which recurses into thenOftuple and passes the count (a number) back in, and the newincheck only works on objects.Website
PrereqTree.checkPrerequisitegained an optionalcohortYearparameter and a newcohortConditionApplieshelper that compares the matriculation year against theS:/E:bounds and inverts the match forIF_NOT_IN/MUST_NOT_BE_IN. When the cohort year is unknown it conservatively assumes the requirement applies, preserving the previous behaviour.planner.minYear(the "Matriculated in" setting) into the check, so a requirement is only enforced for matching cohorts.conflictToTextrenders the gated requirement.ModuleTreerenders the condition as a label such as "For cohort 2022 onwards" since the module page has no user context.Testing
The full scraper suite (140 tests) and the affected website suites pass, lint is clean, and typecheck introduces zero new errors. New scraper cases cover the greedy trailing-
ANDgrouping (the NGT2001E shape),E:bounds, academic-year ranges,IF_NOT_INinversion, the bare-predicate drop, andnOfflattening (standalone and nested under a cohort gate). New website cases cover the planner evaluation across cohort bounds and the unknown-cohort fallback, plus aModuleTreesnapshot for the cohort-gated render. Regenerating NGT2001E with these changes produces{ cohort: { rule: "IF_IN", years: ["S:2022"] }, then: { and: ["NTW%:D", "LC1016:D", "NSW%:D"] } }, with all three modules correctly gated by cohort 2022 onwards.Before:

After:

Notes for reviewers
planner.minYeardefaults to the current academic year when the user has never opened planner settings, so gates evaluate as "current cohort" until a matriculation year is chosen.combine <year>re-collates from cached semester data without re-fetching from NUS, or the next scheduled full scrape) and the output is redeployed. The website code is forward-compatible and inert until then.NusModsParser.ts/NusModsVisitor.ts, so those files show large diffs, mostly formatting related — the only hand-written grammar change is inNusMods.g4.