@qlm/learnlog-mcp 0.0.0-stage → 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +43 -0
  4. package/README.md +121 -2
  5. package/data/SOURCE_CORRECTION.md +9 -0
  6. package/data/THIRD_PARTY_DATA.md +148 -0
  7. package/data/examples/chain-tpc-clinical-v2-tampered.json +97 -0
  8. package/data/examples/chain-tpc-clinical-v2.json +95 -0
  9. package/data/examples/evidence-record-clean.json +173 -0
  10. package/data/examples/evidence-record-tampered.json +173 -0
  11. package/data/labpath-worlds.json +107 -0
  12. package/data/preprint/README.md +10 -0
  13. package/data/preprint/assistments_episodes.json +37 -0
  14. package/data/preprint/cross_source_episodes.json +63 -0
  15. package/data/provenance/oatutor.json +84 -0
  16. package/data/provenance/source-audit.json +69 -0
  17. package/data/provenance/tutormoments.json +91 -0
  18. package/data/samples/explicit-assistance-SYNTHETIC.jsonl +5 -0
  19. package/data/samples/oatutor-real-participants.csv +13 -0
  20. package/data/samples/sandpiper-SYNTHETIC.csv +9 -0
  21. package/data/samples/tutormoments-real-transcripts.jsonl +2 -0
  22. package/dist/bin.js +6 -0
  23. package/dist/episodes.js +488 -0
  24. package/dist/files.js +39 -0
  25. package/dist/package.js +14 -0
  26. package/dist/py.js +419 -0
  27. package/dist/respond.js +47 -0
  28. package/dist/sealed.js +31 -0
  29. package/dist/server.js +115 -0
  30. package/dist/stages.js +35 -0
  31. package/dist/tools/formats.js +35 -0
  32. package/dist/tools/inventory.js +179 -0
  33. package/dist/tools/learning-graph.js +111 -0
  34. package/dist/tools/misconceptions.js +54 -0
  35. package/dist/tools/preprint.js +241 -0
  36. package/dist/tools/qlm-datasets.js +56 -0
  37. package/dist/tools/transform.js +61 -0
  38. package/dist/tools/validate.js +47 -0
  39. package/dist/tools/verify-credential.js +155 -0
  40. package/dist/tools/verify-evidence.js +51 -0
  41. package/dist/tools/worlds.js +24 -0
  42. package/dist/transformer.js +92 -0
  43. package/dist/zip.js +71 -0
  44. package/package.json +66 -4
  45. package/skills/learnlog-assistments/SKILL.md +38 -0
  46. package/skills/learnlog-export-to-action/SKILL.md +31 -0
  47. package/skills/learnlog-learning-commons/SKILL.md +33 -0
  48. package/skills/learnlog-nto-sandpiper/SKILL.md +31 -0
  49. package/vendor/PROVENANCE.json +259 -0
  50. package/vendor/qlm-measure/checks.d.ts +24 -0
  51. package/vendor/qlm-measure/checks.js +42 -0
  52. package/vendor/qlm-measure/cli.d.ts +17 -0
  53. package/vendor/qlm-measure/cli.js +461 -0
  54. package/vendor/qlm-measure/rules.d.ts +10 -0
  55. package/vendor/qlm-measure/rules.js +46 -0
  56. package/vendor/qlm-measure/schemes/index.d.ts +133 -0
  57. package/vendor/qlm-measure/schemes/index.js +339 -0
  58. package/vendor/qlm-measure/schemes/node.d.ts +8 -0
  59. package/vendor/qlm-measure/schemes/node.js +15 -0
  60. package/vendor/qlm-measure/schemes/products.d.ts +38 -0
  61. package/vendor/qlm-measure/schemes/products.js +725 -0
  62. package/vendor/qlm-measure/verifier/hash.d.ts +27 -0
  63. package/vendor/qlm-measure/verifier/hash.js +163 -0
  64. package/vendor/qlm-measure/verifier/verify-record.d.ts +22 -0
  65. package/vendor/qlm-measure/verifier/verify-record.js +123 -0
  66. package/vendor/qlm-measure/verifier-v03.d.ts +24 -0
  67. package/vendor/qlm-measure/verifier-v03.js +205 -0
  68. package/vendor/transformer/LICENSE.txt +29 -0
  69. package/vendor/transformer/cross-source-analysis-data.d.mts +3 -0
  70. package/vendor/transformer/cross-source-analysis-data.mjs +238 -0
  71. package/vendor/transformer/engine.d.mts +61 -0
  72. package/vendor/transformer/engine.mjs +445 -0
  73. package/vendor/transformer/profiles.mjs +309 -0
  74. package/vendor/transformer/research-semantics.mjs +1453 -0
@@ -0,0 +1,35 @@
1
+ /** learnlog_formats: which exports the transformer converts, and what each one records, stage by stage. */
2
+ import { VENDORED_FROM } from "../package.js";
3
+ import { LEARNLOG_DRAFT, STAGES } from "../stages.js";
4
+ import { engine, formatStages } from "../transformer.js";
5
+ import { PREPRINT_DATASETS } from "./preprint.js";
6
+ const UNIT = { dialogue: "transcript turns", summary: "participant summaries", assistance_events: "explicit lifecycle assertions", annotated_dialogue: "Sandpiper utterances" };
7
+ export function learnlogFormats() {
8
+ return {
9
+ learnlog: LEARNLOG_DRAFT,
10
+ stages: STAGES,
11
+ formats: engine.ADAPTER_PROFILES.map((p) => {
12
+ const contract = engine.getSemanticContract(p.id);
13
+ return {
14
+ id: p.id,
15
+ name: p.name,
16
+ description: p.description,
17
+ observation_unit: UNIT[p.mode],
18
+ status: p.status,
19
+ what_one_record_is: contract.definition,
20
+ coverage: contract.coverage,
21
+ help_stages: formatStages(p),
22
+ ...(p.mode === "assistance_events" ? { also_recorded: "learner_response_observed: a later learner action the source asserts. It is kept apart from the draft's accepted stage." } : {}),
23
+ limitations: p.source.limitations,
24
+ sample: { bundled: true, file: `data/samples/${p.sampleName}`, summary: p.source.summary, license: p.source.license, citation: p.source.citation, source: p.source.url },
25
+ input: "CSV, a JSON array or JSON Lines; 1 to 5,000 rows; up to 2 MB (the transformer's limits)",
26
+ };
27
+ }),
28
+ preprint_datasets: PREPRINT_DATASETS,
29
+ transformer: { engine_version: engine.ENGINE_VERSION, vendored_from: `${VENDORED_FROM} demo/transformer/dist/engine.mjs`, license: "MIT" },
30
+ does_not_show: [
31
+ "Whether a product's own exports match these formats. The adapters cover published research releases, an explicit lifecycle contract and Sandpiper's documented utterance export, not vendor integrations.",
32
+ "Anything about your data. This lists formats only; run learnlog_transform or assistance_inventory on a file to see what it records.",
33
+ ],
34
+ };
35
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * assistance_inventory: what does this dataset actually record about help? Stage by stage, in the
3
+ * draft's six terms, with counts by source and what the counts can't show. It reads Learnlog
4
+ * records (made now from an export, or from a Learnlog file), or the preprint's three released
5
+ * datasets (see preprint.ts).
6
+ */
7
+ import { Refusal, readLocalText } from "../files.js";
8
+ import { LEARNLOG_DRAFT, NOT_RECORDED, STAGE_VERB, TRANSFORMER_EVENT_STAGE, byStage } from "../stages.js";
9
+ import { engine } from "../transformer.js";
10
+ import { preprintInventory } from "./preprint.js";
11
+ import { runTransform } from "./transform.js";
12
+ const inc = (c, k) => {
13
+ c[k] = (c[k] ?? 0) + 1;
14
+ };
15
+ async function recordsFrom(input) {
16
+ if (input.learnlog_path === undefined)
17
+ return (await runTransform(input)).records;
18
+ let doc;
19
+ try {
20
+ doc = engine.parseStrictJSON(readLocalText(input.learnlog_path, "learnlog_path", 50_000_000).replace(/^\ufeff/, ""));
21
+ }
22
+ catch (e) {
23
+ if (e instanceof Refusal)
24
+ throw e;
25
+ throw new Refusal(`${input.learnlog_path} isn't valid JSON: ${e.message}`);
26
+ }
27
+ try {
28
+ await engine.verify(doc);
29
+ }
30
+ catch (e) {
31
+ throw new Refusal(`${input.learnlog_path} does not verify, so it isn't inventoried: ${e.message} Run learnlog_validate for details.`);
32
+ }
33
+ return doc.records;
34
+ }
35
+ function sourceInventory(records) {
36
+ const h = records[0].header;
37
+ const entries = records.flatMap((r) => r.entries);
38
+ const data = entries.map((e) => e.event.data);
39
+ const mode = h.import_mode;
40
+ const base = { source: h.source_platform, ...(h.source_namespace ? { source_namespace: h.source_namespace } : {}), import_mode: mode, source_kind: h.source_kind, records: records.length, entries: entries.length };
41
+ const coverage = { help_coverage: h.assistance_coverage, mapping_coverage: h.semantic_mapping.coverage };
42
+ if (mode === "dialogue") {
43
+ const bySpeaker = {};
44
+ for (const d of data)
45
+ inc(bySpeaker, d.dialogue.role);
46
+ return {
47
+ ...base,
48
+ what_is_recorded: { unit: "dialogue turns", sessions: records.length, turns: entries.length, turns_by_speaker: bySpeaker },
49
+ stages: byStage(() => ({ status: NOT_RECORDED, note: "This source keeps who spoke and what they said. The adapter doesn't classify any turn as help, and a Tutor turn isn't a help event." })),
50
+ coverage,
51
+ };
52
+ }
53
+ if (mode === "annotated_dialogue") {
54
+ // Roles, annotator names and fields are any text the export uses, so the tallies have no prototype.
55
+ const tally = () => Object.create(null);
56
+ const byRole = tally();
57
+ const byAnnotator = tally();
58
+ const byField = tally();
59
+ const byAnnotatorAndField = tally();
60
+ const identifiedBy = tally();
61
+ let codes = 0;
62
+ let coded = 0;
63
+ for (const d of data) {
64
+ inc(byRole, d.utterance.role);
65
+ const annotations = d.inferred_annotations;
66
+ if (annotations.length)
67
+ coded++;
68
+ for (const a of annotations) {
69
+ if (a.identifiedBy !== undefined)
70
+ inc(identifiedBy, a.identifiedBy);
71
+ for (const field of Object.keys(a.fields)) {
72
+ codes++;
73
+ inc(byAnnotator, a.annotator);
74
+ inc(byField, field);
75
+ inc((byAnnotatorAndField[a.annotator] ??= tally()), field);
76
+ }
77
+ }
78
+ }
79
+ return {
80
+ ...base,
81
+ what_is_recorded: {
82
+ unit: "utterances",
83
+ sessions: records.length,
84
+ utterances: entries.length,
85
+ turns_by_role: byRole,
86
+ inferred_codes: {
87
+ basis: "inferred",
88
+ note: "Annotation codes are an annotator's inferred assertions about utterances, not help events. They aren't counted at any stage, and identifiedBy is counted only where the export carries it.",
89
+ codes,
90
+ utterances_with_codes: coded,
91
+ by_annotator: byAnnotator,
92
+ by_field: byField,
93
+ by_annotator_and_field: byAnnotatorAndField,
94
+ identified_by: identifiedBy,
95
+ },
96
+ },
97
+ stages: byStage(() => ({ status: NOT_RECORDED, note: "This export keeps who spoke and what they said. Its annotation codes are inferred assertions by an annotator, so none of them records help being asked for, offered, generated, sent, shown or accepted." })),
98
+ coverage,
99
+ };
100
+ }
101
+ if (mode === "summary") {
102
+ const conditions = {};
103
+ const use = {};
104
+ for (const d of data) {
105
+ const ev = d.help_evidence;
106
+ inc(conditions, ev.condition);
107
+ inc(use, ev.actual_help_use);
108
+ }
109
+ return {
110
+ ...base,
111
+ what_is_recorded: { unit: "participant summaries", participants: entries.length, assigned_condition: conditions, actual_help_use: use },
112
+ stages: byStage(() => ({ status: NOT_RECORDED, note: "This table gives each participant's assigned help condition. Being assigned to a condition doesn't show that help was asked for, offered or shown." })),
113
+ coverage,
114
+ };
115
+ }
116
+ const types = {};
117
+ const capture = {};
118
+ for (const d of data) {
119
+ inc(types, d.lifecycle.event_type);
120
+ inc(capture, d.assistance_context.capture);
121
+ }
122
+ const stages = byStage((stage) => {
123
+ const type = Object.keys(TRANSFORMER_EVENT_STAGE).find((t) => TRANSFORMER_EVENT_STAGE[t] === stage);
124
+ if (!type)
125
+ return { status: NOT_RECORDED, note: "The transformer's lifecycle input has no event type for this stage, so nothing here can record it." };
126
+ const n = types[type] ?? 0;
127
+ if (n === 0)
128
+ return { status: NOT_RECORDED, note: `No ${type} assertions in this file. Not recorded is not proof that no help was ${STAGE_VERB[stage]}: capture of this stage isn't established.` };
129
+ return { status: "recorded", count: n, note: `${n} ${type} assertions in this file. Each is the source's assertion, and a count isn't a total unless capture of this stage was complete.` };
130
+ });
131
+ return {
132
+ ...base,
133
+ what_is_recorded: { unit: "lifecycle assertions", assertions: entries.length, event_types: types },
134
+ stages,
135
+ learner_response_observed: types.learner_response_observed ?? 0,
136
+ capture_declared: capture,
137
+ coverage,
138
+ };
139
+ }
140
+ export const RECORD_LIMITS = [
141
+ "An inventory isn't a help rate: there is no denominator of opportunities, time or learners.",
142
+ "A missing record isn't proof that no help was given. Not recorded is never the same as none.",
143
+ "Each stage is its own observation. A request doesn't show that help was generated, and a display doesn't show that it was read.",
144
+ "Counts from different sources measure different things and can't be pooled into one number.",
145
+ ];
146
+ export async function assistanceInventoryOfRecords(input) {
147
+ const records = await recordsFrom(input);
148
+ const groups = new Map();
149
+ for (const r of records) {
150
+ const key = JSON.stringify([r.header.source_platform, r.header.source_namespace ?? null]);
151
+ groups.set(key, [...(groups.get(key) ?? []), r]);
152
+ }
153
+ return {
154
+ kind: "learnlog_records",
155
+ learnlog: LEARNLOG_DRAFT,
156
+ sources: [...groups.values()].map(sourceInventory),
157
+ cannot_conclude: RECORD_LIMITS,
158
+ does_not_show: [
159
+ "Help outside the recorded source: other apps, devices, people or AI tools.",
160
+ "Whether any help worked. These are records of help, not of learning.",
161
+ "Learner identities. Learner IDs are pseudonyms and no rows are listed.",
162
+ ],
163
+ };
164
+ }
165
+ /** Chooses what to inventory: Learnlog records, the preprint's files, or (with nothing given) the published figures. */
166
+ export async function assistanceInventory(input) {
167
+ const records = ["format", "path", "use_sample", "learnlog_path"].some((k) => input[k] !== undefined);
168
+ const preprint = ["zenodo_zip", "idetrace_json", "assistments_csv"].some((k) => input[k] !== undefined);
169
+ if (records && preprint)
170
+ throw new Refusal("Ask about one thing at a time: either Learnlog records (format with path or use_sample, or learnlog_path) or the preprint's files (zenodo_zip, idetrace_json, assistments_csv).");
171
+ if (records) {
172
+ if (input.learnlog_path === undefined && input.format === undefined)
173
+ throw new Refusal("Name the export's format (see learnlog_formats), or give learnlog_path for a Learnlog file.");
174
+ if (input.learnlog_path !== undefined && input.format !== undefined)
175
+ throw new Refusal("Give either learnlog_path or format with path or use_sample, not both.");
176
+ return assistanceInventoryOfRecords(input);
177
+ }
178
+ return preprintInventory(input);
179
+ }
@@ -0,0 +1,111 @@
1
+ /** qlm_learning_graph: what comes before or after a LabPath activity (mode), with its standards. */
2
+ import { Refusal } from "../files.js";
3
+ import { ANSWER_BUDGET, answerSize } from "../respond.js";
4
+ import { datasetInfo } from "./qlm-datasets.js";
5
+ import { worldLink } from "./worlds.js";
6
+ export const EDGE_KINDS = ["prerequisite", "labpath-sequence", "reinforcement", "transfer", "extension"];
7
+ /** Closest links first; within a kind, the export's own order. */
8
+ const KIND_ORDER = ["prerequisite", "labpath-sequence", "reinforcement", "extension", "transfer"];
9
+ const rank = (e) => KIND_ORDER.indexOf(e.kind);
10
+ /** An edge keeps its first CONCEPTS_SHOWN concepts; concepts_total says how many it has when that's more. */
11
+ export const CONCEPTS_SHOWN = 8;
12
+ export const DEFAULT_LIMIT = 10;
13
+ const edge = (e) => {
14
+ const concepts = Array.isArray(e.concepts) ? e.concepts : [];
15
+ return {
16
+ from: e.fromModeId,
17
+ to: e.toModeId,
18
+ kind: e.kind,
19
+ priority: e.priority,
20
+ concepts: concepts.slice(0, CONCEPTS_SHOWN),
21
+ ...(concepts.length > CONCEPTS_SHOWN ? { concepts_total: concepts.length } : {}),
22
+ misconceptions: e.misconceptions,
23
+ teacher_facing_reason: e.teacherFacingReason,
24
+ student_facing_reason: e.studentFacingReason,
25
+ };
26
+ };
27
+ export async function qlmLearningGraph(input, client) {
28
+ if ((input.mode_id === undefined) === (input.standard === undefined))
29
+ throw new Refusal("Give either mode_id or standard, not both and not neither.");
30
+ const [graph, standards] = await Promise.all([client.get("learning-graph"), client.get("standards")]);
31
+ const edges = graph.learningGraph;
32
+ const rows = standards.standardsAlignment;
33
+ const known = new Set([...edges.flatMap((e) => [e.fromModeId, e.toModeId]), ...rows.map((s) => s.modeId)]);
34
+ let modes;
35
+ if (input.mode_id !== undefined) {
36
+ const id = input.mode_id;
37
+ if (!known.has(id)) {
38
+ const close = [...known].filter((m) => m.includes(id.toLowerCase())).slice(0, 10);
39
+ throw new Refusal(`No LabPath mode "${id}".${close.length ? ` Close matches: ${close.join(", ")}` : ""}`);
40
+ }
41
+ modes = [id];
42
+ }
43
+ else {
44
+ const code = input.standard.toLowerCase();
45
+ modes = [...new Set(rows.filter((s) => String(s.code).toLowerCase() === code).map((s) => s.modeId))];
46
+ if (!modes.length)
47
+ throw new Refusal(`No LabPath mode is aligned to the standard ${input.standard}.`);
48
+ }
49
+ const kinds = new Set(input.kinds ?? EDGE_KINDS);
50
+ const direction = input.direction ?? "both";
51
+ const asked = input.limit ?? DEFAULT_LIMIT;
52
+ const lists = {};
53
+ for (const side of ["before", "after"]) {
54
+ if (direction !== "both" && direction !== side)
55
+ continue;
56
+ lists[side] = edges.filter((e) => kinds.has(e.kind) && modes.includes((side === "before" ? e.toModeId : e.fromModeId))).sort((a, b) => rank(a) - rank(b));
57
+ }
58
+ const build = (limit) => {
59
+ const out = { modes };
60
+ const truncated = {};
61
+ const linked = new Set();
62
+ for (const side of ["before", "after"]) {
63
+ const list = lists[side];
64
+ if (!list)
65
+ continue;
66
+ if (list.length > limit)
67
+ truncated[side] = list.length;
68
+ out[side] = list.slice(0, limit).map((e) => {
69
+ for (const m of [e.fromModeId, e.toModeId])
70
+ if (!modes.includes(m))
71
+ linked.add(m);
72
+ return edge(e);
73
+ });
74
+ }
75
+ if (Object.keys(truncated).length)
76
+ out.truncated = truncated;
77
+ if (![out.before, out.after].some((l) => Array.isArray(l) && l.length))
78
+ out.note = `${modes.join(", ")} has no edges of the kinds asked for in this direction.`;
79
+ out.standards = Object.fromEntries(modes.map((m) => [m, rows.filter((s) => s.modeId === m).map((s) => ({ code: s.code, description: s.description, gradeBand: s.gradeBand, disciplinaryCore: s.disciplinaryCore, discipline: s.discipline }))]));
80
+ out.linked_mode_standards = Object.fromEntries([...linked].map((m) => [m, rows.filter((s) => s.modeId === m).map((s) => s.code)]));
81
+ const links = [...modes, ...linked].map(worldLink);
82
+ out.worlds = Object.fromEntries(links.filter((w) => w.url !== null).map((w) => [w.id, w]));
83
+ const unknown = links.filter((w) => w.url === null).map((w) => w.id);
84
+ if (unknown.length)
85
+ out.worlds_without_a_known_page = unknown;
86
+ return out;
87
+ };
88
+ // Fit the answer: lower the per-direction limit until it does, and say so.
89
+ let used = asked;
90
+ let out = build(used);
91
+ while (used > 1 && answerSize({ ...out, does_not_show: ["x"] }) > ANSWER_BUDGET - 2_000) {
92
+ used = Math.floor(used * 0.75);
93
+ out = build(used);
94
+ }
95
+ if (used < asked)
96
+ out.limit_lowered = { asked, used, why: `${asked} links per direction would not fit in one answer, so ${used} are shown. Ask for one direction or fewer kinds to see others.` };
97
+ const priorities = new Set(edges.map((e) => e.priority));
98
+ if (priorities.size === 1)
99
+ out.priority_note = `Every edge in the export has priority "${String([...priorities][0])}", so priority doesn't rank these links.`;
100
+ const byKind = {};
101
+ for (const e of edges)
102
+ byKind[e.kind] = (byKind[e.kind] ?? 0) + 1;
103
+ out.dataset = { ...datasetInfo(graph, "learning-graph"), standards_source: datasetInfo(standards, "standards").source, edges_in_export: edges.length, edges_by_kind: byKind, standards_rows_in_export: rows.length };
104
+ out.does_not_show = [
105
+ "Whether a particular student needs a step. This is LabPath's designed sequence, not evidence about a learner.",
106
+ "How strong a link is. Priority is the export's coarse band of a tuned ranking, not a measured effect.",
107
+ "An official alignment. Standards are LabPath's own mapping of its modes to NGSS-style codes, not a standards body's.",
108
+ "The misconceptions themselves. Edges carry misconception ids; look them up with qlm_misconceptions.",
109
+ ];
110
+ return out;
111
+ }
@@ -0,0 +1,54 @@
1
+ /** qlm_misconceptions: search QLM's public misconception dataset (CC BY 4.0). */
2
+ import { datasetInfo } from "./qlm-datasets.js";
3
+ import { worldLinks } from "./worlds.js";
4
+ const TEXT = ["id", "name", "subdomain", "studentBelief", "scientificReality", "counterexample", "teacherIntervention"];
5
+ const KEEP = ["id", "tier", "domain", "subdomain", "gradeBands", "name", "studentBelief", "scientificReality", "counterexample", "teacherIntervention", "researchSource", "severity", "frequency", "persistence", "modes"];
6
+ const placeholder = (v) => typeof v === "string" && v.startsWith("[");
7
+ const lc = (v) => (typeof v === "string" ? v.toLowerCase() : "");
8
+ const bands = (r) => (Array.isArray(r.gradeBands) ? r.gradeBands : []);
9
+ /** A record as returned: never an evaluation-tier record's trigger patterns or diagnostic question. */
10
+ function shown(r) {
11
+ const out = Object.fromEntries(KEEP.map((k) => [k, r[k]]));
12
+ out.worlds = worldLinks(r.modes);
13
+ if (r.tier === "evaluation") {
14
+ out.withheld = "triggerPatterns and diagnosticQuestion are withheld for evaluation-tier records, for benchmark integrity.";
15
+ return out;
16
+ }
17
+ if (placeholder(r.diagnosticQuestion))
18
+ out.withheld = "The diagnostic question is embedded in its residency scenario, not in the export.";
19
+ else
20
+ out.diagnosticQuestion = r.diagnosticQuestion;
21
+ out.triggerPatterns = Array.isArray(r.triggerPatterns) ? r.triggerPatterns.filter((p) => !placeholder(p)) : [];
22
+ return out;
23
+ }
24
+ const tally = (values) => {
25
+ const c = {};
26
+ for (const v of values)
27
+ c[v] = (c[v] ?? 0) + 1;
28
+ return c;
29
+ };
30
+ export async function qlmMisconceptions(input, client) {
31
+ const body = await client.get("misconceptions");
32
+ const all = body.misconceptions.filter((r) => typeof r === "object" && r !== null);
33
+ const words = lc(input.search).split(/\s+/).filter(Boolean);
34
+ const matches = all.filter((r) => (!input.id || lc(r.id) === lc(input.id)) &&
35
+ (!input.domain || lc(r.domain) === lc(input.domain)) &&
36
+ (!input.grade_band || bands(r).some((b) => lc(b) === lc(input.grade_band))) &&
37
+ words.every((w) => TEXT.some((k) => lc(r[k]).includes(w))));
38
+ const limit = input.limit ?? 10;
39
+ return {
40
+ dataset: { ...datasetInfo(body, "misconceptions"), records_in_export: all.length, records_by_tier: tally(all.map((r) => String(r.tier))) },
41
+ total_matching: matches.length,
42
+ returned: Math.min(limit, matches.length),
43
+ misconceptions: matches.slice(0, limit).map(shown),
44
+ worlds_note: "Each misconception's worlds are the LabPath worlds that work on it, with their public pages. Suggest them by name and link.",
45
+ ...(matches.length === 0 ? { note: "No misconception in the export matches. Try fewer words, or see the domains and grade bands listed under available." } : {}),
46
+ available: { domains: tally(all.map((r) => String(r.domain))), grade_bands: tally(all.flatMap(bands)) },
47
+ does_not_show: [
48
+ "Whether any student holds one of these. They are curated, research-based descriptions of common misconceptions, not observations of a learner.",
49
+ "Held-out records, kept back for benchmark integrity; they aren't in the export at all.",
50
+ "Evaluation-tier trigger patterns and diagnostic questions, withheld for benchmark integrity.",
51
+ "How often a misconception occurs in your setting. Frequency, persistence and severity are the dataset's research-based bands.",
52
+ ],
53
+ };
54
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The three released datasets of the Learnlog preprint (v0.6), "Preserving assistance meaning in
3
+ * cross-source learning analytics". Their learner records are not bundled: the tool reads the
4
+ * user's own copies, recomputes the preprint's counts with the published rules (episodes.ts), and,
5
+ * when a file is the exact release the preprint pinned, compares every number with the published
6
+ * aggregate outputs bundled in data/preprint and vendor/transformer.
7
+ */
8
+ import tableTwo from "../../vendor/transformer/cross-source-analysis-data.mjs";
9
+ import { analyzeAssistments, assistmentsBySkill, analyzeIdetrace, analyzeZenodo, crossSourceEpisodes } from "../episodes.js";
10
+ import { Refusal, localFile, readLocalBytes } from "../files.js";
11
+ import { VENDORED_FROM, readJsonFile } from "../package.js";
12
+ import { LEARNLOG_DRAFT, NOT_RECORDED } from "../stages.js";
13
+ export const PINNED = {
14
+ zenodo: "5527127f0a3c939601b17ef207ef8b34df7cad9159c9617f879efbf4e989d499",
15
+ idetrace: "09972201c16a9f6def7d1e25a9b9e6827994601d720a37696afb0927168befc8",
16
+ assistments: "1d06aee9e649c5ba9db49052fe9a86e68d0c3ccb4709a7487289d7721f5464db",
17
+ };
18
+ export const PREPRINT_DATASETS = [
19
+ {
20
+ id: "zenodo.17551130",
21
+ argument: "zenodo_zip",
22
+ name: "JetBrains Academy next-step hint study (Zenodo 17551130 v1)",
23
+ records: "Hint requests, errors and displays inside 1,050 HTML scenario reports, plus a raw IDE activity file that isn't linked to them",
24
+ license: "CC BY 4.0",
25
+ bundled: false,
26
+ why_not_bundled: "QLM's releases carry only aggregate counts from this dataset, never its learner records (THIRD_PARTY_DATA.md).",
27
+ get_it: "https://zenodo.org/records/17551130 (ai_hints_interaction.zip)",
28
+ pinned_sha256: PINNED.zenodo,
29
+ },
30
+ {
31
+ id: "tutortrace.deployment8",
32
+ argument: "idetrace_json",
33
+ name: "TutorTrace / IDETrace, deployment 8",
34
+ records: "Chat queries and responses, code edits and test results in an instrumented programming environment",
35
+ license: "CC BY 4.0, as declared in its pinned Croissant metadata (GitHub reports no repository license)",
36
+ bundled: false,
37
+ why_not_bundled: "No permission to redistribute the raw payload is inferred. Reading it here needs allow_idetrace_research: true, the same opt-in the preprint's pipeline asks for.",
38
+ get_it: "https://github.com/divadbaroon/TutorTrace_dataset_and_benchmark/tree/bc7990370da18a611a122255ebc3e475bd8a3fc6 (dataset/raw_telemetry/deployment_8.json)",
39
+ pinned_sha256: PINNED.idetrace,
40
+ },
41
+ {
42
+ id: "assistments.2012-13",
43
+ argument: "assistments_csv",
44
+ name: "ASSISTments 2012-13 School Data with Affect",
45
+ records: "One row per problem: hints asked, whether the last hint was reached, the first action, scaffolding, and four affect detector confidences",
46
+ license: "ASSISTments terms of use",
47
+ bundled: false,
48
+ why_not_bundled: "The data were provided by the ASSISTments TestBed (WPI) and are not redistributed. Get them from ASSISTments under its terms of use.",
49
+ get_it: "https://sites.google.com/site/assistmentsdata/datasets/2012-13-school-data-with-affect",
50
+ pinned_sha256: PINNED.assistments,
51
+ },
52
+ ];
53
+ export const PUBLISHED = {
54
+ tableTwo,
55
+ crossSource: readJsonFile("data/preprint/cross_source_episodes.json"),
56
+ assistments: readJsonFile("data/preprint/assistments_episodes.json"),
57
+ };
58
+ /** Every leaf of the published value against the recomputed value at the same path, both ways. */
59
+ export function compare(published, recomputed) {
60
+ const mismatches = [];
61
+ let n = 0;
62
+ const walk = (p, r, path) => {
63
+ if (typeof p === "object" && p !== null && typeof r === "object" && r !== null && Array.isArray(p) === Array.isArray(r)) {
64
+ const keys = new Set([...Object.keys(p), ...Object.keys(r)]);
65
+ for (const k of keys)
66
+ walk(p[k], r[k], Array.isArray(p) ? `${path}[${k}]` : `${path}.${k}`);
67
+ return;
68
+ }
69
+ n++;
70
+ if (p !== r)
71
+ mismatches.push({ path, published: p ?? null, recomputed: r ?? null });
72
+ };
73
+ walk(published, recomputed, "$");
74
+ return { numbers_compared: n, matches: mismatches.length === 0, mismatches };
75
+ }
76
+ const pick = (o, keys) => Object.fromEntries(keys.map((k) => [k, o[k]]));
77
+ const sourceRow = (p, id) => p.tableTwo.sources.find((s) => s.source_id === id);
78
+ const n = (x) => x;
79
+ export function zenodoStages(inv, ep) {
80
+ const labels = inv.native_log_label_counts;
81
+ const outcomes = ep.outcomes;
82
+ return {
83
+ requested: { status: "recorded", count: n(ep.requests), note: "Get Hint and Retry clicks (Table 2 inventory rule). Each report holds one." },
84
+ offered: NOT_RECORDED,
85
+ generated: { status: "not mapped", native_rows: pick(labels, ["Code hint generated", "Text hint generated"]), note: "The release logs generation rows, but the preprint's rules don't link them to requests, so no count is given for this stage." },
86
+ transmitted: NOT_RECORDED,
87
+ displayed: { status: "recorded by the episode rule", count: n(ep.shown), of_requests: n(ep.requests), not_displayed: n(ep.not_shown), not_displayed_with_error: outcomes.not_shown_error ?? 0, note: "A banner shown, or the code hint revealed or accepted, after the request. Links between stages come from the rule; the source declares none." },
88
+ accepted: { status: "recorded by the episode rule", count: outcomes.shown_acted_accepted ?? 0, revealed_only: outcomes.shown_acted_revealed_not_accepted ?? 0, note: "The product's 'Code hint is accepted' action. Accepting doesn't show use or learning." },
89
+ never_asked: "not in the release: it contains only request episodes, so going without help can't be counted here",
90
+ };
91
+ }
92
+ export function idetraceStages(ep) {
93
+ return {
94
+ requested: { status: "recorded", count: n(ep.requests), note: "CHAT_QUERY events (Table 2 inventory rule)." },
95
+ offered: NOT_RECORDED,
96
+ generated: { status: "not mapped", note: "The release logs CHAT_RESPONSE events; the episode rule reads a response as a display, not as its own generated stage." },
97
+ transmitted: NOT_RECORDED,
98
+ displayed: { status: "recorded by the episode rule", count: n(ep.shown), of_requests: n(ep.requests), note: "No separate display event exists: a response before the next query is counted as a display. A reply that was itself an error would still count." },
99
+ accepted: NOT_RECORDED,
100
+ never_asked: { records: n(ep.records), records_never_asked: n(ep.records_never_asked), note: "Learner records, which may not be people: the source's population descriptions conflict." },
101
+ };
102
+ }
103
+ export function assistmentsStages(a) {
104
+ const ep = a.episodes;
105
+ const hints = (ep["requested; hints short of the answer"] ?? 0) + (ep["requested; hints to the answer (bottom-out)"] ?? 0);
106
+ const steps = ep["requested; asked for steps (scaffolding)"] ?? 0;
107
+ const checks = a.checks;
108
+ return {
109
+ requested: { status: "recorded", count: hints + steps, with_hints: hints, asked_for_steps: steps, hints_to_the_answer: ep["requested; hints to the answer (bottom-out)"] ?? 0, note: "Counted by main problem. The last hint 'will generally contain the problem's answer' (ASSISTments' own description)." },
110
+ offered: { status: "candidates only", count: ep["not requested; system gave steps after a wrong first answer"] ?? 0, note: "Steps the system likely gave after a wrong first answer, by rule. The release logs no initiation event and keeps only the first action, so these aren't established offers." },
111
+ generated: NOT_RECORDED,
112
+ transmitted: NOT_RECORDED,
113
+ displayed: "not recorded as its own event (asking for a hint shows it, but display isn't logged)",
114
+ accepted: NOT_RECORDED,
115
+ never_asked: { students: n(a.students), never_asked_on_main_problems: n(a.students) - n(a.students_who_ever_requested), never_asked_anywhere_including_hints_inside_steps: n(a.students) - checks.students_who_ever_requested_including_hints_inside_scaffolding },
116
+ inferred: "The four affect values are detector inferences on every row, not observations. Whether hint use was among the detectors' inputs isn't known.",
117
+ };
118
+ }
119
+ const LIMIT = {
120
+ inventory: "1,109 is an inventory of selected released request records. It isn't a request rate, a dose of help or a learning effect.",
121
+ denominator: "There is no shared denominator of opportunities, time or learners, so rates can't be compared across the releases.",
122
+ rules: "Links between stages come from the scripts' rules (Supplement S1); the sources declare none. Display shares are provisional.",
123
+ missing: "A missing record isn't proof that no help was given. Not recorded is never the same as none.",
124
+ pooling: "Pooling the two programming releases hides that IDETrace has no separate display record.",
125
+ assistmentsBeside: "ASSISTments is counted by problem and is set beside the other two, never pooled with them.",
126
+ assistmentsAlone: "ASSISTments is counted by problem: a main problem is one opportunity, not one request event.",
127
+ review: "The mappings were written and audited by software agents; independent human review of them is still pending.",
128
+ };
129
+ export const PREPRINT_LIMITS = [LIMIT.inventory, LIMIT.denominator, LIMIT.rules, LIMIT.missing, LIMIT.pooling, LIMIT.assistmentsBeside, LIMIT.review];
130
+ /** The cautions that apply to the files actually given. */
131
+ function limitsFor(programming, assistments) {
132
+ return [
133
+ ...(programming ? [LIMIT.inventory, LIMIT.denominator] : []),
134
+ LIMIT.rules,
135
+ LIMIT.missing,
136
+ ...(programming ? [LIMIT.pooling] : []),
137
+ ...(assistments ? [programming ? LIMIT.assistmentsBeside : LIMIT.assistmentsAlone] : []),
138
+ LIMIT.review,
139
+ ];
140
+ }
141
+ const NOT_ESTABLISHED = {
142
+ actual_request_total: "not established: release selection and capture completeness are unknown",
143
+ request_rate: "not established: no comparable denominator of exposure or opportunity",
144
+ request_to_display_rate: "provisional only: links come from episode rules, not the sources",
145
+ learning_effect: "not estimated: a count of help records isn't an outcome",
146
+ };
147
+ const DOES_NOT_SHOW = [
148
+ "Any learner row, identity, dialogue or code. Only counts leave the files.",
149
+ "Help outside the instrumented tools: other apps, people or AI.",
150
+ "Whether any help worked.",
151
+ ];
152
+ const GB = 1_000_000_000;
153
+ export async function preprintInventory(input, pins = PINNED, published = PUBLISHED) {
154
+ if (input.idetrace_json !== undefined && input.allow_idetrace_research !== true) {
155
+ throw new Refusal("Reading the IDETrace file needs allow_idetrace_research: true. It holds learner telemetry whose redistribution terms are unclear, so the preprint's pipeline asks for the same explicit opt-in. Only counts are returned.");
156
+ }
157
+ const z = input.zenodo_zip === undefined ? undefined : analyzeZenodo(readLocalBytes(input.zenodo_zip, "zenodo_zip", 2 * GB));
158
+ const i = input.idetrace_json === undefined ? undefined : analyzeIdetrace(readLocalBytes(input.idetrace_json, "idetrace_json", GB).toString("utf8"));
159
+ const aPath = input.assistments_csv === undefined ? undefined : localFile(input.assistments_csv, "assistments_csv", 100 * GB).path;
160
+ const a = aPath === undefined ? undefined : await analyzeAssistments(aPath);
161
+ const inputs = {};
162
+ const inventory = {};
163
+ const stages = {};
164
+ const comparison = {};
165
+ const episodes = z || i ? crossSourceEpisodes(z, i) : undefined;
166
+ if (z) {
167
+ const pinned = z.input_sha256 === pins.zenodo;
168
+ inputs.zenodo = { sha256: z.input_sha256, is_the_preprint_release: pinned };
169
+ inventory.zenodo = z.inventory;
170
+ stages.zenodo = zenodoStages(z.inventory, z.episodes);
171
+ const cs = published.crossSource;
172
+ comparison.zenodo = pinned
173
+ ? compare({ table_2: pick(sourceRow(published, "zenodo.17551130"), Object.keys(z.inventory)), table_4: cs.zenodo, checks: pick(cs.checks, Object.keys(z.checks)) }, { table_2: z.inventory, table_4: z.episodes, checks: z.checks })
174
+ : "not compared: this file isn't the release the preprint pinned, so its numbers aren't the preprint's";
175
+ }
176
+ if (i) {
177
+ const pinned = i.input_sha256 === pins.idetrace;
178
+ inputs.idetrace = { sha256: i.input_sha256, is_the_preprint_release: pinned };
179
+ inventory.idetrace = i.inventory;
180
+ stages.idetrace = idetraceStages(i.episodes);
181
+ const cs = published.crossSource;
182
+ comparison.idetrace = pinned
183
+ ? compare({ table_2: pick(sourceRow(published, "tutortrace.deployment8"), Object.keys(i.inventory)), table_4: cs.idetrace, checks: pick(cs.checks, Object.keys(i.checks)) }, { table_2: i.inventory, table_4: i.episodes, checks: i.checks })
184
+ : "not compared: this file isn't the release the preprint pinned, so its numbers aren't the preprint's";
185
+ }
186
+ if (z && i) {
187
+ inventory.total_request_observations = z.inventory.assistance_request_observations + i.inventory.assistance_request_observations;
188
+ if (z.input_sha256 === pins.zenodo && i.input_sha256 === pins.idetrace) {
189
+ comparison.pooled = compare({ table_2_total: published.tableTwo.aggregate.assistance_request_observations, table_4_pooled: published.crossSource.pooled }, { table_2_total: inventory.total_request_observations, table_4_pooled: episodes.pooled });
190
+ }
191
+ }
192
+ if (a) {
193
+ const pinned = a.input_sha256 === pins.assistments;
194
+ inputs.assistments = { sha256: a.input_sha256, is_the_preprint_release: pinned };
195
+ stages.assistments = assistmentsStages(a);
196
+ comparison.assistments = pinned ? compare(published.assistments, a) : "not compared: this file isn't the release the preprint pinned, so its numbers aren't the preprint's";
197
+ }
198
+ if (!z && !i && !a)
199
+ return publishedFigures(published);
200
+ return {
201
+ kind: "recomputed from your files",
202
+ learnlog: LEARNLOG_DRAFT,
203
+ inputs,
204
+ ...(z || i ? { inventory_table_2: { ...inventory, ...NOT_ESTABLISHED }, episodes_table_4: episodes } : {}),
205
+ ...(a ? { assistments_table_4: a, assistments_requests_by_skill: await assistmentsBySkill(aPath) } : {}),
206
+ stages,
207
+ comparison_with_preprint: comparison,
208
+ rules: RULES,
209
+ cannot_conclude: limitsFor(Boolean(z || i), Boolean(a)),
210
+ does_not_show: DOES_NOT_SHOW,
211
+ };
212
+ }
213
+ const RULES = {
214
+ inventory: "aggregate.py native filters: Zenodo Log rows 'Hint button is clicked' or 'Hint retry is clicked'; IDETrace CHAT_QUERY events. This tool recomputes the counts; it doesn't rerun the capture-schema validation and xAPI/Caliper round trips the Python pipeline also performs.",
215
+ episodes: "research/learnlog-episodes/cross_source.py and assistments_episodes.py at preprint v0.6 (paste rule by timestamp), ported to TypeScript and tested against the Python on the same files.",
216
+ source: VENDORED_FROM,
217
+ };
218
+ /** The published figures, bundled with this package, when no files are given. */
219
+ export function publishedFigures(published = PUBLISHED) {
220
+ const cs = published.crossSource;
221
+ const zRow = sourceRow(published, "zenodo.17551130");
222
+ const iRow = sourceRow(published, "tutortrace.deployment8");
223
+ return {
224
+ kind: "published figures, not recomputed",
225
+ note: "These are the preprint's published aggregate outputs, bundled with this package. Nothing was recomputed in this call. To recompute them, give your own copies of the files (zenodo_zip, idetrace_json with allow_idetrace_research: true, assistments_csv).",
226
+ learnlog: LEARNLOG_DRAFT,
227
+ inventory_table_2: {
228
+ zenodo: pick(zRow, ["input_sha256", "source_file_count", "native_input_count", "native_assistance_log_count", "native_log_label_counts", "assistance_request_observations", "help_request_subtypes"]),
229
+ idetrace: pick(iRow, ["input_sha256", "source_file_count", "native_input_count", "native_event_label_counts", "assistance_request_observations"]),
230
+ total_request_observations: published.tableTwo.aggregate.assistance_request_observations,
231
+ ...NOT_ESTABLISHED,
232
+ },
233
+ episodes_table_4: cs,
234
+ assistments_table_4: published.assistments,
235
+ stages: { zenodo: zenodoStages(zRow, cs.zenodo), idetrace: idetraceStages(cs.idetrace), assistments: assistmentsStages(published.assistments) },
236
+ datasets: PREPRINT_DATASETS,
237
+ rules: RULES,
238
+ cannot_conclude: PREPRINT_LIMITS,
239
+ does_not_show: DOES_NOT_SHOW,
240
+ };
241
+ }