@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,445 @@
1
+ // QLM research-data adapters. QLM-authored code: MIT.
2
+ // These adapters preserve documented observations, without inferring help delivery
3
+ // from an assigned condition or classifying dialogue without a validated annotation.
4
+ import {PROFILES, ALL_PROFILES, ADAPTER_PROFILES, getProfile, getSample} from './profiles.mjs';
5
+ import {IMPORT_EVENT_SCHEMA,LIFECYCLE_EVENT_SCHEMA,SANDPIPER_EVENT_SCHEMA,normalizeContext,getSemanticContract,semanticProfileFor,ASSISTANCE_EVENT_TYPES,assessPoolingCompatibility} from './research-semantics.mjs';
6
+ // ALL_PROFILES are the sources the demo page offers; ADAPTER_PROFILES adds engine-only adapters (sandpiper).
7
+ export {PROFILES, ALL_PROFILES, ADAPTER_PROFILES, getSample, normalizeContext, getSemanticContract, assessPoolingCompatibility};
8
+ export const ENGINE_VERSION = '0.4.0-research-adapters';
9
+ export const IMPORT_SCHEMA = 'https://transformer.quantumlearningmachines.com/schemas/research-import-v2.json';
10
+ export const LIFECYCLE_SCHEMA = LIFECYCLE_EVENT_SCHEMA.$id;
11
+ export const DEMO_SECRET = 'QLM browser demonstration key 2026; use a deployment-specific secret for real exports';
12
+ export const SESSION_SECRET = crypto.randomUUID()+crypto.randomUUID();
13
+ const enc = new TextEncoder();
14
+ const numericKinds = new WeakMap();
15
+ const own = (o,k) => Object.prototype.hasOwnProperty.call(o,k);
16
+ const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
17
+ function unicode(text) {
18
+ for (let i=0;i<text.length;i++) {
19
+ const c=text.charCodeAt(i);
20
+ if (c>=0xd800 && c<=0xdbff) {
21
+ const n=text.charCodeAt(++i);
22
+ if (!(n>=0xdc00 && n<=0xdfff)) throw new Error('Unpaired Unicode surrogate is not supported.');
23
+ } else if (c>=0xdc00 && c<=0xdfff) throw new Error('Unpaired Unicode surrogate is not supported.');
24
+ }
25
+ return text;
26
+ }
27
+ // RFC 8785: ECMAScript number serialization, UTF-16 property ordering, no spaces.
28
+ export function canonicalize(value) {
29
+ if (value === null) return 'null';
30
+ if (typeof value === 'string') return JSON.stringify(unicode(value));
31
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
32
+ if (typeof value === 'number') {
33
+ if (!Number.isFinite(value)) throw new Error('Non-finite JSON numbers are not supported.');
34
+ return JSON.stringify(value);
35
+ }
36
+ if (Array.isArray(value)) return '['+value.map(canonicalize).join(',')+']';
37
+ if (plain(value)) return '{'+Object.keys(value).sort().map(k => JSON.stringify(unicode(k))+':'+canonicalize(value[k])).join(',')+'}';
38
+ throw new Error('Only JSON values can be canonicalized.');
39
+ }
40
+ const hex = bytes => Array.from(new Uint8Array(bytes), b=>b.toString(16).padStart(2,'0')).join('');
41
+ export async function sha256(value) {
42
+ const bytes = typeof value === 'string' ? enc.encode(value) : value;
43
+ return hex(await crypto.subtle.digest('SHA-256', bytes));
44
+ }
45
+ export async function digest(value) { return sha256(canonicalize(value)); }
46
+ export async function subjectId(platform, tenant, value, secret=DEMO_SECRET) {
47
+ const bytes = enc.encode(secret);
48
+ if (bytes.length < 32) throw new Error('The pseudonym key must contain at least 32 UTF-8 bytes.');
49
+ let identifier;
50
+ if (typeof value === 'string' && value.trim()) identifier=unicode(value);
51
+ else if (typeof value === 'number' && Number.isSafeInteger(value)) identifier=String(value);
52
+ else throw new Error('Learner identifiers must be nonempty text or safe, non-boolean integers.');
53
+ const key=await crypto.subtle.importKey('raw',bytes,{name:'HMAC',hash:'SHA-256'},false,['sign']);
54
+ return 'qlm:'+hex(await crypto.subtle.sign('HMAC', key, enc.encode(canonicalize([tenant,platform,identifier]))));
55
+ }
56
+ // Small strict parser: duplicate members and unsafe literal integers are rejected
57
+ // before JSON.parse can silently discard keys or round identifiers.
58
+ export function parseStrictJSON(text) {
59
+ let i=0;
60
+ const fail = message => { throw new Error(message+' at character '+(i+1)+'.'); };
61
+ const ws = () => { while (/\s/.test(text[i]||'') && i<text.length) { if (!' \t\n\r'.includes(text[i])) fail('Invalid JSON whitespace'); i++; } };
62
+ function string() {
63
+ const begin=i++;
64
+ while (i<text.length) {
65
+ if (text[i]==='"') { i++; try { return unicode(JSON.parse(text.slice(begin,i))); } catch { fail('Invalid JSON string'); } }
66
+ if (text.charCodeAt(i)<32) fail('Unescaped control character');
67
+ if (text[i]==='\\') i+=2; else i++;
68
+ }
69
+ fail('Unterminated JSON string');
70
+ }
71
+ function value(depth=0) {
72
+ if (depth>50) fail('JSON nesting exceeds the demo limit');
73
+ ws();
74
+ if (text[i]==='"') return string();
75
+ if (text[i]==='{') {
76
+ i++; const obj=Object.create(null), keys=new Set(); ws();
77
+ if (text[i]==='}') {i++;return obj;}
78
+ while (i<text.length) {
79
+ ws(); if(text[i]!== '"') fail('Expected an object member');
80
+ const key=string(); if(keys.has(key)) fail('Duplicate JSON member'); keys.add(key);
81
+ ws(); if(text[i++]!==':') fail('Expected a colon'); ws(); const start=i;obj[key]=value(depth+1);
82
+ if(typeof obj[key]==='number'&&/[.eE]/.test(text.slice(start,i))){if(!numericKinds.has(obj))numericKinds.set(obj,new Map());numericKinds.get(obj).set(key,'float');}ws();
83
+ const c=text[i++]; if(c==='}') return obj; if(c!==',') fail('Expected a comma or closing brace');
84
+ }
85
+ fail('Unterminated JSON object');
86
+ }
87
+ if(text[i]==='[') {
88
+ i++; const arr=[]; ws(); if(text[i]===']'){i++;return arr;}
89
+ while(i<text.length) { arr.push(value(depth+1));ws();const c=text[i++];if(c===']')return arr;if(c!==',')fail('Expected a comma or closing bracket'); }
90
+ fail('Unterminated JSON array');
91
+ }
92
+ for(const [literal,result] of [['true',true],['false',false],['null',null]]) if(text.slice(i,i+literal.length)===literal){i+=literal.length;return result;}
93
+ const match=text.slice(i).match(/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/);
94
+ if(match){i+=match[0].length;const n=Number(match[0]);if(!Number.isFinite(n))fail('Non-finite JSON number');if(!/[.eE]/.test(match[0])&&!Number.isSafeInteger(n))fail('Integer exceeds the exact JSON range; quote identifiers');return n;}
95
+ fail('Expected a JSON value');
96
+ }
97
+ const result=value();ws();if(i!==text.length)fail('Unexpected trailing content');return result;
98
+ }
99
+ export function parseCSV(text) {
100
+ const table=[];let row=[],cell='',quoted=false,afterQuote=false;
101
+ for(let i=0;i<=text.length;i++) {
102
+ const c=text[i];
103
+ if(quoted) {if(c===undefined)throw new Error('CSV has an unterminated quoted field.');if(c==='"'){if(text[i+1]==='"'){cell+='"';i++;}else{quoted=false;afterQuote=true;}}else cell+=c;continue;}
104
+ if(afterQuote && c!==',' && c!=='\n' && c!=='\r' && c!==undefined) throw new Error('Unexpected text after a quoted CSV field.');
105
+ if(c==='"'){if(cell.length)throw new Error('CSV quote must start a field.');quoted=true;continue;}
106
+ if(c===','||c==='\n'||c==='\r'||c===undefined){row.push(unicode(cell));cell='';afterQuote=false;if(c===',')continue;if(c==='\r'&&text[i+1]==='\n')i++;if(row.some(x=>x!==''))table.push(row);row=[];}else cell+=c;
107
+ }
108
+ if(table.length<2)throw new Error('CSV needs a header and at least one data row.');
109
+ const headers=table.shift();if(headers.some(h=>!h.trim())||new Set(headers).size!==headers.length)throw new Error('CSV headers must be nonempty and unique.');
110
+ return table.map((cells,index)=>{if(cells.length!==headers.length)throw new Error('CSV row '+(index+2)+' has a different number of fields.');const o=Object.create(null);headers.forEach((h,j)=>o[h]=cells[j]);return o;});
111
+ }
112
+ export function parseInput(text, sourceName='input.json') {
113
+ if(typeof text!=='string'||!text.trim())throw new Error('Add a sample or choose a file before transforming.');
114
+ if(enc.encode(text).length>2_000_000)throw new Error('This browser demo accepts files up to 2 MB.');
115
+ const clean=text.charCodeAt(0)===0xfeff?text.slice(1):text;
116
+ let rows;
117
+ if(/\.csv$/i.test(sourceName)||(!/^[\s]*[\[{]/.test(clean))) rows=parseCSV(clean);
118
+ else if(clean.trimStart().startsWith('[')) rows=parseStrictJSON(clean);
119
+ else rows=clean.split(/\r?\n/).filter(line=>line.trim()).map(parseStrictJSON);
120
+ if(!Array.isArray(rows)||!rows.length||rows.length>5000||rows.some(r=>!plain(r)))throw new Error('Use 1–5,000 object rows in CSV, a JSON array or JSONL.');
121
+ return rows;
122
+ }
123
+ function randomSalt() {const bytes=crypto.getRandomValues(new Uint8Array(16));return btoa(String.fromCharCode(...bytes)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');}
124
+ function timestamp(text) {
125
+ if(typeof text!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(text)||!Number.isFinite(Date.parse(text)))throw new Error('Import time must be a valid UTC timestamp.');
126
+ if(text.startsWith('0000')||new Date(text).toISOString().slice(0,19)!==text.slice(0,19))throw new Error('Import time is outside the supported calendar range.');
127
+ return text;
128
+ }
129
+ function fieldAt(row,path) {
130
+ let container=row;const parts=path.split('.');
131
+ for(const key of parts.slice(0,-1)) {
132
+ if(!plain(container)||!own(container,key))return {present:false};
133
+ container=container[key];
134
+ }
135
+ const key=parts.at(-1);
136
+ return {present:plain(container)&&own(container,key),value:plain(container)?container[key]:undefined,container,key};
137
+ }
138
+ function mappedField(row,path,index,label,{identifier=false}={}) {
139
+ const field=fieldAt(row,path);
140
+ if(!field.present||field.value===null||field.value==='')throw new Error('Row '+(index+1)+': the mapped '+label+' column is missing.');
141
+ if(identifier&&numericKinds.get(field.container)?.get(field.key)==='float')throw new Error('Row '+(index+1)+': floating-point learner identifiers are not supported; use text or integers.');
142
+ return field.value;
143
+ }
144
+ function sourceDetails(profile) {
145
+ const result={};
146
+ for(const key of ['kind','url','downloadUrl','revision','license','licenseUrl','citation','selectionNote','limitations','sampleSha256','sourceSha256','sourceRows','summary'])if(profile.source&&own(profile.source,key))result[key]=profile.source[key];
147
+ return result;
148
+ }
149
+ function dialogueFields(row,turn) {
150
+ const result={turn:Object.fromEntries(['turn_number','start_seconds','end_seconds','role','text'].map(key=>[key,turn[key]]))};
151
+ for(const key of ['source','batch','has_video'])if(own(row,key))result[key]=row[key];
152
+ const session={};
153
+ for(const key of ['transcription_by','primary_language','prior_session_count'])if(own(row.session,key))session[key]=row.session[key];
154
+ result.session=session;
155
+ return result;
156
+ }
157
+ function validateTurns(row,index) {
158
+ if(!Array.isArray(row.turns)||!row.turns.length)throw new Error('Row '+(index+1)+': a transcript requires a nonempty turns array.');
159
+ const turnNumbers=new Set();
160
+ for(let i=0;i<row.turns.length;i++) {
161
+ const turn=row.turns[i];
162
+ if(!plain(turn)||!['Student','Tutor'].includes(turn.role)||typeof turn.text!=='string'||!Number.isSafeInteger(turn.turn_number)||turn.turn_number<1)throw new Error('Row '+(index+1)+', turn '+(i+1)+': expected source role, text and positive integer turn_number.');
163
+ if(turnNumbers.has(turn.turn_number))throw new Error('Row '+(index+1)+': duplicate source turn_number.');
164
+ turnNumbers.add(turn.turn_number);
165
+ for(const key of ['start_seconds','end_seconds'])if(typeof turn[key]!=='number'||!Number.isFinite(turn[key])||turn[key]<0)throw new Error('Row '+(index+1)+', turn '+(i+1)+': source offsets must be finite, nonnegative seconds.');
166
+ if(turn.end_seconds<turn.start_seconds)throw new Error('Row '+(index+1)+', turn '+(i+1)+': source end offset precedes its start.');
167
+ // No chronological sorting: source array order and raw offsets remain observable.
168
+ }
169
+ }
170
+ function validateLifecycle(row,index) {
171
+ const label='Row '+(index+1)+': ';
172
+ for(const key of ['source_id','event_id','subject_id','activity_id'])if(typeof row[key]!=='string'||!row[key].trim()||Array.from(row[key]).length>512)throw new Error(label+key+' must be nonempty text of at most 512 characters.');
173
+ if(!Number.isSafeInteger(row.source_revision)||row.source_revision<0)throw new Error(label+'source_revision must be a nonnegative integer.');
174
+ if(!ASSISTANCE_EVENT_TYPES.includes(row.event_type))throw new Error(label+'unsupported explicit assistance event_type.');
175
+ if(row.source_timestamp!==undefined&&row.source_timestamp!==null)timestamp(row.source_timestamp);
176
+ for(const key of ['assistance_id','request_event_id'])if(row[key]!==undefined&&row[key]!==null&&(typeof row[key]!=='string'||!row[key].trim()||Array.from(row[key]).length>512))throw new Error(label+key+' must be nonempty bounded text or null.');
177
+ if(row.request_origin!==undefined&&!['requested','unsolicited','unknown'].includes(row.request_origin))throw new Error(label+'invalid request_origin.');
178
+ if(row.request_origin==='unsolicited'&&row.request_event_id)throw new Error(label+'unsolicited origin conflicts with a request link.');
179
+ if(row.native_hint_depth!==undefined&&row.native_hint_depth!==null&&!(typeof row.native_hint_depth==='string'&&row.native_hint_depth.trim())&&!(Number.isSafeInteger(row.native_hint_depth)&&row.native_hint_depth>=0))throw new Error(label+'native_hint_depth must be a nonnegative integer, nonempty text or null.');
180
+ if(row.delivery_order!==undefined&&row.delivery_order!==null&&!(Number.isSafeInteger(row.delivery_order)&&row.delivery_order>0))throw new Error(label+'delivery_order must be a positive integer or null.');
181
+ if(row.content!==undefined&&typeof row.content!=='string')throw new Error(label+'content must be text.');
182
+ normalizeContext(row.context===undefined?{}:row.context);
183
+ }
184
+ // Sandpiper (National Tutoring Observatory) PER_UTTERANCE utterances CSV, as written by
185
+ // app/functions/outputRunDataToCSV: these utterance columns, then annotator[<name>][<index>]<field> columns.
186
+ const SANDPIPER_COLUMNS=['_id','session_id','sequence_id','role','content','start_time','end_time'];
187
+ const SANDPIPER_ANNOTATION=/^annotator\[([\s\S]+?)\]\[(0|[1-9][0-9]*)\]([\s\S]+)$/;
188
+ const SANDPIPER_EVENT='qlm_import:transcript_utterance_observed';
189
+ const SANDPIPER_COVERAGE='source_dialogue_and_inferred_codes_only_no_help_stage_recorded';
190
+ const SANDPIPER_ORDER='source_sequence_id_numeric_order';
191
+ const HELP_STAGES=['requested','offered','generated','transmitted','displayed','accepted'];
192
+ // sequence_id is a positive integer string without leading zeros: length, then text, is numeric order.
193
+ const sequenceOrder=(a,b)=>a.length-b.length||(a<b?-1:a>b?1:0);
194
+ function sandpiperAnnotationColumns(row) {
195
+ const columns=[];
196
+ for(const name of Object.keys(row)){
197
+ if(SANDPIPER_COLUMNS.includes(name))continue;
198
+ const match=SANDPIPER_ANNOTATION.exec(name);
199
+ if(!match)throw new Error('Column '+JSON.stringify(name)+' is neither a Sandpiper utterance column nor annotator[<name>][<index>]<field>.');
200
+ const index=Number(match[2]);
201
+ if(!Number.isSafeInteger(index))throw new Error('The annotation index in column '+JSON.stringify(name)+' exceeds the exact integer range.');
202
+ columns.push([name,match[1],index,match[3]]);
203
+ }
204
+ return columns;
205
+ }
206
+ /**
207
+ * Validate an utterances export and group its rows by session_id, each in sequence_id order.
208
+ * Callers pass parseInput rows (1–5,000 plain objects) or one plain raw_source_fields object.
209
+ */
210
+ function sandpiperSessions(rows) {
211
+ const sessions=new Map();
212
+ for(const [index,row]of rows.entries()){
213
+ const label='Row '+(index+1)+': ';
214
+ const missing=SANDPIPER_COLUMNS.filter(name=>!own(row,name));
215
+ if(missing.length)throw new Error(label+'missing Sandpiper utterance column '+missing.join(', ')+'.');
216
+ sandpiperAnnotationColumns(row);
217
+ for(const [name,value]of Object.entries(row))if(typeof value!=='string')throw new Error(label+'Sandpiper export cells must be text; '+name+' is not.');
218
+ if(!row.session_id.trim())throw new Error(label+'session_id must be nonempty text.');
219
+ if(!row.role)throw new Error(label+'role must be nonempty text.');
220
+ if(!/^[1-9][0-9]*$/.test(row.sequence_id))throw new Error(label+'sequence_id must be a positive integer string (1, 2, 3, ...).');
221
+ if(!sessions.has(row.session_id))sessions.set(row.session_id,[]);
222
+ const items=sessions.get(row.session_id);
223
+ if(items.some(([,other])=>other.sequence_id===row.sequence_id))throw new Error(label+'sequence_id must be unique within a session.');
224
+ items.push([index+1,row]);
225
+ }
226
+ for(const items of sessions.values())items.sort((a,b)=>sequenceOrder(a[1].sequence_id,b[1].sequence_id));
227
+ return sessions;
228
+ }
229
+ function sandpiperUtterance(row) {
230
+ const utterance=Object.fromEntries(SANDPIPER_COLUMNS.map(name=>[name,row[name]]));
231
+ // Sandpiper writes an empty cell for an absent time; time text is never parsed.
232
+ for(const name of ['start_time','end_time'])utterance[name]=row[name]===''?null:row[name];
233
+ return utterance;
234
+ }
235
+ /** Annotation codes are inferred assertions by a named annotator, never help events. */
236
+ function sandpiperAnnotations(row) {
237
+ const groups=new Map();
238
+ for(const [column,annotator,index,field]of sandpiperAnnotationColumns(row)){
239
+ const value=row[column];
240
+ if(value==='')continue;
241
+ const key=JSON.stringify([annotator,index]);
242
+ if(!groups.has(key))groups.set(key,{annotation:{basis:'inferred',annotator,index},fields:[],identifiedBy:null});
243
+ const group=groups.get(key);
244
+ if(field==='identifiedBy')group.identifiedBy=value;else group.fields.push([field,value]);
245
+ }
246
+ return [...groups.values()].map(({annotation,fields,identifiedBy})=>({...annotation,fields:Object.fromEntries(fields),...(identifiedBy===null?{}:{identifiedBy})}));
247
+ }
248
+ function verifySandpiperRecord(record) {
249
+ // verify() has already required source_platform sandpiper for this import mode.
250
+ const header=record.header;
251
+ if(header.assistance_coverage!==SANDPIPER_COVERAGE||header.event_order_basis!==SANDPIPER_ORDER)throw new Error('Sandpiper record coverage or order declaration differs.');
252
+ let previous=null;const sessions=new Set();
253
+ for(const entry of record.entries){
254
+ const sequence=entry.event.data.utterance.sequence_id;
255
+ sessions.add(entry.event.activity.id);
256
+ if(sessions.size!==1)throw new Error('A Sandpiper record holds one session, named by its activity.');
257
+ if(previous!==null&&sequenceOrder(sequence,previous)<=0)throw new Error('Sandpiper entries must be in strictly increasing sequence_id order.');
258
+ previous=sequence;
259
+ }
260
+ }
261
+ async function validateSandpiperEvent(event,data) {
262
+ const same=(value,keys)=>plain(value)&&canonicalize(Object.keys(value).sort())===canonicalize([...keys].sort());
263
+ if(event.name!==SANDPIPER_EVENT||!same(event,['name','salt','data','activity','actor'])||!same(event.activity,['id']))throw new Error('Invalid imported Sandpiper event shape.');
264
+ if(!same(data,['provenance','time_basis','source_time_basis','original_source_timestamp','utterance','inferred_annotations','assistance','assistance_stages','raw_source_fields','reported_metrics','missing_metrics','assistance_context'])||!same(data.provenance,['platform','source_sha256','source_row','source_record_sha256','imported_at','mapping_sha256','source_kind']))throw new Error('Invalid imported Sandpiper data shape.');
265
+ if(data.provenance.source_kind==='verified_public_sample')throw new Error('No Sandpiper input is a verified public sample.');
266
+ if(data.source_time_basis!=='source_strings_unparsed'||data.original_source_timestamp!==null||data.assistance!=='utterance_recorded_no_help_stage'||canonicalize(data.assistance_stages)!==canonicalize(Object.fromEntries(HELP_STAGES.map(stage=>[stage,'not_recorded'])))||Object.keys(data.reported_metrics).length||data.missing_metrics.length)throw new Error('Invalid imported Sandpiper semantics: annotation codes never set a help stage.');
267
+ const raw=data.raw_source_fields;
268
+ try{sandpiperSessions([raw]);}catch(error){throw new Error('Sandpiper raw source row is not a valid utterance row: '+error.message);}
269
+ if(data.provenance.source_record_sha256!==await digest(raw))throw new Error('Sandpiper raw source row differs from its recorded digest.');
270
+ if(canonicalize(data.utterance)!==canonicalize(sandpiperUtterance(raw))||canonicalize(data.inferred_annotations)!==canonicalize(sandpiperAnnotations(raw)))throw new Error('Sandpiper utterance or inferred annotations differ from the raw source row.');
271
+ if(event.activity.id!==raw.session_id)throw new Error('A Sandpiper record holds one session, named by its activity.');
272
+ }
273
+ export async function transform({profileId,text,secret=SESSION_SECRET,tenant='demo',sourceName='input.json',sourceKind='unverified_user_input',importedAt=new Date().toISOString().replace('.000Z','Z')}) {
274
+ const profile=getProfile(profileId),mapping=profile.mapping,rows=parseInput(text,sourceName);
275
+ if(typeof tenant!=='string'||!tenant.trim())throw new Error('A tenant scope is required.');
276
+ timestamp(importedAt);
277
+ const sourceHash=await sha256(text),mappingHash=await digest(mapping),semanticContract=getSemanticContract(profileId),semanticProfile=semanticProfileFor(profileId);
278
+ const eventSchema=profile.mode==='assistance_events'?LIFECYCLE_EVENT_SCHEMA:profile.mode==='annotated_dialogue'?SANDPIPER_EVENT_SCHEMA:IMPORT_EVENT_SCHEMA;
279
+ const semanticBinding={id:semanticProfile.profile_id,version:semanticProfile.version,sha256:await digest(semanticProfile),hash_basis:'RFC8785'};
280
+ const eventSchemaBinding={id:eventSchema.$id,sha256:await digest(eventSchema),hash_basis:'RFC8785'};
281
+ const sourceVerified=!!profile.source?.sampleSha256&&sourceHash===profile.source.sampleSha256;
282
+ // Caller descriptions cannot establish published provenance. Only exact public
283
+ // sample bytes bind this import to the pinned, independently inspected source.
284
+ const verifiedKind=sourceVerified?'verified_public_sample':profile.source?.fixtureSha256===sourceHash?'synthetic_fixture':'unverified_user_input';
285
+ const publishedSource=sourceVerified?sourceDetails(profile):null;
286
+ if(profile.mode==='annotated_dialogue'){
287
+ const records=[];
288
+ for(const [session,items]of sandpiperSessions(rows)){
289
+ const record={header:{record_id:crypto.randomUUID(),subject:await subjectId(profileId,tenant,session,secret),hash_alg:'sha-256',created:importedAt,producer:{name:'QLM-transformer research adapters',version:ENGINE_VERSION},source_platform:profileId,source_sha256:sourceHash,mapping_sha256:mappingHash,import_mode:profile.mode,source_kind:verifiedKind,subject_scope:profile.subjectScope,record_scope:profile.recordScope,semantic_profile_binding:semanticBinding,event_schema_bindings:[eventSchemaBinding],semantic_mapping:semanticContract,assistance_coverage:SANDPIPER_COVERAGE,event_order_basis:SANDPIPER_ORDER},event_schemas:[eventSchema.$id],entries:[]};
290
+ const recordHash=await digest(record.header);
291
+ for(const [sourceRow,row]of items){
292
+ const event={name:SANDPIPER_EVENT,salt:randomSalt(),data:{provenance:{platform:profileId,source_kind:verifiedKind,source_sha256:sourceHash,source_row:sourceRow,source_record_sha256:await digest(row),imported_at:importedAt,mapping_sha256:mappingHash},time_basis:'import_time',source_time_basis:'source_strings_unparsed',original_source_timestamp:null,utterance:sandpiperUtterance(row),inferred_annotations:sandpiperAnnotations(row),assistance:'utterance_recorded_no_help_stage',assistance_stages:Object.fromEntries(HELP_STAGES.map(stage=>[stage,'not_recorded'])),raw_source_fields:{...row},reported_metrics:{},missing_metrics:[],assistance_context:normalizeContext()},activity:{id:session},actor:{role:'system',kind:'software',id:'QLM-transformer'}};
293
+ const entry={seq:record.entries.length+1,time:importedAt,event_hash:await digest(event),prev_hash:record.entries.length?record.entries.at(-1).entry_hash:recordHash};
294
+ entry.entry_hash=await digest({record_hash:recordHash,...entry});entry.event=event;record.entries.push(entry);
295
+ }
296
+ records.push(record);
297
+ }
298
+ const document={file_schema:'urn:ietf:params:learnlog:file:contained',serialization_format:'application/learnlog+json',records};
299
+ const checkpoint=await verify(document),eventCount=records.reduce((n,record)=>n+record.entries.length,0);
300
+ const summary={inputRowCount:rows.length,rowCount:eventCount,recordCount:records.length,eventCount,unit:'transcript_utterances',eventTypes:{[SANDPIPER_EVENT]:eventCount},missingMetricCount:0,assistanceUnknownCount:0};
301
+ return {id:crypto.randomUUID(),profile,semanticContract,sourceNamespace:profileId,rows,document,records,checkpoint,sourceName,sourceHash,mappingHash,sourceKind:verifiedKind,sourceVerified,requestedSourceKind:sourceKind,summary};
302
+ }
303
+ if(profile.mode==='dialogue') {
304
+ for(const [index,row]of rows.entries())validateTurns(row,index);
305
+ if(rows.reduce((n,row)=>n+row.turns.length,0)>5000)throw new Error('This browser demo accepts at most 5,000 dialogue turns.');
306
+ }
307
+ if(profile.mode==='assistance_events'){
308
+ const identities=new Set();
309
+ for(const [index,row]of rows.entries()){
310
+ validateLifecycle(row,index);
311
+ const identity=JSON.stringify([row.source_id,row.event_id,row.source_revision]);
312
+ if(identities.has(identity))throw new Error('Repeated native event identity and revision in one input; use the cumulative ledger to reconcile batches.');
313
+ identities.add(identity);
314
+ }
315
+ if(new Set(rows.map(row=>row.source_id)).size!==1)throw new Error('Use one source_id per input to preserve source-specific mapping review and strata.');
316
+ for(const row of rows){if(row.request_event_id){const requests=rows.filter(candidate=>candidate.event_id===row.request_event_id&&candidate.source_id===row.source_id);if(requests.some(request=>request.event_type!=='help_requested'||request.activity_id!==row.activity_id||request.subject_id!==row.subject_id))throw new Error('Request link must identify a same-learner, same-activity explicit help request.');}}
317
+ }
318
+ const records=[];
319
+ for(const [index,row]of rows.entries()) {
320
+ const identifier=mappedField(row,mapping.fields.subject_id,index,'learner identifier',{identifier:true});
321
+ const sourceNamespace=profile.mode==='assistance_events'?row.source_id:null;
322
+ const subject=await subjectId(sourceNamespace?profileId+':'+sourceNamespace:profileId,tenant,identifier,secret);
323
+ const activity=mappedField(row,mapping.fields.activity_id,index,'activity');
324
+ if(typeof activity!=='string'||!activity)throw new Error('Row '+(index+1)+': this source adapter requires a nonempty text activity label.');
325
+ const record={header:{record_id:crypto.randomUUID(),subject,hash_alg:'sha-256',created:importedAt,producer:{name:'QLM-transformer research adapters',version:ENGINE_VERSION},source_platform:profileId,source_sha256:sourceHash,mapping_sha256:mappingHash,import_mode:profile.mode,source_kind:verifiedKind,subject_scope:profile.subjectScope||'dataset + tenant + key',record_scope:profile.recordScope||(profile.mode==='dialogue'?'transcript_session':'participant_summary'),semantic_profile_binding:semanticBinding,event_schema_bindings:[eventSchemaBinding],semantic_mapping:semanticContract,assistance_coverage:!sourceVerified?'actual_help_use_not_established_by_this_adapter':profile.mode==='dialogue'?'source_dialogue_only_no_validated_help_classification':'actual_help_use_not_reported_in_participant_table',event_order_basis:profile.mode==='dialogue'?'source_turn_array_order':'input_row_order_not_independently_verified'},event_schemas:[eventSchema.$id],entries:[]};
326
+ if(sourceNamespace)record.header.source_namespace=sourceNamespace;
327
+ if(publishedSource)record.header.source_provenance=publishedSource;
328
+ const recordHash=await digest(record.header),sourceRecordHash=await digest(row);
329
+ const baseProvenance={platform:profileId,source_kind:verifiedKind,source_sha256:sourceHash,source_row:index+1,source_record_sha256:sourceRecordHash,imported_at:importedAt,mapping_sha256:mappingHash};
330
+ if(publishedSource){baseProvenance.source_file_sha256=publishedSource.sourceSha256;if(publishedSource.sourceRows?.[index]!==undefined)baseProvenance.source_file_row=publishedSource.sourceRows[index];}
331
+ const observations=profile.mode==='dialogue'?row.turns:[row];
332
+ for(const [observationIndex,observation]of observations.entries()) {
333
+ let event;
334
+ if(profile.mode==='dialogue') {
335
+ const dialogue={};
336
+ for(const key of ['role','text','turn_number','start_seconds','end_seconds'])dialogue[key]=observation[key];
337
+ event={name:'qlm_import:dialogue_turn_observed',salt:randomSalt(),data:{provenance:{...baseProvenance,source_field_path:'turns['+observationIndex+']'},time_basis:'import_time',source_time_basis:'relative_offsets_seconds',dialogue,raw_source_fields:dialogueFields(row,observation),reported_metrics:{},missing_metrics:[],assistance:observation.role==='Tutor'?'tutor_utterance_recorded':'student_utterance_recorded'},activity:{id:String(activity)}};
338
+ // Actor describes the present importer; the historical speaker remains source data.
339
+ } else if(profile.mode==='assistance_events') {
340
+ const lifecycle=Object.fromEntries(['source_id','event_id','source_revision','event_type'].map(key=>[key,row[key]]));
341
+ Object.assign(lifecycle,{source_timestamp:row.source_timestamp??null,assistance_id:row.assistance_id??null,request_event_id:row.request_event_id??null,request_origin:row.request_origin??'unknown',native_hint_depth:row.native_hint_depth??null,delivery_order:row.delivery_order??null});
342
+ if(Object.hasOwn(row,'content'))lifecycle.content=row.content;
343
+ const nativeIdentity=await digest([profileId,row.source_id,row.event_id]);
344
+ event={name:'qlm_import:assistance_lifecycle_observed',salt:randomSalt(),data:{provenance:{...baseProvenance,native_identity_sha256:nativeIdentity,source_revision:row.source_revision},time_basis:'import_time',original_source_timestamp:row.source_timestamp??null,lifecycle,assistance_context:normalizeContext(row.context===undefined?{}:row.context),raw_source_fields:{...lifecycle,context:row.context===undefined?{}:row.context},reported_metrics:{},missing_metrics:[],assistance:row.event_type},activity:{id:String(activity)}};
345
+ } else {
346
+ if(typeof row.condition!=='string'||!['ChatGPT','Human tutor','No-hint control'].includes(row.condition))throw new Error('Row '+(index+1)+': the OATutor adapter requires a documented assigned condition (ChatGPT, Human tutor or No-hint control).');
347
+ const reported_metrics=Object.create(null),missing_metrics=[];
348
+ for(const [label,column]of Object.entries(mapping.metrics)){
349
+ const field=fieldAt(row,column);
350
+ if(field.present&&field.value!==''&&field.value!==null)reported_metrics[label]=field.value;else missing_metrics.push(label);
351
+ }
352
+ const raw_source_fields=Object.fromEntries(['lesson','condition','preTest','postTest','sessionTime','totalUniqueSteps','learningGain'].filter(key=>own(row,key)).map(key=>[key,row[key]]));
353
+ const help_evidence={condition:row.condition,actual_help_use:sourceVerified?'not_reported_in_participant_table':'not_established_by_this_adapter'};
354
+ event={name:'qlm_import:participant_summary_observed',salt:randomSalt(),data:{provenance:{...baseProvenance},time_basis:'import_time',reported_metrics,missing_metrics,raw_source_fields,help_evidence,assistance:'assigned_condition_only'},actor:{role:'system',kind:'software',id:'QLM-transformer'},activity:{id:String(activity)}};
355
+ }
356
+ event.actor={role:'system',kind:'software',id:'QLM-transformer'};
357
+ if(profile.mode!=='assistance_events'){event.data.original_source_timestamp=null;event.data.assistance_context=normalizeContext();}
358
+ const entry={seq:record.entries.length+1,time:importedAt,event_hash:await digest(event),prev_hash:record.entries.length?record.entries.at(-1).entry_hash:recordHash};
359
+ entry.entry_hash=await digest({record_hash:recordHash,...entry});entry.event=event;record.entries.push(entry);
360
+ }
361
+ records.push(record);
362
+ }
363
+ const document={file_schema:'urn:ietf:params:learnlog:file:contained',serialization_format:'application/learnlog+json',records};
364
+ const checkpoint=await verify(document),entries=records.flatMap(r=>r.entries),eventTypes={};
365
+ for(const entry of entries)eventTypes[entry.event.name]=(eventTypes[entry.event.name]||0)+1;
366
+ const summary={inputRowCount:rows.length,rowCount:entries.length,recordCount:records.length,eventCount:entries.length,unit:profile.mode==='dialogue'?'dialogue_turns':profile.mode==='assistance_events'?'assistance_lifecycle_events':'participant_summaries',eventTypes,missingMetricCount:entries.reduce((n,e)=>n+e.event.data.missing_metrics.length,0),assistanceUnknownCount:profile.mode==='summary'?entries.length:0};
367
+ return {id:crypto.randomUUID(),profile,semanticContract,sourceNamespace:profile.mode==='assistance_events'?rows[0].source_id:profileId,rows,document,records,checkpoint,sourceName,sourceHash,mappingHash,sourceKind:verifiedKind,sourceVerified,requestedSourceKind:sourceKind,summary};
368
+ }
369
+ async function validateImportedEvent(event,header,entryTime) {
370
+ if(!plain(event)||!plain(event.data)||!plain(event.activity)||typeof event.activity.id!=='string'||!event.activity.id.trim())throw new Error('Invalid imported event activity or payload.');
371
+ if(canonicalize(event.actor)!==canonicalize({role:'system',kind:'software',id:'QLM-transformer'}))throw new Error('Imported event actor must describe the importer software.');
372
+ const data=event.data,p=data.provenance;
373
+ if(!plain(p)||p.source_kind!==header.source_kind||p.platform!==header.source_platform||p.mapping_sha256!==header.mapping_sha256||p.source_sha256!==header.source_sha256||!Number.isSafeInteger(p.source_row)||p.source_row<1||p.imported_at!==entryTime||!['verified_public_sample','unverified_user_input','synthetic_fixture'].includes(p.source_kind))throw new Error('Invalid import provenance.');
374
+ for(const key of ['source_sha256','mapping_sha256','source_record_sha256'])if(typeof p[key]!=='string'||!/^[a-f0-9]{64}$/.test(p[key]))throw new Error('Invalid import provenance digest.');
375
+ if(data.time_basis!=='import_time'||!plain(data.raw_source_fields)||!plain(data.reported_metrics)||!Array.isArray(data.missing_metrics)||data.missing_metrics.some(value=>typeof value!=='string')||new Set(data.missing_metrics).size!==data.missing_metrics.length)throw new Error('Invalid import data structure.');
376
+ if(!plain(data.assistance_context)||canonicalize(data.assistance_context)!==canonicalize(normalizeContext(data.assistance_context)))throw new Error('Imported assistance context must include the complete normalized context.');
377
+ if(header.import_mode==='dialogue'){
378
+ if(event.name!=='qlm_import:dialogue_turn_observed'||!plain(data.dialogue)||Object.keys(data.dialogue).sort().join(',')!==['role','text','turn_number','start_seconds','end_seconds'].sort().join(','))throw new Error('Invalid imported dialogue shape.');
379
+ validateTurns({turns:[data.dialogue]},0);
380
+ if(data.original_source_timestamp!==null||data.source_time_basis!=='relative_offsets_seconds'||Object.keys(data.reported_metrics).length||data.missing_metrics.length||data.assistance!==(data.dialogue.role==='Tutor'?'tutor_utterance_recorded':'student_utterance_recorded'))throw new Error('Invalid imported dialogue semantics.');
381
+ }else if(header.import_mode==='summary'){
382
+ if(event.name!=='qlm_import:participant_summary_observed'||data.original_source_timestamp!==null||data.assistance!=='assigned_condition_only'||!plain(data.help_evidence)||!['ChatGPT','Human tutor','No-hint control'].includes(data.help_evidence.condition)||!['not_reported_in_participant_table','not_established_by_this_adapter'].includes(data.help_evidence.actual_help_use))throw new Error('Invalid imported participant summary semantics.');
383
+ }else if(header.import_mode==='assistance_events'){
384
+ const lifecycle=data.lifecycle;
385
+ if(event.name!=='qlm_import:assistance_lifecycle_observed'||!plain(lifecycle)||!['source_id','event_id','source_revision','event_type','source_timestamp','assistance_id','request_event_id','request_origin','native_hint_depth','delivery_order'].every(key=>own(lifecycle,key))||Object.keys(lifecycle).some(key=>!['source_id','event_id','source_revision','event_type','source_timestamp','assistance_id','request_event_id','request_origin','native_hint_depth','delivery_order','content'].includes(key)))throw new Error('Invalid imported lifecycle shape.');
386
+ validateLifecycle({...lifecycle,subject_id:header.subject,activity_id:event.activity.id,context:data.assistance_context},0);
387
+ if(p.native_identity_sha256!==await digest([header.source_platform,lifecycle.source_id,lifecycle.event_id])||p.source_revision!==lifecycle.source_revision)throw new Error('Native event identity or revision contradicts import provenance.');
388
+ if(data.assistance!==lifecycle.event_type||data.original_source_timestamp!==lifecycle.source_timestamp||lifecycle.source_id!==header.source_namespace||Object.keys(data.reported_metrics).length||data.missing_metrics.length)throw new Error('Invalid imported lifecycle semantics.');
389
+ }else if(header.import_mode==='annotated_dialogue'){
390
+ await validateSandpiperEvent(event,data);
391
+ }else throw new Error('Unsupported import mode.');
392
+ }
393
+ export async function verify(document,expectedHeads) {
394
+ if(!plain(document)||document.file_schema!=='urn:ietf:params:learnlog:file:contained'||document.serialization_format!=='application/learnlog+json'||!Array.isArray(document.records)||!document.records.length)throw new Error('Unsupported LearnLog document.');
395
+ const heads=Object.create(null);
396
+ for(const record of document.records){
397
+ if(!plain(record.header)||typeof record.header.record_id!=='string'||!record.header.record_id.trim()||record.header.hash_alg!=='sha-256'||!Array.isArray(record.entries)||!record.entries.length)throw new Error('Invalid LearnLog record.');
398
+ if('truncated' in record||'signed_heads' in record)throw new Error('This browser subset does not verify signatures or truncated records.');
399
+ timestamp(record.header.created);
400
+ if(own(heads,record.header.record_id))throw new Error('Duplicate record identifier.');
401
+ // Each platform has exactly one accepted profile binding: the version that introduced its contract.
402
+ const semantic=record.header.semantic_profile_binding;
403
+ let expectedProfile=null;try{expectedProfile=semanticProfileFor(record.header.source_platform);}catch{expectedProfile=null;}
404
+ if(!expectedProfile||!plain(semantic)||semantic.id!==expectedProfile.profile_id||semantic.version!==expectedProfile.version||semantic.hash_basis!=='RFC8785'||semantic.sha256!==await digest(expectedProfile))throw new Error('Unsupported or mismatched semantic profile binding.');
405
+ if((record.header.source_platform==='sandpiper')!==(record.header.import_mode==='annotated_dialogue'))throw new Error('Sandpiper records require the annotated_dialogue import mode.');
406
+ const schema=record.header.import_mode==='assistance_events'?LIFECYCLE_EVENT_SCHEMA:record.header.import_mode==='annotated_dialogue'?SANDPIPER_EVENT_SCHEMA:IMPORT_EVENT_SCHEMA;
407
+ const bindings=record.header.event_schema_bindings;
408
+ if(!Array.isArray(bindings)||bindings.length!==1||bindings[0].id!==schema.$id||bindings[0].hash_basis!=='RFC8785'||bindings[0].sha256!==await digest(schema)||canonicalize(record.event_schemas)!==canonicalize([schema.$id]))throw new Error('Unsupported or mismatched event schema binding.');
409
+ if(canonicalize(record.header.semantic_mapping)!==canonicalize(getSemanticContract(record.header.source_platform)))throw new Error('Semantic mapping does not match the versioned profile.');
410
+ const recordHash=await digest(record.header);let prev=recordHash;
411
+ for(let i=0;i<record.entries.length;i++){
412
+ const entry=record.entries[i];
413
+ if(entry.seq!==i+1||entry.prev_hash!==prev)throw new Error('Broken LearnLog sequence or previous hash.');
414
+ timestamp(entry.time);
415
+ if(!plain(entry.event)||typeof entry.event.name!=='string'||!entry.event.name.trim()||typeof entry.event.salt!=='string'||!entry.event.salt||!plain(entry.event.data)||'redacted' in entry)throw new Error('This browser subset requires complete, structured events.');
416
+ if(entry.event.actor&&(!plain(entry.event.actor)||typeof entry.event.actor.role!=='string'||!entry.event.actor.role.trim()||!['human','software'].includes(entry.event.actor.kind)||('id' in entry.event.actor&&(typeof entry.event.actor.id!=='string'||!entry.event.actor.id.trim()))))throw new Error('Invalid event actor.');
417
+ await validateImportedEvent(entry.event,record.header,entry.time);
418
+ if(!entry.event||await digest(entry.event)!==entry.event_hash)throw new Error('LearnLog event hash mismatch.');
419
+ const computed=await digest({record_hash:recordHash,seq:entry.seq,time:entry.time,event_hash:entry.event_hash,prev_hash:entry.prev_hash});
420
+ if(computed!==entry.entry_hash)throw new Error('LearnLog entry hash mismatch.');prev=computed;
421
+ }
422
+ if(record.header.import_mode==='annotated_dialogue')verifySandpiperRecord(record);
423
+ heads[record.header.record_id]={seq:record.entries.length,entry_hash:prev};
424
+ }
425
+ if(expectedHeads&&canonicalize(heads)!==canonicalize(expectedHeads))throw new Error('External checkpoint mismatch.');
426
+ return heads;
427
+ }
428
+ export function aggregate(runs) {
429
+ const products=new Map();
430
+ for(const run of runs){
431
+ const platform=run.profile.id,stratum=platform+'\u0000'+(run.sourceNamespace||platform);
432
+ if(!products.has(stratum))products.set(stratum,{platform,sourceNamespace:run.sourceNamespace||platform,name:run.profile.name,mode:run.profile.mode,unit:run.summary.unit,inputRowCount:0,rowCount:0,recordCount:0,eventCount:0,missingMetricCount:0,assistanceUnknownCount:0,eventTypes:{},metricSummaries:[],activities:[]});
433
+ const product=products.get(stratum);
434
+ for(const key of ['inputRowCount','rowCount','recordCount','eventCount','missingMetricCount','assistanceUnknownCount'])product[key]+=run.summary[key];
435
+ for(const [name,count]of Object.entries(run.summary.eventTypes))product.eventTypes[name]=(product.eventTypes[name]||0)+count;
436
+ for(const [name]of Object.entries(run.profile.metrics)){
437
+ let metric=product.metricSummaries.find(m=>m.name===name);if(!metric){metric={name,values:[],count:0,missing:0};product.metricSummaries.push(metric);}
438
+ for(const record of run.records)for(const entry of record.entries){const d=entry.event.data;if(own(d.reported_metrics,name)){metric.values.push(d.reported_metrics[name]);metric.count++;}else metric.missing++;}
439
+ }
440
+ for(const record of run.records)for(const entry of record.entries){const id=entry.event.activity?.id||'Unspecified';let a=product.activities.find(a=>a.id===id);if(!a){a={id,count:0};product.activities.push(a);}a.count++;}
441
+ }
442
+ const list=[...products.values()],eventTypes={};
443
+ for(const p of list)for(const [name,count]of Object.entries(p.eventTypes))eventTypes[name]=(eventTypes[name]||0)+count;
444
+ return {inputRowCount:list.reduce((n,p)=>n+p.inputRowCount,0),rowCount:list.reduce((n,p)=>n+p.rowCount,0),recordCount:list.reduce((n,p)=>n+p.recordCount,0),eventCount:list.reduce((n,p)=>n+p.eventCount,0),productCount:list.length,products:list,eventTypes,assistanceUnknownCount:list.reduce((n,p)=>n+p.assistanceUnknownCount,0),missingMetricCount:list.reduce((n,p)=>n+p.missingMetricCount,0)};
445
+ }