We maintain about a hundred JSON files of authored test content. The obvious shape for that is the one everybody starts with:
interface Question {
id: number;
difficulty: 1 | 2 | 3;
prompt: string;
options: string[];
correctAnswer: string;
}
It covers maybe half our library. The other half needed shapes that this one cannot express, and the interesting engineering was working out which requests for a new shape were real and which were someone wanting a slightly different field name.
The rule we settled on: a new shape is justified when the thing being modelled is a different size from a question. Not when it has extra fields.
Shape two: the stimulus group, because the clock is on the group
Plenty of tests show one chart or one passage and ask three or four questions about it. You can model that flat, by repeating the chart on every question. It normalises badly but it works.
It stops working when timing enters. One vendor's own page source calls the unit a testlet, and the timing model attached to it is: the first question against a new stimulus is the expensive one, because that is where you read the chart. Every question after it is cheap.
If the bank is flat, the scorer cannot see that structure. It has a list of response times with no way to know which ones included a comprehension cost, so "slow" and "slow because it was the first" are indistinguishable. Grouping is what makes that difference expressible:
interface StimulusEntry {
id: number;
difficulty: 1 | 2 | 3;
stimulus: string;
questions: Question[];
}
The shape change is small. The capability it buys is a metric you could not otherwise compute.
Shape three: the legend, where the answer is computed rather than stored
One test shows a legend of operators, some worked examples of what each operator does to a figure, then a start figure and an end figure, and asks which operator was applied.
An item here is a legend, plus examples, plus questions. But the property that made this shape worth building is what it leaves out:
/** One question asked against a legend. The end figure is computed, never stored. */
export interface TGAbstractBankQuestion {
start: TGFigureShape;
/** A legend key, or the literal `none` when no listed operator produced it. */
answerKey: string;
/** Present only when the key is `none`: the effect that was actually applied. */
offLegendEffect?: TGEffectShape;
}
The end figure is not in the file. It is computed at runtime by applying the declared effect of the keyed operator to the start figure.
That means an item cannot be authored with an example that contradicts its own rule. With a stored end figure, a content author can write a legend saying "this operator rotates by 90 degrees" and draw an end figure rotated by 180, and nothing catches it except a person noticing. With a computed one, the class of bug does not exist, because the answer key and the rendered answer are the same fact.
This is the strongest reason to change a data shape: not that it is tidier, but that it removes a way for content to be wrong.
The types for this are re-exported from the game state module rather than redeclared, because the bank entry and the runtime item share the legend structure exactly, and two copies of a structure that must agree is a drift waiting to happen.
Shape four: the chain, where an answer depends on earlier answers
Some sets chain: question 3 is computed from the answers to questions 1 and 2. Those items carry the relationship between their answer and the earlier ones:
{ "questionId": 4, "dependsOn": [1, 2], "consistency": "{q1} / {q2} * 100" }
which lets the scorer tell "cannot do this arithmetic" apart from "carried a bad number forward". Again: a field that exists to make a distinction the flat shape cannot express.
What we refused
Two requests for new shapes got turned down, and both refusals taught us more than the approvals.
A bank with no options array. One verbal test offers the same three choices on every single item. Storing those three strings sixty times per file invites them to drift, and worse, invites a translator to change one of them in one place. The key is an enum, the engine supplies the labels, and the shape stays as it was.
A separate bank for a combined test. One provider sells a timed combined test that is simply the same item styles from three separate tests under one clock. The tempting move is a fourth bank. The right move is for it to draw from the three existing banks, because a fourth file would be a copy that starts drifting the day one of the three is edited.
Both refusals are the same principle as the approvals, inverted: change the shape when the content is genuinely a different size, and never when it is the same content wearing a different hat.
Where all of this is written down
In the type file, next to each interface, and in the id list that enumerates the banks. That list is not a bare array; it is annotated with why each entry exists and, more usefully, why certain tests have no entry at all:
Four of the five subtests generate their items at runtime and have no bank, which is fidelity rather than shortcut: the real task streams items until its clock runs out. Word Meaning is the exception because its items depend on real lexical semantics and cannot be produced from a rule.
That distinction, "fidelity rather than shortcut", is the one I would most recommend writing down in any content system. A missing file looks identical whether it was a deliberate decision or an unfinished task, and six months later nobody can tell which. One clause in a comment is the difference between a colleague trusting the gap and re-authoring it.
The tests these shapes back are listed by format at https://cogniprep.app/tests, and the provider suites are at https://cogniprep.app/games. Play a numerical test and notice that several questions share one table: that grouping is the shape above, and it is the reason the feedback can tell you whether you were slow at reading data or slow at using it.
Top comments (0)