Skip to content

refactor(optimiser): include slots with unknown venue locations and several code quality improvements#4340

Open
thejus03 wants to merge 41 commits intonusmodifications:masterfrom
thejus03:feat/add-venues-with-no-location
Open

refactor(optimiser): include slots with unknown venue locations and several code quality improvements#4340
thejus03 wants to merge 41 commits intonusmodifications:masterfrom
thejus03:feat/add-venues-with-no-location

Conversation

@thejus03
Copy link
Contributor

@thejus03 thejus03 commented Mar 6, 2026

Context

#4339

Summary

Logic Changes

  • Stop filtering out module slots that lack venue coordinates, so classes in venues not listed in venues.json are still included in timetable optimisation
  • Add NO_VENUE_PENALTY = 100.0 constant — scores worse than the furthest known on-campus distance (~80), discouraging unknown venues without making them unviable
  • Update beam width from 2500 -> 5000 for deeper searches.

Bug Fixes

  • Unsafe type assertions on Weeks field (_modules/modules.go, _solver/solver.go): Added comma-ok checks before week.(float64) assertions to prevent panics on malformed NUSMods API data
  • HTTP status check in GetModuleData (_client/client.go): Non-200 responses from the NUSMods API now return a descriptive error instead of silently passing bad data downstream
  • Raw error message leaked to client (optimise.go): JSON decode errors now return a generic "Invalid request format" message instead of exposing internal error details

Input Validation

  • Empty modules guard (_models/models.go): ParseOptimiserRequestFields now returns an error if the modules list is empty
  • JSON tag hygiene (_models/models.go): Parsed/computed fields (EarliestMin, LatestMin, etc.) annotated with json:"-" to exclude them from JSON decode/encode

Code Quality

  • Magic numbers replaced with named constants (_constants/constants.go): BeamWidth = 2500, BranchingFactor = 100, DaysPerWeek = 6 — all usages in _solver/solver.go updated
  • Variable shadowing fixed (_solver/solver.go): Inner loop variable i renamed to slotIdx to avoid shadowing the outer loop index
  • serializeLessonIndices simplified (_solver/nusmods_link.go): Replaced a convoluted fmt.Sprint + strings.Fields + strings.Trim pipeline with a straightforward strconv.Itoa loop; also adds strconv import
  • Handler doc comment fixed (optimise.go): Blank line between comment blocks caused revive to report a malformed doc comment — merged into a single contiguous block
  • Pre-existing style fixes from earlier in the branch: snake_case variables, function name casing (scoreConsecutiveHoursOfStudy), block-to-line comment conversion, duplicate map construction removed, recordings format standardised to MODULE|LessonType pipe separator

Tests

  • TestOptimiser_EmptyModules — validates the new empty-modules guard returns a non-200 response
  • TestOptimiser_InvalidTimeFormat — validates malformed time strings are rejected
  • TestOptimiser_NonExistentModule — validates unknown module codes are rejected (exercises the HTTP status check in GetModuleData)
  • TestOptimiser_MethodNotAllowed — validates GET requests return 405
  • TestOptimiser_ShareableLinks — verifies both shareable links are well-formed URLs containing all requested module codes
  • TestOptimiser_AllSlotsHaveAssignments — internal consistency check that every slot in DaySlots has a corresponding entry in Assignments

Documentation

  • README comprehensively updated:
    • Explains _ directory prefix convention (Vercel serverless requirement)
    • End-to-end data flow walkthrough (handler → module fetch → beam search → link generation)
    • Hard vs soft constraints clearly distinguished, with guidance on where to add new constraints
    • MRV heuristic documented in algorithm section
    • All scoring constants documented with rationale and priority order
    • Response fields table (shareableLink vs defaultShareableLink explained)
    • Venue data maintenance instructions (venues.json embed, deduplication logic)

Test plan

  • Run existing optimiser tests: start test server with pnpm start:optimiser, then go test ./_test/... -v
  • Verify modules with unknown venues appear in optimiser results
  • Confirm timetables with known-venue slots are still preferred over unknown-venue ones
  • Test error cases: empty modules, bad time format, non-existent module — all should return non-200

🤖 Generated with Claude Code

@vercel
Copy link

vercel bot commented Mar 6, 2026

@thejus03 is attempting to deploy a commit to the modsbot's projects Team on Vercel.

A member of the Team first needs to authorize it.

@codecov
Copy link

codecov bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 0% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 56.64%. Comparing base (988c6fd) to head (f1b9a17).
⚠️ Report is 208 commits behind head on master.

Files with missing lines Patch % Lines
.../optimiser/OptimiserContainer/OptimiserContent.tsx 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4340      +/-   ##
==========================================
+ Coverage   54.52%   56.64%   +2.12%     
==========================================
  Files         274      308      +34     
  Lines        6076     6964     +888     
  Branches     1455     1682     +227     
==========================================
+ Hits         3313     3945     +632     
- Misses       2763     3019     +256     

☔ View full report in Codecov by Sentry.
📢 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.

Copy link
Member

@jloh02 jloh02 left a comment

Choose a reason for hiding this comment

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

Thanks for the updates. it makes sense. sorry for backtracking on some of the older PR stuff. wanted to clean up the code now that i have time to properly review. would be good to check out go best practices when you have time too

thejus03 added 25 commits March 9, 2026 11:43
@thejus03
Copy link
Contributor Author

thejus03 commented Mar 9, 2026

@jloh02 Since we have decided to refactor the code now, I have made several changes in the code and abstracted it out. I also made few performance improvements due to unnecessary copies that were being made. I have updated the PR description to match the changes.

Beside including slots without venue locations (the original PR) there has been no other functional changes.

@thejus03 thejus03 changed the title feat(optimiser): include slots with unknown venue locations feat(optimiser): include slots with unknown venue locations and several code quality improvements Mar 9, 2026
@thejus03 thejus03 changed the title feat(optimiser): include slots with unknown venue locations and several code quality improvements refactor(optimiser): include slots with unknown venue locations and several code quality improvements Mar 9, 2026
@thejus03 thejus03 requested a review from jloh02 March 9, 2026 09:10
@jloh02
Copy link
Member

jloh02 commented Mar 9, 2026

@greptileai

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 9, 2026

Confidence Score: 3/5

  • Safe to merge with caveats — one logic bug (malformed WeeksString) and one incorrect HTTP status code for validation errors should be addressed first.
  • The overall refactor is well-structured and the main feature (including unknown-venue slots) is correctly implemented. However, the pre-allocated weeksStrings slice bug can produce malformed WeeksString values that silently corrupt the deduplication key, and validation errors being returned as 500 rather than 400 is a semantic error in the HTTP API.
  • website/api/optimiser/_modules/modules.go (WeeksString bug) and website/api/optimiser/_solver/solver.go (HTTP status for validation errors).

Important Files Changed

Filename Overview
website/api/optimiser/_modules/modules.go Core logic change: slots with unknown venues now included (given InvalidCoordinates) instead of being skipped. Contains a bug where pre-allocated weeksStrings slice produces malformed WeeksString (e.g. "3,,5") when non-float64 week values are encountered.
website/api/optimiser/_solver/solver.go Significant refactor: Solve() and beamSearch() reorganised, Score field now pre-computed before sorting (avoids repeated scoring), variable shadowing fixed, DaysPerWeek constant used. Validation errors from GetAllModuleSlots are returned as HTTP 500 instead of the more appropriate 400.
website/api/optimiser/_models/models.go Clean additions: ParseOptimiserRequestFields() with empty-modules guard, json:"-" tags on computed fields, Score field on TimetableState, SolveResponse moved here, WeeksSet changed to map[int]struct{}.
website/api/optimiser/_constants/constants.go Magic numbers replaced with named constants (BeamWidth, BranchingFactor, DaysPerWeek, NoVenuePenalty), EVenues map changed to map[string]struct{}, var→const for URL constants, InvalidCoordinates sentinel added.
website/api/optimiser/_client/client.go HTTP status check added — non-200 responses from NUSMods API now return a descriptive error instead of silently forwarding bad data. GetVenues() correctly moved to _modules package.
website/api/optimiser/_solver/nusmods_link.go serializeLessonIndices simplified from fmt.Sprint/strings.Fields pipeline to a clean strconv.Itoa loop; CreateConfig/SerializeConfig made unexported; GenerateNUSModsShareableLink moved to top of file.
website/api/optimiser/_test/api_test.go Six new integration tests added covering empty modules, invalid time format, non-existent module, method not allowed, shareable link validation, and assignment consistency. Recordings format updated to pipe separator throughout.
website/api/optimiser/optimise.go JSON decode error now returns a generic "Invalid request format" message instead of leaking internal error details; doc comment converted to contiguous godoc block.
website/src/views/optimiser/OptimiserContainer/OptimiserContent.tsx recordings payload switched from displayText to lessonKey, aligning the frontend with the new pipe-separated format (e.g. "CS1010S
website/src/views/optimiser/OptimiserResults.tsx Removes "Missing venue information" from the unscheduled-lessons explanation, accurately reflecting that slots with unknown venues are now included in optimisation.

Sequence Diagram

sequenceDiagram
    participant FE as Frontend (OptimiserContent.tsx)
    participant H as Handler (optimise.go)
    participant S as Solve (_solver/solver.go)
    participant M as GetAllModuleSlots (_modules/modules.go)
    participant C as GetModuleData (_client/client.go)
    participant API as NUSMods API
    participant BS as beamSearch (_solver/solver.go)
    participant L as GenerateNUSModsShareableLink (_solver/nusmods_link.go)

    FE->>H: POST /api/optimiser/optimise (recordings as "MODULE|LessonType")
    H->>H: json.Decode → OptimiserRequest
    H->>S: Solve(w, optimiserRequest)
    S->>M: GetAllModuleSlots(&req)
    M->>M: ParseOptimiserRequestFields() [validates empty modules, time formats]
    M->>M: getVenues() [unmarshal venues.json]
    loop for each module
        M->>C: GetModuleData(acadYear, module)
        C->>API: GET /v2/{year}/modules/{module}.json
        API-->>C: 200 OK / non-200 error
        C-->>M: []byte or error (non-200 now returns error)
        M->>M: parse weeks with comma-ok float64 assertion
        M->>M: mergeAndFilterModuleSlots() [unknown venues → InvalidCoordinates]
    end
    M-->>S: moduleSlots, defaultSlots, recordingsMap
    S->>BS: beamSearch(lessons sorted by MRV, ...)
    BS->>BS: expand beam, score states (pre-compute Score field), prune to BeamWidth
    BS-->>S: best TimetableState
    S->>L: GenerateNUSModsShareableLink(assignments, defaultSlots, ...)
    L-->>S: shareableLink, defaultShareableLink
    S-->>H: JSON encode SolveResponse
    H-->>FE: 200 OK { Assignments, DaySlots, Score, shareableLink, defaultShareableLink }
Loading

Last reviewed commit: 576ae48

Comment on lines 84 to 95
weeksStrings := make([]string, len(weeks))

for j, week := range weeks {
weekInt := int(week.(float64))
moduleTimetable[i].WeeksSet[weekInt] = true
weekFloat, ok := week.(float64)
if !ok {
continue
}
weekInt := int(weekFloat)
moduleTimetable[i].WeeksSet[weekInt] = struct{}{}
weeksStrings[j] = strconv.Itoa(weekInt)
}
moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",")
Copy link
Contributor

Choose a reason for hiding this comment

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

Malformed WeeksString when non-float64 week value is encountered

weeksStrings is pre-allocated with make([]string, len(weeks)), so each element starts as "". When the comma-ok check fails and continue is hit for index j, weeksStrings[j] remains "". The subsequent strings.Join then produces a string with empty segments, e.g. "3,,5,6" or ",4,5".

This malformed WeeksString is used to build the combinationKey for deduplication (line 228 in mergeAndFilterModuleSlots), which could cause incorrect deduplication of class options.

Switch to an append-based approach so failed conversions are simply omitted:

Suggested change
weeksStrings := make([]string, len(weeks))
for j, week := range weeks {
weekInt := int(week.(float64))
moduleTimetable[i].WeeksSet[weekInt] = true
weekFloat, ok := week.(float64)
if !ok {
continue
}
weekInt := int(weekFloat)
moduleTimetable[i].WeeksSet[weekInt] = struct{}{}
weeksStrings[j] = strconv.Itoa(weekInt)
}
moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",")
moduleTimetable[i].WeeksSet = make(map[int]struct{})
weeks := moduleTimetable[i].Weeks.([]any)
var weeksStrings []string
for _, week := range weeks {
weekFloat, ok := week.(float64)
if !ok {
continue
}
weekInt := int(weekFloat)
moduleTimetable[i].WeeksSet[weekInt] = struct{}{}
weeksStrings = append(weeksStrings, strconv.Itoa(weekInt))
}
moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",")
Prompt To Fix With AI
This is a comment left during a code review.
Path: website/api/optimiser/_modules/modules.go
Line: 84-95

Comment:
**Malformed `WeeksString` when non-float64 week value is encountered**

`weeksStrings` is pre-allocated with `make([]string, len(weeks))`, so each element starts as `""`. When the comma-ok check fails and `continue` is hit for index `j`, `weeksStrings[j]` remains `""`. The subsequent `strings.Join` then produces a string with empty segments, e.g. `"3,,5,6"` or `",4,5"`.

This malformed `WeeksString` is used to build the `combinationKey` for deduplication (line 228 in `mergeAndFilterModuleSlots`), which could cause incorrect deduplication of class options.

Switch to an `append`-based approach so failed conversions are simply omitted:

```suggestion
			moduleTimetable[i].WeeksSet = make(map[int]struct{})
			weeks := moduleTimetable[i].Weeks.([]any)
			var weeksStrings []string

			for _, week := range weeks {
				weekFloat, ok := week.(float64)
				if !ok {
					continue
				}
				weekInt := int(weekFloat)
				moduleTimetable[i].WeeksSet[weekInt] = struct{}{}
				weeksStrings = append(weeksStrings, strconv.Itoa(weekInt))
			}
			moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",")
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

nice

Comment on lines +23 to +27
slots, defaultSlots, recordings, err := modules.GetAllModuleSlots(&req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Validation errors returned as HTTP 500 instead of 400

GetAllModuleSlots can return errors from two very different sources:

  1. Client errors (user-caused): empty modules list, invalid time format — these originate from ParseOptimiserRequestFields() and should be a 400 Bad Request.
  2. Server errors (infrastructure): failed HTTP request to NUSMods API, JSON parse failure — these warrant a 500 Internal Server Error.

Currently both cases are returned as 500, which misrepresents client errors and may confuse frontend error handling. Consider distinguishing them — for example by using a sentinel error type or by returning a status code alongside the error from GetAllModuleSlots:

slots, defaultSlots, recordings, err := modules.GetAllModuleSlots(&req)
if err != nil {
    // Distinguish user errors (validation) from server errors
    // e.g. wrap validation errors with a distinct type and check here
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
}

TestOptimiser_EmptyModules and TestOptimiser_InvalidTimeFormat only verify a non-200 response, so they pass today with a 500 — but the semantically correct response is 400.

Prompt To Fix With AI
This is a comment left during a code review.
Path: website/api/optimiser/_solver/solver.go
Line: 23-27

Comment:
**Validation errors returned as HTTP 500 instead of 400**

`GetAllModuleSlots` can return errors from two very different sources:

1. **Client errors** (user-caused): empty modules list, invalid time format — these originate from `ParseOptimiserRequestFields()` and should be a `400 Bad Request`.
2. **Server errors** (infrastructure): failed HTTP request to NUSMods API, JSON parse failure — these warrant a `500 Internal Server Error`.

Currently both cases are returned as `500`, which misrepresents client errors and may confuse frontend error handling. Consider distinguishing them — for example by using a sentinel error type or by returning a status code alongside the error from `GetAllModuleSlots`:

```go
slots, defaultSlots, recordings, err := modules.GetAllModuleSlots(&req)
if err != nil {
    // Distinguish user errors (validation) from server errors
    // e.g. wrap validation errors with a distinct type and check here
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
}
```

`TestOptimiser_EmptyModules` and `TestOptimiser_InvalidTimeFormat` only verify a non-200 response, so they pass today with a 500 — but the semantically correct response is 400.

How can I resolve this? If you propose a fix, please make it concise.

@vercel
Copy link

vercel bot commented Mar 9, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
nusmods-website Ignored Ignored Preview Mar 9, 2026 1:20pm

Request Review


// Beam search parameters
const (
BeamWidth = 5000
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jloh02 I doubled the beam width from 2500 to 5000 now for deeper and more accurate searches. This is the only other logical change in the code besides adding back the slots with no venue info.

Copy link
Member

Choose a reason for hiding this comment

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

Sorry I'm not super well versed. Does this potentially lead to longer runtimes?

@thejus03
Copy link
Contributor Author

thejus03 commented Mar 9, 2026

@jloh02 wondering if the vercel preview for the website still works? I wanted to see if there were any performance improvements for optimising large module count (10 mods) timetables from the one in prod.

@jloh02
Copy link
Member

jloh02 commented Mar 10, 2026

@jloh02 wondering if the vercel preview for the website still works? I wanted to see if there were any performance improvements for optimising large module count (10 mods) timetables from the one in prod.

@thejus03 mybad i broke it partially in #4350 #4328. will try to push a fix out in the next 1-2 days

Copy link
Member

@jloh02 jloh02 left a comment

Choose a reason for hiding this comment

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

changes make sense to me. will approve on resolve of my 1 comment. sorry facing a financial crunch a lil


// Beam search parameters
const (
BeamWidth = 5000
Copy link
Member

Choose a reason for hiding this comment

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

Sorry I'm not super well versed. Does this potentially lead to longer runtimes?

@thejus03
Copy link
Contributor Author

My bad I should have expanded on that. Yes it does increase runtime because we are searching through double the amount of timetables as before. But overall I think latency should have decreased from the one in prod now because I reduced latency by more than half from before. So we can afford to search deeper?

We can test out how fast it is compared to prod using the vercel preview link.

Also the reason why I doubled it is because for one of my friend's 10 mod timetable it was not returning a completely optimised timetable (sometimes) because we didn't search deep enough. But after modifying, it was able to find it every time.

image

Prod

image

Current PR

image

@thejus03
Copy link
Contributor Author

sorry facing a financial crunch a lil

Just confirming but we are using Vercel's free tier right?

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.

2 participants