QuizGenerator 0.8.1__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/PKG-INFO +9 -6
  2. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/contentast.py +1 -1
  3. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/generate.py +1 -1
  4. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/mixins.py +6 -2
  5. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/basic.py +49 -7
  6. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +92 -82
  7. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +68 -45
  8. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +235 -162
  9. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +51 -45
  10. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +212 -215
  11. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/question.py +139 -18
  12. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/README.md +8 -5
  13. quizgenerator-0.8.1/pyproject_prev.toml → quizgenerator-0.9.0/pyproject.toml +1 -1
  14. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/uv.lock +1 -1
  15. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/.envrc +0 -0
  16. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/.gitignore +0 -0
  17. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/CODEOWNERS +0 -0
  18. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/LICENSE +0 -0
  19. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/__init__.py +0 -0
  20. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/__main__.py +0 -0
  21. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/canvas/__init__.py +0 -0
  22. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/canvas/canvas_interface.py +0 -0
  23. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/canvas/classes.py +0 -0
  24. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/constants.py +0 -0
  25. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/misc.py +0 -0
  26. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/performance.py +0 -0
  27. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/__init__.py +0 -0
  28. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst334/__init__.py +0 -0
  29. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst334/languages.py +0 -0
  30. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst334/math_questions.py +0 -0
  31. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst334/memory_questions.py +0 -0
  32. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +0 -0
  33. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst334/persistence_questions.py +0 -0
  34. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst334/process.py +0 -0
  35. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/__init__.py +0 -0
  36. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +0 -0
  37. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +0 -0
  38. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +0 -0
  39. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +0 -0
  40. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +0 -0
  41. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
  42. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/models/attention.py +0 -0
  43. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/models/cnns.py +0 -0
  44. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/models/matrices.py +0 -0
  45. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/models/rnns.py +0 -0
  46. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/models/text.py +0 -0
  47. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/models/weight_counting.py +0 -0
  48. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +0 -0
  49. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +0 -0
  50. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/qrcode_generator.py +0 -0
  51. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/quiz.py +0 -0
  52. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/regenerate.py +0 -0
  53. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/QuizGenerator/typst_utils.py +0 -0
  54. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/examples/web_ui_integration_example.py +0 -0
  55. /quizgenerator-0.8.1/pyproject.toml → /quizgenerator-0.9.0/pyproject_prev.toml +0 -0
  56. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/scripts/generate_practice_yaml.sh +0 -0
  57. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/scripts/print.sh +0 -0
  58. {quizgenerator-0.8.1 → quizgenerator-0.9.0}/scripts/vendor_lms_interface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QuizGenerator
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: Generate randomized quiz questions for Canvas LMS and PDF exams
5
5
  Project-URL: Homepage, https://github.com/OtterDen-Lab/QuizGenerator
6
6
  Project-URL: Documentation, https://github.com/OtterDen-Lab/QuizGenerator/tree/main/documentation
@@ -150,26 +150,29 @@ All questions follow the same three‑method flow:
150
150
 
151
151
  ```python
152
152
  class MyQuestion(Question):
153
- def _build_context(self, *, rng_seed=None, **kwargs):
153
+ @classmethod
154
+ def _build_context(cls, *, rng_seed=None, **kwargs):
154
155
  context = super()._build_context(rng_seed=rng_seed, **kwargs)
155
- rng = context["rng"]
156
+ rng = context.rng
156
157
  context["value"] = rng.randint(1, 10)
157
158
  return context
158
159
 
159
- def _build_body(self, context):
160
+ @classmethod
161
+ def _build_body(cls, context):
160
162
  body = ca.Section()
161
163
  body.add_element(ca.Paragraph([f"Value: {context['value']}"]))
162
164
  body.add_element(ca.AnswerTypes.Int(context["value"], label="Value"))
163
165
  return body
164
166
 
165
- def _build_explanation(self, context):
167
+ @classmethod
168
+ def _build_explanation(cls, context):
166
169
  explanation = ca.Section()
167
170
  explanation.add_element(ca.Paragraph([f"Answer: {context['value']}"]))
168
171
  return explanation
169
172
  ```
170
173
 
171
174
  Notes:
172
- - Always use `context["rng"]` for deterministic randomness.
175
+ - Always use `context.rng` (or `context["rng"]`) for deterministic randomness.
173
176
  - Avoid `refresh()`; it is no longer part of the API.
174
177
 
175
178
  ## Built-in Question Types
@@ -668,7 +668,7 @@ class Section(Container):
668
668
  - Organizing complex question content
669
669
 
670
670
  Example:
671
- def _build_body(self, context):
671
+ def _build_body(cls, context):
672
672
  body = Section()
673
673
  answers = []
674
674
  body.add_element(Paragraph(["Calculate the determinant:"]))
@@ -153,7 +153,7 @@ def test_all_questions(
153
153
  )
154
154
 
155
155
  # Generate the question (this calls refresh and builds the AST)
156
- instance = question.instantiate(rng_seed=seed)
156
+ instance = question.instantiate(rng_seed=seed, max_backoff_attempts=200)
157
157
  question_ast = question._build_question_ast(instance)
158
158
 
159
159
  # Try rendering to both formats to catch format-specific issues
@@ -419,8 +419,10 @@ class MathOperationQuestion(MultiPartQuestionMixin, abc.ABC):
419
419
  subparts.append((operand_a_latex, self.get_operator(), operand_b_latex))
420
420
  return subparts
421
421
 
422
- def _build_body(self, context):
422
+ @classmethod
423
+ def _build_body(cls, context):
423
424
  """Build question body and collect answers."""
425
+ self = context
424
426
  body = ca.Section()
425
427
  answers = []
426
428
 
@@ -453,8 +455,10 @@ class MathOperationQuestion(MultiPartQuestionMixin, abc.ABC):
453
455
  # Default implementation - subclasses should override for specific answer formats
454
456
  return []
455
457
 
456
- def _build_explanation(self, context):
458
+ @classmethod
459
+ def _build_explanation(cls, context):
457
460
  """Default explanation structure. Subclasses should override for specific explanations."""
461
+ self = context
458
462
  explanation = ca.Section()
459
463
 
460
464
  explanation.add_element(ca.Paragraph([self.get_explanation_intro()]))
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from typing import Tuple, List
5
+ from types import SimpleNamespace
5
6
  import random
6
7
 
7
8
  import logging
@@ -17,19 +18,23 @@ log = logging.getLogger(__name__)
17
18
  class FromText(Question):
18
19
 
19
20
  def __init__(self, *args, text, **kwargs):
21
+ kwargs["text"] = text
20
22
  super().__init__(*args, **kwargs)
21
23
  self.text = text
22
24
  self.possible_variations = 1
23
25
 
24
- def _build_context(self, *, rng_seed=None, **kwargs):
26
+ @classmethod
27
+ def _build_context(cls, *, rng_seed=None, **kwargs):
25
28
  context = super()._build_context(rng_seed=rng_seed, **kwargs)
26
- context["text"] = self.text
29
+ context["text"] = kwargs.get("text", "")
27
30
  return context
28
31
 
29
- def _build_body(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
32
+ @classmethod
33
+ def _build_body(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
30
34
  return ca.Section([ca.Text(context["text"])]), []
31
35
 
32
- def _build_explanation(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
36
+ @classmethod
37
+ def _build_explanation(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
33
38
  return ca.Section(), []
34
39
 
35
40
 
@@ -43,6 +48,7 @@ class FromGenerator(FromText, TableQuestionMixin):
43
48
  if generator is None:
44
49
  generator = text
45
50
 
51
+ kwargs["generator"] = generator
46
52
  super().__init__(*args, text="", **kwargs)
47
53
  self.possible_variations = kwargs.get("possible_variations", float('inf'))
48
54
 
@@ -72,15 +78,51 @@ class FromGenerator(FromText, TableQuestionMixin):
72
78
  # Attach the function dynamically
73
79
  attach_function_to_object(self, generator, "generator")
74
80
 
75
- def _build_context(self, *, rng_seed=None, **kwargs):
81
+ @staticmethod
82
+ def _compile_generator(function_code, function_name="generator"):
83
+ # Provide a deterministic RNG handle for generator snippets.
84
+ function_code = "rng = self.rng\n" + function_code
85
+
86
+ local_namespace = {
87
+ 'ca': ca,
88
+ 'Section': ca.Section,
89
+ 'Text': ca.Text,
90
+ 'Table': ca.Table,
91
+ 'Paragraph': ca.Paragraph
92
+ }
93
+
94
+ exec_globals = {**globals(), **local_namespace}
95
+ exec(
96
+ f"def {function_name}(self):\n" + "\n".join(f" {line}" for line in function_code.splitlines()),
97
+ exec_globals,
98
+ local_namespace
99
+ )
100
+ return local_namespace[function_name]
101
+
102
+ @classmethod
103
+ def _build_context(cls, *, rng_seed=None, **kwargs):
76
104
  context = super()._build_context(rng_seed=rng_seed, **kwargs)
105
+ for key, value in kwargs.items():
106
+ if key not in context:
107
+ context[key] = value
77
108
  # Preserve prior behavior for generators that use the global random module.
78
109
  random.seed(rng_seed)
110
+ generator_text = kwargs.get("generator")
111
+ if generator_text is not None:
112
+ context["generator_fn"] = cls._compile_generator(generator_text)
113
+ context["generator_scope"] = SimpleNamespace(
114
+ rng=context.rng,
115
+ **context.data
116
+ )
79
117
  return context
80
118
 
81
- def _build_body(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
119
+ @classmethod
120
+ def _build_body(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
82
121
  try:
83
- generated_content = self.generator()
122
+ generator_fn = context.get("generator_fn")
123
+ if generator_fn is None:
124
+ raise TypeError("No generator provided for FromGenerator.")
125
+ generated_content = generator_fn(context.get("generator_scope"))
84
126
  if isinstance(generated_content, ca.Section):
85
127
  body = generated_content
86
128
  elif isinstance(generated_content, str):
@@ -15,48 +15,52 @@ log = logging.getLogger(__name__)
15
15
  class DerivativeQuestion(Question, abc.ABC):
16
16
  """Base class for derivative calculation questions."""
17
17
 
18
+ DEFAULT_NUM_VARIABLES = 2
19
+ DEFAULT_MAX_DEGREE = 2
20
+
18
21
  def __init__(self, *args, **kwargs):
19
22
  kwargs["topic"] = kwargs.get("topic", Question.Topic.ML_OPTIMIZATION)
20
23
  super().__init__(*args, **kwargs)
21
- self.num_variables = kwargs.get("num_variables", 2)
22
- self.max_degree = kwargs.get("max_degree", 2)
23
-
24
- def _build_context(self, *, rng_seed=None, **kwargs):
25
- if "num_variables" in kwargs:
26
- self.num_variables = kwargs.get("num_variables", self.num_variables)
27
- if "max_degree" in kwargs:
28
- self.max_degree = kwargs.get("max_degree", self.max_degree)
29
- self.rng.seed(rng_seed)
30
- context = dict(kwargs)
31
- context["rng_seed"] = rng_seed
24
+ self.num_variables = kwargs.get("num_variables", self.DEFAULT_NUM_VARIABLES)
25
+ self.max_degree = kwargs.get("max_degree", self.DEFAULT_MAX_DEGREE)
26
+
27
+ @classmethod
28
+ def _build_context(cls, *, rng_seed=None, **kwargs):
29
+ context = super()._build_context(rng_seed=rng_seed, **kwargs)
30
+ context["num_variables"] = kwargs.get("num_variables", cls.DEFAULT_NUM_VARIABLES)
31
+ context["max_degree"] = kwargs.get("max_degree", cls.DEFAULT_MAX_DEGREE)
32
32
  return context
33
33
 
34
- def _generate_evaluation_point(self) -> List[float]:
34
+ @classmethod
35
+ def _generate_evaluation_point(cls, context) -> List[float]:
35
36
  """Generate a random point for gradient evaluation."""
36
- return [self.rng.randint(-3, 3) for _ in range(self.num_variables)]
37
+ return [context.rng.randint(-3, 3) for _ in range(context.num_variables)]
37
38
 
38
- def _format_partial_derivative(self, var_index: int) -> str:
39
+ @staticmethod
40
+ def _format_partial_derivative(var_index: int, num_variables: int) -> str:
39
41
  """Format partial derivative symbol for display."""
40
- if self.num_variables == 1:
42
+ if num_variables == 1:
41
43
  return "\\frac{df}{dx_0}"
42
- else:
43
- return f"\\frac{{\\partial f}}{{\\partial x_{var_index}}}"
44
+ return f"\\frac{{\\partial f}}{{\\partial x_{var_index}}}"
44
45
 
45
- def _create_derivative_answers(self, evaluation_point: List[float]) -> List[ca.Answer]:
46
+ @staticmethod
47
+ def _create_derivative_answers(context) -> List[ca.Answer]:
46
48
  """Create answer fields for each partial derivative at the evaluation point."""
47
49
  answers: List[ca.Answer] = []
48
50
 
49
51
  # Evaluate gradient at the specified point
50
- subs_map = dict(zip(self.variables, evaluation_point))
52
+ subs_map = dict(zip(context.variables, context.evaluation_point))
51
53
 
52
54
  # Format evaluation point for label
53
- eval_point_str = ", ".join([f"x_{i} = {evaluation_point[i]}" for i in range(self.num_variables)])
55
+ eval_point_str = ", ".join(
56
+ [f"x_{i} = {context.evaluation_point[i]}" for i in range(context.num_variables)]
57
+ )
54
58
 
55
59
  # Create answer for each partial derivative
56
- for i in range(self.num_variables):
60
+ for i in range(context.num_variables):
57
61
  answer_key = f"partial_derivative_{i}"
58
62
  # Evaluate the partial derivative and convert to float
59
- partial_value = self.gradient_function[i].subs(subs_map)
63
+ partial_value = context.gradient_function[i].subs(subs_map)
60
64
  try:
61
65
  gradient_value = float(partial_value)
62
66
  except (TypeError, ValueError):
@@ -71,14 +75,15 @@ class DerivativeQuestion(Question, abc.ABC):
71
75
 
72
76
  return answers
73
77
 
74
- def _create_gradient_vector_answer(self) -> ca.Answer:
78
+ @staticmethod
79
+ def _create_gradient_vector_answer(context) -> ca.Answer:
75
80
  """Create a single gradient vector answer for PDF format."""
76
81
  # Format gradient as vector notation
77
- subs_map = dict(zip(self.variables, self.evaluation_point))
82
+ subs_map = dict(zip(context.variables, context.evaluation_point))
78
83
  gradient_values = []
79
84
 
80
- for i in range(self.num_variables):
81
- partial_value = self.gradient_function[i].subs(subs_map)
85
+ for i in range(context.num_variables):
86
+ partial_value = context.gradient_function[i].subs(subs_map)
82
87
  try:
83
88
  gradient_value = float(partial_value)
84
89
  except TypeError:
@@ -89,7 +94,8 @@ class DerivativeQuestion(Question, abc.ABC):
89
94
  vector_str = format_vector(gradient_values)
90
95
  return ca.AnswerTypes.String(vector_str, pdf_only=True)
91
96
 
92
- def _build_body(self, context) -> Tuple[ca.Section, List[ca.Answer]]:
97
+ @classmethod
98
+ def _build_body(cls, context) -> Tuple[ca.Section, List[ca.Answer]]:
93
99
  """Build question body and collect answers."""
94
100
  body = ca.Section()
95
101
  answers = []
@@ -98,15 +104,17 @@ class DerivativeQuestion(Question, abc.ABC):
98
104
  body.add_element(
99
105
  ca.Paragraph([
100
106
  "Given the function ",
101
- ca.Equation(sp.latex(self.equation), inline=True),
107
+ ca.Equation(sp.latex(context.equation), inline=True),
102
108
  ", calculate the gradient at the point ",
103
- ca.Equation(format_vector(self.evaluation_point), inline=True),
109
+ ca.Equation(format_vector(context.evaluation_point), inline=True),
104
110
  "."
105
111
  ])
106
112
  )
107
113
 
108
114
  # Format evaluation point for LaTeX
109
- eval_point_str = ", ".join([f"x_{i} = {self.evaluation_point[i]}" for i in range(self.num_variables)])
115
+ eval_point_str = ", ".join(
116
+ [f"x_{i} = {context.evaluation_point[i]}" for i in range(context.num_variables)]
117
+ )
110
118
 
111
119
  # For PDF: Use OnlyLatex to show gradient vector format (no answer blank)
112
120
  body.add_element(
@@ -121,14 +129,14 @@ class DerivativeQuestion(Question, abc.ABC):
121
129
  )
122
130
 
123
131
  # For Canvas: Use OnlyHtml to show individual partial derivatives
124
- derivative_answers = self._create_derivative_answers(self.evaluation_point)
132
+ derivative_answers = cls._create_derivative_answers(context)
125
133
  for i, answer in enumerate(derivative_answers):
126
134
  answers.append(answer)
127
135
  body.add_element(
128
136
  ca.OnlyHtml([
129
137
  ca.Paragraph([
130
138
  ca.Equation(
131
- f"\\left. {self._format_partial_derivative(i)} \\right|_{{{eval_point_str}}} = ",
139
+ f"\\left. {cls._format_partial_derivative(i, context.num_variables)} \\right|_{{{eval_point_str}}} = ",
132
140
  inline=True
133
141
  ),
134
142
  answer
@@ -138,7 +146,8 @@ class DerivativeQuestion(Question, abc.ABC):
138
146
 
139
147
  return body, answers
140
148
 
141
- def _build_explanation(self, context) -> Tuple[ca.Section, List[ca.Answer]]:
149
+ @classmethod
150
+ def _build_explanation(cls, context) -> Tuple[ca.Section, List[ca.Answer]]:
142
151
  """Build question explanation."""
143
152
  explanation = ca.Section()
144
153
 
@@ -146,27 +155,27 @@ class DerivativeQuestion(Question, abc.ABC):
146
155
  explanation.add_element(
147
156
  ca.Paragraph([
148
157
  "To find the gradient, we calculate the partial derivatives of ",
149
- ca.Equation(sp.latex(self.equation), inline=True),
158
+ ca.Equation(sp.latex(context.equation), inline=True),
150
159
  ":"
151
160
  ])
152
161
  )
153
162
 
154
163
  # Show analytical gradient
155
164
  explanation.add_element(
156
- ca.Equation(f"\\nabla f = {sp.latex(self.gradient_function)}", inline=False)
165
+ ca.Equation(f"\\nabla f = {sp.latex(context.gradient_function)}", inline=False)
157
166
  )
158
167
 
159
168
  # Show evaluation at the specific point
160
169
  explanation.add_element(
161
170
  ca.Paragraph([
162
- f"Evaluating at the point {format_vector(self.evaluation_point)}:"
171
+ f"Evaluating at the point {format_vector(context.evaluation_point)}:"
163
172
  ])
164
173
  )
165
174
 
166
175
  # Show each partial derivative calculation
167
- subs_map = dict(zip(self.variables, self.evaluation_point))
168
- for i in range(self.num_variables):
169
- partial_expr = self.gradient_function[i]
176
+ subs_map = dict(zip(context.variables, context.evaluation_point))
177
+ for i in range(context.num_variables):
178
+ partial_expr = context.gradient_function[i]
170
179
  partial_value = partial_expr.subs(subs_map)
171
180
 
172
181
  # Use ca.Answer.accepted_strings for clean numerical formatting
@@ -181,7 +190,7 @@ class DerivativeQuestion(Question, abc.ABC):
181
190
  explanation.add_element(
182
191
  ca.Paragraph([
183
192
  ca.Equation(
184
- f"{self._format_partial_derivative(i)} = {sp.latex(partial_expr)} = {clean_value}",
193
+ f"{cls._format_partial_derivative(i, context.num_variables)} = {sp.latex(partial_expr)} = {clean_value}",
185
194
  inline=False
186
195
  )
187
196
  ])
@@ -194,22 +203,21 @@ class DerivativeQuestion(Question, abc.ABC):
194
203
  class DerivativeBasic(DerivativeQuestion):
195
204
  """Basic derivative calculation using polynomial functions."""
196
205
 
197
- def _build_context(self, *, rng_seed=None, **kwargs):
198
- super()._build_context(rng_seed=rng_seed, **kwargs)
206
+ @classmethod
207
+ def _build_context(cls, *, rng_seed=None, **kwargs):
208
+ context = super()._build_context(rng_seed=rng_seed, **kwargs)
199
209
 
200
210
  # Generate a basic polynomial function
201
- self.variables, self.function, self.gradient_function, self.equation = generate_function(
202
- self.rng, self.num_variables, self.max_degree
211
+ context.variables, context.function, context.gradient_function, context.equation = generate_function(
212
+ context.rng, context.num_variables, context.max_degree
203
213
  )
204
214
 
205
215
  # Generate evaluation point
206
- self.evaluation_point = self._generate_evaluation_point()
216
+ context.evaluation_point = cls._generate_evaluation_point(context)
207
217
 
208
218
  # Create answers for evaluation point (used in _build_body)
209
- self._create_derivative_answers(self.evaluation_point)
219
+ cls._create_derivative_answers(context)
210
220
 
211
- context = dict(kwargs)
212
- context["rng_seed"] = rng_seed
213
221
  return context
214
222
 
215
223
 
@@ -217,21 +225,22 @@ class DerivativeBasic(DerivativeQuestion):
217
225
  class DerivativeChain(DerivativeQuestion):
218
226
  """Chain rule derivative calculation using function composition."""
219
227
 
220
- def _build_context(self, *, rng_seed=None, **kwargs):
221
- super()._build_context(rng_seed=rng_seed, **kwargs)
228
+ @classmethod
229
+ def _build_context(cls, *, rng_seed=None, **kwargs):
230
+ context = super()._build_context(rng_seed=rng_seed, **kwargs)
222
231
 
223
232
  # Try to generate a valid function/point combination, regenerating if we hit complex numbers
224
233
  max_attempts = 10
225
234
  for attempt in range(max_attempts):
226
235
  try:
227
236
  # Generate inner and outer functions for composition
228
- self._generate_composed_function()
237
+ cls._generate_composed_function(context)
229
238
 
230
239
  # Generate evaluation point
231
- self.evaluation_point = self._generate_evaluation_point()
240
+ context.evaluation_point = cls._generate_evaluation_point(context)
232
241
 
233
242
  # Create answers - this will raise ValueError if we get complex numbers
234
- self._create_derivative_answers(self.evaluation_point)
243
+ cls._create_derivative_answers(context)
235
244
 
236
245
  # If we get here, everything worked
237
246
  break
@@ -239,30 +248,28 @@ class DerivativeChain(DerivativeQuestion):
239
248
  except ValueError as e:
240
249
  if "Complex number encountered" in str(e) and attempt < max_attempts - 1:
241
250
  # Advance RNG state by making a dummy call
242
- _ = self.rng.random()
251
+ _ = context.rng.random()
243
252
  continue
244
253
  else:
245
254
  # If we've exhausted attempts or different error, re-raise
246
255
  raise
247
-
248
- context = dict(kwargs)
249
- context["rng_seed"] = rng_seed
250
256
  return context
251
257
 
252
- def _generate_composed_function(self) -> None:
258
+ @staticmethod
259
+ def _generate_composed_function(context) -> None:
253
260
  """Generate a composed function f(g(x)) for chain rule practice."""
254
261
  # Create variable symbols
255
- var_names = [f'x_{i}' for i in range(self.num_variables)]
256
- self.variables = sp.symbols(var_names)
262
+ var_names = [f'x_{i}' for i in range(context.num_variables)]
263
+ context.variables = sp.symbols(var_names)
257
264
 
258
265
  # Generate inner function g(x) - simpler polynomial
259
- inner_terms = [m for m in sp.polys.itermonomials(self.variables, max(1, self.max_degree-1)) if m != 1]
266
+ inner_terms = [m for m in sp.polys.itermonomials(context.variables, max(1, context.max_degree-1)) if m != 1]
260
267
  coeff_pool = [*range(-5, 0), *range(1, 6)] # Smaller coefficients for inner function
261
268
 
262
269
  if inner_terms:
263
- inner_poly = sp.Add(*(self.rng.choice(coeff_pool) * t for t in inner_terms))
270
+ inner_poly = sp.Add(*(context.rng.choice(coeff_pool) * t for t in inner_terms))
264
271
  else:
265
- inner_poly = sp.Add(*[self.rng.choice(coeff_pool) * v for v in self.variables])
272
+ inner_poly = sp.Add(*[context.rng.choice(coeff_pool) * v for v in context.variables])
266
273
 
267
274
  # Generate outer function - use polynomials, exp, and ln for reliable evaluation
268
275
  u = sp.Symbol('u') # Intermediate variable
@@ -274,21 +281,24 @@ class DerivativeChain(DerivativeQuestion):
274
281
  sp.log(u + 2) # Add 2 to ensure positive argument for evaluation points
275
282
  ]
276
283
 
277
- outer_func = self.rng.choice(outer_functions)
284
+ outer_func = context.rng.choice(outer_functions)
278
285
 
279
286
  # Compose the functions: f(g(x))
280
- self.inner_function = inner_poly
281
- self.outer_function = outer_func
282
- self.function = outer_func.subs(u, inner_poly)
287
+ context.inner_function = inner_poly
288
+ context.outer_function = outer_func
289
+ context.function = outer_func.subs(u, inner_poly)
283
290
 
284
291
  # Calculate gradient using chain rule
285
- self.gradient_function = sp.Matrix([self.function.diff(v) for v in self.variables])
292
+ context.gradient_function = sp.Matrix([context.function.diff(v) for v in context.variables])
286
293
 
287
294
  # Create equation for display
288
295
  f = sp.Function('f')
289
- self.equation = sp.Eq(f(*self.variables), self.function)
296
+ context.equation = sp.Eq(f(*context.variables), context.function)
297
+
298
+ return context
290
299
 
291
- def _build_explanation(self, context) -> Tuple[ca.Section, List[ca.Answer]]:
300
+ @classmethod
301
+ def _build_explanation(cls, context) -> Tuple[ca.Section, List[ca.Answer]]:
292
302
  """Build question explanation."""
293
303
  explanation = ca.Section()
294
304
 
@@ -296,9 +306,9 @@ class DerivativeChain(DerivativeQuestion):
296
306
  explanation.add_element(
297
307
  ca.Paragraph([
298
308
  "This is a composition of functions requiring the chain rule. The function ",
299
- ca.Equation(sp.latex(self.equation), inline=True),
309
+ ca.Equation(sp.latex(context.equation), inline=True),
300
310
  " can be written as ",
301
- ca.Equation(f"f(g(x)) \\text{{ where }} g(x) = {sp.latex(self.inner_function)}", inline=True),
311
+ ca.Equation(f"f(g(x)) \\text{{ where }} g(x) = {sp.latex(context.inner_function)}", inline=True),
302
312
  "."
303
313
  ])
304
314
  )
@@ -313,7 +323,7 @@ class DerivativeChain(DerivativeQuestion):
313
323
  )
314
324
 
315
325
  # Show chain rule formula for each variable
316
- for i in range(self.num_variables):
326
+ for i in range(context.num_variables):
317
327
  var_name = f"x_{i}"
318
328
  explanation.add_element(
319
329
  ca.Equation(
@@ -329,12 +339,12 @@ class DerivativeChain(DerivativeQuestion):
329
339
  )
330
340
 
331
341
  # Show the specific derivatives step by step
332
- for i in range(self.num_variables):
342
+ for i in range(context.num_variables):
333
343
  var_name = f"x_{i}"
334
344
 
335
345
  # Get outer function derivative with respect to inner function
336
- outer_deriv = self.outer_function.diff(sp.Symbol('u'))
337
- inner_deriv = self.inner_function.diff(self.variables[i])
346
+ outer_deriv = context.outer_function.diff(sp.Symbol('u'))
347
+ inner_deriv = context.inner_function.diff(context.variables[i])
338
348
 
339
349
  explanation.add_element(
340
350
  ca.Paragraph([
@@ -357,20 +367,20 @@ class DerivativeChain(DerivativeQuestion):
357
367
  )
358
368
 
359
369
  explanation.add_element(
360
- ca.Equation(f"\\nabla f = {sp.latex(self.gradient_function)}", inline=False)
370
+ ca.Equation(f"\\nabla f = {sp.latex(context.gradient_function)}", inline=False)
361
371
  )
362
372
 
363
373
  # Show evaluation at the specific point
364
374
  explanation.add_element(
365
375
  ca.Paragraph([
366
- f"Evaluating at the point {format_vector(self.evaluation_point)}:"
376
+ f"Evaluating at the point {format_vector(context.evaluation_point)}:"
367
377
  ])
368
378
  )
369
379
 
370
380
  # Show each partial derivative calculation
371
- subs_map = dict(zip(self.variables, self.evaluation_point))
372
- for i in range(self.num_variables):
373
- partial_expr = self.gradient_function[i]
381
+ subs_map = dict(zip(context.variables, context.evaluation_point))
382
+ for i in range(context.num_variables):
383
+ partial_expr = context.gradient_function[i]
374
384
  partial_value = partial_expr.subs(subs_map)
375
385
 
376
386
  # Use ca.Answer.accepted_strings for clean numerical formatting
@@ -385,7 +395,7 @@ class DerivativeChain(DerivativeQuestion):
385
395
  explanation.add_element(
386
396
  ca.Paragraph([
387
397
  ca.Equation(
388
- f"{self._format_partial_derivative(i)} = {sp.latex(partial_expr)} = {clean_value}",
398
+ f"{cls._format_partial_derivative(i, context.num_variables)} = {sp.latex(partial_expr)} = {clean_value}",
389
399
  inline=False
390
400
  )
391
401
  ])