@panaversity/ksor 0.0.28 → 0.0.30
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.
- package/CHANGELOG.md +84 -0
- package/dist/cli.mjs +48 -13
- package/dist/{gateway-api-8lNruq9e-CuohjtoK.mjs → gateway-api-CbFkHZiU-HvlJRjRB.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/docs/authorization.md +1 -1
- package/docs/deploying.md +71 -20
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +3 -1
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +3 -1
- package/templates/scaffold/AGENTS.md +56 -10
- package/templates/scaffold/README.md +3 -3
- package/templates/scaffold/env.example +24 -8
- package/templates/scaffold/knowledge/what-is-a-ksor.quiz.yaml +90 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +9 -2
- package/templates/scaffold/system/site/app/global.css +68 -0
- package/templates/scaffold/system/site/components/quiz.tsx +321 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +1 -1
- package/templates/scaffold/system/site/lib/attachment-rule.ts +7 -0
- package/templates/scaffold/system/site/lib/attachments.ts +41 -3
- package/templates/scaffold/system/site/lib/deck.ts +3 -12
- package/templates/scaffold/system/site/lib/identity.ts +55 -0
- package/templates/scaffold/system/site/lib/quiz-audit.ts +306 -0
- package/templates/scaffold/system/site/lib/quiz-round.ts +57 -0
- package/templates/scaffold/system/site/lib/quiz.ts +84 -0
- package/templates/scaffold/system/site/source.config.ts +16 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity for authored text: the hash, and what a question hashes.
|
|
3
|
+
*
|
|
4
|
+
* FNV-1a, 32-bit, hand-rolled — the site has no crypto import at build time and
|
|
5
|
+
* this has to produce the same value in the browser, where the saved progress
|
|
6
|
+
* it keys actually lives. A collision costs one card's or one question's saved
|
|
7
|
+
* state, never correctness, so 32 bits is the right size of hammer.
|
|
8
|
+
*
|
|
9
|
+
* Extracted so the deck and the quiz share ONE implementation. Two hand-rolled
|
|
10
|
+
* copies of a hash is the kind of duplication that stays identical right up
|
|
11
|
+
* until someone fixes a separator in one of them.
|
|
12
|
+
*
|
|
13
|
+
* A LEAF, and it has to stay one. This repo can only unit-test a scaffold
|
|
14
|
+
* module with no relative imports: `tsc` under node16 resolution demands a
|
|
15
|
+
* `.js` specifier, and Next's bundler in the scaffold cannot resolve that back
|
|
16
|
+
* to a `.ts` file — so a scaffold module that imports a sibling either fails
|
|
17
|
+
* the typecheck or fails the site build. `questionHash` therefore lives here
|
|
18
|
+
* beside the hash it calls rather than in a file of its own.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The separator between parts, written as an escape rather than embedded: a raw
|
|
23
|
+
* NUL in the source makes git treat the file as binary.
|
|
24
|
+
*
|
|
25
|
+
* A separator is load-bearing. Without one, `["ab", "c"]` and `["a", "bc"]`
|
|
26
|
+
* hash identically, and NUL is the one character authored text cannot contain.
|
|
27
|
+
*/
|
|
28
|
+
const SEPARATOR = "\u0000";
|
|
29
|
+
|
|
30
|
+
/** Hash these parts as one identity. */
|
|
31
|
+
export function textHash(parts: readonly string[]): string {
|
|
32
|
+
const text = parts.join(SEPARATOR);
|
|
33
|
+
let hash = 0x811c9dc5;
|
|
34
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
35
|
+
hash ^= text.charCodeAt(i);
|
|
36
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
37
|
+
}
|
|
38
|
+
return hash.toString(16).padStart(8, "0");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A question's identity: a hash of the text the reader actually sees.
|
|
43
|
+
*
|
|
44
|
+
* The stem AND the options, deliberately including their ORDER — reordering
|
|
45
|
+
* changes which index is correct, so a saved answer would otherwise be
|
|
46
|
+
* re-scored against a different question and silently become right or wrong.
|
|
47
|
+
* `explanation` and `source` are excluded: they teach ABOUT the question
|
|
48
|
+
* rather than being it, so improving an explanation costs the reader nothing.
|
|
49
|
+
*/
|
|
50
|
+
export function questionHash(question: {
|
|
51
|
+
readonly question: string;
|
|
52
|
+
readonly options: readonly string[];
|
|
53
|
+
}): string {
|
|
54
|
+
return textHash([question.question, ...question.options]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical hygiene checks for a quiz, run before it can be published.
|
|
3
|
+
*
|
|
4
|
+
* Carried from the predecessor's `scripts/quiz-audit/` under decision 6, and
|
|
5
|
+
* carried because of what its own README records: these bugs SHIPPED and were
|
|
6
|
+
* found by students rather than by the project — every correct answer at
|
|
7
|
+
* position A across 9 quizzes and 451 questions, explanations dismissing their
|
|
8
|
+
* own marked answer, and the correct option systematically the longest. Its
|
|
9
|
+
* `findings-2026-04-14.txt` still reports 88% pick-longest in one file, which
|
|
10
|
+
* is the argument for running this in the build instead of on request: the
|
|
11
|
+
* script existed, and the findings sat.
|
|
12
|
+
*
|
|
13
|
+
* What is NOT carried is its thresholds. It targets a 15-35% distribution,
|
|
14
|
+
* which a five-question bank cannot satisfy without the checker dictating the
|
|
15
|
+
* answers. These are floors against the shipped bug, not a distribution target
|
|
16
|
+
* — see MIN_BANK_FOR_RATIOS.
|
|
17
|
+
*
|
|
18
|
+
* Verified against the predecessor's real data before shipping (2026-08-23):
|
|
19
|
+
* run over the 18 parsed questions of its `11-chapter-quiz.md`, this reports
|
|
20
|
+
* 67% of answers at option C and 78% pick-longest — both real, both shipped,
|
|
21
|
+
* and both the exact class its README says students found rather than the
|
|
22
|
+
* project. A rule that fires only on fixtures would not have earned this.
|
|
23
|
+
*
|
|
24
|
+
* A LEAF: no imports, so the site's build and the record's checker can both
|
|
25
|
+
* hold this rule without either taking the other's dependencies.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** One question, in the shape the audit needs — a structural subset of the schema. */
|
|
29
|
+
export interface AuditQuestion {
|
|
30
|
+
readonly question: string;
|
|
31
|
+
readonly options: readonly string[];
|
|
32
|
+
readonly answer: number;
|
|
33
|
+
readonly explanation?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AuditQuiz {
|
|
37
|
+
readonly quiz: { readonly title: string };
|
|
38
|
+
readonly questions: readonly AuditQuestion[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface QuizFinding {
|
|
42
|
+
readonly slug: string;
|
|
43
|
+
/** 1-based question numbers, so a human can find them in the file. */
|
|
44
|
+
readonly questions: readonly number[];
|
|
45
|
+
/** What was measured, with the number that failed. */
|
|
46
|
+
readonly detail: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const QUIZ_AUDIT_SLUGS = [
|
|
50
|
+
"ksor-quiz-answer-bias",
|
|
51
|
+
"ksor-quiz-length-bias",
|
|
52
|
+
"ksor-quiz-answer-run",
|
|
53
|
+
"ksor-quiz-contradiction",
|
|
54
|
+
"ksor-quiz-duplicate-stem",
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Below this many questions the ratio checks do not run.
|
|
59
|
+
*
|
|
60
|
+
* A four-question bank cannot spread answers across four indices without the
|
|
61
|
+
* checker deciding which option is correct, and a record's answers are the
|
|
62
|
+
* record's business. The run and contradiction checks have no such floor —
|
|
63
|
+
* they are wrong at any size.
|
|
64
|
+
*/
|
|
65
|
+
export const MIN_BANK_FOR_RATIOS = 5;
|
|
66
|
+
/** Above this share of answers on one index, the reader can guess. */
|
|
67
|
+
export const MAX_INDEX_SHARE = 0.6;
|
|
68
|
+
/** Above this share, "always pick the longest" is a winning strategy. */
|
|
69
|
+
export const MAX_STRATEGY_WIN = 0.6;
|
|
70
|
+
/** A run this long reads as a pattern rather than as chance. */
|
|
71
|
+
export const MAX_SAME_ANSWER_RUN = 3;
|
|
72
|
+
/** Two stems sharing this much of their opening are the same question twice. */
|
|
73
|
+
export const STEM_PREFIX = 60;
|
|
74
|
+
|
|
75
|
+
/** Letters for the dismissal check: `answer: 0` is spoken about as "A". */
|
|
76
|
+
const LETTERS = "ABCDEFGH";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Phrases that dismiss an option, paired with how an author names it.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately narrow. A broad reading of explanation prose refuses honest
|
|
82
|
+
* text — "option B is wrong" is exactly what a good explanation SAYS about the
|
|
83
|
+
* distractors, so only a phrase naming the MARKED answer is a contradiction.
|
|
84
|
+
*/
|
|
85
|
+
function dismissalsOf(letter: string, index: number): readonly RegExp[] {
|
|
86
|
+
const names = [`option ${letter}`, `\\(${letter}\\)`, `answer ${letter}`, `option ${index + 1}`];
|
|
87
|
+
const verdicts = ["is wrong", "is incorrect", "is not correct", "is false"];
|
|
88
|
+
return names.flatMap((name) =>
|
|
89
|
+
verdicts.map((verdict) => new RegExp(`${name}\\b[^.]{0,40}?${verdict}`, "i")),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The option a length strategy would pick, or null when nothing is picked.
|
|
95
|
+
*
|
|
96
|
+
* A TIE is not a win. If two options are the longest, "always pick the longest"
|
|
97
|
+
* does not name an answer, so counting it as a win would report 100% on a quiz
|
|
98
|
+
* whose options are all deliberately the same length — which is the shape the
|
|
99
|
+
* check exists to encourage. Found by the conformance table: options of equal
|
|
100
|
+
* length scored as pick-longest wins on every question.
|
|
101
|
+
*/
|
|
102
|
+
function uniqueExtreme(options: readonly string[], want: "long" | "short"): number | null {
|
|
103
|
+
const lengths = options.map((o) => o.length);
|
|
104
|
+
const target = want === "long" ? Math.max(...lengths) : Math.min(...lengths);
|
|
105
|
+
const hits = lengths.flatMap((len, i) => (len === target ? [i] : []));
|
|
106
|
+
return hits.length === 1 ? (hits[0] ?? null) : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every hygiene problem in this quiz, or an empty array.
|
|
111
|
+
*
|
|
112
|
+
* Pure and total: it never throws on a malformed quiz, because the schema is
|
|
113
|
+
* what refuses that and an audit that threw would replace a precise schema
|
|
114
|
+
* error with a vague one.
|
|
115
|
+
*/
|
|
116
|
+
export function auditQuiz(quiz: AuditQuiz): readonly QuizFinding[] {
|
|
117
|
+
const findings: QuizFinding[] = [];
|
|
118
|
+
const questions = quiz.questions;
|
|
119
|
+
const total = questions.length;
|
|
120
|
+
if (total === 0) return findings;
|
|
121
|
+
|
|
122
|
+
if (total >= MIN_BANK_FOR_RATIOS) {
|
|
123
|
+
// Answer-position bias — the predecessor's 451-question bug.
|
|
124
|
+
const byIndex = new Map<number, number[]>();
|
|
125
|
+
questions.forEach((q, i) => {
|
|
126
|
+
byIndex.set(q.answer, [...(byIndex.get(q.answer) ?? []), i + 1]);
|
|
127
|
+
});
|
|
128
|
+
for (const [index, numbers] of [...byIndex].sort((a, b) => b[1].length - a[1].length)) {
|
|
129
|
+
const share = numbers.length / total;
|
|
130
|
+
if (share > MAX_INDEX_SHARE) {
|
|
131
|
+
findings.push({
|
|
132
|
+
slug: "ksor-quiz-answer-bias",
|
|
133
|
+
questions: numbers,
|
|
134
|
+
detail: `${numbers.length} of ${total} answers (${Math.round(share * 100)}%) are option ${LETTERS[index] ?? index}, above ${Math.round(MAX_INDEX_SHARE * 100)}% — a reader can pass by guessing it`,
|
|
135
|
+
});
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Length bias, both directions: if either "always pick the longest" or
|
|
141
|
+
// "always pick the shortest" wins, the reader never has to read.
|
|
142
|
+
for (const want of ["long", "short"] as const) {
|
|
143
|
+
const label = want === "long" ? "longest" : "shortest";
|
|
144
|
+
const wins = questions.flatMap((q, i) =>
|
|
145
|
+
uniqueExtreme(q.options, want) === q.answer ? [i + 1] : [],
|
|
146
|
+
);
|
|
147
|
+
const share = wins.length / total;
|
|
148
|
+
if (share > MAX_STRATEGY_WIN) {
|
|
149
|
+
findings.push({
|
|
150
|
+
slug: "ksor-quiz-length-bias",
|
|
151
|
+
questions: wins,
|
|
152
|
+
detail: `picking the ${label} option answers ${wins.length} of ${total} (${Math.round(share * 100)}%), above ${Math.round(MAX_STRATEGY_WIN * 100)}% — the answer is visible without reading`,
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A run of identical answers. No size floor: it is a pattern at any length.
|
|
160
|
+
let runStart = 0;
|
|
161
|
+
for (let i = 1; i <= total; i++) {
|
|
162
|
+
const same = i < total && questions[i]?.answer === questions[runStart]?.answer;
|
|
163
|
+
if (same) continue;
|
|
164
|
+
const length = i - runStart;
|
|
165
|
+
if (length > MAX_SAME_ANSWER_RUN) {
|
|
166
|
+
const numbers = Array.from({ length }, (_, k) => runStart + k + 1);
|
|
167
|
+
findings.push({
|
|
168
|
+
slug: "ksor-quiz-answer-run",
|
|
169
|
+
questions: numbers,
|
|
170
|
+
detail: `questions ${numbers[0]}-${numbers[numbers.length - 1]} all answer option ${LETTERS[questions[runStart]?.answer ?? 0] ?? "?"} — ${length} in a row, above ${MAX_SAME_ANSWER_RUN}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
runStart = i;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// An explanation that dismisses the answer the quiz marks correct. One of
|
|
177
|
+
// the two is wrong and neither the reader nor the author can tell which.
|
|
178
|
+
const contradicting = questions.flatMap((q, i) => {
|
|
179
|
+
const text = q.explanation ?? "";
|
|
180
|
+
if (text === "") return [];
|
|
181
|
+
const letter = LETTERS[q.answer] ?? String(q.answer);
|
|
182
|
+
return dismissalsOf(letter, q.answer).some((re) => re.test(text)) ? [i + 1] : [];
|
|
183
|
+
});
|
|
184
|
+
if (contradicting.length > 0) {
|
|
185
|
+
findings.push({
|
|
186
|
+
slug: "ksor-quiz-contradiction",
|
|
187
|
+
questions: contradicting,
|
|
188
|
+
detail: `the explanation calls the marked answer wrong — either the answer index or the explanation is incorrect`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Two questions opening identically are the same question twice.
|
|
193
|
+
const seen = new Map<string, number>();
|
|
194
|
+
const duplicates: number[] = [];
|
|
195
|
+
questions.forEach((q, i) => {
|
|
196
|
+
const key = q.question.trim().slice(0, STEM_PREFIX).toLowerCase();
|
|
197
|
+
const first = seen.get(key);
|
|
198
|
+
if (first === undefined) seen.set(key, i + 1);
|
|
199
|
+
else duplicates.push(first, i + 1);
|
|
200
|
+
});
|
|
201
|
+
if (duplicates.length > 0) {
|
|
202
|
+
findings.push({
|
|
203
|
+
slug: "ksor-quiz-duplicate-stem",
|
|
204
|
+
questions: [...new Set(duplicates)].sort((a, b) => a - b),
|
|
205
|
+
detail: `two questions share their first ${STEM_PREFIX} characters`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return findings;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A bank whose answers cycle, so no ratio, run or duplicate rule fires. */
|
|
213
|
+
function clean(count: number): readonly AuditQuestion[] {
|
|
214
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
215
|
+
question: `Question ${i} about a distinct matter entirely, worded at length`,
|
|
216
|
+
// All the same length on purpose: the clean bank must be clean on the
|
|
217
|
+
// length rule too, and equal-length options are what an author should aim
|
|
218
|
+
// for anyway.
|
|
219
|
+
options: ["option alpha", "option gamma", "option delta", "option omega"],
|
|
220
|
+
answer: i % 4,
|
|
221
|
+
explanation: "The marked option follows from the document.",
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The rule, as a decision table.
|
|
227
|
+
*
|
|
228
|
+
* The checker implements these same rules in plain JS because it cannot import
|
|
229
|
+
* TypeScript, so this table is what both halves are asserted against rather
|
|
230
|
+
* than each being internally consistent with itself — the shape decision 18
|
|
231
|
+
* exists to enforce.
|
|
232
|
+
*/
|
|
233
|
+
/** One row of the decision table: a quiz, and every slug it must produce. */
|
|
234
|
+
export interface QuizAuditCase {
|
|
235
|
+
readonly name: string;
|
|
236
|
+
readonly quiz: AuditQuiz;
|
|
237
|
+
readonly expect: readonly string[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const QUIZ_AUDIT_CASES: readonly QuizAuditCase[] = [
|
|
241
|
+
{
|
|
242
|
+
name: "a balanced bank is clean",
|
|
243
|
+
quiz: { quiz: { title: "Clean" }, questions: clean(8) },
|
|
244
|
+
expect: [],
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "every answer at A",
|
|
248
|
+
quiz: {
|
|
249
|
+
quiz: { title: "Bias" },
|
|
250
|
+
questions: clean(8).map((q) => ({ ...q, answer: 0 })),
|
|
251
|
+
},
|
|
252
|
+
expect: ["ksor-quiz-answer-bias", "ksor-quiz-answer-run"],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "the correct option is always the longest",
|
|
256
|
+
quiz: {
|
|
257
|
+
quiz: { title: "Length" },
|
|
258
|
+
questions: clean(8).map((q, i) => ({
|
|
259
|
+
...q,
|
|
260
|
+
options: q.options.map((o, k) => (k === i % 4 ? `${o} with considerably more words` : o)),
|
|
261
|
+
})),
|
|
262
|
+
},
|
|
263
|
+
expect: ["ksor-quiz-length-bias"],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "four consecutive questions share an answer",
|
|
267
|
+
quiz: {
|
|
268
|
+
quiz: { title: "Run" },
|
|
269
|
+
// 12 questions so the four-long run cannot also trip the ratio rule.
|
|
270
|
+
questions: clean(12).map((q, i) => (i < 4 ? { ...q, answer: 1 } : q)),
|
|
271
|
+
},
|
|
272
|
+
expect: ["ksor-quiz-answer-run"],
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "an explanation dismisses its own marked answer",
|
|
276
|
+
quiz: {
|
|
277
|
+
quiz: { title: "Contradiction" },
|
|
278
|
+
questions: clean(8).map((q, i) =>
|
|
279
|
+
i === 2
|
|
280
|
+
? { ...q, answer: 1, explanation: "Option B is wrong because it inverts the rule." }
|
|
281
|
+
: q,
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
expect: ["ksor-quiz-contradiction"],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: "an explanation dismissing a DISTRACTOR is honest text, not a finding",
|
|
288
|
+
quiz: {
|
|
289
|
+
quiz: { title: "Honest" },
|
|
290
|
+
questions: clean(8).map((q, i) =>
|
|
291
|
+
i === 2
|
|
292
|
+
? { ...q, answer: 2, explanation: "Option B is wrong because it inverts the rule." }
|
|
293
|
+
: q,
|
|
294
|
+
),
|
|
295
|
+
},
|
|
296
|
+
expect: [],
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "two questions open identically",
|
|
300
|
+
quiz: {
|
|
301
|
+
quiz: { title: "Duplicate" },
|
|
302
|
+
questions: clean(8).map((q, i) => (i === 5 ? { ...q, question: clean(8)[1]!.question } : q)),
|
|
303
|
+
},
|
|
304
|
+
expect: ["ksor-quiz-duplicate-stem"],
|
|
305
|
+
},
|
|
306
|
+
];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many questions one round of a quiz asks, and which ones.
|
|
3
|
+
*
|
|
4
|
+
* Split from `quiz.ts` because that file carries zod, and a zod-carrying module
|
|
5
|
+
* cannot be pulled into this repo's unit tier — `isolatedDeclarations` cannot
|
|
6
|
+
* infer a schema's type and the annotations it would need are unwritable by
|
|
7
|
+
* hand. The same split already happened once, when `progressPercent` moved out
|
|
8
|
+
* of `deck.ts` into `srs.ts`. Rounds are pure arithmetic and belong on this
|
|
9
|
+
* side of that line anyway.
|
|
10
|
+
*
|
|
11
|
+
* A LEAF: no imports.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default round size.
|
|
16
|
+
*
|
|
17
|
+
* The predecessor shows 15-20 from a bank of 50 — about a third, so three
|
|
18
|
+
* retakes are mostly new questions. Ten keeps that ratio at a size a record's
|
|
19
|
+
* document can actually reach without inventing questions to pad it.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_QUESTIONS_PER_ROUND = 10;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The questions one round asks.
|
|
25
|
+
*
|
|
26
|
+
* A bank at or below the round size is returned WHOLE and in authored order:
|
|
27
|
+
* there is no second round to make different, so shuffling would only cost the
|
|
28
|
+
* author their deliberate ordering. A larger bank is sampled, which is what
|
|
29
|
+
* makes another round worth taking.
|
|
30
|
+
*
|
|
31
|
+
* `pick` is a parameter rather than a call to `Math.random()` inside, so the
|
|
32
|
+
* sampling is assertable and the module stays pure — the same reason `schedule`
|
|
33
|
+
* takes `now`.
|
|
34
|
+
*/
|
|
35
|
+
export function roundOf<T>(
|
|
36
|
+
bank: readonly T[],
|
|
37
|
+
size: number,
|
|
38
|
+
pick: () => number = Math.random,
|
|
39
|
+
): readonly T[] {
|
|
40
|
+
if (bank.length <= size) return bank;
|
|
41
|
+
const pool = [...bank];
|
|
42
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
43
|
+
const j = Math.floor(pick() * (i + 1));
|
|
44
|
+
const a = pool[i];
|
|
45
|
+
const b = pool[j];
|
|
46
|
+
if (a !== undefined && b !== undefined) {
|
|
47
|
+
pool[i] = b;
|
|
48
|
+
pool[j] = a;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return pool.slice(0, size);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when the bank is bigger than one round, so another round differs. */
|
|
55
|
+
export function hasMoreRounds(bankSize: number, roundSize: number): boolean {
|
|
56
|
+
return bankSize > roundSize;
|
|
57
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { QUIZ_AUDIT_SLUGS, auditQuiz, type QuizFinding } from "./quiz-audit";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shape of a `<doc>.quiz.yaml`.
|
|
7
|
+
*
|
|
8
|
+
* NO AUTHORED IDS, as with the deck: the quiz's identity is its path and a
|
|
9
|
+
* question's identity is its own text. See `lib/deck.ts` for the full argument.
|
|
10
|
+
*
|
|
11
|
+
* Two of the predecessor's hard requirements are deliberately not here, and the
|
|
12
|
+
* spec (`specs/ksor/quiz/spec.md` §6) records why: exactly four options, and a
|
|
13
|
+
* bank of exactly fifty. Both are conventions from timed multiple-choice exams,
|
|
14
|
+
* and a governed record's document may honestly warrant five questions with
|
|
15
|
+
* three options each. Quality is protected by the audit instead of by a count.
|
|
16
|
+
*/
|
|
17
|
+
export const QuestionSchema = z.object({
|
|
18
|
+
question: z.string().min(1).max(400),
|
|
19
|
+
/**
|
|
20
|
+
* Two to six. Two is a true/false question, which is a legitimate check on a
|
|
21
|
+
* policy statement; past six the reader is scanning a list rather than
|
|
22
|
+
* choosing.
|
|
23
|
+
*/
|
|
24
|
+
options: z.array(z.string().min(1).max(300)).min(2).max(6),
|
|
25
|
+
/** Zero-based index into `options`. Range is checked below, against THIS question. */
|
|
26
|
+
answer: z.number().int().min(0),
|
|
27
|
+
/**
|
|
28
|
+
* Required. The predecessor's immediate-feedback model teaches through the
|
|
29
|
+
* mistake, and a wrong answer with no explanation teaches nothing at all —
|
|
30
|
+
* which is the whole reason to put a quiz in a record rather than a course.
|
|
31
|
+
*/
|
|
32
|
+
explanation: z.string().min(1).max(1200),
|
|
33
|
+
/**
|
|
34
|
+
* Where in the document the answer lives — prose, not a citation.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately NOT called a source in the UI. A citation in this product
|
|
37
|
+
* carries a generation (product invariant 1) and an attachment has no id to
|
|
38
|
+
* pin, so presenting this as one would be selling provenance we do not have.
|
|
39
|
+
*/
|
|
40
|
+
source: z.string().max(200).optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const QuizSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
quiz: z.object({
|
|
46
|
+
title: z.string().min(1).max(120),
|
|
47
|
+
description: z.string().max(300).optional(),
|
|
48
|
+
/** How many questions one round shows. See `roundOf`. */
|
|
49
|
+
questionsPerRound: z.number().int().min(1).max(50).optional(),
|
|
50
|
+
}),
|
|
51
|
+
questions: z.array(QuestionSchema).min(1).max(200),
|
|
52
|
+
})
|
|
53
|
+
.superRefine((value, ctx) => {
|
|
54
|
+
// The answer index must point at an option THIS question has. A schema-wide
|
|
55
|
+
// max would admit `answer: 3` on a two-option question, which renders as a
|
|
56
|
+
// quiz nobody can pass and no error anybody can see.
|
|
57
|
+
value.questions.forEach((q, i) => {
|
|
58
|
+
if (q.answer >= q.options.length) {
|
|
59
|
+
ctx.addIssue({
|
|
60
|
+
code: "custom",
|
|
61
|
+
path: ["questions", i, "answer"],
|
|
62
|
+
message: `answer ${q.answer} is out of range: question ${i + 1} has ${q.options.length} options, so the last valid index is ${q.options.length - 1}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The audit runs as part of parsing, so a quiz that would let a reader
|
|
68
|
+
// guess cannot be loaded at all — not by a separate pass somebody has to
|
|
69
|
+
// remember to run. This is the predecessor's mistake corrected: it had
|
|
70
|
+
// these checks and they lived in a script (spec §5).
|
|
71
|
+
for (const finding of auditQuiz(value)) {
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: "custom",
|
|
74
|
+
path: ["questions"],
|
|
75
|
+
message: `${finding.slug}: ${finding.detail} (questions ${finding.questions.join(", ")})`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export type Quiz = z.infer<typeof QuizSchema>;
|
|
81
|
+
export type Question = z.infer<typeof QuestionSchema>;
|
|
82
|
+
|
|
83
|
+
/** Re-exported so a caller needs one import to name what refused it. */
|
|
84
|
+
export { QUIZ_AUDIT_SLUGS, type QuizFinding };
|
|
@@ -2,6 +2,7 @@ import { defineCollections, defineConfig, defineDocs } from "fumadocs-mdx/config
|
|
|
2
2
|
import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { DeckSchema } from "./lib/deck";
|
|
5
|
+
import { QuizSchema } from "./lib/quiz";
|
|
5
6
|
import { knowledgeSourceDir } from "./lib/stage-knowledge";
|
|
6
7
|
|
|
7
8
|
// The record lives at <repo>/knowledge — two levels up from this site.
|
|
@@ -87,6 +88,21 @@ export const decks = defineCollections({
|
|
|
87
88
|
schema: DeckSchema,
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Quizzes, on the deck's terms exactly — same loader, same reason for `.yaml`.
|
|
93
|
+
*
|
|
94
|
+
* `QuizSchema` runs the hygiene audit as part of parsing, so a quiz whose
|
|
95
|
+
* answers are guessable fails HERE, during the build, naming the questions.
|
|
96
|
+
* That is the point of putting it in the schema rather than in a script: the
|
|
97
|
+
* predecessor had these checks and shipped the bugs anyway.
|
|
98
|
+
*/
|
|
99
|
+
export const quizzes = defineCollections({
|
|
100
|
+
type: "meta",
|
|
101
|
+
dir: knowledgeSourceDir(),
|
|
102
|
+
files: ["**/*.quiz.yaml"],
|
|
103
|
+
schema: QuizSchema,
|
|
104
|
+
});
|
|
105
|
+
|
|
90
106
|
export default defineConfig({
|
|
91
107
|
mdxOptions: {
|
|
92
108
|
// MDX options
|