@graffiticode/l0175 0.2.0
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/dist/compiler.d.ts +12 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +1285 -0
- package/dist/compiler.js.map +1 -0
- package/dist/embedding.d.ts +64 -0
- package/dist/embedding.d.ts.map +1 -0
- package/dist/embedding.js +294 -0
- package/dist/embedding.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/lexicon.d.ts +644 -0
- package/dist/lexicon.d.ts.map +1 -0
- package/dist/lexicon.js +101 -0
- package/dist/lexicon.js.map +1 -0
- package/dist/static/instructions.md +527 -0
- package/dist/static/language-info.json +85 -0
- package/dist/static/lexicon.json +1112 -0
- package/dist/static/schema.json +162 -0
- package/dist/static/scope.json +28 -0
- package/dist/static/spec.html +572 -0
- package/dist/static/stems.md +374 -0
- package/dist/static/template.gc +67 -0
- package/dist/static/usage-guide.md +111 -0
- package/package.json +33 -0
- package/spec/README.md +18 -0
- package/spec/data/examples.gc +84 -0
- package/spec/data/examples_with_explanations.md +124 -0
- package/spec/data/training_examples.json +122 -0
- package/spec/docs.md +102 -0
- package/spec/examples.md +91 -0
- package/spec/instructions.md +337 -0
- package/spec/language-info.json +78 -0
- package/spec/schema.json +162 -0
- package/spec/scope.json +28 -0
- package/spec/spec.md +277 -0
- package/spec/stems.md +374 -0
- package/spec/template.gc +67 -0
- package/spec/unparse-hints.json +3 -0
- package/spec/usage-guide.md +111 -0
package/dist/compiler.js
ADDED
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/* Copyright (c) 2026, ARTCOMPILER INC */
|
|
3
|
+
//
|
|
4
|
+
// L0175 — a content-composition language for 5th-grade ELA assessment items
|
|
5
|
+
// (Smarter Balanced · Grade 5 · Claim 1 · Target 4: Reasoning & Evidence).
|
|
6
|
+
//
|
|
7
|
+
// A program authors an inline SUPERSET of tagged content for a literary passage —
|
|
8
|
+
// candidate inference/conclusion `claim`s and evidence `source`s, each tagged — plus one
|
|
9
|
+
// or more `outcome`s. The overridden PROG runs a deterministic COMPOSE that selects, per
|
|
10
|
+
// outcome, the subset of content that best fits and assembles a finished item.
|
|
11
|
+
//
|
|
12
|
+
// The authoring surface is the l0169 builder idiom; see lexicon.ts. The Transformer's
|
|
13
|
+
// attribute/collection/element handlers reconstruct plain records (L0000's deepConvertRecords
|
|
14
|
+
// supports `{ ...record, key: v0 }`), and PROG reads them as plain props and composes.
|
|
15
|
+
//
|
|
16
|
+
// Validation: enum membership + required fields are HARD errors (pushed into the error array,
|
|
17
|
+
// failing the compile, per the Compiler pipeline). Selection compromises (missing id refs,
|
|
18
|
+
// thin distractor pools, unsatisfiable outcomes, hot-text ambiguity) are non-fatal WARNINGS
|
|
19
|
+
// carried in each item's `warnings`.
|
|
20
|
+
import { Checker as BaseChecker, Transformer as BaseTransformer, Compiler, } from "@graffiticode/l0000";
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Enumerations (the closed vocabularies; bare kebab identifiers resolve to these strings).
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const ITEM_TYPES = new Set(["ebsr", "hot-text", "short-text", "multiple-choice", "multi-select"]);
|
|
25
|
+
const PASSAGE_TYPES = new Set(["literary", "informational"]);
|
|
26
|
+
const CLAIM_STATUS = new Set(["supported", "distractor"]);
|
|
27
|
+
const SOURCE_STATUS = new Set(["directly-supports", "supports-wrong-claim", "irrelevant"]);
|
|
28
|
+
// Distractor error taxonomies, per target family. Reasoning & Evidence (T4/T11) classifies foils by
|
|
29
|
+
// reasoning failure; Central Ideas (T9) classifies them by SIGNIFICANCE (a true statement that just
|
|
30
|
+
// isn't the central idea). Each target profile picks its taxonomy via `errorTypes`.
|
|
31
|
+
const ERROR_TYPES = ["misreads-detail", "erroneous-inference", "faulty-reasoning"]; // Reasoning & Evidence
|
|
32
|
+
const T9_ERROR_TYPES = ["too-narrow", "too-broad", "misreads-detail", "insignificant"]; // Central Ideas
|
|
33
|
+
// Small prior on distractor temptingness by error type. Used by the computed plausibility score;
|
|
34
|
+
// author `plausibility` overrides it. Unlisted types default to 0.
|
|
35
|
+
const ERROR_TYPE_PRIOR = {
|
|
36
|
+
"faulty-reasoning": 0.1, "erroneous-inference": 0.08, "misreads-detail": 0.05,
|
|
37
|
+
"too-narrow": 0.1, "too-broad": 0.07, "insignificant": 0.05,
|
|
38
|
+
};
|
|
39
|
+
// Hand-tuned thresholds — located here for later calibration (the IRT/response-data track in
|
|
40
|
+
// the backlog would replace these and the plausibility weights with learned values).
|
|
41
|
+
const TUNING = {
|
|
42
|
+
MIN_VIABLE_DISTRACTORS: 5, // below this, warn — a richer Part A pool gives selection real choice
|
|
43
|
+
MIN_VIABLE_PART_B: 5, // below this many Part B foil sources, warn (item draws 3 of them)
|
|
44
|
+
DISTRACTOR_SLOTS: 3, // foils chosen per item (Part A or Part B)
|
|
45
|
+
PART_OPTIONS: 4, // options per part (EBSR Part A/B) and Multiple Choice
|
|
46
|
+
MULTI_SELECT_OPTIONS: 6, // total options for Multi-Select (correct set + distractors)
|
|
47
|
+
HOT_TEXT_SELECT_MAX: 3, // absolute cap on Part B sentence selections (per-item cap is min(this, validCount - 1))
|
|
48
|
+
SHORT_TEXT_MIN_LINES: 3, // fewer than 3 passage paragraphs → warn (a constructed response wants a substantial passage)
|
|
49
|
+
LENGTH_BALANCE_RATIO: 1.35, // correct option longer than this × the mean distractor length → length-giveaway warn
|
|
50
|
+
GRADE_LEVEL_TOLERANCE: 1.5, // passage reading level may run up to this many grades above target before we warn
|
|
51
|
+
STEM_GIVEAWAY_RATIO: 0.5, // Part A stem shares ≥ this fraction of the correct option's content words → giveaway warn
|
|
52
|
+
STEM_GIVEAWAY_MIN: 4, // …and at least this many shared content words (ignore tiny overlaps)
|
|
53
|
+
};
|
|
54
|
+
const RE_ITEM_TYPES = new Set(["ebsr", "hot-text", "short-text"]);
|
|
55
|
+
const TARGETS = {
|
|
56
|
+
// Claim 1 · Target 4 — Reasoning & Evidence, literary texts (RL standards). The original L0175.
|
|
57
|
+
"c1-t4": {
|
|
58
|
+
id: "c1-t4",
|
|
59
|
+
label: "Grade 5 · Claim 1 · Target 4 (Reasoning & Evidence)",
|
|
60
|
+
grade: 5,
|
|
61
|
+
textType: "literary",
|
|
62
|
+
baseStandard: "rl-1",
|
|
63
|
+
defaultDok: "r-dok3",
|
|
64
|
+
answerKind: "statement",
|
|
65
|
+
singlePartHotText: false,
|
|
66
|
+
itemTypes: RE_ITEM_TYPES,
|
|
67
|
+
standards: new Set(["rl-1", "rl-3", "rl-6", "rl-9"]),
|
|
68
|
+
dimensions: new Set([
|
|
69
|
+
"character", "setting", "event", "point-of-view",
|
|
70
|
+
"theme", "topic", "narrators-feelings", "character-relationship",
|
|
71
|
+
]),
|
|
72
|
+
errorTypes: ERROR_TYPES,
|
|
73
|
+
dimStandard: {
|
|
74
|
+
"character": "rl-3", "character-relationship": "rl-3", "setting": "rl-3", "event": "rl-3",
|
|
75
|
+
"point-of-view": "rl-6", "narrators-feelings": "rl-6",
|
|
76
|
+
"theme": "rl-9", "topic": "rl-9",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
// Claim 1 · Target 11 — Reasoning & Evidence, informational texts (RI standards).
|
|
80
|
+
"c1-t11": {
|
|
81
|
+
id: "c1-t11",
|
|
82
|
+
label: "Grade 5 · Claim 1 · Target 11 (Reasoning & Evidence)",
|
|
83
|
+
grade: 5,
|
|
84
|
+
textType: "informational",
|
|
85
|
+
baseStandard: "ri-1",
|
|
86
|
+
defaultDok: "r-dok3",
|
|
87
|
+
answerKind: "statement",
|
|
88
|
+
singlePartHotText: false,
|
|
89
|
+
itemTypes: RE_ITEM_TYPES,
|
|
90
|
+
standards: new Set(["ri-1", "ri-3", "ri-6", "ri-7", "ri-8", "ri-9"]),
|
|
91
|
+
dimensions: new Set([
|
|
92
|
+
"relationships-interactions", "author-use-of-information",
|
|
93
|
+
"point-of-view", "purpose", "authors-opinion",
|
|
94
|
+
]),
|
|
95
|
+
errorTypes: ERROR_TYPES,
|
|
96
|
+
dimStandard: {
|
|
97
|
+
"relationships-interactions": "ri-3",
|
|
98
|
+
"author-use-of-information": "ri-8",
|
|
99
|
+
"point-of-view": "ri-6",
|
|
100
|
+
"purpose": "ri-8",
|
|
101
|
+
"authors-opinion": "ri-8",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
// Claim 1 · Target 9 — Central Ideas, informational texts (RI-1 + RI-2). A DIFFERENT skill from
|
|
105
|
+
// Reasoning & Evidence: whole-text synthesis and significance (the main idea, the key details that
|
|
106
|
+
// build it, and summary), DOK 2 (3 only for the written summary). Distractors are a SIGNIFICANCE
|
|
107
|
+
// taxonomy — usually true statements that just aren't the central idea.
|
|
108
|
+
"c1-t9": {
|
|
109
|
+
id: "c1-t9",
|
|
110
|
+
label: "Grade 5 · Claim 1 · Target 9 (Central Ideas)",
|
|
111
|
+
grade: 5,
|
|
112
|
+
textType: "informational",
|
|
113
|
+
baseStandard: "ri-1",
|
|
114
|
+
defaultDok: "r-dok2",
|
|
115
|
+
answerKind: "statement",
|
|
116
|
+
singlePartHotText: true, // T9 Hot Text (TM4): click the sentence(s) that show the main idea
|
|
117
|
+
itemTypes: new Set(["multiple-choice", "multi-select", "ebsr", "hot-text", "short-text"]),
|
|
118
|
+
standards: new Set(["ri-1", "ri-2"]),
|
|
119
|
+
dimensions: new Set(["central-idea", "key-detail", "summary"]),
|
|
120
|
+
errorTypes: T9_ERROR_TYPES,
|
|
121
|
+
dimStandard: {
|
|
122
|
+
"central-idea": "ri-2",
|
|
123
|
+
"key-detail": "ri-2",
|
|
124
|
+
"summary": "ri-2",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
// Claim 1 · Target 8 — Key Details, informational texts (RI-1 + RI-7). A DIFFERENT model: the
|
|
128
|
+
// inference/conclusion is GIVEN in the stem and the student selects the supporting EVIDENCE
|
|
129
|
+
// (answerKind "evidence"). Options are passage sources, not claims; no statement Part A, no
|
|
130
|
+
// EBSR/short-text. DOK 1–2 (default 2).
|
|
131
|
+
"c1-t8": {
|
|
132
|
+
id: "c1-t8",
|
|
133
|
+
label: "Grade 5 · Claim 1 · Target 8 (Key Details)",
|
|
134
|
+
grade: 5,
|
|
135
|
+
textType: "informational",
|
|
136
|
+
baseStandard: "ri-1",
|
|
137
|
+
defaultDok: "r-dok2",
|
|
138
|
+
answerKind: "evidence",
|
|
139
|
+
singlePartHotText: true,
|
|
140
|
+
itemTypes: new Set(["multiple-choice", "multi-select", "hot-text"]),
|
|
141
|
+
standards: new Set(["ri-1", "ri-7"]),
|
|
142
|
+
dimensions: new Set(["supporting-evidence"]),
|
|
143
|
+
errorTypes: [], // T8 wrong answers are non-supporting sources, not distractor claims
|
|
144
|
+
dimStandard: {
|
|
145
|
+
"supporting-evidence": "ri-7",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
// Claim 1 · Target 10 — Word Meanings, informational texts (RI-4 + the L-4 family). The most
|
|
149
|
+
// different model: the question asks for the MEANING of a targeted word/phrase in context, so the
|
|
150
|
+
// options are candidate MEANINGS (answerKind "meaning"), authored as `word`/`meaning`, not claims.
|
|
151
|
+
// DOK 1–2. The strategy (context / roots & affixes / word relationships / reference) is expressed
|
|
152
|
+
// via the authored standard (l-4a / l-4b / l-5c / l-4c).
|
|
153
|
+
"c1-t10": {
|
|
154
|
+
id: "c1-t10",
|
|
155
|
+
label: "Grade 5 · Claim 1 · Target 10 (Word Meanings)",
|
|
156
|
+
grade: 5,
|
|
157
|
+
textType: "informational",
|
|
158
|
+
baseStandard: "ri-4",
|
|
159
|
+
defaultDok: "r-dok2",
|
|
160
|
+
answerKind: "meaning",
|
|
161
|
+
singlePartHotText: false, // T10 Hot Text is word-level (composeWordMeaning), not sentence-level
|
|
162
|
+
itemTypes: new Set(["multiple-choice", "multi-select", "hot-text"]),
|
|
163
|
+
standards: new Set(["ri-4", "ri-1", "l-4", "l-4a", "l-4b", "l-4c", "l-5c"]),
|
|
164
|
+
dimensions: new Set(["word-meaning"]),
|
|
165
|
+
errorTypes: ["other-meaning", "misinterprets", "wrong-context"],
|
|
166
|
+
dimStandard: {
|
|
167
|
+
"word-meaning": "l-4",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const DEFAULT_TARGET = "c1-t4"; // best-effort fallback when `target` is missing/unknown (still a hard error)
|
|
172
|
+
// --- Stems (Smarter Balanced · Grade 5 · Claim 1 · Target 4) --------------------------------
|
|
173
|
+
// Stems are AUTHORED, not generated: the upstream code generator instantiates the guideline's
|
|
174
|
+
// "Appropriate Stems" catalog (spec/stems.md) and emits each item's `stem` (and `stem-b` on
|
|
175
|
+
// EBSR) on the outcome. The compiler trusts the authored text — it does not synthesize stems.
|
|
176
|
+
// The lead-in below is not per-item question text. The Hot-Text Part B instruction is synthesized
|
|
177
|
+
// per item from the selection cap (Hot Text has no authored Part B stem) — see hotTextPartB.
|
|
178
|
+
const LEAD_IN = "This question has two parts. First, answer Part A. Then, answer Part B.";
|
|
179
|
+
const HOT_TEXT_PART_B_PLACEHOLDER = "Click the sentence(s) from the passage that support your answer in Part A.";
|
|
180
|
+
// Hot Text Part B asks for an EXACT number of supporting sentences (the per-item count). The
|
|
181
|
+
// student must pick exactly `count`; any selection of that many drawn from the valid set is
|
|
182
|
+
// correct (composeOutcome).
|
|
183
|
+
function hotTextPartB(count) {
|
|
184
|
+
return count <= 1
|
|
185
|
+
? "Click 1 sentence from the passage that supports your answer in Part A."
|
|
186
|
+
: `Click ${count} sentences from the passage that support your answer in Part A.`;
|
|
187
|
+
}
|
|
188
|
+
const DEFAULT_RUBRIC = [
|
|
189
|
+
{ score: 2, descriptor: "Makes a valid inference and supports it with specific, relevant details from the passage." },
|
|
190
|
+
{ score: 1, descriptor: "Makes a partially valid inference, or supports it with limited or only partially relevant details." },
|
|
191
|
+
{ score: 0, descriptor: "Does not make a valid inference, or provides no relevant textual support." },
|
|
192
|
+
];
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Builder attribute handlers — generated below. Each is arity-2 (value, continuation) and
|
|
195
|
+
// merges one key into the continuation record. ERROR_TYPE stores under the JS-friendly key.
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Per-element source coordinate, stamped onto the element record by the wrappers. A Symbol
|
|
198
|
+
// key keeps it out of Object.entries → it never reaches deepConvertRecords / the output.
|
|
199
|
+
const COORD = Symbol("coord");
|
|
200
|
+
const coordOf = (x) => (x && x[COORD]) || {};
|
|
201
|
+
const ATTR_KEYS = {
|
|
202
|
+
ID: "id", STATUS: "status", DIMENSION: "dimension", ERROR_TYPE: "errorType",
|
|
203
|
+
TEXT: "text", RATIONALE: "rationale", CITES: "cites", TARGETS: "targets",
|
|
204
|
+
LINE: "line", QUOTE: "quote",
|
|
205
|
+
SUPPORTS: "supports", TYPE: "type", SUBJECT: "subject", STANDARD: "standard",
|
|
206
|
+
FOCUS: "focus", PASSAGE: "passage", LINES: "lines", TITLE: "title", TARGET: "target", GRADE: "grade", STEM: "stem",
|
|
207
|
+
RUBRIC: "rubric", DOK: "dok", PLAUSIBILITY: "plausibility", MODE: "mode", OTHER: "other",
|
|
208
|
+
STEM_B: "stemB", SCORE: "score", DESCRIPTOR: "descriptor",
|
|
209
|
+
CLAIMS: "claims", EVIDENCE: "evidence", OUTCOMES: "outcomes",
|
|
210
|
+
WORDS: "words", MEANINGS: "meanings", // T10 (Word Meanings)
|
|
211
|
+
};
|
|
212
|
+
export class Checker extends BaseChecker {
|
|
213
|
+
}
|
|
214
|
+
export class Transformer extends BaseTransformer {
|
|
215
|
+
constructor(code) {
|
|
216
|
+
super(code);
|
|
217
|
+
// Attribute & collection builders: { ...continuation, key: value }.
|
|
218
|
+
for (const [tag, key] of Object.entries(ATTR_KEYS)) {
|
|
219
|
+
this[tag] = (node, options, resume) => {
|
|
220
|
+
this.visit(node.elts[0], options, (e0, v0) => {
|
|
221
|
+
this.visit(node.elts[1], options, (e1, v1) => {
|
|
222
|
+
const base = isPlain(v1) ? v1 : {};
|
|
223
|
+
resume([].concat(e0).concat(e1), { ...base, [key]: v0 });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// Element wrappers: pass the assembled attribute-chain record through, stamping the
|
|
229
|
+
// element's source coord (Symbol key, so it never leaks into output) for error highlighting.
|
|
230
|
+
for (const tag of ["CLAIM", "SOURCE", "OUTCOME", "BAND", "WORD", "MEANING"]) {
|
|
231
|
+
this[tag] = (node, options, resume) => {
|
|
232
|
+
this.visit(node.elts[0], options, (e0, v0) => {
|
|
233
|
+
if (v0 && typeof v0 === "object")
|
|
234
|
+
v0[COORD] = node.coord ?? this.nodePool[node.elts[0]]?.coord;
|
|
235
|
+
resume(e0, v0);
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
PROG(node, options, resume) {
|
|
241
|
+
this.visit(node.elts[0], options, (e0, v0) => {
|
|
242
|
+
const errors = [].concat(e0 || []);
|
|
243
|
+
const top = Array.isArray(v0) ? v0[v0.length - 1] : v0;
|
|
244
|
+
const out = composeProgram(top || {}, errors);
|
|
245
|
+
resume(errors, out);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export const compiler = new Compiler({
|
|
250
|
+
langID: "0175",
|
|
251
|
+
version: "v0.0.1",
|
|
252
|
+
Checker,
|
|
253
|
+
Transformer,
|
|
254
|
+
});
|
|
255
|
+
// ===========================================================================
|
|
256
|
+
// Composition (pure, deterministic — no LLM). A future error-transform pass (swap-referent,
|
|
257
|
+
// overgeneralize, ...) would slot in where distractor claims are gathered.
|
|
258
|
+
// ===========================================================================
|
|
259
|
+
function isPlain(v) {
|
|
260
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
261
|
+
}
|
|
262
|
+
function str(v) {
|
|
263
|
+
// Enum values may arrive as a bare lexeme string (IDENT fallback) or, when registered as
|
|
264
|
+
// tags, as a { tag } object — normalize both to the plain string.
|
|
265
|
+
if (v && typeof v === "object" && typeof v.tag === "string")
|
|
266
|
+
return v.tag;
|
|
267
|
+
return typeof v === "string" ? v : v == null ? "" : String(v);
|
|
268
|
+
}
|
|
269
|
+
function slug(s) {
|
|
270
|
+
return str(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "p";
|
|
271
|
+
}
|
|
272
|
+
function composeProgram(top, errors) {
|
|
273
|
+
// Resolve the learning target first — it parameterizes validation and standards. `target` is
|
|
274
|
+
// required; when missing/unknown we record a hard error but fall back to a profile so the rest
|
|
275
|
+
// of composition still runs (best-effort, like a missing `focus`).
|
|
276
|
+
const targetTag = str(top.target);
|
|
277
|
+
const profile = TARGETS[targetTag] || TARGETS[DEFAULT_TARGET];
|
|
278
|
+
// Resolve the grade: an explicit top-level `grade` (from the user's prompt) wins; otherwise use
|
|
279
|
+
// the guideline's grade carried on the target profile. Drives the reading-level guard below.
|
|
280
|
+
const grade = Number(top.grade) > 0 ? Number(top.grade) : profile.grade;
|
|
281
|
+
const heading = str(top.passage);
|
|
282
|
+
const passageType = top.type !== undefined ? str(top.type) : profile.textType;
|
|
283
|
+
const lineTexts = Array.isArray(top.lines) ? top.lines.map(str) : [];
|
|
284
|
+
const claims = Array.isArray(top.claims) ? top.claims : [];
|
|
285
|
+
const sources = Array.isArray(top.evidence) ? top.evidence : [];
|
|
286
|
+
const words = Array.isArray(top.words) ? top.words : []; // T10 targeted words
|
|
287
|
+
const outcomes = Array.isArray(top.outcomes) ? top.outcomes : [];
|
|
288
|
+
// --- hard validation (fails the compile) ---
|
|
289
|
+
// `target` should be authored explicitly (the instructions tell the generator to always pick
|
|
290
|
+
// one), but an OMITTED target defaults to c1-t4 with a warning rather than a hard error — so
|
|
291
|
+
// minimal/template generation and legacy programs still compile. An explicit but UNKNOWN tag is
|
|
292
|
+
// a genuine mistake and stays a hard error.
|
|
293
|
+
if (targetTag && !TARGETS[targetTag]) {
|
|
294
|
+
errors.push({ message: `unknown target '${targetTag}'. Expected one of: ${Object.keys(TARGETS).join(", ")}.` });
|
|
295
|
+
}
|
|
296
|
+
if (passageType && !PASSAGE_TYPES.has(passageType)) {
|
|
297
|
+
errors.push({ message: `Unknown passage type '${passageType}'. Expected one of: ${[...PASSAGE_TYPES].join(", ")}.` });
|
|
298
|
+
}
|
|
299
|
+
if (lineTexts.length === 0) {
|
|
300
|
+
errors.push({ message: "Passage has no `lines`." });
|
|
301
|
+
}
|
|
302
|
+
for (const c of claims)
|
|
303
|
+
validateClaim(c, errors, profile);
|
|
304
|
+
for (const s of sources)
|
|
305
|
+
validateSource(s, errors);
|
|
306
|
+
for (const w of words)
|
|
307
|
+
validateWord(w, errors, profile);
|
|
308
|
+
for (const o of outcomes)
|
|
309
|
+
validateOutcome(o, errors, profile);
|
|
310
|
+
const passageId = slug(heading);
|
|
311
|
+
const passage = {
|
|
312
|
+
id: passageId,
|
|
313
|
+
heading,
|
|
314
|
+
type: passageType,
|
|
315
|
+
lines: lineTexts.map((text, i) => ({ id: i + 1, text })),
|
|
316
|
+
};
|
|
317
|
+
const ctx = {
|
|
318
|
+
passage,
|
|
319
|
+
claims,
|
|
320
|
+
sources,
|
|
321
|
+
words,
|
|
322
|
+
outcomes,
|
|
323
|
+
profile,
|
|
324
|
+
claimById: index(claims, "id"),
|
|
325
|
+
sourceById: index(sources, "id"),
|
|
326
|
+
wordById: index(words, "id"),
|
|
327
|
+
outcomeById: index(outcomes, "id"),
|
|
328
|
+
};
|
|
329
|
+
const targetWarnings = !targetTag
|
|
330
|
+
? [`No target declared; defaulting to ${DEFAULT_TARGET}. Author a top-level 'target' (${Object.keys(TARGETS).join(" | ")}).`]
|
|
331
|
+
: [];
|
|
332
|
+
const readabilityWarnings = [];
|
|
333
|
+
checkReadability(passage, grade, readabilityWarnings);
|
|
334
|
+
const graphWarnings = [...targetWarnings, ...readabilityWarnings, ...validateGraph(ctx, errors)];
|
|
335
|
+
const title = str(top.title);
|
|
336
|
+
const items = outcomes.map((o, i) => composeOutcome(o, ctx, graphWarnings, i));
|
|
337
|
+
if (items.length === 1) {
|
|
338
|
+
items[0].grade = grade;
|
|
339
|
+
if (title)
|
|
340
|
+
items[0].title = title;
|
|
341
|
+
return items[0];
|
|
342
|
+
}
|
|
343
|
+
const result = { kind: "items", items, grade };
|
|
344
|
+
if (title)
|
|
345
|
+
result.title = title;
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
function validateClaim(c, errors, profile) {
|
|
349
|
+
const id = str(c.id);
|
|
350
|
+
const where = id ? `claim '${id}'` : "a claim";
|
|
351
|
+
const at = coordOf(c);
|
|
352
|
+
const push = (message) => errors.push({ message, ...at });
|
|
353
|
+
if (!CLAIM_STATUS.has(str(c.status))) {
|
|
354
|
+
push(`${where}: invalid status '${str(c.status)}'. Expected supported or distractor.`);
|
|
355
|
+
}
|
|
356
|
+
// dimension is required on supported claims (it must match the outcome); on distractors the
|
|
357
|
+
// binding is by `targets` (not dimension), so it is optional there but validated if present.
|
|
358
|
+
if (str(c.dimension)) {
|
|
359
|
+
if (!profile.dimensions.has(str(c.dimension)))
|
|
360
|
+
push(`${where}: invalid dimension '${str(c.dimension)}' for target ${profile.id}.`);
|
|
361
|
+
}
|
|
362
|
+
else if (str(c.status) === "supported") {
|
|
363
|
+
push(`${where}: supported claim needs a dimension.`);
|
|
364
|
+
}
|
|
365
|
+
if (!str(c.text))
|
|
366
|
+
push(`${where}: missing text.`);
|
|
367
|
+
if (str(c.status) === "distractor") {
|
|
368
|
+
if (!profile.errorTypes.includes(str(c.errorType))) {
|
|
369
|
+
push(`${where}: distractor needs a valid error-type for target ${profile.id} (${profile.errorTypes.join(", ")}).`);
|
|
370
|
+
}
|
|
371
|
+
if (!str(c.rationale)) {
|
|
372
|
+
push(`${where}: distractor needs a rationale (the justification for the foil).`);
|
|
373
|
+
}
|
|
374
|
+
if (!Array.isArray(c.targets) || c.targets.length === 0) {
|
|
375
|
+
push(`${where}: distractor needs targets (the outcome id(s) of the question(s) it foils).`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (c.plausibility !== undefined && (typeof c.plausibility !== "number" || c.plausibility < 0 || c.plausibility > 1)) {
|
|
379
|
+
push(`${where}: plausibility must be a number between 0 and 1.`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function validateSource(s, errors) {
|
|
383
|
+
const id = str(s.id);
|
|
384
|
+
const where = id ? `source '${id}'` : "a source";
|
|
385
|
+
const at = coordOf(s);
|
|
386
|
+
const push = (message) => errors.push({ message, ...at });
|
|
387
|
+
if (!id)
|
|
388
|
+
push(`${where}: missing id.`);
|
|
389
|
+
if (!SOURCE_STATUS.has(str(s.status))) {
|
|
390
|
+
push(`${where}: invalid status '${str(s.status)}'. Expected directly-supports, supports-wrong-claim, or irrelevant.`);
|
|
391
|
+
}
|
|
392
|
+
if (s.line === undefined && !str(s.quote)) {
|
|
393
|
+
push(`${where}: needs a line number or a quote.`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// A T10 `word`: a targeted word/phrase plus its candidate `meaning`s. Each meaning is `correct`
|
|
397
|
+
// (the answer) or `distractor` (with a T10 error-type + rationale). A usable word needs ≥1 correct
|
|
398
|
+
// and ≥1 distractor meaning; Multi-Select questions need ≥2 correct (checked at compose).
|
|
399
|
+
function validateWord(w, errors, profile) {
|
|
400
|
+
const id = str(w.id);
|
|
401
|
+
const where = id ? `word '${id}'` : "a word";
|
|
402
|
+
const at = coordOf(w);
|
|
403
|
+
const push = (message) => errors.push({ message, ...at });
|
|
404
|
+
if (!id)
|
|
405
|
+
push(`${where}: missing id.`);
|
|
406
|
+
if (!str(w.text))
|
|
407
|
+
push(`${where}: missing text (the targeted word/phrase).`);
|
|
408
|
+
// `meanings` are for MC/Multi-Select (the candidate meanings of this word). A word with no
|
|
409
|
+
// meanings is a click-the-word (TM3) candidate — valid; the meaning shape is checked only when
|
|
410
|
+
// meanings are present, and the MC/MS compose path warns if a focused word has no correct meaning.
|
|
411
|
+
const meanings = Array.isArray(w.meanings) ? w.meanings : [];
|
|
412
|
+
if (meanings.length === 0)
|
|
413
|
+
return;
|
|
414
|
+
let nCorrect = 0;
|
|
415
|
+
for (const m of meanings) {
|
|
416
|
+
const mw = `${where} meaning '${str(m.id) || "?"}'`;
|
|
417
|
+
const st = str(m.status);
|
|
418
|
+
if (st !== "correct" && st !== "distractor")
|
|
419
|
+
push(`${mw}: invalid status '${st}'. Expected correct or distractor.`);
|
|
420
|
+
if (!str(m.text))
|
|
421
|
+
push(`${mw}: missing text (the meaning).`);
|
|
422
|
+
if (st === "correct")
|
|
423
|
+
nCorrect++;
|
|
424
|
+
if (st === "distractor") {
|
|
425
|
+
if (!profile.errorTypes.includes(str(m.errorType)))
|
|
426
|
+
push(`${mw}: distractor meaning needs a valid error-type for target ${profile.id} (${profile.errorTypes.join(", ")}).`);
|
|
427
|
+
if (!str(m.rationale))
|
|
428
|
+
push(`${mw}: distractor meaning needs a rationale.`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (nCorrect === 0)
|
|
432
|
+
push(`${where}: needs at least one meaning with status correct.`);
|
|
433
|
+
if (meanings.length - nCorrect === 0)
|
|
434
|
+
push(`${where}: needs at least one distractor meaning.`);
|
|
435
|
+
}
|
|
436
|
+
function validateOutcome(o, errors, profile) {
|
|
437
|
+
const id = str(o.id);
|
|
438
|
+
const where = id ? `outcome '${id}'` : "an outcome";
|
|
439
|
+
const at = coordOf(o);
|
|
440
|
+
const push = (message) => errors.push({ message, ...at });
|
|
441
|
+
if (!id)
|
|
442
|
+
push(`${where}: missing id (each question needs a unique id so distractors can target it).`);
|
|
443
|
+
const t = str(o.type);
|
|
444
|
+
if (!ITEM_TYPES.has(t)) {
|
|
445
|
+
push(`${where}: invalid type '${t}'. Expected ${[...ITEM_TYPES].join(", ")}.`);
|
|
446
|
+
}
|
|
447
|
+
else if (!profile.itemTypes.has(t)) {
|
|
448
|
+
push(`${where}: item type '${t}' is not available for target ${profile.id} (allowed: ${[...profile.itemTypes].join(", ")}).`);
|
|
449
|
+
}
|
|
450
|
+
if (!profile.dimensions.has(str(o.dimension)))
|
|
451
|
+
push(`${where}: invalid dimension '${str(o.dimension)}' for target ${profile.id}.`);
|
|
452
|
+
if (o.standard !== undefined && !profile.standards.has(str(o.standard)))
|
|
453
|
+
push(`${where}: invalid standard '${str(o.standard)}' for target ${profile.id}.`);
|
|
454
|
+
// Item-first contract: the question owns its correct answer (focus) and its stem text,
|
|
455
|
+
// authored from the guideline's Appropriate-Stem catalog (the compiler no longer synthesizes stems).
|
|
456
|
+
// `focus` names the supported claim(s). For STATEMENT multi-select it's the correct SET (≥2); for
|
|
457
|
+
// every other case it's a single claim — including T8's EVIDENCE multi-select, where `focus` is
|
|
458
|
+
// the one GIVEN inference and the correct set is sources, not focus claims.
|
|
459
|
+
const focus = focusIds(o);
|
|
460
|
+
const statementMultiSelect = t === "multi-select" && profile.answerKind === "statement";
|
|
461
|
+
if (focus.length === 0)
|
|
462
|
+
push(`${where}: missing focus (the id of the supported claim this question is built around).`);
|
|
463
|
+
else if (statementMultiSelect && focus.length < 2)
|
|
464
|
+
push(`${where}: multi-select needs at least 2 focus claims (the correct set).`);
|
|
465
|
+
else if (!statementMultiSelect && focus.length > 1)
|
|
466
|
+
push(`${where}: ${t} takes a single focus claim; only statement multi-select takes a list.`);
|
|
467
|
+
if (!str(o.stem))
|
|
468
|
+
push(`${where}: missing stem (author it from the guideline's Appropriate-Stem catalog).`);
|
|
469
|
+
if (str(o.type) === "ebsr" && !str(o.stemB))
|
|
470
|
+
push(`${where}: EBSR needs a Part B stem (stem-b).`);
|
|
471
|
+
}
|
|
472
|
+
function index(arr, key) {
|
|
473
|
+
const m = {};
|
|
474
|
+
for (const x of arr)
|
|
475
|
+
if (x && x[key] !== undefined)
|
|
476
|
+
m[str(x[key])] = x;
|
|
477
|
+
return m;
|
|
478
|
+
}
|
|
479
|
+
// `focus` names the question's correct claim(s). One id for single-answer items (MC / EBSR /
|
|
480
|
+
// Hot-Text / Short-Text); a list of ids for Multi-Select (the full correct set).
|
|
481
|
+
function focusIds(outcome) {
|
|
482
|
+
return (Array.isArray(outcome.focus) ? outcome.focus.map(str) : [str(outcome.focus)]).filter(Boolean);
|
|
483
|
+
}
|
|
484
|
+
// DOK for an item: an explicit `dok` wins; otherwise the target's default, with the written summary
|
|
485
|
+
// (Short Text) bumped to r-dok3 (strategic reasoning) per the guidelines.
|
|
486
|
+
function dokFor(profile, itemType) {
|
|
487
|
+
return itemType === "short-text" ? "r-dok3" : profile.defaultDok;
|
|
488
|
+
}
|
|
489
|
+
// Program-level referential integrity. Duplicate ids corrupt the indices → hard errors;
|
|
490
|
+
// dangling references and out-of-range lines are non-fatal warnings (the plan's deferred
|
|
491
|
+
// cross-reference checks). Returns the warnings to seed each item's `warnings`.
|
|
492
|
+
function validateGraph(ctx, errors) {
|
|
493
|
+
const warnings = [];
|
|
494
|
+
const lineCount = ctx.passage.lines.length;
|
|
495
|
+
// The guideline ties each target to a text type; flag a mismatch (non-fatal — dual-text is future scope).
|
|
496
|
+
if (ctx.passage.type && ctx.passage.type !== ctx.profile.textType) {
|
|
497
|
+
warnings.push(`Target ${ctx.profile.id} expects an ${ctx.profile.textType} passage, but this passage is ${ctx.passage.type}.`);
|
|
498
|
+
}
|
|
499
|
+
for (const [label, arr] of [["claim", ctx.claims], ["source", ctx.sources], ["outcome", ctx.outcomes]]) {
|
|
500
|
+
const seen = new Set();
|
|
501
|
+
for (const x of arr) {
|
|
502
|
+
const id = str(x.id);
|
|
503
|
+
if (!id)
|
|
504
|
+
continue;
|
|
505
|
+
if (seen.has(id))
|
|
506
|
+
errors.push({ message: `Duplicate ${label} id '${id}'.`, ...coordOf(x) });
|
|
507
|
+
seen.add(id);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
for (const c of ctx.claims) {
|
|
511
|
+
for (const ref of Array.isArray(c.cites) ? c.cites : []) {
|
|
512
|
+
if (!ctx.sourceById[str(ref)])
|
|
513
|
+
warnings.push(`claim '${str(c.id)}' cites unknown evidence id '${str(ref)}'.`);
|
|
514
|
+
}
|
|
515
|
+
// A distractor's targets must name real questions (hard error — the binding is the contract).
|
|
516
|
+
if (str(c.status) === "distractor") {
|
|
517
|
+
for (const ref of Array.isArray(c.targets) ? c.targets : []) {
|
|
518
|
+
if (!ctx.outcomeById[str(ref)])
|
|
519
|
+
errors.push({ message: `distractor '${str(c.id)}' targets unknown outcome id '${str(ref)}'.`, ...coordOf(c) });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// T10 click-the-word: a distractor candidate `word` targets the hot-text outcome it foils.
|
|
524
|
+
for (const w of ctx.words) {
|
|
525
|
+
for (const ref of Array.isArray(w.targets) ? w.targets : []) {
|
|
526
|
+
if (!ctx.outcomeById[str(ref)])
|
|
527
|
+
warnings.push(`word '${str(w.id)}' targets unknown outcome id '${str(ref)}'.`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// Each question must pin a real, supported correct answer, and (for option items) have enough
|
|
531
|
+
// foils bound to it — both hard errors, so a thin or mis-wired item fails the compile.
|
|
532
|
+
for (const o of ctx.outcomes) {
|
|
533
|
+
const oid = str(o.id);
|
|
534
|
+
for (const f of focusIds(o)) {
|
|
535
|
+
if (ctx.profile.answerKind === "meaning") {
|
|
536
|
+
// T10: focus names a `word` (the targeted word), not a claim.
|
|
537
|
+
if (!ctx.wordById[f])
|
|
538
|
+
errors.push({ message: `outcome '${oid}' focus '${f}' is not a known word id.`, ...coordOf(o) });
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
const fc = ctx.claimById[f];
|
|
542
|
+
if (!fc)
|
|
543
|
+
errors.push({ message: `outcome '${oid}' focus '${f}' is not a known claim id.`, ...coordOf(o) });
|
|
544
|
+
else if (str(fc.status) !== "supported")
|
|
545
|
+
errors.push({ message: `outcome '${oid}' focus '${f}' must be a supported claim, not a ${str(fc.status)}.`, ...coordOf(o) });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Items whose options are distractor CLAIMS need enough distinct ones bound to them, or they
|
|
549
|
+
// can't be composed (hard error). EBSR / two-part Hot-Text / Multiple-Choice want 3 (4 options);
|
|
550
|
+
// Multi-Select wants ≥2 foils beyond its correct set. Items whose foils are SOURCES — evidence
|
|
551
|
+
// targets (T8) and any single-part Hot-Text (T8/T9) — don't use distractor claims, so this gate
|
|
552
|
+
// doesn't apply (their viability is warned in composition).
|
|
553
|
+
const t = str(o.type);
|
|
554
|
+
const sourceFoils = ctx.profile.answerKind !== "statement" || (t === "hot-text" && ctx.profile.singlePartHotText);
|
|
555
|
+
const min = sourceFoils ? 0
|
|
556
|
+
: (t === "ebsr" || t === "hot-text" || t === "multiple-choice") ? TUNING.DISTRACTOR_SLOTS
|
|
557
|
+
: t === "multi-select" ? 2 : 0;
|
|
558
|
+
if (oid && min > 0) {
|
|
559
|
+
const distinct = new Set(ctx.claims
|
|
560
|
+
.filter((c) => str(c.status) === "distractor" && (Array.isArray(c.targets) ? c.targets.map(str) : []).includes(oid))
|
|
561
|
+
.map((c) => norm(str(c.text)))).size;
|
|
562
|
+
if (distinct < min) {
|
|
563
|
+
errors.push({ message: `outcome '${oid}': only ${distinct} distractor(s) target it; a ${t} item needs at least ${min}.`, ...coordOf(o) });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
for (const s of ctx.sources) {
|
|
568
|
+
for (const ref of Array.isArray(s.supports) ? s.supports : []) {
|
|
569
|
+
if (!ctx.claimById[str(ref)])
|
|
570
|
+
warnings.push(`source '${str(s.id)}' supports unknown claim id '${str(ref)}'.`);
|
|
571
|
+
}
|
|
572
|
+
if (s.line !== undefined && !str(s.quote)) {
|
|
573
|
+
const ln = Number(s.line);
|
|
574
|
+
if (!Number.isFinite(ln) || ln < 1 || ln > lineCount) {
|
|
575
|
+
warnings.push(`source '${str(s.id)}' line ${str(s.line)} is outside the passage (1..${lineCount}).`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return warnings;
|
|
580
|
+
}
|
|
581
|
+
function standardsFor(outcome, correct, dim, profile) {
|
|
582
|
+
const companion = str(outcome.standard) || str(correct && correct.standard) || profile.dimStandard[dim];
|
|
583
|
+
const out = [profile.baseStandard];
|
|
584
|
+
if (companion && companion !== profile.baseStandard)
|
|
585
|
+
out.push(companion);
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
function sourceText(s, passage) {
|
|
589
|
+
if (str(s.quote))
|
|
590
|
+
return str(s.quote);
|
|
591
|
+
const ln = passage.lines.find((l) => l.id === s.line);
|
|
592
|
+
return ln ? ln.text : "";
|
|
593
|
+
}
|
|
594
|
+
// Segment a paragraph into sentences for Hot Text selection. Heuristic: take runs ending in
|
|
595
|
+
// sentence punctuation (.!?), absorbing any trailing closing quote/paren, then trim. The passage
|
|
596
|
+
// keeps its paragraph structure (one `lines` entry per paragraph); Hot Text makes each sentence
|
|
597
|
+
// within a paragraph individually selectable. An occasional dialogue-tag mis-split is acceptable
|
|
598
|
+
// for grade-level prose, and correctness is anchored to authored `quote`s rather than the split.
|
|
599
|
+
function splitSentences(text) {
|
|
600
|
+
const t = str(text).trim();
|
|
601
|
+
if (!t)
|
|
602
|
+
return [];
|
|
603
|
+
const parts = t.match(/[^.!?]+[.!?]+["'”’)\]]*\s*/g);
|
|
604
|
+
return parts ? parts.map((s) => s.trim()).filter(Boolean) : [t];
|
|
605
|
+
}
|
|
606
|
+
// Deterministic shuffle (seeded) so recompiling the same program yields stable option labels.
|
|
607
|
+
function strHash(s) {
|
|
608
|
+
let h = 2166136261 >>> 0;
|
|
609
|
+
for (let i = 0; i < s.length; i++) {
|
|
610
|
+
h ^= s.charCodeAt(i);
|
|
611
|
+
h = Math.imul(h, 16777619);
|
|
612
|
+
}
|
|
613
|
+
return h >>> 0;
|
|
614
|
+
}
|
|
615
|
+
function mulberry32(a) {
|
|
616
|
+
return function () {
|
|
617
|
+
a |= 0;
|
|
618
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
619
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
620
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
621
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function seededShuffle(arr, seed) {
|
|
625
|
+
const rnd = mulberry32(strHash(seed));
|
|
626
|
+
const a = arr.slice();
|
|
627
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
628
|
+
const j = Math.floor(rnd() * (i + 1));
|
|
629
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
630
|
+
}
|
|
631
|
+
return a;
|
|
632
|
+
}
|
|
633
|
+
const LABELS = ["A", "B", "C", "D", "E", "F"];
|
|
634
|
+
// Distinct = not a normalized-text duplicate of an already-chosen option.
|
|
635
|
+
function norm(t) {
|
|
636
|
+
return str(t).toLowerCase().replace(/[^a-z0-9 ]+/g, "").replace(/\s+/g, " ").trim();
|
|
637
|
+
}
|
|
638
|
+
function clamp01(x) {
|
|
639
|
+
return Math.max(0, Math.min(1, x));
|
|
640
|
+
}
|
|
641
|
+
// How tempting a distractor is to a partial-understander, in [0,1]. An author-supplied
|
|
642
|
+
// `plausibility` overrides (pins) the score; otherwise it is computed from graph signals:
|
|
643
|
+
// evidence that also backs the correct claim (a confused student would cite it), same
|
|
644
|
+
// dimension as the correct answer, structural parallelism, and an error-type prior.
|
|
645
|
+
export function plausibility(d, correct, ctx) {
|
|
646
|
+
if (typeof d.plausibility === "number")
|
|
647
|
+
return clamp01(d.plausibility); // author override
|
|
648
|
+
let s = 0.4; // base
|
|
649
|
+
const cited = (Array.isArray(d.cites) ? d.cites : [])
|
|
650
|
+
.map((id) => ctx.sourceById[str(id)])
|
|
651
|
+
.filter(Boolean);
|
|
652
|
+
const overlaps = cited.some((src) => str(src.status) === "supports-wrong-claim" &&
|
|
653
|
+
(Array.isArray(src.supports) ? src.supports.map(str) : []).includes(str(correct.id)));
|
|
654
|
+
if (overlaps)
|
|
655
|
+
s += 0.3; // strongest tell: real text seems to back the foil
|
|
656
|
+
if (str(d.dimension) && str(d.dimension) === str(correct.dimension))
|
|
657
|
+
s += 0.15;
|
|
658
|
+
const la = str(d.text).length, lb = str(correct.text).length;
|
|
659
|
+
if (la && lb)
|
|
660
|
+
s += 0.1 * (1 - Math.abs(la - lb) / Math.max(la, lb)); // structural parallelism
|
|
661
|
+
s += ERROR_TYPE_PRIOR[str(d.errorType)] ?? 0;
|
|
662
|
+
return clamp01(s);
|
|
663
|
+
}
|
|
664
|
+
// Select up to 3 foils for an item from the distractors explicitly bound to this question via
|
|
665
|
+
// `targets` (NOT a dimension join) — so the foils are authored against this exact stem + key.
|
|
666
|
+
function selectDistractorClaims(outcome, correct, ctx, warnings, slots = TUNING.DISTRACTOR_SLOTS) {
|
|
667
|
+
const oid = str(outcome.id);
|
|
668
|
+
const errorTypes = ctx.profile.errorTypes;
|
|
669
|
+
const correctSet = new Set(focusIds(outcome)); // never let a correct claim become its own foil (multi-select)
|
|
670
|
+
const pool = ctx.claims.filter((c) => str(c.status) === "distractor" && !correctSet.has(str(c.id)) &&
|
|
671
|
+
(Array.isArray(c.targets) ? c.targets.map(str) : []).includes(oid));
|
|
672
|
+
const seen = new Set([norm(correct.text)]);
|
|
673
|
+
// Rank candidates by plausibility (desc), tie-break by id for determinism.
|
|
674
|
+
const byScore = (a, b) => plausibility(b, correct, ctx) - plausibility(a, correct, ctx) || str(a.id).localeCompare(str(b.id));
|
|
675
|
+
const byType = {};
|
|
676
|
+
for (const c of pool)
|
|
677
|
+
(byType[str(c.errorType)] = byType[str(c.errorType)] || []).push(c);
|
|
678
|
+
for (const t of errorTypes)
|
|
679
|
+
byType[t]?.sort(byScore);
|
|
680
|
+
const chosen = [];
|
|
681
|
+
const take = (c) => {
|
|
682
|
+
if (!c)
|
|
683
|
+
return;
|
|
684
|
+
const n = norm(c.text);
|
|
685
|
+
if (seen.has(n)) {
|
|
686
|
+
warnings.push(`Dropped near-duplicate distractor '${str(c.id)}'.`);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
seen.add(n);
|
|
690
|
+
chosen.push(c);
|
|
691
|
+
};
|
|
692
|
+
// Coverage: take the most plausible foil of each error type (in taxonomy order).
|
|
693
|
+
for (const t of errorTypes)
|
|
694
|
+
if (chosen.length < slots && byType[t] && byType[t].length)
|
|
695
|
+
take(byType[t].shift());
|
|
696
|
+
// Fill remaining slots with the most plausible leftovers.
|
|
697
|
+
const rest = pool.filter((c) => !chosen.includes(c)).sort(byScore);
|
|
698
|
+
while (chosen.length < slots && rest.length)
|
|
699
|
+
take(rest.shift());
|
|
700
|
+
if (chosen.length < slots)
|
|
701
|
+
warnings.push(`Only ${chosen.length} distractor claim(s) target this outcome; this item wants ${slots}.`);
|
|
702
|
+
// Only nudge for full error-type coverage when the taxonomy fits the slot count (R&E: 3 types, 3
|
|
703
|
+
// slots). Wider taxonomies (e.g. T9's 4) can't all appear in 3 options, so don't warn.
|
|
704
|
+
if (errorTypes.length <= slots) {
|
|
705
|
+
const missing = errorTypes.filter((t) => !chosen.some((c) => str(c.errorType) === t));
|
|
706
|
+
if (missing.length)
|
|
707
|
+
warnings.push(`Distractor error types not represented: ${missing.join(", ")}.`);
|
|
708
|
+
}
|
|
709
|
+
return chosen.slice(0, slots);
|
|
710
|
+
}
|
|
711
|
+
function labelize(opts) {
|
|
712
|
+
return opts.map((o, i) => ({ key: LABELS[i], ...o }));
|
|
713
|
+
}
|
|
714
|
+
// Rough syllable count for one word — the vowel-group heuristic the classic readability formulas
|
|
715
|
+
// assume: count runs of vowels, drop a silent trailing "e", floor at 1. Not linguistically exact,
|
|
716
|
+
// but stable and dependency-free, which is all the grade-level estimate needs.
|
|
717
|
+
function countSyllables(word) {
|
|
718
|
+
const w = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
719
|
+
if (!w)
|
|
720
|
+
return 0;
|
|
721
|
+
const groups = w.match(/[aeiouy]+/g);
|
|
722
|
+
let n = groups ? groups.length : 0;
|
|
723
|
+
if (w.length > 2 && w.endsWith("e") && !/[aeiouy]e$/.test(w))
|
|
724
|
+
n -= 1; // silent final e
|
|
725
|
+
return Math.max(1, n);
|
|
726
|
+
}
|
|
727
|
+
// Flesch–Kincaid grade level over a block of prose: 0.39·(words/sentence) + 11.8·(syllables/word)
|
|
728
|
+
// − 15.59. A rough proxy for text complexity — enough to flag prose that reads well above the
|
|
729
|
+
// target grade. Returns null when the sample is too small to be meaningful.
|
|
730
|
+
function estimateGradeLevel(text) {
|
|
731
|
+
const sentences = (text.match(/[.!?]+/g) || []).length || (text.trim() ? 1 : 0);
|
|
732
|
+
const words = text.match(/[A-Za-z]+(?:'[A-Za-z]+)?/g) || [];
|
|
733
|
+
if (sentences === 0 || words.length < 20)
|
|
734
|
+
return null; // too little text to judge
|
|
735
|
+
const syllables = words.reduce((sum, w) => sum + countSyllables(w), 0);
|
|
736
|
+
return 0.39 * (words.length / sentences) + 11.8 * (syllables / words.length) - 15.59;
|
|
737
|
+
}
|
|
738
|
+
// Reading-level guard: estimate the passage's grade level and warn (non-fatal) when it reads
|
|
739
|
+
// notably above the target grade, so the upstream generator's repair loop can simplify the prose.
|
|
740
|
+
// The threshold is RELATIVE to the resolved grade (the guideline's grade, or a top-level `grade`
|
|
741
|
+
// override) — not a fixed grade-5 constant — so the same check serves future grade bands.
|
|
742
|
+
function checkReadability(passage, grade, warnings) {
|
|
743
|
+
const text = (Array.isArray(passage.lines) ? passage.lines : []).map((l) => str(l.text)).join(" ");
|
|
744
|
+
const est = estimateGradeLevel(text);
|
|
745
|
+
if (est === null)
|
|
746
|
+
return;
|
|
747
|
+
if (est > grade + TUNING.GRADE_LEVEL_TOLERANCE) {
|
|
748
|
+
warnings.push(`Passage reads above grade ${grade} (est. grade ${est.toFixed(1)}); shorten sentences and use simpler, more concrete vocabulary to match the target reading level.`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Length-giveaway guard: the correct option should not stand out as the longest/most-detailed
|
|
752
|
+
// choice — a partial-understander can pick the key on heft alone. Warn (non-fatal) when the
|
|
753
|
+
// correct option's text runs notably longer than the mean distractor length, so the author/
|
|
754
|
+
// generator can pad the foils or trim the key until the options read as parallel in length.
|
|
755
|
+
function checkLengthBalance(options, label, warnings) {
|
|
756
|
+
const correct = options.find((o) => o.correct);
|
|
757
|
+
const foils = options.filter((o) => !o.correct);
|
|
758
|
+
if (!correct || foils.length === 0)
|
|
759
|
+
return;
|
|
760
|
+
const len = (o) => str(o.text).length;
|
|
761
|
+
const correctLen = len(correct);
|
|
762
|
+
const meanFoil = foils.reduce((sum, o) => sum + len(o), 0) / foils.length;
|
|
763
|
+
if (meanFoil === 0)
|
|
764
|
+
return;
|
|
765
|
+
const ratio = correctLen / meanFoil;
|
|
766
|
+
const isLongest = foils.every((o) => len(o) <= correctLen);
|
|
767
|
+
if (isLongest && ratio >= TUNING.LENGTH_BALANCE_RATIO) {
|
|
768
|
+
warnings.push(`${label}: the correct option (${correctLen} chars) is ${Math.round((ratio - 1) * 100)}% longer than the average distractor (${Math.round(meanFoil)} chars) — possible length giveaway. Balance the options' length/detail.`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// Function words + stem boilerplate excluded when comparing a stem against the answer — only
|
|
772
|
+
// distinctive content words should count toward an overlap.
|
|
773
|
+
const STEM_STOPWORDS = new Set([
|
|
774
|
+
"the", "a", "an", "of", "to", "in", "on", "at", "for", "and", "or", "but", "that", "this", "these",
|
|
775
|
+
"those", "is", "are", "was", "were", "be", "been", "being", "it", "its", "as", "by", "with", "from",
|
|
776
|
+
"into", "about", "which", "what", "how", "who", "whose", "why", "where", "when", "your", "their",
|
|
777
|
+
"them", "they", "had", "has", "have", "do", "does", "did", "will", "would", "can", "could", "more",
|
|
778
|
+
"than", "then", "so", "such", "best", "most", "two", "three",
|
|
779
|
+
// stem-template boilerplate
|
|
780
|
+
"passage", "sentence", "sentences", "inference", "inferences", "conclusion", "conclusions", "click",
|
|
781
|
+
"statement", "statements", "supported", "support", "supports", "answer", "part", "show", "shows",
|
|
782
|
+
"select", "provides", "author", "most", "likely",
|
|
783
|
+
]);
|
|
784
|
+
function contentWords(s, subject) {
|
|
785
|
+
const subj = new Set(norm(subject).split(" ").filter(Boolean));
|
|
786
|
+
return new Set(norm(s).split(" ").filter((w) => w.length > 2 && !STEM_STOPWORDS.has(w) && !subj.has(w)));
|
|
787
|
+
}
|
|
788
|
+
// Stem-giveaway guard: the Part A stem should not echo the correct answer's wording. When the stem
|
|
789
|
+
// reuses most of the correct option's distinctive content words (ignoring function words, stem
|
|
790
|
+
// boilerplate, and the subject), the answer is obvious without reading the options. Warn (non-fatal)
|
|
791
|
+
// so the generator rewords the stem into a neutral question. The subject is excluded so a stem that
|
|
792
|
+
// merely names what the question is about (e.g. the character/topic) is not flagged.
|
|
793
|
+
function checkStemGiveaway(stem, correctText, subject, warnings) {
|
|
794
|
+
const stemWords = contentWords(stem, subject);
|
|
795
|
+
const ansWords = [...contentWords(correctText, subject)];
|
|
796
|
+
if (ansWords.length < TUNING.STEM_GIVEAWAY_MIN)
|
|
797
|
+
return; // too short to judge
|
|
798
|
+
const shared = ansWords.filter((w) => stemWords.has(w));
|
|
799
|
+
if (shared.length >= TUNING.STEM_GIVEAWAY_MIN && shared.length / ansWords.length >= TUNING.STEM_GIVEAWAY_RATIO) {
|
|
800
|
+
warnings.push(`Part A: the stem reuses much of the correct option's wording (shared: ${shared.slice(0, 8).join(", ")}) — reword the stem into a neutral question so it doesn't echo the answer.`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Place the correct option at a balanced slot, then label. A per-item seeded shuffle distributes
|
|
804
|
+
// uniformly in expectation but, over the few items in one program, can land the answer key first
|
|
805
|
+
// (or last) for every item — the pattern authors noticed. Instead the distractors are seeded-
|
|
806
|
+
// shuffled among themselves and the correct option is inserted at a slot that round-robins by
|
|
807
|
+
// outcome index, rotated by a per-program/part offset so the key neither always starts at A nor
|
|
808
|
+
// repeats the same position across items. Deterministic: same program → same labels.
|
|
809
|
+
function placeCorrect(correctOpt, distractorOpts, seed, programSeed, part, outcomeIndex) {
|
|
810
|
+
const opts = seededShuffle(distractorOpts, `${seed}:${part}`);
|
|
811
|
+
const n = opts.length + 1;
|
|
812
|
+
const slot = (outcomeIndex + (strHash(`${programSeed}:${part}`) % n)) % n;
|
|
813
|
+
opts.splice(slot, 0, correctOpt);
|
|
814
|
+
return labelize(opts);
|
|
815
|
+
}
|
|
816
|
+
function partAOptions(correct, distractors, seed, programSeed, outcomeIndex) {
|
|
817
|
+
const correctOpt = { text: str(correct.text), correct: true, claimId: str(correct.id) };
|
|
818
|
+
const distractorOpts = distractors.map((d) => ({ text: str(d.text), correct: false, claimId: str(d.id), errorType: str(d.errorType) }));
|
|
819
|
+
return placeCorrect(correctOpt, distractorOpts, seed, programSeed, "A", outcomeIndex);
|
|
820
|
+
}
|
|
821
|
+
// Multi-Select options: the full correct set plus distractors, seeded-shuffled together and labelled.
|
|
822
|
+
// (Unlike placeCorrect, more than one option is correct, so there is no single insertion slot.)
|
|
823
|
+
function multiSelectOptions(correctClaims, distractors, seed) {
|
|
824
|
+
const opts = [
|
|
825
|
+
...correctClaims.map((c) => ({ text: str(c.text), correct: true, claimId: str(c.id) })),
|
|
826
|
+
...distractors.map((d) => ({ text: str(d.text), correct: false, claimId: str(d.id), errorType: str(d.errorType) })),
|
|
827
|
+
];
|
|
828
|
+
return labelize(seededShuffle(opts, `${seed}:MS`));
|
|
829
|
+
}
|
|
830
|
+
// Per-option distractor analysis (the non-correct options), shared by Part A, Multiple Choice, and
|
|
831
|
+
// Multi-Select. `part` tags the entry ("A" for the single-part selected-response items).
|
|
832
|
+
function optionAnalysis(options, correct, ctx, part = "A") {
|
|
833
|
+
return options
|
|
834
|
+
.filter((o) => !o.correct)
|
|
835
|
+
.map((o) => {
|
|
836
|
+
const claim = ctx.claimById[o.claimId];
|
|
837
|
+
return {
|
|
838
|
+
part, key: o.key, claimId: o.claimId, errorType: o.errorType,
|
|
839
|
+
tiesTo: [o.claimId],
|
|
840
|
+
plausibility: Math.round(plausibility(claim, correct, ctx) * 100) / 100,
|
|
841
|
+
rationale: str(claim?.rationale),
|
|
842
|
+
};
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
// Function words that are never the answer in a click-the-word item, so they aren't clickable.
|
|
846
|
+
const CLICK_STOPWORDS = new Set([
|
|
847
|
+
"the", "and", "are", "was", "were", "for", "but", "with", "this", "that", "these", "those",
|
|
848
|
+
"its", "into", "their", "they", "them", "from", "has", "have", "had", "not", "you", "your",
|
|
849
|
+
]);
|
|
850
|
+
// T10 Word Meanings: compose a Multiple-Choice / Multi-Select item whose options are the candidate
|
|
851
|
+
// `meaning`s of the targeted `word` named by `focus`. correct meanings are the key(s); distractor
|
|
852
|
+
// meanings (with T10 error-types) are the foils. The targeted word + its context ride along on
|
|
853
|
+
// `item.word` for the renderer.
|
|
854
|
+
function composeWordMeaning(outcome, ctx, dim, dok, itemType, seed, outcomeIndex, warnings) {
|
|
855
|
+
const wordId = focusIds(outcome)[0];
|
|
856
|
+
const word = ctx.wordById[wordId];
|
|
857
|
+
const meanings = word && Array.isArray(word.meanings) ? word.meanings : [];
|
|
858
|
+
const correctM = meanings.filter((m) => str(m.status) === "correct");
|
|
859
|
+
const distractorM = meanings.filter((m) => str(m.status) === "distractor");
|
|
860
|
+
const key0 = correctM[0];
|
|
861
|
+
const correct = key0 ? { id: str(key0.id), text: str(key0.text), standard: key0.standard } : null;
|
|
862
|
+
const item = baseItem(itemType, outcome, ctx, dim, dok, correct, warnings);
|
|
863
|
+
if (word)
|
|
864
|
+
item.word = { text: str(word.text), line: word.line, quote: str(word.quote) || undefined };
|
|
865
|
+
if (!word) {
|
|
866
|
+
warnings.push(`Outcome '${str(outcome.id)}' focus '${wordId}' is not a known word; cannot compose.`);
|
|
867
|
+
return item;
|
|
868
|
+
}
|
|
869
|
+
// Task Model 3 — click the word: show the PARAGRAPH containing the target word and let the student
|
|
870
|
+
// click the word matching the definition (in the stem). The clickable CANDIDATES are the authored
|
|
871
|
+
// `word`s that appear in that paragraph (the curated, underlined choices) — the focus word is the
|
|
872
|
+
// correct one; the others are distractor candidates. If only the correct word is authored, every
|
|
873
|
+
// content word in the paragraph becomes a choice. The excerpt is the passage paragraph identified
|
|
874
|
+
// by the word's `line`, else the first paragraph that contains it, else its `quote`.
|
|
875
|
+
if (itemType === "hot-text") {
|
|
876
|
+
const oid = str(outcome.id);
|
|
877
|
+
const correctNorm = norm(str(word.text));
|
|
878
|
+
const byLine = str(ctx.passage.lines.find((l) => l.id === word.line)?.text);
|
|
879
|
+
const byContains = str(ctx.passage.lines.find((l) => norm(l.text).split(" ").includes(correctNorm))?.text);
|
|
880
|
+
const excerpt = byLine || byContains || str(word.quote);
|
|
881
|
+
if (!excerpt)
|
|
882
|
+
warnings.push(`Outcome '${oid}': word '${wordId}' needs a 'line' (the paragraph it appears in) or a 'quote' so there is an excerpt to select from.`);
|
|
883
|
+
const excerptNorms = new Set(norm(excerpt).split(" ").filter(Boolean));
|
|
884
|
+
// Curated candidates come from two authoring styles, both keyed to words in this paragraph:
|
|
885
|
+
// 1. other authored `word`s (the canonical hot-text model), and
|
|
886
|
+
// 2. the focus word's single-word distractor MEANINGS — the generator often authors the
|
|
887
|
+
// click candidates this way (reusing the MC/MS shape: a word + meanings) rather than as
|
|
888
|
+
// separate `word`s. A meaning whose text is one word in the paragraph IS a candidate word;
|
|
889
|
+
// multi-word definitions (e.g. the correct meaning "very tiring and difficult") are ignored.
|
|
890
|
+
// If neither yields a candidate, fall back to every content word being a choice.
|
|
891
|
+
const otherWords = ctx.words.filter((w) => str(w.id) !== str(word.id));
|
|
892
|
+
const wordCandidates = otherWords.filter((w) => excerptNorms.has(norm(str(w.text))));
|
|
893
|
+
const meaningCandidates = distractorM.filter((m) => str(m.text).trim().split(/\s+/).length === 1 && excerptNorms.has(norm(str(m.text))));
|
|
894
|
+
const candTexts = [...wordCandidates.map((w) => str(w.text)), ...meaningCandidates.map((m) => str(m.text))];
|
|
895
|
+
const curated = candTexts.length >= 1;
|
|
896
|
+
const candNorms = new Set([correctNorm, ...candTexts.map((t) => norm(t))]);
|
|
897
|
+
const tokens = excerpt.split(/\s+/).filter(Boolean).map((raw, idx) => {
|
|
898
|
+
const pre = (raw.match(/^[^A-Za-z0-9]+/) || [""])[0];
|
|
899
|
+
const post = (raw.match(/[^A-Za-z0-9]+$/) || [""])[0];
|
|
900
|
+
const core = raw.slice(pre.length, raw.length - post.length);
|
|
901
|
+
const n = norm(core);
|
|
902
|
+
const selectable = curated ? candNorms.has(n) : (core.length > 2 && !CLICK_STOPWORDS.has(n));
|
|
903
|
+
return { idx, pre, text: core || raw, post, selectable, correct: selectable && n === correctNorm };
|
|
904
|
+
});
|
|
905
|
+
// An authored candidate word that isn't in the correct word's paragraph can't be a choice — warn
|
|
906
|
+
// (skip words that are another outcome's focus, which belong to a different item).
|
|
907
|
+
const focusedIds = new Set(ctx.outcomes.flatMap((o) => focusIds(o)));
|
|
908
|
+
for (const w of otherWords) {
|
|
909
|
+
if (focusedIds.has(str(w.id)) || w.meanings)
|
|
910
|
+
continue; // another item's word, or an MC/MS word
|
|
911
|
+
if (!excerptNorms.has(norm(str(w.text))))
|
|
912
|
+
warnings.push(`word '${str(w.id)}' ("${str(w.text)}") is not in the correct word's paragraph, so it cannot be a click-the-word choice — keep all candidates in that paragraph.`);
|
|
913
|
+
}
|
|
914
|
+
if (!tokens.some((t) => t.correct))
|
|
915
|
+
warnings.push(`Outcome '${oid}': the correct word "${str(word.text)}" is not a selectable word in the excerpt.`);
|
|
916
|
+
const nSelectable = tokens.filter((t) => t.selectable).length;
|
|
917
|
+
if (nSelectable < 3)
|
|
918
|
+
warnings.push(`Click-the-word: only ${nSelectable} selectable word(s) — author a few distractor candidate words in the correct word's paragraph (or use a fuller paragraph).`);
|
|
919
|
+
if (excerpt && norm(str(outcome.stem)).includes(norm(excerpt)))
|
|
920
|
+
warnings.push("Click-the-word stem should be just the instruction and the definition — the paragraph is shown separately; do not paste it into the stem.");
|
|
921
|
+
item.wordSelect = { excerpt, tokens };
|
|
922
|
+
item.selectCount = 1;
|
|
923
|
+
item.stem = { partA: str(outcome.stem) }; // single-part: the authored definition + click instruction
|
|
924
|
+
item.review.correctClaim = { id: str(word.id), text: str(word.text) };
|
|
925
|
+
item.distractorAnalysis = [];
|
|
926
|
+
item.answerKey = { word: str(word.text), rationale: str(key0?.rationale) };
|
|
927
|
+
return item;
|
|
928
|
+
}
|
|
929
|
+
const toOpt = (m, correctFlag) => ({ text: str(m.text), correct: correctFlag, meaningId: str(m.id), errorType: str(m.errorType) || undefined });
|
|
930
|
+
const meaningById = index(meanings, "id");
|
|
931
|
+
const analysis = (options) => options.filter((o) => !o.correct).map((o) => ({
|
|
932
|
+
part: "A", key: o.key, meaningId: o.meaningId, errorType: o.errorType, rationale: str(meaningById[o.meaningId]?.rationale),
|
|
933
|
+
}));
|
|
934
|
+
if (itemType === "multi-select") {
|
|
935
|
+
if (correctM.length < 2)
|
|
936
|
+
warnings.push("Multi-select (word meaning) needs at least 2 correct meanings.");
|
|
937
|
+
const slots = Math.max(1, TUNING.MULTI_SELECT_OPTIONS - correctM.length);
|
|
938
|
+
const opts = [...correctM.map((m) => toOpt(m, true)), ...distractorM.slice(0, slots).map((m) => toOpt(m, false))];
|
|
939
|
+
const options = labelize(seededShuffle(opts, `${seed}:WM`));
|
|
940
|
+
checkLengthBalance(options, "Options", warnings);
|
|
941
|
+
item.choice = { options };
|
|
942
|
+
item.selectCount = options.filter((o) => o.correct).length;
|
|
943
|
+
item.distractorAnalysis = analysis(options);
|
|
944
|
+
item.answerKey = { choices: options.filter((o) => o.correct).map((o) => o.key), rationale: str(key0?.rationale) };
|
|
945
|
+
return item;
|
|
946
|
+
}
|
|
947
|
+
// Multiple Choice: one correct meaning + up to 3 distractor meanings.
|
|
948
|
+
const correctOpt = key0 ? toOpt(key0, true) : null;
|
|
949
|
+
const distractorOpts = distractorM.slice(0, TUNING.PART_OPTIONS - 1).map((m) => toOpt(m, false));
|
|
950
|
+
const options = correctOpt
|
|
951
|
+
? placeCorrect(correctOpt, distractorOpts, seed, ctx.passage.id, "MC", outcomeIndex)
|
|
952
|
+
: labelize(seededShuffle(distractorOpts, `${seed}:MC`));
|
|
953
|
+
if (!correctOpt)
|
|
954
|
+
warnings.push("No correct meaning for the targeted word; this item has no correct option.");
|
|
955
|
+
checkLengthBalance(options, "Options", warnings);
|
|
956
|
+
checkStemGiveaway(str(outcome.stem), str(correctOpt?.text), str(outcome.subject), warnings);
|
|
957
|
+
item.choice = { options };
|
|
958
|
+
item.distractorAnalysis = analysis(options);
|
|
959
|
+
item.answerKey = { choice: options.find((o) => o.correct)?.key, rationale: str(key0?.rationale) };
|
|
960
|
+
return item;
|
|
961
|
+
}
|
|
962
|
+
function composeOutcome(outcome, ctx, graphWarnings = [], outcomeIndex = 0) {
|
|
963
|
+
const warnings = [...graphWarnings];
|
|
964
|
+
const dim = str(outcome.dimension);
|
|
965
|
+
const itemType = str(outcome.type);
|
|
966
|
+
const dok = str(outcome.dok) || dokFor(ctx.profile, itemType);
|
|
967
|
+
const seed = `${ctx.passage.id}:${str(outcome.id)}:${itemType}`;
|
|
968
|
+
// T10 Word Meanings: `focus` names a `word`, and the options are its candidate meanings — a
|
|
969
|
+
// separate compose path (no claim/evidence graph).
|
|
970
|
+
if (ctx.profile.answerKind === "meaning") {
|
|
971
|
+
return composeWordMeaning(outcome, ctx, dim, dok, itemType, seed, outcomeIndex, warnings);
|
|
972
|
+
}
|
|
973
|
+
// 1. The question pins its correct answer(s) via `focus`. One claim for single-answer items; the
|
|
974
|
+
// full correct set for multi-select. `correct` is the primary (first) for the shared machinery.
|
|
975
|
+
const fids = focusIds(outcome);
|
|
976
|
+
const correctClaims = fids.map((id) => ctx.claimById[id]).filter(Boolean);
|
|
977
|
+
const correct = correctClaims[0];
|
|
978
|
+
if (!correct) {
|
|
979
|
+
warnings.push(`Outcome '${str(outcome.id)}' focus '${fids.join(", ")}' not found; cannot compose.`);
|
|
980
|
+
return baseItem(itemType, outcome, ctx, dim, dok, null, warnings);
|
|
981
|
+
}
|
|
982
|
+
const alternativeClaims = Math.max(0, ctx.claims.filter((c) => str(c.status) === "supported" && str(c.dimension) === dim).length - 1);
|
|
983
|
+
const item = baseItem(itemType, outcome, ctx, dim, dok, correct, warnings);
|
|
984
|
+
item.review.alternativeClaims = alternativeClaims;
|
|
985
|
+
const directSources = (Array.isArray(correct.cites) ? correct.cites : [])
|
|
986
|
+
.map((id) => ctx.sourceById[str(id)])
|
|
987
|
+
.filter((s) => s && str(s.status) === "directly-supports");
|
|
988
|
+
if (itemType === "short-text") {
|
|
989
|
+
item.prompt = str(outcome.stem); // authored from the guideline catalog (required)
|
|
990
|
+
item.rubric = Array.isArray(outcome.rubric) && outcome.rubric.length
|
|
991
|
+
? outcome.rubric.map((b) => ({ score: Number(b.score), descriptor: str(b.descriptor) }))
|
|
992
|
+
: DEFAULT_RUBRIC;
|
|
993
|
+
item.distractorAnalysis = [];
|
|
994
|
+
item.answerKey = { rationale: str(correct.rationale) };
|
|
995
|
+
if (ctx.passage.lines.length < TUNING.SHORT_TEXT_MIN_LINES)
|
|
996
|
+
warnings.push("Short Text items should use a long literary passage; this passage is short.");
|
|
997
|
+
return item;
|
|
998
|
+
}
|
|
999
|
+
// Multiple Choice (single-part, 4 options, exactly one correct).
|
|
1000
|
+
if (itemType === "multiple-choice") {
|
|
1001
|
+
if (ctx.profile.answerKind === "evidence") {
|
|
1002
|
+
// T8: the inference is given in the stem; the options are passage evidence. Correct = a
|
|
1003
|
+
// directly-supporting source for the focus claim; foils = non-supporting sources.
|
|
1004
|
+
if (directSources.length === 0)
|
|
1005
|
+
warnings.push("No directly-supporting evidence for the given inference; this item has no correct option.");
|
|
1006
|
+
const { options, pool } = evidenceOptions(correct, directSources, ctx, seed, outcomeIndex, false);
|
|
1007
|
+
if (pool < TUNING.MIN_VIABLE_PART_B)
|
|
1008
|
+
warnings.push(`Only ${pool} non-supporting evidence source(s) available; author at least ${TUNING.MIN_VIABLE_PART_B} so the best foils can be chosen.`);
|
|
1009
|
+
checkLengthBalance(options, "Options", warnings);
|
|
1010
|
+
checkStemGiveaway(str(outcome.stem), str(options.find((o) => o.correct)?.text), str(outcome.subject), warnings);
|
|
1011
|
+
item.choice = { options };
|
|
1012
|
+
item.distractorAnalysis = evidenceAnalysis(options, ctx);
|
|
1013
|
+
item.answerKey = { choice: options.find((o) => o.correct)?.key, rationale: str(correct.rationale) };
|
|
1014
|
+
return item;
|
|
1015
|
+
}
|
|
1016
|
+
// Statement targets: the `focus` claim is the key; its `targets` distractor claims are the foils.
|
|
1017
|
+
const distractors = selectDistractorClaims(outcome, correct, ctx, warnings);
|
|
1018
|
+
const options = partAOptions(correct, distractors, seed, ctx.passage.id, outcomeIndex);
|
|
1019
|
+
checkLengthBalance(options, "Options", warnings);
|
|
1020
|
+
checkStemGiveaway(str(outcome.stem), str(correct.text), str(outcome.subject), warnings);
|
|
1021
|
+
item.choice = { options };
|
|
1022
|
+
item.distractorAnalysis = optionAnalysis(options, correct, ctx);
|
|
1023
|
+
item.answerKey = { choice: options.find((o) => o.correct)?.key, rationale: str(correct.rationale) };
|
|
1024
|
+
return item;
|
|
1025
|
+
}
|
|
1026
|
+
// Multi-Select (single-part, 5–6 options, an exact correct SET; guideline: "all responses correct").
|
|
1027
|
+
if (itemType === "multi-select") {
|
|
1028
|
+
if (ctx.profile.answerKind === "evidence") {
|
|
1029
|
+
// T8: the correct set is the directly-supporting sources; foils are non-supporting sources.
|
|
1030
|
+
if (directSources.length < 2)
|
|
1031
|
+
warnings.push("Multi-select (evidence) needs at least 2 directly-supporting sources as the correct set.");
|
|
1032
|
+
const { options, pool } = evidenceOptions(correct, directSources, ctx, seed, outcomeIndex, true);
|
|
1033
|
+
if (pool < TUNING.MIN_VIABLE_PART_B)
|
|
1034
|
+
warnings.push(`Only ${pool} non-supporting evidence source(s) available; author at least ${TUNING.MIN_VIABLE_PART_B}.`);
|
|
1035
|
+
checkLengthBalance(options, "Options", warnings);
|
|
1036
|
+
item.choice = { options };
|
|
1037
|
+
item.selectCount = options.filter((o) => o.correct).length;
|
|
1038
|
+
item.distractorAnalysis = evidenceAnalysis(options, ctx);
|
|
1039
|
+
item.answerKey = { choices: options.filter((o) => o.correct).map((o) => o.key), rationale: str(correct.rationale) };
|
|
1040
|
+
return item;
|
|
1041
|
+
}
|
|
1042
|
+
// Statement targets: the full correct set is `focus` (a list); distractor claims are the foils.
|
|
1043
|
+
const correctCount = correctClaims.length;
|
|
1044
|
+
const slots = Math.max(1, TUNING.MULTI_SELECT_OPTIONS - correctCount);
|
|
1045
|
+
const distractors = selectDistractorClaims(outcome, correct, ctx, warnings, slots);
|
|
1046
|
+
const options = multiSelectOptions(correctClaims, distractors, seed);
|
|
1047
|
+
checkLengthBalance(options, "Options", warnings);
|
|
1048
|
+
checkStemGiveaway(str(outcome.stem), str(correct.text), str(outcome.subject), warnings);
|
|
1049
|
+
item.choice = { options };
|
|
1050
|
+
item.selectCount = correctCount; // how many to select (the stem says "Choose two", etc.)
|
|
1051
|
+
item.distractorAnalysis = optionAnalysis(options, correct, ctx);
|
|
1052
|
+
item.answerKey = { choices: options.filter((o) => o.correct).map((o) => o.key), rationale: str(correct.rationale) };
|
|
1053
|
+
return item;
|
|
1054
|
+
}
|
|
1055
|
+
// Single-part Hot Text (T8 Key Details, T9 Central Ideas): the authored stem is the whole click
|
|
1056
|
+
// instruction (it states the given inference, or asks for the main-idea sentences); the student
|
|
1057
|
+
// clicks the directly-supporting sentences. No Part A statement options.
|
|
1058
|
+
if (itemType === "hot-text" && ctx.profile.singlePartHotText) {
|
|
1059
|
+
const { selectable, selectCount } = buildSelectable(ctx, directSources, warnings);
|
|
1060
|
+
item.selectable = selectable;
|
|
1061
|
+
item.selectCount = selectCount;
|
|
1062
|
+
item.stem = { partA: str(outcome.stem) }; // single-part: the authored click instruction (no lead-in / Part B)
|
|
1063
|
+
item.distractorAnalysis = [];
|
|
1064
|
+
item.answerKey = { partB: selectable.filter((s) => s.correct).map((s) => s.id).join(", "), rationale: str(correct.rationale) };
|
|
1065
|
+
return item;
|
|
1066
|
+
}
|
|
1067
|
+
// EBSR & Hot Text share Part A (statement options).
|
|
1068
|
+
// Viability check: a healthy pool has >=5 distinct distractors bound to THIS question (via
|
|
1069
|
+
// `targets`), so selection (and the plausibility ranking) has real choice. Thin pools warn — a
|
|
1070
|
+
// signal the upstream generator's repair loop can use to regenerate more foils. (Fewer than 3
|
|
1071
|
+
// targeted foils is a hard error raised earlier in validateGraph.)
|
|
1072
|
+
const oid = str(outcome.id);
|
|
1073
|
+
const viableDistractors = new Set(ctx.claims
|
|
1074
|
+
.filter((c) => str(c.status) === "distractor" && (Array.isArray(c.targets) ? c.targets.map(str) : []).includes(oid))
|
|
1075
|
+
.map((c) => norm(str(c.text)))).size;
|
|
1076
|
+
if (viableDistractors < TUNING.MIN_VIABLE_DISTRACTORS) {
|
|
1077
|
+
warnings.push(`Only ${viableDistractors} viable distractor(s) target outcome '${oid}'; author at least 5 for stronger selection.`);
|
|
1078
|
+
}
|
|
1079
|
+
const distractors = selectDistractorClaims(outcome, correct, ctx, warnings);
|
|
1080
|
+
item.partA = { options: partAOptions(correct, distractors, seed, ctx.passage.id, outcomeIndex) };
|
|
1081
|
+
checkLengthBalance(item.partA.options, "Part A", warnings);
|
|
1082
|
+
checkStemGiveaway(str(outcome.stem), str(correct.text), str(outcome.subject), warnings);
|
|
1083
|
+
const aKey = item.partA.options.find((o) => o.correct)?.key;
|
|
1084
|
+
const analysis = optionAnalysis(item.partA.options, correct, ctx);
|
|
1085
|
+
if (itemType === "hot-text") {
|
|
1086
|
+
// Part A asks for the best STATEMENT (an inference), authored from the Task Model 2 "Click on
|
|
1087
|
+
// the statement that best…" catalog. Selecting passage sentences is Part B (fixed by the
|
|
1088
|
+
// compiler). A Part A stem that asks the student to click sentences from the passage is the
|
|
1089
|
+
// wrong form — warn so the generator swaps in a statement stem.
|
|
1090
|
+
if (/\bsentences?\b/i.test(str(outcome.stem))) {
|
|
1091
|
+
warnings.push("Hot Text Part A must ask for the best STATEMENT (an inference), not passage sentences — selecting sentences is Part B (fixed by the compiler). Use a \"Click on the statement that best…\" stem from stems.md Task Model 2.");
|
|
1092
|
+
}
|
|
1093
|
+
// The passage is segmented into sentences (grouped by paragraph) and the directly-supporting
|
|
1094
|
+
// ones are marked correct; the student selects an exact count (a proper subset of the valid
|
|
1095
|
+
// superset). See buildSelectable.
|
|
1096
|
+
const { selectable, selectCount } = buildSelectable(ctx, directSources, warnings);
|
|
1097
|
+
item.selectable = selectable;
|
|
1098
|
+
item.selectCount = selectCount;
|
|
1099
|
+
item.stem.partB = hotTextPartB(selectCount);
|
|
1100
|
+
item.distractorAnalysis = analysis;
|
|
1101
|
+
item.answerKey = { partA: aKey, partB: selectable.filter((s) => s.correct).map((s) => s.id).join(", "), rationale: str(correct.rationale) };
|
|
1102
|
+
return item;
|
|
1103
|
+
}
|
|
1104
|
+
// EBSR Part B — curated 4 line options.
|
|
1105
|
+
const correctSrc = directSources[0];
|
|
1106
|
+
const { pool: partBPool, chosen: distractorSrcs } = pickPartBDistractors(correct, distractors, ctx);
|
|
1107
|
+
if (partBPool < TUNING.MIN_VIABLE_PART_B) {
|
|
1108
|
+
warnings.push(`Only ${partBPool} Part B foil source(s) available; author at least ${TUNING.MIN_VIABLE_PART_B} non-supporting evidence lines (supports-wrong-claim + irrelevant) so the best 3 can be chosen.`);
|
|
1109
|
+
}
|
|
1110
|
+
const correctOpt = correctSrc
|
|
1111
|
+
? { line: correctSrc.line, text: sourceText(correctSrc, ctx.passage), correct: true, sourceId: str(correctSrc.id) }
|
|
1112
|
+
: null;
|
|
1113
|
+
const distractorOpts = distractorSrcs.map((s) => ({
|
|
1114
|
+
line: s.line, text: sourceText(s, ctx.passage), correct: false, sourceId: str(s.id),
|
|
1115
|
+
status: str(s.status), tiesTo: firstWrongClaim(s, correct),
|
|
1116
|
+
}));
|
|
1117
|
+
const bCount = (correctOpt ? 1 : 0) + distractorOpts.length;
|
|
1118
|
+
if (!correctSrc)
|
|
1119
|
+
warnings.push("No directly-supporting evidence for the correct claim; EBSR Part B has no correct option.");
|
|
1120
|
+
if (bCount < TUNING.PART_OPTIONS)
|
|
1121
|
+
warnings.push(`Only ${bCount} Part B option(s) available; EBSR wants 4. Add irrelevant or supports-wrong-claim evidence sources.`);
|
|
1122
|
+
item.partB = {
|
|
1123
|
+
options: correctOpt
|
|
1124
|
+
? placeCorrect(correctOpt, distractorOpts, seed, ctx.passage.id, "B", outcomeIndex)
|
|
1125
|
+
: labelize(seededShuffle(distractorOpts, `${seed}:B`)),
|
|
1126
|
+
};
|
|
1127
|
+
const bKey = item.partB.options.find((o) => o.correct)?.key;
|
|
1128
|
+
checkLengthBalance(item.partB.options, "Part B", warnings);
|
|
1129
|
+
// A<->B no-giveaway check: at least one Part B distractor should also tie to the correct claim.
|
|
1130
|
+
const overlap = distractorSrcs.some((s) => (Array.isArray(s.supports) ? s.supports.map(str) : []).includes(str(correct.id)));
|
|
1131
|
+
if (distractorSrcs.length && !overlap) {
|
|
1132
|
+
warnings.push("Part B options do not overlap the correct Part A option — possible A↔B giveaway.");
|
|
1133
|
+
}
|
|
1134
|
+
for (const o of item.partB.options) {
|
|
1135
|
+
if (!o.correct) {
|
|
1136
|
+
analysis.push({
|
|
1137
|
+
part: "B", key: o.key, sourceId: o.sourceId, status: o.status, tiesTo: o.tiesTo,
|
|
1138
|
+
rationale: partBRationale(ctx.sourceById[o.sourceId], o.status),
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
item.distractorAnalysis = analysis;
|
|
1143
|
+
item.answerKey = { partA: aKey, partB: bKey, rationale: str(correct.rationale) };
|
|
1144
|
+
return item;
|
|
1145
|
+
}
|
|
1146
|
+
// Part B foils come from sources that don't directly support the correct claim:
|
|
1147
|
+
// `supports-wrong-claim` (real text backing an erroneous inference) and `irrelevant` lines.
|
|
1148
|
+
// Rank so the most tempting win — a wrong-claim source tied to a CHOSEN distractor AND to the
|
|
1149
|
+
// correct claim (plausibly supports more than one Part A option) scores highest; irrelevant
|
|
1150
|
+
// lines lowest. Returns the full candidate `pool` size (for the viability floor) + the best 3.
|
|
1151
|
+
function pickPartBDistractors(correct, distractors, ctx, slots = TUNING.DISTRACTOR_SLOTS) {
|
|
1152
|
+
const distractorIds = new Set(distractors.map((d) => str(d.id)));
|
|
1153
|
+
const correctId = str(correct.id);
|
|
1154
|
+
const candidates = ctx.sources.filter((s) => {
|
|
1155
|
+
const st = str(s.status);
|
|
1156
|
+
return st === "supports-wrong-claim" || st === "irrelevant";
|
|
1157
|
+
});
|
|
1158
|
+
const score = (s) => {
|
|
1159
|
+
if (str(s.status) !== "supports-wrong-claim")
|
|
1160
|
+
return 0; // irrelevant
|
|
1161
|
+
const sup = Array.isArray(s.supports) ? s.supports.map(str) : [];
|
|
1162
|
+
return 0.5 + (sup.some((id) => distractorIds.has(id)) ? 2 : 0) + (sup.includes(correctId) ? 1 : 0);
|
|
1163
|
+
};
|
|
1164
|
+
const ranked = candidates.slice().sort((a, b) => score(b) - score(a) || str(a.id).localeCompare(str(b.id)));
|
|
1165
|
+
return { pool: candidates.length, chosen: ranked.slice(0, slots) };
|
|
1166
|
+
}
|
|
1167
|
+
function firstWrongClaim(s, correct) {
|
|
1168
|
+
const ids = (Array.isArray(s.supports) ? s.supports.map(str) : []).filter((id) => id !== str(correct.id));
|
|
1169
|
+
return ids[0] || "";
|
|
1170
|
+
}
|
|
1171
|
+
function partBRationale(s, status) {
|
|
1172
|
+
if (s && str(s.rationale))
|
|
1173
|
+
return str(s.rationale);
|
|
1174
|
+
if (status === "supports-wrong-claim")
|
|
1175
|
+
return "Real evidence, but it supports a different (erroneous) inference, not the correct one.";
|
|
1176
|
+
return "Does not directly support the inference.";
|
|
1177
|
+
}
|
|
1178
|
+
// Segment the passage into selectable sentences and mark the ones the `directSources` support (the
|
|
1179
|
+
// Hot-Text selection set). A directly-supporting source with a `quote` marks the matching sentence
|
|
1180
|
+
// (normalized equality → containment); without a `quote` it marks every sentence of its paragraph.
|
|
1181
|
+
// Returns the grouped-by-paragraph `selectable` plus the exact-count `selectCount` (one less than
|
|
1182
|
+
// the valid set, capped at HOT_TEXT_SELECT_MAX, floored at 1); pushes viability warnings.
|
|
1183
|
+
function buildSelectable(ctx, directSources, warnings) {
|
|
1184
|
+
const directNoQuote = new Set(directSources.filter((s) => !str(s.quote)).map((s) => s.line));
|
|
1185
|
+
const directQuotes = directSources.filter((s) => str(s.quote)).map((s) => norm(str(s.quote)));
|
|
1186
|
+
const isCorrect = (lineId, text) => {
|
|
1187
|
+
if (directNoQuote.has(lineId))
|
|
1188
|
+
return true;
|
|
1189
|
+
const n = norm(text);
|
|
1190
|
+
return directQuotes.some((q) => q === n || q.includes(n) || n.includes(q));
|
|
1191
|
+
};
|
|
1192
|
+
const selectable = [];
|
|
1193
|
+
for (const l of ctx.passage.lines) {
|
|
1194
|
+
splitSentences(l.text).forEach((sentence, i) => {
|
|
1195
|
+
selectable.push({ id: `${l.id}.${i + 1}`, lineId: l.id, sentence: i + 1, text: sentence, correct: isCorrect(l.id, sentence) });
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
const valid = selectable.filter((s) => s.correct).length;
|
|
1199
|
+
if (valid === 0)
|
|
1200
|
+
warnings.push("No directly-supporting evidence; Part B has no correct selection.");
|
|
1201
|
+
if (valid === 1)
|
|
1202
|
+
warnings.push("Only 1 valid supporting sentence; author a superset (2+ directly-supporting sentences) so the expected answer is a subset of the valid responses.");
|
|
1203
|
+
const selectCount = valid <= 1 ? 1 : Math.min(TUNING.HOT_TEXT_SELECT_MAX, valid - 1);
|
|
1204
|
+
return { selectable, selectCount };
|
|
1205
|
+
}
|
|
1206
|
+
// Build labeled EVIDENCE options (answerKind "evidence", e.g. T8): the correct option(s) are the
|
|
1207
|
+
// directly-supporting sources for the given-inference claim; foils are the most tempting
|
|
1208
|
+
// non-supporting sources. `correctMany` controls Multiple-Choice (1 correct) vs Multi-Select (the
|
|
1209
|
+
// full directly-supporting set). Returns the options + the candidate-pool size.
|
|
1210
|
+
function evidenceOptions(correct, directSources, ctx, seed, outcomeIndex, correctMany) {
|
|
1211
|
+
const correctSrcs = correctMany ? directSources : directSources.slice(0, 1);
|
|
1212
|
+
const foilSlots = Math.max(1, (correctMany ? TUNING.MULTI_SELECT_OPTIONS : TUNING.PART_OPTIONS) - correctSrcs.length);
|
|
1213
|
+
const { pool, chosen } = pickPartBDistractors(correct, [], ctx, foilSlots);
|
|
1214
|
+
const correctOpts = correctSrcs.map((s) => ({ text: sourceText(s, ctx.passage), correct: true, sourceId: str(s.id) }));
|
|
1215
|
+
const foilOpts = chosen.map((s) => ({
|
|
1216
|
+
text: sourceText(s, ctx.passage), correct: false, sourceId: str(s.id), status: str(s.status), tiesTo: firstWrongClaim(s, correct),
|
|
1217
|
+
}));
|
|
1218
|
+
const options = correctMany
|
|
1219
|
+
? labelize(seededShuffle([...correctOpts, ...foilOpts], `${seed}:ev`))
|
|
1220
|
+
: (correctOpts.length
|
|
1221
|
+
? placeCorrect(correctOpts[0], foilOpts, seed, ctx.passage.id, "MC", outcomeIndex)
|
|
1222
|
+
: labelize(seededShuffle(foilOpts, `${seed}:MC`)));
|
|
1223
|
+
return { options, pool };
|
|
1224
|
+
}
|
|
1225
|
+
// Per-option analysis for evidence options (the non-correct sources), tagged part "A".
|
|
1226
|
+
function evidenceAnalysis(options, ctx) {
|
|
1227
|
+
return options
|
|
1228
|
+
.filter((o) => !o.correct)
|
|
1229
|
+
.map((o) => ({
|
|
1230
|
+
part: "A", key: o.key, sourceId: o.sourceId, status: o.status, tiesTo: o.tiesTo,
|
|
1231
|
+
rationale: partBRationale(ctx.sourceById[o.sourceId], o.status),
|
|
1232
|
+
}));
|
|
1233
|
+
}
|
|
1234
|
+
// Stems are authored on the outcome (from the guideline catalog). Part A is the authored `stem`;
|
|
1235
|
+
// EBSR Part B is the authored `stem-b`; Hot Text Part B is the fixed selection instruction. The
|
|
1236
|
+
// two-part lead-in is added ONLY for the two-part models (EBSR, Hot Text); Short Text is a single
|
|
1237
|
+
// constructed-response prompt with one answer box, so it carries no Part A/B lead-in.
|
|
1238
|
+
function stemFor(itemType, outcome) {
|
|
1239
|
+
const stem = { partA: str(outcome.stem) };
|
|
1240
|
+
if (itemType === "ebsr") {
|
|
1241
|
+
stem.leadIn = LEAD_IN;
|
|
1242
|
+
stem.partB = str(outcome.stemB);
|
|
1243
|
+
}
|
|
1244
|
+
else if (itemType === "hot-text") {
|
|
1245
|
+
stem.leadIn = LEAD_IN;
|
|
1246
|
+
stem.partB = HOT_TEXT_PART_B_PLACEHOLDER;
|
|
1247
|
+
}
|
|
1248
|
+
return stem;
|
|
1249
|
+
}
|
|
1250
|
+
const SCORING = {
|
|
1251
|
+
"short-text": "0–2 points; hand-scored against the rubric.",
|
|
1252
|
+
"multiple-choice": "Correct option = 1 point; otherwise 0.",
|
|
1253
|
+
"multi-select": "All correct selections (and no others) = 1 point; otherwise 0.",
|
|
1254
|
+
"ebsr": "Both parts correct = 1 point; otherwise 0.",
|
|
1255
|
+
"hot-text": "Both parts correct = 1 point; otherwise 0.",
|
|
1256
|
+
};
|
|
1257
|
+
function baseItem(itemType, outcome, ctx, dim, dok, correct, warnings) {
|
|
1258
|
+
const scoring = SCORING[itemType] || "Both parts correct = 1 point; otherwise 0.";
|
|
1259
|
+
const standards = standardsFor(outcome, correct, dim, ctx.profile);
|
|
1260
|
+
return {
|
|
1261
|
+
kind: "item",
|
|
1262
|
+
id: `${ctx.passage.id}-${str(outcome.id) || itemType + "-" + dim}`,
|
|
1263
|
+
type: itemType,
|
|
1264
|
+
target: ctx.profile.id,
|
|
1265
|
+
standards,
|
|
1266
|
+
dok,
|
|
1267
|
+
dimension: dim,
|
|
1268
|
+
passage: ctx.passage,
|
|
1269
|
+
passages: null,
|
|
1270
|
+
stem: stemFor(itemType, outcome),
|
|
1271
|
+
distractorAnalysis: [],
|
|
1272
|
+
answerKey: {},
|
|
1273
|
+
review: {
|
|
1274
|
+
target: ctx.profile.id,
|
|
1275
|
+
standards,
|
|
1276
|
+
dok,
|
|
1277
|
+
dimension: dim,
|
|
1278
|
+
scoring,
|
|
1279
|
+
correctClaim: correct ? { id: str(correct.id), text: str(correct.text), subject: str(correct.subject) || undefined } : null,
|
|
1280
|
+
alternativeClaims: 0,
|
|
1281
|
+
},
|
|
1282
|
+
warnings,
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
//# sourceMappingURL=compiler.js.map
|