Skip to content

Commit 584835c

Browse files
authored
Merge pull request #682 from UTDNebula/fix-valid-major-double-dipping
fix(NP-96): Allow valid major double dipping
2 parents bba9789 + 2ecdaf0 commit 584835c

File tree

15 files changed

+255
-221
lines changed

15 files changed

+255
-221
lines changed

validator/degree_solver.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
from __future__ import annotations
22
from enum import Enum
33
from glob import glob
4+
from collections import Counter
45

56
from pydantic import Json
67

78
from typing import Any
89

910
import core
10-
from major.requirements import AbstractRequirement
11+
from major.requirements import AbstractRequirement, FreeElectiveRequirement
1112
from dataclasses import dataclass
1213

13-
from major.requirements.map import REQUIREMENTS_MAP
14+
from major.requirements import loader
1415
import json
1516

1617
from major.requirements.shared import (
@@ -19,6 +20,7 @@
1920
)
2021
from course import Course
2122

23+
LOADER = loader.Loader()
2224

2325
# Read all degree plan JSON files and store their contents in a hashmap
2426
# This is so that we can avoid reading all the files each time we want to get the data for a certain course
@@ -144,7 +146,7 @@ def __init__(
144146
requirements: DegreeRequirementsInput,
145147
bypasses: BypassInput,
146148
) -> None:
147-
self.courses = set(courses)
149+
self.courses = set([Course.from_name(course) for course in courses])
148150
self.degree_requirements = self.load_requirements(requirements)
149151
self.solved_core: core.store.AssignmentStore | None = None
150152
self.bypasses = bypasses
@@ -187,9 +189,7 @@ def load_requirements(
187189

188190
# Add requirements
189191
for req_data in requirements_data:
190-
major_req.requirements.append(
191-
REQUIREMENTS_MAP[req_data["matcher"]].from_json(req_data)
192-
)
192+
major_req.requirements.append(LOADER.requirement_from_json(req_data))
193193
degree_requirements.append(major_req)
194194
# We don't need to check the other JSON files
195195
break
@@ -201,23 +201,28 @@ def load_requirements(
201201
def solve(self) -> DegreeRequirementsSolver:
202202
# Run for core
203203
core_solver = self.load_core()
204-
self.solved_core = core_solver.solve(
205-
[Course.from_name(course) for course in self.courses], []
206-
)
207-
# Set of the core courses that are fulfilled, so they won't be considered as free electives
208-
used_core_courses = set()
204+
self.solved_core = core_solver.solve(list(self.courses), [])
205+
206+
# Counter of the core courses and their used hours, so they won't be considered as free electives.
207+
used_core_courses: Counter[Course] = Counter()
209208
if self.solved_core is not None:
210209
for req_fill in self.solved_core.reqs_to_courses.values():
211-
used_core_courses.update([course.name for course in req_fill.keys()])
210+
used_core_courses.update(req_fill)
212211

213212
# Run for major
214213
for degree_req in self.degree_requirements:
215214
for course in self.courses:
216-
# Make sure it's not a core course
217-
if course in used_core_courses:
218-
continue
219215
for requirement in degree_req.requirements:
220-
if requirement.attempt_fulfill(course):
216+
# Free elective requirements are special, since they can take left over hours from core courses.
217+
if type(requirement) == FreeElectiveRequirement:
218+
if requirement.attempt_fulfill(
219+
course.name,
220+
available_hours=(
221+
int(course.hours) - used_core_courses[course]
222+
),
223+
):
224+
break
225+
elif requirement.attempt_fulfill(course.name):
221226
break
222227

223228
# Handle requirements bypasses for major

validator/gen_schema.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from types import GenericAlias
77
from typing import Any, ForwardRef
88
from jsonschema import Draft7Validator
9-
from major.requirements import REQUIREMENTS_MAP
9+
from major.requirements import loader
1010

1111
schema: dict[str, Any] = {
1212
"$schema": Draft7Validator.META_SCHEMA["$id"],
@@ -89,8 +89,9 @@ def forward_ref_to_schema(ref: ForwardRef) -> dict[str, Any]:
8989
raise Exception("Expected type, got", type(ref_type), ref_type)
9090

9191

92-
for req_name in REQUIREMENTS_MAP:
93-
req = REQUIREMENTS_MAP[req_name]
92+
req_loader = loader.Loader()
93+
for req_name in req_loader.REQUIREMENTS_MAP:
94+
req = req_loader.REQUIREMENTS_MAP[req_name]
9495
requirement_schema_props: dict[str, Any] = {"matcher": {"const": req_name}}
9596

9697
for prop_name, prop_type in req.JSON.__annotations__.items():
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
from .base import AbstractRequirement
22
from .shared import *
3-
from .map import *
3+
from . import loader

validator/major/requirements/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44
from abc import abstractmethod, ABC
55
from dataclasses import dataclass
6-
76
from typing import Any
87

98
from pydantic import Json
@@ -19,6 +18,7 @@ class AbstractRequirement(ABC):
1918
def attempt_fulfill(
2019
self,
2120
course: str,
21+
available_hours: int = 0,
2222
) -> bool:
2323
pass
2424

validator/major/requirements/edge_cases/arts_technology_emerging_communication.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import json
2-
from typing import Any, TypedDict
2+
from typing import Any
33

44
from pydantic import Json
5+
from major.requirements import loader
56
from major.requirements.base import AbstractRequirement
67
from major.requirements.shared import MultiGroupElectiveRequirement
78
import utils
@@ -45,13 +46,9 @@ def __init__(
4546

4647
@classmethod
4748
def from_json(cls, json: JSON) -> MultiGroupElectiveRequirement: # type: ignore[override]
48-
from ..map import REQUIREMENTS_MAP
49-
5049
requirements: list[AbstractRequirement] = []
5150
for requirement_data in json["requirements"]:
52-
requirement = REQUIREMENTS_MAP[requirement_data["matcher"]].from_json(
53-
requirement_data
54-
)
51+
requirement = loader.Loader().requirement_from_json(requirement_data)
5552
requirements.append(requirement)
5653

5754
return cls(
@@ -87,7 +84,7 @@ def to_json(self) -> Json[Any]:
8784
}
8885
)
8986

90-
def attempt_fulfill(self, course: str) -> bool:
87+
def attempt_fulfill(self, course: str, _: int = 0) -> bool:
9188
fulfilled = super().attempt_fulfill(course)
9289
if fulfilled:
9390
if utils.get_level_from_course(course) == 4:

validator/major/requirements/edge_cases/business_administration.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import json
33

44
from pydantic import Json
5-
from major.requirements import AbstractRequirement, map
5+
from major.requirements import AbstractRequirement
66

7-
from functools import reduce
87
from typing import Any, TypedDict
98
from major.requirements.shared import OrRequirement
109

1110
import utils
11+
from major.requirements import loader
1212

1313
"""
1414
Note: assuming BA 4V90 & BA 4090 cover one of the groups
@@ -21,7 +21,7 @@ class SomeRequirement(OrRequirement):
2121
NOTE: Allows attempt_filled to work even if is_fulfilled() is true
2222
"""
2323

24-
def attempt_fulfill(self, course: str) -> bool:
24+
def attempt_fulfill(self, course: str, _: int = 0) -> bool:
2525
for requirement in self.requirements:
2626
if requirement.attempt_fulfill(course):
2727
return True
@@ -66,7 +66,7 @@ def __init__(
6666
self.metadata = metadata
6767
self.override_filled = False
6868

69-
def attempt_fulfill(self, course: str) -> bool:
69+
def attempt_fulfill(self, course: str, _: int = 0) -> bool:
7070
if self.is_fulfilled():
7171
return False
7272

@@ -146,9 +146,7 @@ def from_json(cls, json: JSON) -> BusinessAdministrationElectiveRequirement:
146146

147147
requirements: list[AbstractRequirement] = []
148148
for requirement_data in json["prefix_groups"]:
149-
requirement = map.REQUIREMENTS_MAP[requirement_data["matcher"]].from_json(
150-
requirement_data
151-
)
149+
requirement = loader.Loader().requirement_from_json(requirement_data)
152150
requirements.append(requirement)
153151

154152
return cls(
@@ -182,4 +180,4 @@ def __str__(self) -> str:
182180
_______________
183181
Required fulfilled: {self.is_fulfilled()}
184182
"""
185-
return s
183+
return s

validator/major/requirements/edge_cases/computer_science.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
import json
33

44
from pydantic import Json
5-
from major.requirements import AbstractRequirement, map
5+
from major.requirements import AbstractRequirement
66

77
from functools import reduce
88
from typing import Any, TypedDict
99

1010
import utils
11+
from major.requirements import loader
1112

1213

1314
class MajorGuidedElectiveRequirement(AbstractRequirement):
@@ -45,7 +46,7 @@ def __init__(
4546
self.metadata = metadata
4647
self.override_filled = False
4748

48-
def attempt_fulfill(self, course: str) -> bool:
49+
def attempt_fulfill(self, course: str, _: int = 0) -> bool:
4950
if self.is_fulfilled():
5051
return False
5152

@@ -57,7 +58,6 @@ def attempt_fulfill(self, course: str) -> bool:
5758
for requirement in self.also_fulfills:
5859
if requirement.attempt_fulfill(course):
5960
return True
60-
self.valid_courses.append(course)
6161

6262
return False
6363

@@ -112,9 +112,7 @@ def from_json(cls, json: JSON) -> MajorGuidedElectiveRequirement:
112112

113113
also_fulfills: list[AbstractRequirement] = []
114114
for requirement in json["also_fulfills"]:
115-
also_fulfills.append(
116-
map.REQUIREMENTS_MAP[requirement["matcher"]].from_json(requirement)
117-
)
115+
also_fulfills.append(loader.Loader().requirement_from_json(requirement))
118116

119117
return cls(
120118
json["required_count"], json["starts_with"], also_fulfills, json["metadata"]

validator/major/requirements/edge_cases/psychology.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from pydantic import Json
22
from typing import Any, TypedDict
33
from major.requirements import base
4-
from major.requirements.base import AbstractRequirement
54
import utils
65
import json
76

@@ -25,7 +24,7 @@ def __init__(
2524
self.accepted_courses = accepted_courses
2625
self.metadata = metadata
2726

28-
def attempt_fulfill(self, course: str) -> bool:
27+
def attempt_fulfill(self, course: str, _: int = 0) -> bool:
2928
if self.is_fulfilled():
3029
return False
3130

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from typing import Type, Literal, get_args, Mapping, TypeGuard, Any
2+
3+
RequirementNameT = Literal[
4+
"CourseRequirement",
5+
"AndRequirement",
6+
"OrRequirement",
7+
"FreeElectiveRequirement",
8+
"SelectRequirement",
9+
"HoursRequirement",
10+
"PrefixBucketRequirement",
11+
"OtherRequirement",
12+
"CSGuidedElectiveRequirement",
13+
"BAGuidedElectiveRequirement",
14+
"SomeRequirement",
15+
"MultiGroupElectiveRequirement",
16+
"ATECPrescribedElectiveRequirement",
17+
"PsychologyPrefixesOrCourses",
18+
]
19+
RequirementNames: list[RequirementNameT] = list(get_args(RequirementNameT))
20+
21+
22+
class Loader:
23+
from major.requirements.base import AbstractRequirement
24+
25+
def __init__(self) -> None:
26+
from major.requirements import shared
27+
from .edge_cases import (
28+
business_administration,
29+
computer_science,
30+
arts_technology_emerging_communication,
31+
psychology,
32+
)
33+
34+
self.REQUIREMENTS_MAP: dict[
35+
RequirementNameT,
36+
Type[Loader.AbstractRequirement],
37+
] = {
38+
# Shared requirements
39+
"CourseRequirement": shared.CourseRequirement,
40+
"AndRequirement": shared.AndRequirement,
41+
"OrRequirement": shared.OrRequirement,
42+
"FreeElectiveRequirement": shared.FreeElectiveRequirement,
43+
"SelectRequirement": shared.SelectRequirement,
44+
"HoursRequirement": shared.HoursRequirement,
45+
"PrefixBucketRequirement": shared.PrefixBucketRequirement,
46+
"OtherRequirement": shared.OtherRequirement,
47+
# Computer Science Edge Cases
48+
"CSGuidedElectiveRequirement": computer_science.MajorGuidedElectiveRequirement,
49+
# Business Administration Edge Cases
50+
"BAGuidedElectiveRequirement": business_administration.BusinessAdministrationElectiveRequirement,
51+
"SomeRequirement": business_administration.SomeRequirement,
52+
"MultiGroupElectiveRequirement": shared.MultiGroupElectiveRequirement,
53+
"ATECPrescribedElectiveRequirement": arts_technology_emerging_communication.ATECPrescribedElectiveRequirement,
54+
# Psychology
55+
"PsychologyPrefixesOrCourses": psychology.PsychologyPrefixesOrCourses,
56+
}
57+
58+
def requirement_from_json(self, json: Mapping[str, Any]) -> AbstractRequirement:
59+
if not "matcher" in json:
60+
raise ValueError(f"Invalid requirement: {json}, missing 'matcher' key.")
61+
if not self._is_valid_requirement(json["matcher"]):
62+
raise ValueError(f"Invalid requirement: {json}")
63+
64+
return self.REQUIREMENTS_MAP[json["matcher"]].from_json(json)
65+
66+
def _is_valid_requirement(self, requirement: str) -> TypeGuard[RequirementNameT]:
67+
return requirement in self.REQUIREMENTS_MAP

validator/major/requirements/map.py

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)