@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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +201 -0
- package/NOTICE +43 -0
- package/README.md +121 -2
- package/data/SOURCE_CORRECTION.md +9 -0
- package/data/THIRD_PARTY_DATA.md +148 -0
- package/data/examples/chain-tpc-clinical-v2-tampered.json +97 -0
- package/data/examples/chain-tpc-clinical-v2.json +95 -0
- package/data/examples/evidence-record-clean.json +173 -0
- package/data/examples/evidence-record-tampered.json +173 -0
- package/data/labpath-worlds.json +107 -0
- package/data/preprint/README.md +10 -0
- package/data/preprint/assistments_episodes.json +37 -0
- package/data/preprint/cross_source_episodes.json +63 -0
- package/data/provenance/oatutor.json +84 -0
- package/data/provenance/source-audit.json +69 -0
- package/data/provenance/tutormoments.json +91 -0
- package/data/samples/explicit-assistance-SYNTHETIC.jsonl +5 -0
- package/data/samples/oatutor-real-participants.csv +13 -0
- package/data/samples/sandpiper-SYNTHETIC.csv +9 -0
- package/data/samples/tutormoments-real-transcripts.jsonl +2 -0
- package/dist/bin.js +6 -0
- package/dist/episodes.js +488 -0
- package/dist/files.js +39 -0
- package/dist/package.js +14 -0
- package/dist/py.js +419 -0
- package/dist/respond.js +47 -0
- package/dist/sealed.js +31 -0
- package/dist/server.js +115 -0
- package/dist/stages.js +35 -0
- package/dist/tools/formats.js +35 -0
- package/dist/tools/inventory.js +179 -0
- package/dist/tools/learning-graph.js +111 -0
- package/dist/tools/misconceptions.js +54 -0
- package/dist/tools/preprint.js +241 -0
- package/dist/tools/qlm-datasets.js +56 -0
- package/dist/tools/transform.js +61 -0
- package/dist/tools/validate.js +47 -0
- package/dist/tools/verify-credential.js +155 -0
- package/dist/tools/verify-evidence.js +51 -0
- package/dist/tools/worlds.js +24 -0
- package/dist/transformer.js +92 -0
- package/dist/zip.js +71 -0
- package/package.json +66 -4
- package/skills/learnlog-assistments/SKILL.md +38 -0
- package/skills/learnlog-export-to-action/SKILL.md +31 -0
- package/skills/learnlog-learning-commons/SKILL.md +33 -0
- package/skills/learnlog-nto-sandpiper/SKILL.md +31 -0
- package/vendor/PROVENANCE.json +259 -0
- package/vendor/qlm-measure/checks.d.ts +24 -0
- package/vendor/qlm-measure/checks.js +42 -0
- package/vendor/qlm-measure/cli.d.ts +17 -0
- package/vendor/qlm-measure/cli.js +461 -0
- package/vendor/qlm-measure/rules.d.ts +10 -0
- package/vendor/qlm-measure/rules.js +46 -0
- package/vendor/qlm-measure/schemes/index.d.ts +133 -0
- package/vendor/qlm-measure/schemes/index.js +339 -0
- package/vendor/qlm-measure/schemes/node.d.ts +8 -0
- package/vendor/qlm-measure/schemes/node.js +15 -0
- package/vendor/qlm-measure/schemes/products.d.ts +38 -0
- package/vendor/qlm-measure/schemes/products.js +725 -0
- package/vendor/qlm-measure/verifier/hash.d.ts +27 -0
- package/vendor/qlm-measure/verifier/hash.js +163 -0
- package/vendor/qlm-measure/verifier/verify-record.d.ts +22 -0
- package/vendor/qlm-measure/verifier/verify-record.js +123 -0
- package/vendor/qlm-measure/verifier-v03.d.ts +24 -0
- package/vendor/qlm-measure/verifier-v03.js +205 -0
- package/vendor/transformer/LICENSE.txt +29 -0
- package/vendor/transformer/cross-source-analysis-data.d.mts +3 -0
- package/vendor/transformer/cross-source-analysis-data.mjs +238 -0
- package/vendor/transformer/engine.d.mts +61 -0
- package/vendor/transformer/engine.mjs +445 -0
- package/vendor/transformer/profiles.mjs +309 -0
- package/vendor/transformer/research-semantics.mjs +1453 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* qlm-measure CLI — verify evidence records from the command line.
|
|
4
|
+
* verify makes no network calls. Records never leave your machine.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync } from "fs";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { verifyRecord } from "./verifier/verify-record.js";
|
|
10
|
+
import { verifyRecordV03 } from "./verifier-v03.js";
|
|
11
|
+
import { verifyChain, listSchemes } from "./schemes/node.js";
|
|
12
|
+
import { CATALOG, SHIPPED_CHECKS, SHIPPED_CHECKS_V03, PLANNED_CHECKS, CATALOG_BY_ID, CATEGORIES, CATEGORIES_V03 } from "./checks.js";
|
|
13
|
+
const VERSION = "0.9.0";
|
|
14
|
+
export const processIO = {
|
|
15
|
+
out: (text) => { process.stdout.write(text); },
|
|
16
|
+
err: (text) => { process.stderr.write(text); },
|
|
17
|
+
readStdin: () => readFileSync(0, "utf-8"),
|
|
18
|
+
};
|
|
19
|
+
/** The value when it is an array, else an empty list, so a malformed record is reported rather than crashing the report. */
|
|
20
|
+
function listOf(value) {
|
|
21
|
+
return Array.isArray(value) ? value : [];
|
|
22
|
+
}
|
|
23
|
+
function baseName(path) {
|
|
24
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
25
|
+
}
|
|
26
|
+
/** True for a record the 0.3 verifier handles: schemaVersion "0.3", or an evidence block. */
|
|
27
|
+
function isV03Record(rec) {
|
|
28
|
+
return rec.schemaVersion === "0.3" || !!rec.evidence;
|
|
29
|
+
}
|
|
30
|
+
function hasPosteriorEntries(entries) {
|
|
31
|
+
return entries.some((e) => typeof e?.updatedPosterior === "number");
|
|
32
|
+
}
|
|
33
|
+
function hasRecordBody(item) {
|
|
34
|
+
const o = item;
|
|
35
|
+
return typeof o === "object" && o !== null && (!!o.entries || !!o.evidence);
|
|
36
|
+
}
|
|
37
|
+
function loadRecords(path, io) {
|
|
38
|
+
let raw;
|
|
39
|
+
if (path === "-") {
|
|
40
|
+
raw = io.readStdin();
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
throw new Error(`File not found: ${path}`);
|
|
45
|
+
raw = readFileSync(path, "utf-8");
|
|
46
|
+
}
|
|
47
|
+
// Strip BOM
|
|
48
|
+
if (raw.charCodeAt(0) === 0xFEFF)
|
|
49
|
+
raw = raw.slice(1);
|
|
50
|
+
raw = raw.trim();
|
|
51
|
+
if (!raw)
|
|
52
|
+
throw new Error(`${path}: empty input`);
|
|
53
|
+
if (raw[0] === "[") {
|
|
54
|
+
const arr = JSON.parse(raw);
|
|
55
|
+
for (const item of arr) {
|
|
56
|
+
if (!hasRecordBody(item))
|
|
57
|
+
throw new Error(`${path}: element missing entries`);
|
|
58
|
+
}
|
|
59
|
+
return arr;
|
|
60
|
+
}
|
|
61
|
+
if (raw[0] === "{") {
|
|
62
|
+
// Try as single JSON object first
|
|
63
|
+
try {
|
|
64
|
+
const obj = JSON.parse(raw);
|
|
65
|
+
if (hasRecordBody(obj))
|
|
66
|
+
return [obj];
|
|
67
|
+
}
|
|
68
|
+
catch { /* not a single object, try JSONL */ }
|
|
69
|
+
// JSONL: multiple JSON objects, one per line
|
|
70
|
+
const lines = raw.split("\n").filter(l => l.trim());
|
|
71
|
+
if (lines.length > 1 && lines.every(l => l.trim().startsWith("{"))) {
|
|
72
|
+
return lines.map((line, i) => {
|
|
73
|
+
const obj = JSON.parse(line);
|
|
74
|
+
if (!hasRecordBody(obj))
|
|
75
|
+
throw new Error(`${path}: line ${i + 1} missing entries`);
|
|
76
|
+
return obj;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`${path}: could not parse as JSON or JSONL`);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`${path}: unrecognized format`);
|
|
82
|
+
}
|
|
83
|
+
// ── Report ──────────────────────────────────────────────────
|
|
84
|
+
function buildReport(path, records, results) {
|
|
85
|
+
const sha = path !== "-" && existsSync(path)
|
|
86
|
+
? createHash("sha256").update(readFileSync(path)).digest("hex") : "";
|
|
87
|
+
const recordReports = records.map((rec, i) => {
|
|
88
|
+
const result = results[i];
|
|
89
|
+
const isV03 = isV03Record(rec);
|
|
90
|
+
const checksList = isV03 ? SHIPPED_CHECKS_V03 : SHIPPED_CHECKS;
|
|
91
|
+
const entries03 = isV03 ? listOf(rec.evidence?.entries) : listOf(rec.entries);
|
|
92
|
+
const hasCompaction = !!rec.compactedBefore || !!rec.evidence?.compactedBefore;
|
|
93
|
+
const hasPosteriors = hasPosteriorEntries(entries03);
|
|
94
|
+
const violsByCheck = {};
|
|
95
|
+
for (const v of result.violations) {
|
|
96
|
+
const cid = v.checkId;
|
|
97
|
+
(violsByCheck[cid] ??= []).push(v);
|
|
98
|
+
}
|
|
99
|
+
const checks = checksList.map(def => {
|
|
100
|
+
const vlist = violsByCheck[def.id] || [];
|
|
101
|
+
let status;
|
|
102
|
+
if (def.id === "COMPACTION.BOUNDARY" && !hasCompaction)
|
|
103
|
+
status = "not_applicable";
|
|
104
|
+
else if (def.id === "POSTERIOR.CHAIN" && !hasPosteriors)
|
|
105
|
+
status = "not_applicable";
|
|
106
|
+
else if (vlist.length > 0)
|
|
107
|
+
status = "fail";
|
|
108
|
+
else
|
|
109
|
+
status = "pass";
|
|
110
|
+
const entry = { id: def.id, status, failures: vlist.length };
|
|
111
|
+
if (vlist.length > 0)
|
|
112
|
+
entry.first_failure = { version: vlist[0].version, message: vlist[0].message };
|
|
113
|
+
return entry;
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
studentScopeId: rec.studentScopeId || "",
|
|
117
|
+
schemaVersion: rec.schemaVersion || "0.2",
|
|
118
|
+
entries_checked: result.entriesChecked,
|
|
119
|
+
valid: result.valid,
|
|
120
|
+
checks,
|
|
121
|
+
violations: result.violations.map(v => ({
|
|
122
|
+
version: v.version, check_id: v.checkId,
|
|
123
|
+
category: v.category, message: v.message,
|
|
124
|
+
})),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
const summary = {
|
|
128
|
+
records: records.length,
|
|
129
|
+
valid: results.filter(r => r.valid).length,
|
|
130
|
+
invalid: results.filter(r => !r.valid).length,
|
|
131
|
+
checks_run: 0, checks_passed: 0, checks_failed: 0, checks_not_applicable: 0,
|
|
132
|
+
};
|
|
133
|
+
for (const rr of recordReports) {
|
|
134
|
+
for (const c of rr.checks) {
|
|
135
|
+
if (c.status === "not_applicable")
|
|
136
|
+
summary.checks_not_applicable++;
|
|
137
|
+
else {
|
|
138
|
+
summary.checks_run++;
|
|
139
|
+
if (c.status === "pass")
|
|
140
|
+
summary.checks_passed++;
|
|
141
|
+
else
|
|
142
|
+
summary.checks_failed++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
tool: { name: "qlm-measure", version: VERSION, language: "javascript", catalog_version: "1" },
|
|
148
|
+
input: { path: baseName(path), sha256: sha, records: records.length },
|
|
149
|
+
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
|
150
|
+
records: recordReports,
|
|
151
|
+
summary,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function formatText(path, records, results) {
|
|
155
|
+
const lines = [];
|
|
156
|
+
const filename = baseName(path);
|
|
157
|
+
lines.push(`qlm-measure verify ${filename}`);
|
|
158
|
+
lines.push(`records: ${records.length}`);
|
|
159
|
+
for (let i = 0; i < records.length; i++) {
|
|
160
|
+
const rec = records[i];
|
|
161
|
+
const result = results[i];
|
|
162
|
+
const isV03_t = isV03Record(rec);
|
|
163
|
+
const checksList_t = isV03_t ? SHIPPED_CHECKS_V03 : SHIPPED_CHECKS;
|
|
164
|
+
const categories_t = isV03_t ? CATEGORIES_V03 : CATEGORIES;
|
|
165
|
+
const entries_t = isV03_t ? listOf(rec.evidence?.entries) : listOf(rec.entries);
|
|
166
|
+
const hasCompaction = !!rec.compactedBefore || !!rec.evidence?.compactedBefore;
|
|
167
|
+
const hasPosteriors_t = hasPosteriorEntries(entries_t);
|
|
168
|
+
const violsByCheck = {};
|
|
169
|
+
for (const v of result.violations) {
|
|
170
|
+
const cid = v.checkId;
|
|
171
|
+
(violsByCheck[cid] ??= []).push(v);
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push(`record ${rec.studentScopeId || "?"} (schema ${rec.schemaVersion || "0.2"}, ${listOf(rec.entries || rec.evidence?.entries).length} entries)`);
|
|
175
|
+
for (const cat of categories_t) {
|
|
176
|
+
lines.push(` ${cat}`);
|
|
177
|
+
for (const def of checksList_t.filter(c => c.category === cat)) {
|
|
178
|
+
const vlist = violsByCheck[def.id] || [];
|
|
179
|
+
if (def.id === "COMPACTION.BOUNDARY" && !hasCompaction) {
|
|
180
|
+
lines.push(` \u2013 ${def.label} (not applicable)`);
|
|
181
|
+
}
|
|
182
|
+
else if (def.id === "POSTERIOR.CHAIN" && !hasPosteriors_t) {
|
|
183
|
+
lines.push(` \u2013 ${def.label} (not applicable)`);
|
|
184
|
+
}
|
|
185
|
+
else if (vlist.length > 0) {
|
|
186
|
+
const first = vlist[0];
|
|
187
|
+
const detail = `${vlist.length} failure${vlist.length > 1 ? "s" : ""}; first at v${first.version}: ${first.message}`;
|
|
188
|
+
lines.push(` \u2717 ${def.label} (${detail})`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
lines.push(` \u2713 ${def.label}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const nv = result.violations.length;
|
|
196
|
+
lines.push(` violations: ${nv}`);
|
|
197
|
+
if (result.valid)
|
|
198
|
+
lines.push(" VERDICT: CLEAN \u2014 record is verifiable");
|
|
199
|
+
else
|
|
200
|
+
lines.push(` VERDICT: NOT CLEAN \u2014 ${nv} violation(s); see report`);
|
|
201
|
+
}
|
|
202
|
+
const report = buildReport(path, records, results);
|
|
203
|
+
const s = report.summary;
|
|
204
|
+
lines.push("");
|
|
205
|
+
lines.push(`summary: ${s.records} record(s), ${s.valid} valid, ${s.invalid} invalid; checks run ${s.checks_run}, passed ${s.checks_passed}, failed ${s.checks_failed}, not applicable ${s.checks_not_applicable}`);
|
|
206
|
+
return lines.join("\n") + "\n";
|
|
207
|
+
}
|
|
208
|
+
// ── Commands ────────────────────────────────────────────────
|
|
209
|
+
/**
|
|
210
|
+
* Options the Python CLI has and this CLI does not implement. They stop with an
|
|
211
|
+
* error rather than being accepted and silently ignored.
|
|
212
|
+
*/
|
|
213
|
+
const UNSUPPORTED_VERIFY_OPTIONS = new Set(["--redact", "--tolerance-ms"]);
|
|
214
|
+
class UsageError extends Error {
|
|
215
|
+
}
|
|
216
|
+
function cmdVerify(args, io) {
|
|
217
|
+
let format = "text";
|
|
218
|
+
let reportFile = null;
|
|
219
|
+
let quiet = false;
|
|
220
|
+
const paths = [];
|
|
221
|
+
try {
|
|
222
|
+
for (let i = 0; i < args.length; i++) {
|
|
223
|
+
const a = args[i];
|
|
224
|
+
if (a === "--format") {
|
|
225
|
+
format = args[++i];
|
|
226
|
+
if (format !== "text" && format !== "json")
|
|
227
|
+
throw new UsageError("--format must be text or json");
|
|
228
|
+
}
|
|
229
|
+
else if (a === "--report") {
|
|
230
|
+
const next = args[++i];
|
|
231
|
+
if (next === undefined)
|
|
232
|
+
throw new UsageError("--report needs a file name");
|
|
233
|
+
reportFile = next;
|
|
234
|
+
}
|
|
235
|
+
else if (a === "--quiet") {
|
|
236
|
+
quiet = true;
|
|
237
|
+
}
|
|
238
|
+
else if (UNSUPPORTED_VERIFY_OPTIONS.has(a)) {
|
|
239
|
+
throw new UsageError(`${a} is not supported by the JavaScript CLI; the Python CLI (python/qlm_measure) supports it`);
|
|
240
|
+
}
|
|
241
|
+
else if (a.startsWith("--")) {
|
|
242
|
+
throw new UsageError(`unknown option: ${a}`);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
paths.push(a);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
io.err(`error: ${e.message}\n`);
|
|
251
|
+
return 2;
|
|
252
|
+
}
|
|
253
|
+
if (paths.length === 0) {
|
|
254
|
+
io.err("error: no files specified\n");
|
|
255
|
+
return 2;
|
|
256
|
+
}
|
|
257
|
+
const allRecords = [];
|
|
258
|
+
const allResults = [];
|
|
259
|
+
for (const p of paths) {
|
|
260
|
+
try {
|
|
261
|
+
const records = loadRecords(p, io);
|
|
262
|
+
for (const r of records) {
|
|
263
|
+
allRecords.push(r);
|
|
264
|
+
// Dispatch by schema version
|
|
265
|
+
if (isV03Record(r)) {
|
|
266
|
+
const r03 = verifyRecordV03(r);
|
|
267
|
+
allResults.push({ valid: r03.valid, violations: r03.violations.map(v => ({
|
|
268
|
+
version: v.version, category: v.category, message: v.message, checkId: v.checkId,
|
|
269
|
+
})), entriesChecked: r03.entriesChecked });
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
allResults.push(verifyRecord(r));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
io.err(`error: ${e.message}\n`);
|
|
278
|
+
return 2;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (allRecords.length === 0) {
|
|
282
|
+
io.err("error: no records loaded\n");
|
|
283
|
+
return 2;
|
|
284
|
+
}
|
|
285
|
+
const label = paths.length === 1 ? paths[0] : `${paths.length} files`;
|
|
286
|
+
if (format === "json" || reportFile) {
|
|
287
|
+
const report = buildReport(label, allRecords, allResults);
|
|
288
|
+
const json = JSON.stringify(report, null, 2);
|
|
289
|
+
if (reportFile)
|
|
290
|
+
writeFileSync(reportFile, json + "\n");
|
|
291
|
+
if (format === "json")
|
|
292
|
+
io.out(json + "\n");
|
|
293
|
+
else if (!quiet)
|
|
294
|
+
io.out(formatText(label, allRecords, allResults));
|
|
295
|
+
}
|
|
296
|
+
else if (!quiet) {
|
|
297
|
+
io.out(formatText(label, allRecords, allResults));
|
|
298
|
+
}
|
|
299
|
+
return allResults.some(r => !r.valid) ? 1 : 0;
|
|
300
|
+
}
|
|
301
|
+
function cmdExplain(args, io) {
|
|
302
|
+
const say = (line = "") => io.out(line + "\n");
|
|
303
|
+
const checkId = args[0];
|
|
304
|
+
if (checkId) {
|
|
305
|
+
const def = CATALOG_BY_ID[checkId];
|
|
306
|
+
if (!def) {
|
|
307
|
+
io.err(`error: unknown check ID: ${checkId}\n`);
|
|
308
|
+
io.err(`Available: ${CATALOG.map(c => c.id).join(", ")}\n`);
|
|
309
|
+
return 2;
|
|
310
|
+
}
|
|
311
|
+
say(`${def.id} — ${def.label}`);
|
|
312
|
+
say(` Status: ${def.status}`);
|
|
313
|
+
say(` Category: ${def.category}`);
|
|
314
|
+
say(` Scope: ${def.scope}`);
|
|
315
|
+
say(` Since: ${def.introducedIn}`);
|
|
316
|
+
say();
|
|
317
|
+
say(` ${def.description}`);
|
|
318
|
+
say();
|
|
319
|
+
say(` How to pass: ${def.howToPass}`);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
say("qlm-measure check catalog (v1)");
|
|
323
|
+
say();
|
|
324
|
+
say("Shipped checks (run by verify):");
|
|
325
|
+
for (const c of SHIPPED_CHECKS)
|
|
326
|
+
say(` ${c.id.padEnd(28)} ${c.label}`);
|
|
327
|
+
say();
|
|
328
|
+
say("Planned checks (listed, not run):");
|
|
329
|
+
for (const c of PLANNED_CHECKS)
|
|
330
|
+
say(` ${c.id.padEnd(28)} ${c.label}`);
|
|
331
|
+
say();
|
|
332
|
+
say("Use 'qlm-measure explain CHECK_ID' for details.");
|
|
333
|
+
}
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
function cmdVersion(io) {
|
|
337
|
+
io.out(`qlm-measure ${VERSION}\n`);
|
|
338
|
+
io.out("schema version: 0.2\n");
|
|
339
|
+
io.out("catalog version: 1\n");
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
// ── verify-chain: per-event hash chains sealed by QLM products ─────────────
|
|
343
|
+
function cmdVerifyChain(argv, io) {
|
|
344
|
+
const say = (line) => io.out(line + "\n");
|
|
345
|
+
const fail = (line) => { io.err(line + "\n"); return 2; };
|
|
346
|
+
const opts = { format: "text", allowMixed: false, list: false };
|
|
347
|
+
const positional = [];
|
|
348
|
+
for (let i = 0; i < argv.length; i++) {
|
|
349
|
+
const a = argv[i];
|
|
350
|
+
if (a === "--family" || a === "--scheme") {
|
|
351
|
+
const v = argv[++i];
|
|
352
|
+
if (v === undefined)
|
|
353
|
+
return fail(`ERROR: ${a} needs a value`);
|
|
354
|
+
opts[a === "--family" ? "family" : "scheme"] = v;
|
|
355
|
+
}
|
|
356
|
+
else if (a === "--format") {
|
|
357
|
+
opts.format = argv[++i];
|
|
358
|
+
if (opts.format !== "text" && opts.format !== "json")
|
|
359
|
+
return fail("ERROR: --format must be text or json");
|
|
360
|
+
}
|
|
361
|
+
else if (a === "--allow-mixed") {
|
|
362
|
+
opts.allowMixed = true;
|
|
363
|
+
}
|
|
364
|
+
else if (a === "--list") {
|
|
365
|
+
opts.list = true;
|
|
366
|
+
}
|
|
367
|
+
else if (a.startsWith("--")) {
|
|
368
|
+
return fail(`ERROR: unknown option: ${a}`);
|
|
369
|
+
}
|
|
370
|
+
else
|
|
371
|
+
positional.push(a);
|
|
372
|
+
}
|
|
373
|
+
if (opts.list) {
|
|
374
|
+
for (const s of listSchemes())
|
|
375
|
+
say(`${s.id}\t${s.family}\tsince ${s.since}\t${s.hashField}/${s.prevField}`);
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
const file = positional[0];
|
|
379
|
+
if (!file)
|
|
380
|
+
return fail("Usage: qlm-measure verify-chain <events.json> (--family F | --scheme S) [--allow-mixed] [--format text|json] | --list");
|
|
381
|
+
if (!opts.family && !opts.scheme)
|
|
382
|
+
return fail("ERROR: pass --family (e.g. tpc/clinical) or --scheme (e.g. tpc/clinical-v2)");
|
|
383
|
+
if (!existsSync(file))
|
|
384
|
+
return fail(`ERROR: ${file} not found`);
|
|
385
|
+
let events;
|
|
386
|
+
try {
|
|
387
|
+
events = JSON.parse(readFileSync(file, "utf-8"));
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
return fail(`ERROR: parse failed: ${e}`);
|
|
391
|
+
}
|
|
392
|
+
if (!Array.isArray(events) && events && typeof events === "object" && Array.isArray(events.events)) {
|
|
393
|
+
events = events.events; // { events: [...] } wrapper (vectors, exports with metadata)
|
|
394
|
+
}
|
|
395
|
+
if (!Array.isArray(events))
|
|
396
|
+
return fail("ERROR: not an array of events (or an object with an events array)");
|
|
397
|
+
let r;
|
|
398
|
+
try {
|
|
399
|
+
r = verifyChain(events, { family: opts.family, scheme: opts.scheme, rejectMixed: !opts.allowMixed });
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
return fail(`ERROR: ${e.message}`);
|
|
403
|
+
}
|
|
404
|
+
if (opts.format === "json") {
|
|
405
|
+
say(JSON.stringify({ verifier: `qlm-measure ${VERSION}`, file, ...r }, null, 2));
|
|
406
|
+
return r.clean ? 0 : 1;
|
|
407
|
+
}
|
|
408
|
+
say(`qlm-measure verify-chain ${VERSION}`);
|
|
409
|
+
say(`file: ${file}`);
|
|
410
|
+
say(`timestamp: ${new Date().toISOString()}`);
|
|
411
|
+
say(`result: ${r.clean ? "CLEAN" : "FAIL"}`);
|
|
412
|
+
say(`events: ${r.stats.total}`);
|
|
413
|
+
say(`gaps: ${r.stats.gaps}`);
|
|
414
|
+
say(`duplicates: ${r.stats.duplicates}`);
|
|
415
|
+
say(`tampered: ${r.stats.tampered}`);
|
|
416
|
+
say(`schema_errors: ${r.stats.schema_errors}`);
|
|
417
|
+
say(`hash_scheme: ${r.stats.hash_scheme}`);
|
|
418
|
+
say(`schemes: ${JSON.stringify(r.stats.schemes)}`);
|
|
419
|
+
if (!r.clean) {
|
|
420
|
+
say("\nerrors:");
|
|
421
|
+
r.errors.forEach((e) => say(` ${e}`));
|
|
422
|
+
}
|
|
423
|
+
return r.clean ? 0 : 1;
|
|
424
|
+
}
|
|
425
|
+
// ── Main ────────────────────────────────────────────────────
|
|
426
|
+
const USAGE = [
|
|
427
|
+
"Usage: qlm-measure <verify|verify-chain|explain|version> [options]",
|
|
428
|
+
" verify PATH... [--format text|json] [--report FILE] [--quiet]",
|
|
429
|
+
" verify-chain EVENTS_JSON (--family F | --scheme S) [--allow-mixed] [--format text|json]",
|
|
430
|
+
" verify-chain --list",
|
|
431
|
+
" explain [CHECK_ID]",
|
|
432
|
+
" version",
|
|
433
|
+
"",
|
|
434
|
+
].join("\n");
|
|
435
|
+
/** Runs one CLI invocation and returns its exit code: 0 clean, 1 not clean, 2 usage or input error. */
|
|
436
|
+
export function main(argv, io = processIO) {
|
|
437
|
+
const [cmd, ...rest] = argv;
|
|
438
|
+
switch (cmd) {
|
|
439
|
+
case "verify": return cmdVerify(rest, io);
|
|
440
|
+
case "verify-chain": return cmdVerifyChain(rest, io);
|
|
441
|
+
case "explain": return cmdExplain(rest, io);
|
|
442
|
+
case "version": return cmdVersion(io);
|
|
443
|
+
default:
|
|
444
|
+
io.out(USAGE);
|
|
445
|
+
return 2;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** True when this module is the script node was started with, including through npm's bin symlink. */
|
|
449
|
+
export function isDirectRun(scriptPath, moduleUrl) {
|
|
450
|
+
if (!scriptPath)
|
|
451
|
+
return false;
|
|
452
|
+
try {
|
|
453
|
+
return realpathSync(scriptPath) === realpathSync(fileURLToPath(moduleUrl));
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (isDirectRun(process.argv[1], import.meta.url))
|
|
460
|
+
process.exit(main(process.argv.slice(2)));
|
|
461
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public update rule registry.
|
|
3
|
+
* Each rule is a pure function: given inputs, produce a posterior.
|
|
4
|
+
* This module ships with the SDK and has no server dependency.
|
|
5
|
+
*/
|
|
6
|
+
export type RuleFn = (prior: number, correct: boolean, weight: number, params: Record<string, number>) => number;
|
|
7
|
+
export declare function registerRule(ruleId: string, fn: RuleFn): void;
|
|
8
|
+
export declare function getRule(ruleId: string): RuleFn | undefined;
|
|
9
|
+
export declare function reproduce(ruleId: string, params: Record<string, number>, prior: number, correct: boolean, weight: number): number | null;
|
|
10
|
+
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public update rule registry.
|
|
3
|
+
* Each rule is a pure function: given inputs, produce a posterior.
|
|
4
|
+
* This module ships with the SDK and has no server dependency.
|
|
5
|
+
*/
|
|
6
|
+
const registry = new Map();
|
|
7
|
+
export function registerRule(ruleId, fn) {
|
|
8
|
+
registry.set(ruleId, fn);
|
|
9
|
+
}
|
|
10
|
+
export function getRule(ruleId) {
|
|
11
|
+
return registry.get(ruleId);
|
|
12
|
+
}
|
|
13
|
+
export function reproduce(ruleId, params, prior, correct, weight) {
|
|
14
|
+
const fn = getRule(ruleId);
|
|
15
|
+
if (!fn)
|
|
16
|
+
return null;
|
|
17
|
+
return fn(prior, correct, weight, params);
|
|
18
|
+
}
|
|
19
|
+
// ── tempered-bkt-1 ─────────────────────────────────────────
|
|
20
|
+
// Written from depth_engine/update.py. Spec: rules/tempered-bkt-1.md
|
|
21
|
+
function temperedBkt1(prior, correct, weight, params) {
|
|
22
|
+
const slip = params.slip;
|
|
23
|
+
const guess = params.guess;
|
|
24
|
+
if (prior <= 0 || prior >= 1)
|
|
25
|
+
throw new Error(`prior must be in (0, 1), got ${prior}`);
|
|
26
|
+
if (weight < 0 || weight > 1)
|
|
27
|
+
throw new Error(`weight must be in [0, 1], got ${weight}`);
|
|
28
|
+
if (weight === 0)
|
|
29
|
+
return prior;
|
|
30
|
+
let pObsMastered;
|
|
31
|
+
let pObsNotMastered;
|
|
32
|
+
if (correct) {
|
|
33
|
+
pObsMastered = Math.pow(1 - slip, weight);
|
|
34
|
+
pObsNotMastered = Math.pow(guess, weight);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
pObsMastered = Math.pow(slip, weight);
|
|
38
|
+
pObsNotMastered = Math.pow(1 - guess, weight);
|
|
39
|
+
}
|
|
40
|
+
const numerator = prior * pObsMastered;
|
|
41
|
+
const denominator = numerator + (1 - prior) * pObsNotMastered;
|
|
42
|
+
const posterior = numerator / denominator;
|
|
43
|
+
return Math.max(1e-15, Math.min(1 - 1e-15, posterior));
|
|
44
|
+
}
|
|
45
|
+
registerRule("tempered-bkt-1", temperedBkt1);
|
|
46
|
+
//# sourceMappingURL=rules.js.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain schemes — one implementation of every per-event hash chain a QLM
|
|
3
|
+
* product seals.
|
|
4
|
+
*
|
|
5
|
+
* Products store one row per event with a hash field and a link to the
|
|
6
|
+
* previous event's hash. Historically each product computed that hash with
|
|
7
|
+
* its own copy of the code; when one copy changed (TeachProof, 2026-09-10:
|
|
8
|
+
* 10 → 12 hashed fields) the standalone verifier did not, and every sealed
|
|
9
|
+
* chain read as "tampered". This module makes each scheme a named,
|
|
10
|
+
* versioned definition with a canonical serialization, so the sealer and
|
|
11
|
+
* every verifier — in TypeScript or Python — hash the same bytes.
|
|
12
|
+
*
|
|
13
|
+
* This file is pure: no Node or browser APIs. Hashing is injected, so the
|
|
14
|
+
* same code runs server-side (`./node.js`) and in a client component.
|
|
15
|
+
* Python twin: `python/qlm_measure/schemes.py`. Both are checked against
|
|
16
|
+
* `schema/vectors/chains/*.json`.
|
|
17
|
+
*/
|
|
18
|
+
export type HashFn = (canonical: string) => string;
|
|
19
|
+
export type AsyncHashFn = (canonical: string) => Promise<string>;
|
|
20
|
+
export type ChainEvent = Record<string, unknown>;
|
|
21
|
+
export type Digest = "sha256" | "fnv1a64" | "djb2-32";
|
|
22
|
+
export interface ChainScheme {
|
|
23
|
+
/** Stable identifier, e.g. "tpc/clinical-v2". */
|
|
24
|
+
id: string;
|
|
25
|
+
/** Schemes in one family share storage and a link convention; detection walks the family newest-first. */
|
|
26
|
+
family: string;
|
|
27
|
+
/** ISO date the scheme entered production. */
|
|
28
|
+
since: string;
|
|
29
|
+
/** Field carrying this event's hash. */
|
|
30
|
+
hashField: string;
|
|
31
|
+
/**
|
|
32
|
+
* Field carrying the previous event's hash, or null when the record stores no
|
|
33
|
+
* link field. With `link: "implicit"` the previous hash still enters the
|
|
34
|
+
* canonical form (threaded from the previous record); with `link: "none"`
|
|
35
|
+
* the scheme is a content hash and records are independent.
|
|
36
|
+
*/
|
|
37
|
+
prevField: string | null;
|
|
38
|
+
/** Value of `prevField` on the first event; null means absent/null is the genesis. */
|
|
39
|
+
genesis: string | null;
|
|
40
|
+
/** How records bind to their predecessor. Default: "explicit" when prevField is set, else "none". */
|
|
41
|
+
link?: "explicit" | "implicit" | "none";
|
|
42
|
+
/** Digest over the canonical string. Default sha256 (injected); the others are built in. */
|
|
43
|
+
digest?: Digest;
|
|
44
|
+
/** Keep only the first N hex characters of the digest (play/measure-session stores 32). */
|
|
45
|
+
truncate?: number;
|
|
46
|
+
/** Written before the digest in the hash field ("sha256:" for yardstick/depth-1). */
|
|
47
|
+
prefix?: string;
|
|
48
|
+
/** Structural precondition for trying this scheme on an event. */
|
|
49
|
+
applies(event: ChainEvent): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The exact string that is hashed. Never includes `hashField`. `prev` is the
|
|
52
|
+
* previous record's stored hash (the genesis value for the first record);
|
|
53
|
+
* schemes with an explicit link normally read the event's own field instead.
|
|
54
|
+
*/
|
|
55
|
+
canonical(event: ChainEvent, prev?: string): string;
|
|
56
|
+
/** Schema errors for one event (not integrity errors). Empty when valid. */
|
|
57
|
+
validate(event: ChainEvent): string[];
|
|
58
|
+
/** Scheme ids that may appear in the same chain as this one without the chain counting as mixed. */
|
|
59
|
+
coexists?: string[];
|
|
60
|
+
}
|
|
61
|
+
export declare function linkOf(s: ChainScheme): "explicit" | "implicit" | "none";
|
|
62
|
+
/** FNV-1a 64-bit over UTF-16 code units, 16 hex chars (qlm-games snapshot-commit hashSnapshotSync). */
|
|
63
|
+
export declare function fnv1a64Hex(str: string): string;
|
|
64
|
+
/** djb2, 32-bit wrap, unsigned hex padded to 8 (q-core evidence-chain hashString). */
|
|
65
|
+
export declare function djb2Hex(str: string): string;
|
|
66
|
+
/** The digest a scheme seals with: built in for fnv/djb2, the injected function for sha256. */
|
|
67
|
+
export declare function digestFor(scheme: ChainScheme, sha256: HashFn): HashFn;
|
|
68
|
+
/** Recursively sorts object keys; arrays keep their order. Mirrors Play's residency canonicalize(). */
|
|
69
|
+
export declare function sortKeysDeep(value: unknown): unknown;
|
|
70
|
+
export declare const TPC_SIGNALS: readonly ["demonstrated", "partial", "missed_opportunity", "not_observable"];
|
|
71
|
+
export declare const TPC_CLINICAL_V1: ChainScheme;
|
|
72
|
+
/** 2026-09-10 (teachproof ac9375b): triage_class and engine_version join the hash, null when absent. */
|
|
73
|
+
export declare const TPC_CLINICAL_V2: ChainScheme;
|
|
74
|
+
/** 2026-09-14: mapping_version joins the hash on events that carry it. */
|
|
75
|
+
export declare const TPC_CLINICAL_V3: ChainScheme;
|
|
76
|
+
/**
|
|
77
|
+
* 2026-09-25 (teachproof schema 0.4, TPC-SPEC-002 A6): an event may carry
|
|
78
|
+
* event_kind ("chart.view", "chart.item", ...) and a per-kind payload. Both
|
|
79
|
+
* join the hash: the v3 fields (mapping_version null when absent), then
|
|
80
|
+
* event_kind, then the payload with its keys sorted at every depth. Only
|
|
81
|
+
* events with event_kind use it; the rest of the chain stays v3.
|
|
82
|
+
*/
|
|
83
|
+
export declare const TPC_CLINICAL_V4: ChainScheme;
|
|
84
|
+
export declare const PLAY_CLINICAL_1_0: ChainScheme;
|
|
85
|
+
export declare const SCHEMES: Readonly<Record<string, ChainScheme>>;
|
|
86
|
+
export declare function getScheme(id: string): ChainScheme;
|
|
87
|
+
/** Schemes of a family, newest first — the order detection tries them in. */
|
|
88
|
+
export declare function familySchemes(family: string): ChainScheme[];
|
|
89
|
+
export declare function listSchemes(): Array<Pick<ChainScheme, "id" | "family" | "since" | "hashField" | "prevField"> & {
|
|
90
|
+
link: string;
|
|
91
|
+
digest: Digest;
|
|
92
|
+
}>;
|
|
93
|
+
export declare function computeEventHashWith(hash: HashFn, event: ChainEvent, schemeId: string, prev?: string): string;
|
|
94
|
+
export declare function computeEventHashAsyncWith(hash: AsyncHashFn, event: ChainEvent, schemeId: string, prev?: string): Promise<string>;
|
|
95
|
+
/**
|
|
96
|
+
* Returns a copy of `event` with the scheme's hash field set. Never mutates the
|
|
97
|
+
* input. `prev` is required for implicit-link schemes (the previous record's
|
|
98
|
+
* hash, or the genesis value for the first record).
|
|
99
|
+
*/
|
|
100
|
+
export declare function sealEventWith<E extends ChainEvent>(hash: HashFn, event: E, schemeId: string, prev?: string): E;
|
|
101
|
+
/** Which scheme of `family` the event's stored hash was sealed under, or null. Tries newest first. */
|
|
102
|
+
export declare function detectSchemeWith(hash: HashFn, event: ChainEvent, family: string, prev?: string): string | null;
|
|
103
|
+
export interface ChainVerification {
|
|
104
|
+
clean: boolean;
|
|
105
|
+
errors: string[];
|
|
106
|
+
stats: {
|
|
107
|
+
total: number;
|
|
108
|
+
gaps: number;
|
|
109
|
+
duplicates: number;
|
|
110
|
+
tampered: number;
|
|
111
|
+
schema_errors: number;
|
|
112
|
+
/** Scheme id when the whole chain is sealed under one scheme; "mixed"; or "none" for an empty chain. */
|
|
113
|
+
hash_scheme: string;
|
|
114
|
+
/** Events per detected scheme id. */
|
|
115
|
+
schemes: Record<string, number>;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export interface VerifyChainOptions {
|
|
119
|
+
/** Detect the scheme per event within this family (default when `scheme` is not given). */
|
|
120
|
+
family?: string;
|
|
121
|
+
/** Require exactly this scheme for every event. */
|
|
122
|
+
scheme?: string;
|
|
123
|
+
/** Treat a chain that mixes schemes as an error (default true). */
|
|
124
|
+
rejectMixed?: boolean;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The path of the first own "__proto__" key at any depth (".payload.__proto__"), or null.
|
|
128
|
+
* sortKeysDeep drops such a key, as the products' original code did, so no hash covers it;
|
|
129
|
+
* verifyChain refuses an event that holds one rather than verify content the hash does not cover.
|
|
130
|
+
*/
|
|
131
|
+
export declare function protoKeyPath(value: unknown, path?: string): string | null;
|
|
132
|
+
export declare function verifyChainWith(hash: HashFn, events: ChainEvent[], opts: VerifyChainOptions): ChainVerification;
|
|
133
|
+
//# sourceMappingURL=index.d.ts.map
|