@world-engines/scenario-review-snapshot 0.0.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.
@@ -0,0 +1,701 @@
1
+ import { appendJsonPointer, canonicalJson, compareUtf8, parseJsonPointer, sha256Hex, toJsonValue, utf8ByteLength, } from "./canonical.js";
2
+ export const DEFAULT_SCENARIO_REVIEW_SNAPSHOT_LIMITS = Object.freeze({
3
+ max_chunk_bytes: 48 * 1024,
4
+ max_overlap_bytes: 4 * 1024,
5
+ max_snapshot_bytes: 16 * 1024 * 1024,
6
+ max_records: 200_000,
7
+ max_fragments: 1_000_000,
8
+ max_chunks: 4_096,
9
+ max_value_depth: 64,
10
+ });
11
+ const SECTION_ORDER = [
12
+ "metadata", "world", "triggers", "view_mobile", "view_pc",
13
+ ];
14
+ const RECORD_KIND_ORDER = ["meta", "node", "attr", "edge", "trigger", "view"];
15
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
16
+ const TECHNICAL_NODE_FIELDS = new Set(["embedding", "embeddingHash", "position", "needs_review"]);
17
+ const TECHNICAL_EDGE_FIELDS = new Set(["embedding", "embeddingHash", "needs_review"]);
18
+ const TECHNICAL_ATTR_FIELDS = new Set(["embedding", "embeddingHash", "position", "needs_review"]);
19
+ function isTechnicalAttrField(key) {
20
+ return TECHNICAL_ATTR_FIELDS.has(key) || ["base64", "raw_base64", "vector"].includes(key) ||
21
+ key.endsWith("_base64") || key.endsWith("_embedding") || key.endsWith("_vector");
22
+ }
23
+ export function stableRelationId(edge) {
24
+ const object = requireObject(edge, "scenario_review_edge_object_required");
25
+ const existing = optionalNonEmptyString(object.relation_id);
26
+ if (existing)
27
+ return existing;
28
+ const from = requireNonEmptyString(object.from_id, "scenario_review_edge_from_id_required");
29
+ const kind = requireNonEmptyString(object.kind, "scenario_review_edge_kind_required");
30
+ const to = requireNonEmptyString(object.to_id, "scenario_review_edge_to_id_required");
31
+ return `relation_${sha256Hex(canonicalJson([from, kind, to])).slice(0, 32)}`;
32
+ }
33
+ export function buildScenarioReviewSnapshot(rawInput, rawLimits = {}) {
34
+ const limits = normalizeLimits(rawLimits);
35
+ validateEnvelope(rawInput);
36
+ const input = normalizeInput(rawInput, limits.max_value_depth);
37
+ const records = buildRecordStream(input);
38
+ if (records.length > limits.max_records)
39
+ throw new Error("scenario_review_record_limit_exceeded");
40
+ assertUnique(records.map((record) => record.record_id), "scenario_review_record_id_duplicate");
41
+ let recordStreamBytes = 0;
42
+ for (const record of records) {
43
+ recordStreamBytes = checkedAdd(recordStreamBytes, utf8ByteLength(canonicalRecordLine(record)) + 1);
44
+ if (recordStreamBytes > limits.max_snapshot_bytes) {
45
+ throw new Error("scenario_review_snapshot_size_limit_exceeded");
46
+ }
47
+ }
48
+ const fragments = [];
49
+ for (const record of records) {
50
+ fragments.push(...splitRecord(record, limits));
51
+ if (fragments.length > limits.max_fragments) {
52
+ throw new Error("scenario_review_fragment_limit_exceeded");
53
+ }
54
+ }
55
+ assertUnique(fragments.map((fragment) => fragment.fragment_id), "scenario_review_fragment_id_duplicate");
56
+ const chunks = buildChunks(fragments, limits);
57
+ if (chunks.length > limits.max_chunks)
58
+ throw new Error("scenario_review_chunk_limit_exceeded");
59
+ const coverage = buildCoverage(records, fragments, chunks);
60
+ validateCoverage(records, fragments, chunks, coverage);
61
+ const core = {
62
+ snapshot_schema_version: 1,
63
+ ticket_id: input.ticket_id,
64
+ scenario_id: input.scenario_id,
65
+ created_at: input.created_at,
66
+ canonical_artifact_digest: input.canonical_artifact_digest,
67
+ records,
68
+ fragments,
69
+ chunks,
70
+ coverage,
71
+ };
72
+ const snapshot_hash = sha256Hex(canonicalJson(toJsonValue(core)));
73
+ const snapshot = { ...core, snapshot_hash };
74
+ if (utf8ByteLength(canonicalJson(toJsonValue(snapshot))) > limits.max_snapshot_bytes) {
75
+ throw new Error("scenario_review_snapshot_size_limit_exceeded");
76
+ }
77
+ return snapshot;
78
+ }
79
+ export function reconstructRecordPayload(record, fragments) {
80
+ const selected = fragments.filter((fragment) => fragment.record_id === record.record_id);
81
+ if (selected.length === 0)
82
+ throw new Error("scenario_review_record_fragments_missing");
83
+ let root;
84
+ const strings = new Map();
85
+ for (const fragment of selected) {
86
+ if (fragment.value_kind === "string_segment") {
87
+ const list = strings.get(fragment.value_pointer) ?? [];
88
+ list.push(fragment);
89
+ strings.set(fragment.value_pointer, list);
90
+ continue;
91
+ }
92
+ const value = fragment.value_kind === "container"
93
+ ? fragment.container_kind === "array" ? [] : {}
94
+ : fragment.payload;
95
+ if (value === undefined)
96
+ throw new Error("scenario_review_fragment_payload_missing");
97
+ root = assignJsonPointer(root, fragment.value_pointer, value);
98
+ }
99
+ for (const [pointer, parts] of strings) {
100
+ parts.sort((a, b) => (a.string_offset_bytes ?? -1) - (b.string_offset_bytes ?? -1));
101
+ let expectedOffset = 0;
102
+ let total = -1;
103
+ let value = "";
104
+ for (const part of parts) {
105
+ if (part.string_offset_bytes !== expectedOffset || typeof part.string_segment !== "string") {
106
+ throw new Error("scenario_review_string_fragment_gap");
107
+ }
108
+ total = part.string_total_bytes ?? -1;
109
+ value += part.string_segment;
110
+ expectedOffset += utf8ByteLength(part.string_segment);
111
+ }
112
+ if (total !== expectedOffset)
113
+ throw new Error("scenario_review_string_fragment_incomplete");
114
+ root = assignJsonPointer(root, pointer, value);
115
+ }
116
+ if (root === undefined)
117
+ throw new Error("scenario_review_record_reconstruct_empty");
118
+ if (sha256Hex(canonicalJson(root)) !== record.canonical_payload_hash) {
119
+ throw new Error("scenario_review_record_reconstruct_hash_mismatch");
120
+ }
121
+ return root;
122
+ }
123
+ function normalizeInput(input, maxDepth) {
124
+ return {
125
+ ticket_id: input.ticket_id,
126
+ scenario_id: input.scenario_id,
127
+ created_at: input.created_at,
128
+ canonical_artifact_digest: input.canonical_artifact_digest,
129
+ metadata: toJsonValue(input.metadata, maxDepth),
130
+ nodes: input.nodes.map((value) => toJsonValue(value, maxDepth)),
131
+ edges: input.edges.map((value) => toJsonValue(value, maxDepth)),
132
+ triggers: input.triggers.map((value) => toJsonValue(value, maxDepth)),
133
+ ...(input.supplemental_records === undefined ? {} : {
134
+ supplemental_records: input.supplemental_records.map((record) => ({
135
+ record_kind: record.record_kind,
136
+ section: record.section,
137
+ record_id: record.record_id,
138
+ json_pointer: record.json_pointer,
139
+ payload: toJsonValue(record.payload, maxDepth),
140
+ })),
141
+ }),
142
+ view_publish_mobile: input.view_publish_mobile,
143
+ view_publish_pc: input.view_publish_pc,
144
+ };
145
+ }
146
+ function validateEnvelope(input) {
147
+ requireNonEmptyString(input.ticket_id, "scenario_review_ticket_id_required");
148
+ requireNonEmptyString(input.scenario_id, "scenario_review_scenario_id_required");
149
+ if (!Number.isSafeInteger(input.created_at) || input.created_at < 0) {
150
+ throw new Error("scenario_review_created_at_invalid");
151
+ }
152
+ if (!SHA256_PATTERN.test(input.canonical_artifact_digest)) {
153
+ throw new Error("scenario_review_artifact_digest_invalid");
154
+ }
155
+ if (typeof input.view_publish_mobile !== "string" || typeof input.view_publish_pc !== "string") {
156
+ throw new Error("scenario_review_view_text_required");
157
+ }
158
+ }
159
+ function normalizeLimits(raw) {
160
+ const limits = { ...DEFAULT_SCENARIO_REVIEW_SNAPSHOT_LIMITS, ...raw };
161
+ for (const [key, value] of Object.entries(limits)) {
162
+ if (!Number.isSafeInteger(value) || value <= 0)
163
+ throw new Error(`scenario_review_${key}_invalid`);
164
+ }
165
+ if (limits.max_overlap_bytes >= limits.max_chunk_bytes) {
166
+ throw new Error("scenario_review_overlap_limit_invalid");
167
+ }
168
+ return limits;
169
+ }
170
+ function buildRecordStream(input) {
171
+ const records = [];
172
+ let sourceDocumentCount = 0;
173
+ let sourceChunkCount = 0;
174
+ let sourceRawBytes = 0;
175
+ records.push(createRecord("meta", "metadata", "meta", "/metadata", input.metadata));
176
+ for (const record of input.supplemental_records ?? []) {
177
+ validateRecordKindSection(record.record_kind, record.section);
178
+ parseJsonPointer(record.json_pointer);
179
+ records.push(createRecord(record.record_kind, record.section, requireNonEmptyString(record.record_id, "scenario_review_record_id_required"), record.json_pointer, record.payload));
180
+ }
181
+ const nodes = input.nodes.map((node) => {
182
+ const object = requireObject(node, "scenario_review_node_object_required");
183
+ const id = requireNonEmptyString(object.entity_id, "scenario_review_node_id_required");
184
+ return { id, object };
185
+ }).sort((a, b) => compareUtf8(a.id, b.id));
186
+ assertUnique(nodes.map((node) => node.id), "scenario_review_node_id_duplicate");
187
+ for (const { id, object } of nodes) {
188
+ const kind = requireNonEmptyString(object.kind, "scenario_review_node_kind_required");
189
+ const props = object.props === undefined ? {} : requireObject(object.props, "scenario_review_node_props_object_required");
190
+ const nodePayload = {};
191
+ for (const key of Object.keys(object).sort(compareUtf8)) {
192
+ if (key !== "props" && !TECHNICAL_NODE_FIELDS.has(key))
193
+ nodePayload[key] = object[key];
194
+ }
195
+ const nodePointer = appendJsonPointer(sourceNodePointerRoot(kind), id);
196
+ records.push(createRecord("node", "world", `node:${id}`, nodePointer, nodePayload));
197
+ const decodedSource = sourceNodePointerRoot(kind) !== "/nodes"
198
+ ? decodeSourceDocument(props)
199
+ : undefined;
200
+ if (decodedSource !== undefined) {
201
+ if (sourceNodePointerRoot(kind) === "/source/documents") {
202
+ sourceDocumentCount += 1;
203
+ sourceRawBytes = checkedAdd(sourceRawBytes, decodedSource.sizeBytes);
204
+ }
205
+ records.push(createRecord("attr", "world", `attr:${id}:source-text`, appendJsonPointer(nodePointer, "source_text"), decodedSource.text));
206
+ records.push(createRecord("attr", "world", `attr:${id}:source-identity`, appendJsonPointer(nodePointer, "source_identity"), {
207
+ encoding: "base64",
208
+ sha256: decodedSource.sha256,
209
+ size_bytes: decodedSource.sizeBytes,
210
+ }));
211
+ }
212
+ if (sourceNodePointerRoot(kind) === "/source/chunks")
213
+ sourceChunkCount += 1;
214
+ for (const key of Object.keys(props).sort(compareUtf8)) {
215
+ if (isTechnicalAttrField(key) ||
216
+ decodedSource !== undefined && (key === "raw_base64" || key === "sha256"))
217
+ continue;
218
+ appendLeafRecords(records, `attr:${id}`, appendJsonPointer(appendJsonPointer(nodePointer, "props"), key), props[key]);
219
+ }
220
+ }
221
+ records.push(createRecord("meta", "metadata", "database:source-coverage-input", "/database/source_coverage_input", {
222
+ source_chunk_count: sourceChunkCount,
223
+ source_document_count: sourceDocumentCount,
224
+ source_raw_bytes: sourceRawBytes,
225
+ }));
226
+ const edges = input.edges.map((edge) => {
227
+ const object = requireObject(edge, "scenario_review_edge_object_required");
228
+ const id = stableRelationId(object);
229
+ const payload = { ...object, relation_id: id };
230
+ for (const field of TECHNICAL_EDGE_FIELDS)
231
+ delete payload[field];
232
+ const nested = [];
233
+ for (const field of ["props", "fields", "trigger", "source_refs"]) {
234
+ const value = payload[field];
235
+ if (value !== undefined) {
236
+ nested.push([field, value]);
237
+ delete payload[field];
238
+ }
239
+ }
240
+ const kind = requireNonEmptyString(payload.kind, "scenario_review_edge_kind_required");
241
+ const pointerRoot = sourceNodePointerRoot(kind) === "/nodes" ? "/edges" : "/source/relations";
242
+ return { id, payload, nested, pointerRoot };
243
+ }).sort((a, b) => compareUtf8(a.id, b.id));
244
+ assertUnique(edges.map((edge) => edge.id), "scenario_review_relation_id_duplicate");
245
+ for (const edge of edges) {
246
+ const edgePointer = appendJsonPointer(edge.pointerRoot, edge.id);
247
+ records.push(createRecord("edge", "world", `edge:${edge.id}`, edgePointer, edge.payload));
248
+ for (const [field, value] of edge.nested) {
249
+ appendLeafRecords(records, `edge-attr:${edge.id}`, appendJsonPointer(edgePointer, field), value);
250
+ }
251
+ }
252
+ const triggers = input.triggers.map((trigger) => {
253
+ const object = requireObject(trigger, "scenario_review_trigger_object_required");
254
+ const id = optionalNonEmptyString(object.trigger_id) ?? optionalNonEmptyString(object.id);
255
+ if (!id)
256
+ throw new Error("scenario_review_trigger_id_required");
257
+ return { id, object };
258
+ }).sort((a, b) => compareUtf8(a.id, b.id));
259
+ assertUnique(triggers.map((trigger) => trigger.id), "scenario_review_trigger_id_duplicate");
260
+ for (const trigger of triggers) {
261
+ records.push(createRecord("trigger", "triggers", `trigger:${trigger.id}`, appendJsonPointer("/triggers", trigger.id), trigger.object));
262
+ }
263
+ records.push(createRecord("view", "view_mobile", "view:mobile", "/views/mobile", input.view_publish_mobile));
264
+ records.push(createRecord("view", "view_pc", "view:pc", "/views/pc", input.view_publish_pc));
265
+ return records.sort(compareRecords);
266
+ }
267
+ function sourceNodePointerRoot(kind) {
268
+ const normalized = kind.toLocaleLowerCase("en-US").replace(/[- /]/gu, "_");
269
+ if (normalized === "source_document" || normalized.endsWith("_source_document"))
270
+ return "/source/documents";
271
+ if (normalized === "source_chunk" || normalized.endsWith("_source_chunk"))
272
+ return "/source/chunks";
273
+ if (normalized === "control" || normalized.startsWith("source_") ||
274
+ normalized.startsWith("world_source_") || normalized.endsWith("_control"))
275
+ return "/source/control";
276
+ return "/nodes";
277
+ }
278
+ function decodeSourceDocument(props) {
279
+ const encodedValue = props.raw_base64;
280
+ if (encodedValue === undefined)
281
+ return undefined;
282
+ const encoded = sourcePropString(encodedValue);
283
+ if (encoded === undefined)
284
+ throw new Error("frozen_review_source_raw_base64_invalid");
285
+ const expectedSha256 = sourcePropString(props.sha256);
286
+ if (expectedSha256 === undefined || !SHA256_PATTERN.test(expectedSha256)) {
287
+ throw new Error("frozen_review_source_raw_sha256_invalid");
288
+ }
289
+ let binary;
290
+ try {
291
+ binary = atob(encoded);
292
+ }
293
+ catch {
294
+ throw new Error("frozen_review_source_raw_base64_invalid");
295
+ }
296
+ if (btoa(binary) !== encoded)
297
+ throw new Error("frozen_review_source_raw_base64_noncanonical");
298
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
299
+ if (sha256Hex(bytes) !== expectedSha256)
300
+ throw new Error("frozen_review_source_raw_sha256_mismatch");
301
+ let text;
302
+ try {
303
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
304
+ }
305
+ catch {
306
+ throw new Error("frozen_review_source_raw_utf8_invalid");
307
+ }
308
+ return { text, sha256: expectedSha256, sizeBytes: bytes.byteLength };
309
+ }
310
+ function sourcePropString(value) {
311
+ if (typeof value === "string")
312
+ return value;
313
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.value === "string") {
314
+ return value.value;
315
+ }
316
+ return undefined;
317
+ }
318
+ function appendLeafRecords(records, recordIdPrefix, pointer, value) {
319
+ if (Array.isArray(value)) {
320
+ if (value.length === 0) {
321
+ records.push(createRecord("attr", "world", `${recordIdPrefix}:${pointer}`, pointer, value));
322
+ return;
323
+ }
324
+ value.forEach((item, index) => appendLeafRecords(records, recordIdPrefix, appendJsonPointer(pointer, index), item));
325
+ return;
326
+ }
327
+ if (value !== null && typeof value === "object") {
328
+ const keys = Object.keys(value).sort(compareUtf8);
329
+ if (keys.length === 0) {
330
+ records.push(createRecord("attr", "world", `${recordIdPrefix}:${pointer}`, pointer, value));
331
+ return;
332
+ }
333
+ for (const key of keys) {
334
+ if (isTechnicalAttrField(key))
335
+ continue;
336
+ appendLeafRecords(records, recordIdPrefix, appendJsonPointer(pointer, key), value[key]);
337
+ }
338
+ return;
339
+ }
340
+ records.push(createRecord("attr", "world", `${recordIdPrefix}:${pointer}`, pointer, value));
341
+ }
342
+ function validateRecordKindSection(recordKind, section) {
343
+ const valid = (recordKind === "meta" && section === "metadata") ||
344
+ ["node", "attr", "edge"].includes(recordKind) && section === "world" ||
345
+ recordKind === "trigger" && section === "triggers" ||
346
+ recordKind === "view" && (section === "view_mobile" || section === "view_pc");
347
+ if (!valid)
348
+ throw new Error("scenario_review_record_section_invalid");
349
+ }
350
+ function createRecord(record_kind, section, record_id, json_pointer, payload) {
351
+ const normalized = toJsonValue(payload);
352
+ return {
353
+ record_kind,
354
+ section,
355
+ record_id,
356
+ json_pointer,
357
+ payload: normalized,
358
+ canonical_payload_hash: sha256Hex(canonicalJson(normalized)),
359
+ };
360
+ }
361
+ function compareRecords(a, b) {
362
+ const section = SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section);
363
+ if (section !== 0)
364
+ return section;
365
+ const kind = RECORD_KIND_ORDER.indexOf(a.record_kind) - RECORD_KIND_ORDER.indexOf(b.record_kind);
366
+ return kind !== 0 ? kind : compareUtf8(a.record_id, b.record_id);
367
+ }
368
+ function splitRecord(record, limits) {
369
+ // 任一 fragment 都必须能完整进入下一块 overlap,保证相邻块至少共享一个语义单元。
370
+ const primaryCapacity = Math.min(limits.max_chunk_bytes - limits.max_overlap_bytes, limits.max_overlap_bytes);
371
+ const whole = fragmentBase(record, "", "json", { payload: record.payload });
372
+ if (fragmentLineBytes(whole) <= primaryCapacity)
373
+ return [{ ...whole, fragment_id: `${record.record_id}#0` }];
374
+ const raw = [];
375
+ decomposeValue(record, record.payload, "", 0, primaryCapacity, limits.max_value_depth, raw);
376
+ return raw.map((fragment, index) => ({ ...fragment, fragment_id: `${record.record_id}#${index}` }));
377
+ }
378
+ function decomposeValue(record, value, pointer, depth, capacity, maxDepth, out) {
379
+ if (depth > maxDepth)
380
+ throw new Error("scenario_review_value_depth_limit_exceeded");
381
+ const whole = fragmentBase(record, pointer, "json", { payload: value });
382
+ if (fragmentLineBytes(whole) <= capacity) {
383
+ out.push(whole);
384
+ return;
385
+ }
386
+ if (typeof value === "string") {
387
+ splitLongString(record, value, pointer, capacity, out);
388
+ return;
389
+ }
390
+ if (value === null || typeof value !== "object") {
391
+ throw new Error("scenario_review_fragment_capacity_exceeded");
392
+ }
393
+ const container = fragmentBase(record, pointer, "container", {
394
+ container_kind: Array.isArray(value) ? "array" : "object",
395
+ });
396
+ if (fragmentLineBytes(container) > capacity)
397
+ throw new Error("scenario_review_fragment_capacity_exceeded");
398
+ out.push(container);
399
+ if (Array.isArray(value)) {
400
+ for (let index = 0; index < value.length; index += 1) {
401
+ decomposeValue(record, value[index], appendJsonPointer(pointer, index), depth + 1, capacity, maxDepth, out);
402
+ }
403
+ }
404
+ else {
405
+ for (const key of Object.keys(value).sort(compareUtf8)) {
406
+ decomposeValue(record, value[key], appendJsonPointer(pointer, key), depth + 1, capacity, maxDepth, out);
407
+ }
408
+ }
409
+ }
410
+ function splitLongString(record, value, pointer, capacity, out) {
411
+ const codepoints = [...value];
412
+ const total = utf8ByteLength(value);
413
+ let index = 0;
414
+ let offset = 0;
415
+ while (index < codepoints.length) {
416
+ let low = index + 1;
417
+ let high = codepoints.length;
418
+ let accepted = index;
419
+ while (low <= high) {
420
+ const middle = Math.floor((low + high) / 2);
421
+ const segment = codepoints.slice(index, middle).join("");
422
+ const candidate = fragmentBase(record, pointer, "string_segment", {
423
+ string_segment: segment,
424
+ string_offset_bytes: offset,
425
+ string_total_bytes: total,
426
+ });
427
+ if (fragmentLineBytes(candidate) <= capacity) {
428
+ accepted = middle;
429
+ low = middle + 1;
430
+ }
431
+ else {
432
+ high = middle - 1;
433
+ }
434
+ }
435
+ if (accepted === index)
436
+ throw new Error("scenario_review_fragment_capacity_exceeded");
437
+ const segment = codepoints.slice(index, accepted).join("");
438
+ out.push(fragmentBase(record, pointer, "string_segment", {
439
+ string_segment: segment,
440
+ string_offset_bytes: offset,
441
+ string_total_bytes: total,
442
+ }));
443
+ offset += utf8ByteLength(segment);
444
+ index = accepted;
445
+ }
446
+ if (codepoints.length === 0)
447
+ throw new Error("scenario_review_fragment_capacity_exceeded");
448
+ }
449
+ function fragmentBase(record, value_pointer, value_kind, extra) {
450
+ const base = {
451
+ record_id: record.record_id,
452
+ record_kind: record.record_kind,
453
+ section: record.section,
454
+ record_pointer: record.json_pointer,
455
+ value_pointer,
456
+ value_kind,
457
+ };
458
+ return { ...base, ...withoutUndefined(extra) };
459
+ }
460
+ function buildChunks(fragments, limits) {
461
+ if (fragments.length === 0)
462
+ throw new Error("scenario_review_fragments_empty");
463
+ const primaryCapacity = limits.max_chunk_bytes - limits.max_overlap_bytes;
464
+ const groups = [];
465
+ let current = [];
466
+ let currentBytes = 0;
467
+ for (const fragment of fragments) {
468
+ const bytes = primaryLineBytes(fragment);
469
+ if (bytes > primaryCapacity)
470
+ throw new Error("scenario_review_fragment_capacity_exceeded");
471
+ if (current.length > 0 &&
472
+ (currentBytes + bytes > primaryCapacity || current.at(-1).section !== fragment.section)) {
473
+ groups.push(current);
474
+ current = [];
475
+ currentBytes = 0;
476
+ }
477
+ current.push(fragment);
478
+ currentBytes += bytes;
479
+ }
480
+ if (current.length > 0)
481
+ groups.push(current);
482
+ if (groups.length > limits.max_chunks)
483
+ throw new Error("scenario_review_chunk_limit_exceeded");
484
+ const chunks = [];
485
+ for (let index = 0; index < groups.length; index += 1) {
486
+ const primary = groups[index];
487
+ const overlap = [];
488
+ let overlapBytes = 0;
489
+ if (index > 0 && groups[index - 1].at(-1).section === primary[0].section) {
490
+ const prior = groups[index - 1];
491
+ for (let cursor = prior.length - 1; cursor >= 0; cursor -= 1) {
492
+ const fragment = prior[cursor];
493
+ const bytes = overlapLineBytes(fragment);
494
+ if (overlapBytes + bytes > limits.max_overlap_bytes)
495
+ break;
496
+ overlap.unshift(fragment);
497
+ overlapBytes += bytes;
498
+ }
499
+ }
500
+ const lines = [
501
+ ...overlap.map((fragment) => renderChunkLine("overlap", fragment)),
502
+ ...primary.map((fragment) => renderChunkLine("primary", fragment)),
503
+ ];
504
+ const content = lines.join("\n");
505
+ const byte_length = utf8ByteLength(content);
506
+ if (byte_length > limits.max_chunk_bytes)
507
+ throw new Error("scenario_review_chunk_size_limit_exceeded");
508
+ chunks.push({
509
+ chunk_index: index,
510
+ primary_fragment_ids: primary.map((fragment) => fragment.fragment_id),
511
+ overlap_fragment_ids: overlap.map((fragment) => fragment.fragment_id),
512
+ content,
513
+ byte_length,
514
+ chunk_hash: sha256Hex(content),
515
+ });
516
+ }
517
+ return chunks;
518
+ }
519
+ function buildCoverage(records, fragments, chunks) {
520
+ const primaryOwners = new Map();
521
+ for (const chunk of chunks) {
522
+ for (const id of chunk.primary_fragment_ids) {
523
+ const owners = primaryOwners.get(id) ?? [];
524
+ owners.push(chunk.chunk_index);
525
+ primaryOwners.set(id, owners);
526
+ }
527
+ }
528
+ const expectedPrimaryOrder = fragments.map((fragment) => fragment.fragment_id);
529
+ const actualPrimaryOrder = chunks.flatMap((chunk) => chunk.primary_fragment_ids);
530
+ if (actualPrimaryOrder.length !== expectedPrimaryOrder.length ||
531
+ actualPrimaryOrder.some((id, index) => id !== expectedPrimaryOrder[index])) {
532
+ throw new Error("scenario_review_primary_stream_reconstruction_invalid");
533
+ }
534
+ const coverageRecords = records.map((record) => {
535
+ const ids = fragments.filter((fragment) => fragment.record_id === record.record_id).map((fragment) => fragment.fragment_id);
536
+ const indexes = [...new Set(ids.flatMap((id) => primaryOwners.get(id) ?? []))].sort((a, b) => a - b);
537
+ return {
538
+ record_id: record.record_id,
539
+ record_kind: record.record_kind,
540
+ section: record.section,
541
+ json_pointer: record.json_pointer,
542
+ record_hash: record.canonical_payload_hash,
543
+ fragment_ids: ids,
544
+ primary_chunk_indexes: indexes,
545
+ };
546
+ });
547
+ const sections = SECTION_ORDER.map((section) => {
548
+ const sectionRecords = records.filter((record) => record.section === section);
549
+ const sectionFragments = fragments.filter((fragment) => fragment.section === section);
550
+ const indexes = [...new Set(sectionFragments.flatMap((fragment) => primaryOwners.get(fragment.fragment_id) ?? []))].sort((a, b) => a - b);
551
+ return {
552
+ section,
553
+ record_count: sectionRecords.length,
554
+ fragment_count: sectionFragments.length,
555
+ canonical_stream_hash: sha256Hex(sectionRecords.map(canonicalRecordLine).join("\n")),
556
+ first_chunk_index: indexes[0] ?? null,
557
+ last_chunk_index: indexes.at(-1) ?? null,
558
+ };
559
+ });
560
+ const ownerCounts = fragments.map((fragment) => primaryOwners.get(fragment.fragment_id)?.length ?? 0);
561
+ const core = {
562
+ records: coverageRecords,
563
+ sections,
564
+ canonical_record_count: records.length,
565
+ canonical_fragment_count: fragments.length,
566
+ primary_coverage_count: ownerCounts.filter((count) => count === 1).length,
567
+ overlap_reference_count: chunks.reduce((sum, chunk) => sum + chunk.overlap_fragment_ids.length, 0),
568
+ missing_primary_count: ownerCounts.filter((count) => count === 0).length,
569
+ duplicate_primary_count: ownerCounts.filter((count) => count > 1).length,
570
+ chunk_count: chunks.length,
571
+ };
572
+ return { ...core, coverage_hash: sha256Hex(canonicalJson(toJsonValue(core))) };
573
+ }
574
+ function validateCoverage(records, fragments, chunks, coverage) {
575
+ if (coverage.missing_primary_count !== 0 || coverage.duplicate_primary_count !== 0 ||
576
+ coverage.primary_coverage_count !== fragments.length) {
577
+ throw new Error("scenario_review_primary_coverage_invalid");
578
+ }
579
+ const fragmentIds = new Set(fragments.map((fragment) => fragment.fragment_id));
580
+ for (const chunk of chunks) {
581
+ for (const id of [...chunk.primary_fragment_ids, ...chunk.overlap_fragment_ids]) {
582
+ if (!fragmentIds.has(id))
583
+ throw new Error("scenario_review_chunk_fragment_unknown");
584
+ }
585
+ if (sha256Hex(chunk.content) !== chunk.chunk_hash || utf8ByteLength(chunk.content) !== chunk.byte_length) {
586
+ throw new Error("scenario_review_chunk_hash_mismatch");
587
+ }
588
+ }
589
+ for (const record of records)
590
+ reconstructRecordPayload(record, fragments);
591
+ const core = {
592
+ records: coverage.records,
593
+ sections: coverage.sections,
594
+ canonical_record_count: coverage.canonical_record_count,
595
+ canonical_fragment_count: coverage.canonical_fragment_count,
596
+ primary_coverage_count: coverage.primary_coverage_count,
597
+ overlap_reference_count: coverage.overlap_reference_count,
598
+ missing_primary_count: coverage.missing_primary_count,
599
+ duplicate_primary_count: coverage.duplicate_primary_count,
600
+ chunk_count: coverage.chunk_count,
601
+ };
602
+ if (sha256Hex(canonicalJson(toJsonValue(core))) !== coverage.coverage_hash) {
603
+ throw new Error("scenario_review_coverage_hash_mismatch");
604
+ }
605
+ }
606
+ function canonicalRecordLine(record) {
607
+ return canonicalJson(toJsonValue(record));
608
+ }
609
+ function renderChunkLine(role, fragment) {
610
+ return canonicalJson(toJsonValue({ role, fragment }));
611
+ }
612
+ function primaryLineBytes(fragment) {
613
+ return utf8ByteLength(renderChunkLine("primary", fragment)) + 1;
614
+ }
615
+ function overlapLineBytes(fragment) {
616
+ return utf8ByteLength(renderChunkLine("overlap", fragment)) + 1;
617
+ }
618
+ function fragmentLineBytes(fragment) {
619
+ // fragment_id 在拆分完成后才按序号赋值;用最大合法序号预留,避免赋值后突破容量。
620
+ const sized = { ...fragment, fragment_id: `${fragment.record_id}#999999` };
621
+ return Math.max(utf8ByteLength(canonicalJson(toJsonValue({ role: "primary", fragment: sized }))) + 1, utf8ByteLength(canonicalJson(toJsonValue({ role: "overlap", fragment: sized }))) + 1);
622
+ }
623
+ function assignJsonPointer(root, pointer, value) {
624
+ const tokens = parseJsonPointer(pointer);
625
+ if (tokens.length === 0)
626
+ return cloneJson(value);
627
+ if (root === undefined || root === null || typeof root !== "object") {
628
+ throw new Error("scenario_review_fragment_parent_missing");
629
+ }
630
+ let current = root;
631
+ for (let index = 0; index < tokens.length - 1; index += 1) {
632
+ const token = tokens[index];
633
+ current = readChild(current, token);
634
+ }
635
+ const finalToken = tokens.at(-1);
636
+ if (Array.isArray(current)) {
637
+ const index = parseArrayIndex(finalToken);
638
+ if (index !== current.length)
639
+ throw new Error("scenario_review_fragment_array_order_invalid");
640
+ current.push(cloneJson(value));
641
+ }
642
+ else if (current !== null && typeof current === "object") {
643
+ current[finalToken] = cloneJson(value);
644
+ }
645
+ else {
646
+ throw new Error("scenario_review_fragment_parent_invalid");
647
+ }
648
+ return root;
649
+ }
650
+ function readChild(parent, token) {
651
+ if (Array.isArray(parent)) {
652
+ const child = parent[parseArrayIndex(token)];
653
+ if (child === undefined)
654
+ throw new Error("scenario_review_fragment_parent_missing");
655
+ return child;
656
+ }
657
+ if (parent !== null && typeof parent === "object") {
658
+ const child = parent[token];
659
+ if (child === undefined)
660
+ throw new Error("scenario_review_fragment_parent_missing");
661
+ return child;
662
+ }
663
+ throw new Error("scenario_review_fragment_parent_invalid");
664
+ }
665
+ function parseArrayIndex(token) {
666
+ if (!/^(0|[1-9][0-9]*)$/u.test(token))
667
+ throw new Error("scenario_review_fragment_array_index_invalid");
668
+ const value = Number(token);
669
+ if (!Number.isSafeInteger(value))
670
+ throw new Error("scenario_review_fragment_array_index_invalid");
671
+ return value;
672
+ }
673
+ function cloneJson(value) {
674
+ return JSON.parse(canonicalJson(value));
675
+ }
676
+ function requireObject(value, code) {
677
+ if (value === null || typeof value !== "object" || Array.isArray(value))
678
+ throw new Error(code);
679
+ return value;
680
+ }
681
+ function requireNonEmptyString(value, code) {
682
+ if (typeof value !== "string" || value.trim().length === 0)
683
+ throw new Error(code);
684
+ return value;
685
+ }
686
+ function optionalNonEmptyString(value) {
687
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
688
+ }
689
+ function assertUnique(values, code) {
690
+ if (new Set(values).size !== values.length)
691
+ throw new Error(code);
692
+ }
693
+ function withoutUndefined(value) {
694
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
695
+ }
696
+ function checkedAdd(left, right) {
697
+ const result = left + right;
698
+ if (!Number.isSafeInteger(result))
699
+ throw new Error("scenario_review_size_overflow");
700
+ return result;
701
+ }