lib/zen/src/quiz/plan.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const model = @import("model.zig");
3 const scan = @import("scan.zig");
4
5 pub const Limits = struct {
6 max_questions: usize,
7 max_options: usize,
8 max_joined_text_bytes: usize,
9 };
10
11 pub const default_limits: Limits = .{
12 .max_questions = 128,
13 .max_options = 512,
14 .max_joined_text_bytes = 64 * 1024,
15 };
16
17 pub const Plan = struct {
18 input_bytes: usize,
19 questions: usize,
20 options: usize,
21 joined_text_bytes: usize,
22
23 pub fn inspect(source: []const u8, limits: Limits) model.Error!Plan {
24 var state: State = .{};
25 var iterator = scan.Iterator.init(source);
26 while (iterator.next()) |token| switch (token) {
27 .prompt => |text| {
28 if (state.current.started()) try state.current.finish();
29 state.questions = try added(state.questions, 1);
30 if (state.questions > limits.max_questions) {
31 return error.QuestionCapacityExceeded;
32 }
33 state.current = .{
34 .element = .prompt,
35 .element_bytes = text.len,
36 };
37 },
38 .option => |option| {
39 if (!state.current.started() or state.current.element == .explanation) {
40 return error.InvalidQuizDirective;
41 }
42 state.options = try added(state.options, 1);
43 if (state.options > limits.max_options) return error.OptionCapacityExceeded;
44 state.current.options = try added(state.current.options, 1);
45 if (option.correct) {
46 state.current.correct = try added(state.current.correct, 1);
47 }
48 state.current.element = .option;
49 state.current.element_bytes = option.text.len;
50 state.current.element_joined = false;
51 },
52 .explanation => |text| {
53 if (!state.current.started()) return error.InvalidQuizDirective;
54 if (state.current.element == .explanation) {
55 try state.join(text, limits.max_joined_text_bytes);
56 } else {
57 state.current.element = .explanation;
58 state.current.element_bytes = text.len;
59 state.current.element_joined = false;
60 state.current.explanation_bytes = text.len;
61 }
62 },
63 .continuation => |text| {
64 if (!state.current.started()) return error.InvalidQuizDirective;
65 try state.join(text, limits.max_joined_text_bytes);
66 },
67 };
68 if (state.current.started()) try state.current.finish();
69 if (state.questions == 0) return error.InvalidQuizDirective;
70 std.debug.assert(state.questions <= limits.max_questions);
71 std.debug.assert(state.options <= limits.max_options);
72 std.debug.assert(state.joined_text_bytes <= limits.max_joined_text_bytes);
73 return .{
74 .input_bytes = source.len,
75 .questions = state.questions,
76 .options = state.options,
77 .joined_text_bytes = state.joined_text_bytes,
78 };
79 }
80
81 pub fn exactLimits(self: Plan) Limits {
82 return .{
83 .max_questions = self.questions,
84 .max_options = self.options,
85 .max_joined_text_bytes = self.joined_text_bytes,
86 };
87 }
88 };
89
90 const Element = enum {
91 none,
92 prompt,
93 option,
94 explanation,
95 };
96
97 const Current = struct {
98 element: Element = .none,
99 element_bytes: usize = 0,
100 element_joined: bool = false,
101 options: usize = 0,
102 correct: usize = 0,
103 explanation_bytes: usize = 0,
104
105 fn started(self: Current) bool {
106 return self.element != .none;
107 }
108
109 fn finish(self: Current) model.ParseError!void {
110 std.debug.assert(self.started());
111 std.debug.assert(self.element_bytes != 0);
112 if (self.options == 1) return error.InvalidQuizDirective;
113 if (self.options != 0 and self.correct != 1) return error.InvalidQuizDirective;
114 if (self.options == 0 and self.explanation_bytes == 0) {
115 return error.InvalidQuizDirective;
116 }
117 }
118 };
119
120 const State = struct {
121 questions: usize = 0,
122 options: usize = 0,
123 joined_text_bytes: usize = 0,
124 current: Current = .{},
125
126 fn join(self: *State, text: []const u8, max_joined_text_bytes: usize) model.Error!void {
127 std.debug.assert(self.current.started());
128 std.debug.assert(self.current.element_bytes != 0);
129 if (!self.current.element_joined) {
130 self.joined_text_bytes = try added(
131 self.joined_text_bytes,
132 self.current.element_bytes,
133 );
134 self.current.element_joined = true;
135 }
136 self.joined_text_bytes = try added(self.joined_text_bytes, 1);
137 self.joined_text_bytes = try added(self.joined_text_bytes, text.len);
138 if (self.joined_text_bytes > max_joined_text_bytes) {
139 return error.JoinedTextByteCapacityExceeded;
140 }
141 self.current.element_bytes = try added(self.current.element_bytes, 1);
142 self.current.element_bytes = try added(self.current.element_bytes, text.len);
143 if (self.current.element == .explanation) {
144 self.current.explanation_bytes = self.current.element_bytes;
145 }
146 }
147 };
148
149 fn added(left: usize, right: usize) error{CapacityOverflow}!usize {
150 return std.math.add(usize, left, right) catch error.CapacityOverflow;
151 }
152
153 test "quiz plan counts descriptors and only joined text" {
154 const source =
155 "? Which pass inserts safety checks?\n" ++
156 "- AstGen\n" ++
157 "* Sema\n" ++
158 "- Liveness\n" ++
159 "! Sema lowers ZIR to AIR and materializes checks\n" ++
160 " as instructions.\n" ++
161 "\n" ++
162 "? What owns the quiz grammar?\n" ++
163 "! The zen quiz module.\n";
164 const plan = try Plan.inspect(source, default_limits);
165 try std.testing.expectEqual(@as(usize, 2), plan.questions);
166 try std.testing.expectEqual(@as(usize, 3), plan.options);
167 try std.testing.expectEqual(
168 "Sema lowers ZIR to AIR and materializes checks as instructions.".len,
169 plan.joined_text_bytes,
170 );
171 }
172
173 test "quiz plan borrows every uncontinued text field" {
174 const plan = try Plan.inspect(
175 "? Prompt\n* Correct\n- Other\n! Explanation\n",
176 default_limits,
177 );
178 try std.testing.expectEqual(@as(usize, 0), plan.joined_text_bytes);
179 }