Skip to content

feat: parse and apply cohort-year conditions in prerequisite trees#4429

Merged
leslieyip02 merged 9 commits into
masterfrom
feat/cohort-prereq-tree
Jun 22, 2026
Merged

feat: parse and apply cohort-year conditions in prerequisite trees#4429
leslieyip02 merged 9 commits into
masterfrom
feat/cohort-prereq-tree

Conversation

@TheMythologist

Copy link
Copy Markdown
Contributor

Closes #4258

Problem

The scraper's ANTLR grammar already recognises COHORT_YEARS predicates, but ReqTreeVisitor had no visitor for them, so the predicate fell through to the default visitChildren and 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)

  • Added CohortRule / CohortCondition types and a { cohort: CohortCondition; then: PrereqTree } variant to PrereqTree. The years field holds the raw YEARS tokens (S: = from-inclusive, E: = until-inclusive; the year part may be a calendar year like 2022 or an academic year like 2019/20; two tokens form a closed range).
  • Fixed the grammar so the cohort consequence binds correctly. Previously the THEN clause 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). The cohort consequence is now lifted to a new compound-level rule cohort_conditional: 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 lives in the new visitCohort_conditional, and a bare cohort (no THEN) still contributes nothing. program_types / programs / plan_types are deliberately left untouched to avoid regressions. The ANTLR parser/visitor were regenerated from the updated grammar.
  • Taught flattenTree to descend only into then so the condition metadata never leaks into fulfillRequirements, and to bail on non-object values — flattenTree walks nodes via Object.values, which recurses into the nOf tuple and passes the count (a number) back in, and the new in check only works on objects.

Website

  • Mirrored the type on the website copy of PrereqTree.
  • checkPrerequisite gained an optional cohortYear parameter and a new cohortConditionApplies helper that compares the matriculation year against the S:/E: bounds and inverts the match for IF_NOT_IN / MUST_NOT_BE_IN. When the cohort year is unknown it conservatively assumes the requirement applies, preserving the previous behaviour.
  • The planner selector threads planner.minYear (the "Matriculated in" setting) into the check, so a requirement is only enforced for matching cohorts. conflictToText renders the gated requirement.
  • ModuleTree renders 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-AND grouping (the NGT2001E shape), E: bounds, academic-year ranges, IF_NOT_IN inversion, the bare-predicate drop, and nOf flattening (standalone and nested under a cohort gate). New website cases cover the planner evaluation across cohort bounds and the unknown-cohort fallback, plus a ModuleTree snapshot 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:
image

After:
image

Notes for reviewers

  • planner.minYear defaults 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.
  • This is a data-shape change, so cohort nodes only appear in the API once the scraper's collation step re-runs (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.
  • The grammar change regenerates NusModsParser.ts / NusModsVisitor.ts, so those files show large diffs, mostly formatting related — the only hand-written grammar change is in NusMods.g4.

TheMythologist and others added 3 commits June 20, 2026 16:46
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>
@vercel

vercel Bot commented Jun 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
nusmods-export Ignored Ignored Preview Jun 21, 2026 7:42pm
nusmods-website Ignored Ignored Preview Jun 21, 2026 7:42pm

Request Review

@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.33%. Comparing base (988c6fd) to head (14c88a4).
⚠️ Report is 242 commits behind head on master.

Files with missing lines Patch % Lines
website/src/utils/planner.ts 91.66% 3 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

TheMythologist and others added 2 commits June 20, 2026 18:35
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>
@jloh02

jloh02 commented Jun 21, 2026

Copy link
Copy Markdown
Member

@greptileai

1 similar comment
@leslieyip02

Copy link
Copy Markdown
Member

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a longstanding silent discard of COHORT_YEARS predicates in the ANTLR visitor and carries the cohort condition through the full pipeline — scraper emission, website prerequisite evaluation, planner conflict display, and module-page rendering.

  • Scraper: Adds CohortRule/CohortCondition types and a new cohort_conditional grammar rule that greedily absorbs trailing AND/OR clauses, fixing the NGT2001E case where ungated siblings escaped the cohort gate. flattenTree is updated to descend only into then and to guard against the non-object nOf count value.
  • Website: checkPrerequisite gains a cohortYear parameter and a cohortConditionApplies helper that evaluates S:/E: bounds; bare cohort constraints without a then are treated as eligibility requirements. ModuleTree renders the condition as a label (e.g., "For cohort 2022 onwards") with the gated subtree as children, and a childless CSS class drops the dangling connector line for bare constraints.

Confidence Score: 5/5

Safe to merge. The cohort gating logic is thoroughly tested, the conservative fallback (unknown cohort year → requirement applies) preserves existing behaviour, and the grammar change is scoped to add the new cohort_conditional rule without touching established program_types/programs/plan_types visitor paths.

All changed paths have accompanying tests covering boundary years, inverted rules (IF_NOT_IN), academic-year token parsing, unknown-cohort fallback, bare constraints, and the NGT2001E trailing-AND shape. The flattenTree guard for non-object values is a correct defensive fix with a new test. No data-loss or incorrect-evaluation path was found.

No files require special attention. website/src/utils/planner.ts carries the core evaluation logic and is well covered by planner.test.ts.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant G as NusMods.g4 (Grammar)
    participant P as NusModsParser (generated)
    participant V as ReqTreeVisitor
    participant T as PrereqTree (scraper)
    participant API as NUSMods API
    participant S as planner.ts (selector)
    participant C as checkPrerequisite
    participant UI as ModuleTree.tsx

    G->>P: cohort_conditional: cohort_years THEN compound
    P->>V: visitCohort_conditional(ctx)
    V->>V: cohortCondition(ctx.cohort_years())
    V->>T: "emit { cohort: CohortCondition, then: PrereqTree }"
    T->>API: collation step (combine year)

    Note over API,S: Website side
    API->>S: prereqTree with cohort nodes
    S->>S: parseInt(planner.minYear, 10) → cohortYear
    S->>C: checkPrerequisite(modulesTaken, prereqs, cohortYear)
    C->>C: "cohortConditionApplies({ rule, years }, cohortYear)"
    alt cohort applies or unknown
        C->>C: walkTree(fragment.then)
    else cohort does not apply
        C-->>S: [] (requirement vacuously met)
    end
    S->>UI: unfulfilledPrereqs conflict

    Note over UI: Module page (no user context)
    UI->>UI: formatCohortCondition(node.cohort)
    UI->>UI: render label For cohort 2022 onwards
    UI->>UI: unwrapLayer → [node.then] as child branch
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant G as NusMods.g4 (Grammar)
    participant P as NusModsParser (generated)
    participant V as ReqTreeVisitor
    participant T as PrereqTree (scraper)
    participant API as NUSMods API
    participant S as planner.ts (selector)
    participant C as checkPrerequisite
    participant UI as ModuleTree.tsx

    G->>P: cohort_conditional: cohort_years THEN compound
    P->>V: visitCohort_conditional(ctx)
    V->>V: cohortCondition(ctx.cohort_years())
    V->>T: "emit { cohort: CohortCondition, then: PrereqTree }"
    T->>API: collation step (combine year)

    Note over API,S: Website side
    API->>S: prereqTree with cohort nodes
    S->>S: parseInt(planner.minYear, 10) → cohortYear
    S->>C: checkPrerequisite(modulesTaken, prereqs, cohortYear)
    C->>C: "cohortConditionApplies({ rule, years }, cohortYear)"
    alt cohort applies or unknown
        C->>C: walkTree(fragment.then)
    else cohort does not apply
        C-->>S: [] (requirement vacuously met)
    end
    S->>UI: unfulfilledPrereqs conflict

    Note over UI: Module page (no user context)
    UI->>UI: formatCohortCondition(node.cohort)
    UI->>UI: render label For cohort 2022 onwards
    UI->>UI: unwrapLayer → [node.then] as child branch
Loading

Reviews (4): Last reviewed commit: "Merge branch 'master' into feat/cohort-p..." | Re-trigger Greptile

Comment thread website/src/utils/planner.ts
Comment thread scrapers/nus-v2/src/services/requisite-tree/antlr4/NusMods.g4
Comment thread scrapers/nus-v2/src/services/requisite-tree/antlr4/NusMods.g4
TheMythologist and others added 3 commits June 21, 2026 11:40
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 leslieyip02 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the quick fixes!

@leslieyip02 leslieyip02 merged commit 9b93e5a into master Jun 22, 2026
8 checks passed
@leslieyip02 leslieyip02 deleted the feat/cohort-prereq-tree branch June 22, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add additional information into prerequisite tree

3 participants