workspec 1.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,1812 @@
1
+ // WorkSpec v2.0 Validator (RFC 7807 Problem Details)
2
+ // Universal Automation Wiki
3
+ //
4
+ // Single-source validator intended to run in both:
5
+ // - Browser (UAW Playground) via `window.WorkSpecValidator`
6
+ // - Node.js (CLI / npm package) via `require()`
7
+ //
8
+ // NOTE: This validator is intentionally dependency-free.
9
+
10
+ (function() {
11
+ 'use strict';
12
+
13
+ const WORKSPEC_NAMESPACE = 'https://universalautomation.wiki/workspec';
14
+ const SUPPORTED_SCHEMA_VERSIONS = Object.freeze(['2.0']);
15
+
16
+ const BUILTIN_TYPES = Object.freeze([
17
+ 'actor',
18
+ 'equipment',
19
+ 'resource',
20
+ 'product',
21
+ 'service',
22
+ 'display',
23
+ 'screen_element',
24
+ 'digital_object'
25
+ ]);
26
+
27
+ const DISALLOWED_TYPE_ALIASES = Object.freeze({
28
+ material: 'resource',
29
+ ingredient: 'resource',
30
+ tool: 'equipment'
31
+ });
32
+
33
+ const RESERVED_TYPE_NAMES = Object.freeze([
34
+ 'timeline_actors',
35
+ 'any',
36
+ 'unknown'
37
+ ]);
38
+
39
+ const PLAIN_ID_RE = /^[a-z][a-z0-9_]{0,249}$/;
40
+ const OBJECT_ID_RE = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]{0,249}$/;
41
+ const TIME_HHMM_RE = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
42
+ const TIME_HHMMSS_RE = /^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/;
43
+ const ISO_DURATION_RE = /^P(?!$)(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/i;
44
+ const SHORTHAND_DURATION_RE = /^([0-9]+(?:\.[0-9]+)?)([smhdwWM])$/;
45
+
46
+ function isPlainObject(value) {
47
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
48
+ }
49
+
50
+ function ensureString(value) {
51
+ return (typeof value === 'string') ? value : '';
52
+ }
53
+
54
+ function safeTrim(value) {
55
+ return ensureString(value).trim();
56
+ }
57
+
58
+ function toJsonPointer(parts) {
59
+ if (!Array.isArray(parts)) return '';
60
+ return parts
61
+ .map((part) => String(part).replace(/~/g, '~0').replace(/\//g, '~1'))
62
+ .join('/')
63
+ .replace(/^/, '/');
64
+ }
65
+
66
+ function buildProblem(metricId, severity, title, detail, instance, context, suggestions, docUri) {
67
+ const safeMetricId = safeTrim(metricId) || 'system.error';
68
+ const safeSeverity = (severity === 'warning' || severity === 'info') ? severity : 'error';
69
+ const safeTitle = safeTrim(title) || safeMetricId;
70
+ const safeDetail = safeTrim(detail) || safeTitle;
71
+ const safeInstance = safeTrim(instance) || '/';
72
+ const safeContext = isPlainObject(context) ? context : {};
73
+ const safeSuggestions = Array.isArray(suggestions)
74
+ ? suggestions.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
75
+ : [];
76
+
77
+ const result = {
78
+ type: `${WORKSPEC_NAMESPACE}/errors/${safeMetricId}`,
79
+ title: safeTitle,
80
+ severity: safeSeverity,
81
+ detail: safeDetail,
82
+ instance: safeInstance,
83
+ metric_id: safeMetricId,
84
+ context: safeContext,
85
+ suggestions: safeSuggestions
86
+ };
87
+
88
+ if (safeTrim(docUri)) {
89
+ result.doc_uri = safeTrim(docUri);
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ function getSimFromDocument(documentValue) {
96
+ if (!isPlainObject(documentValue)) return null;
97
+ if (isPlainObject(documentValue.simulation)) return documentValue.simulation;
98
+
99
+ // Support legacy "flat" root with simulation fields (playground convenience).
100
+ if (isPlainObject(documentValue.meta) || isPlainObject(documentValue.config) || Array.isArray(documentValue.tasks) || Array.isArray(documentValue.objects)) {
101
+ return documentValue;
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ function normalizeTimeUnit(rawValue) {
108
+ const value = safeTrim(rawValue).toLowerCase();
109
+ if (value === 'seconds') return 'seconds';
110
+ if (value === 'minutes') return 'minutes';
111
+ if (value === 'hours') return 'hours';
112
+ return '';
113
+ }
114
+
115
+ function durationToMinutesByUnit(amount, timeUnit) {
116
+ if (typeof amount !== 'number' || !Number.isFinite(amount)) return null;
117
+ if (timeUnit === 'seconds') return amount / 60;
118
+ if (timeUnit === 'minutes') return amount;
119
+ if (timeUnit === 'hours') return amount * 60;
120
+ return null;
121
+ }
122
+
123
+ function parseIso8601DurationToMinutes(value) {
124
+ if (typeof value !== 'string' || !ISO_DURATION_RE.test(value)) return null;
125
+ const upper = value.toUpperCase();
126
+
127
+ // Very small ISO8601 subset parser (PnYnMnDTnHnMnS)
128
+ // WorkSpec v2 only requires common cases (PT30M, PT1H, P2D, etc.)
129
+ const match = upper.match(/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
130
+ if (!match) return null;
131
+
132
+ const years = match[1] ? Number(match[1]) : 0;
133
+ const months = match[2] ? Number(match[2]) : 0;
134
+ const days = match[3] ? Number(match[3]) : 0;
135
+ const hours = match[4] ? Number(match[4]) : 0;
136
+ const minutes = match[5] ? Number(match[5]) : 0;
137
+ const seconds = match[6] ? Number(match[6]) : 0;
138
+
139
+ if (![years, months, days, hours, minutes, seconds].every((n) => Number.isFinite(n) && n >= 0)) return null;
140
+
141
+ // Approximation for calendar units: 1 month = 30 days, 1 year = 365 days
142
+ const totalMinutes = (years * 365 * 24 * 60)
143
+ + (months * 30 * 24 * 60)
144
+ + (days * 24 * 60)
145
+ + (hours * 60)
146
+ + minutes
147
+ + (seconds / 60);
148
+
149
+ return totalMinutes;
150
+ }
151
+
152
+ function parseShorthandDurationToMinutes(value) {
153
+ if (typeof value !== 'string') return null;
154
+ const match = value.match(SHORTHAND_DURATION_RE);
155
+ if (!match) return null;
156
+
157
+ const amount = Number(match[1]);
158
+ const unit = match[2];
159
+
160
+ if (!Number.isFinite(amount) || amount <= 0) return null;
161
+
162
+ switch (unit) {
163
+ case 's':
164
+ return amount / 60;
165
+ case 'm':
166
+ return amount;
167
+ case 'h':
168
+ return amount * 60;
169
+ case 'd':
170
+ return amount * 24 * 60;
171
+ case 'w':
172
+ case 'W':
173
+ return amount * 7 * 24 * 60;
174
+ case 'M':
175
+ return amount * 30 * 24 * 60;
176
+ default:
177
+ return null;
178
+ }
179
+ }
180
+
181
+ function parseDurationToMinutes(value, timeUnit) {
182
+ if (typeof value === 'number') {
183
+ if (!Number.isFinite(value)) return { ok: false, minutes: null, kind: 'number' };
184
+ if (!Number.isInteger(value)) return { ok: false, minutes: null, kind: 'number' };
185
+ if (value <= 0) return { ok: false, minutes: null, kind: 'number' };
186
+
187
+ const minutes = durationToMinutesByUnit(value, timeUnit);
188
+ if (minutes === null) return { ok: false, minutes: null, kind: 'number' };
189
+ return { ok: true, minutes, kind: 'number' };
190
+ }
191
+
192
+ if (typeof value === 'string') {
193
+ const trimmed = value.trim();
194
+ if (!trimmed) return { ok: false, minutes: null, kind: 'string' };
195
+
196
+ const iso = parseIso8601DurationToMinutes(trimmed);
197
+ if (iso !== null) return { ok: true, minutes: iso, kind: 'iso8601' };
198
+
199
+ const shorthand = parseShorthandDurationToMinutes(trimmed);
200
+ if (shorthand !== null) return { ok: true, minutes: shorthand, kind: 'shorthand' };
201
+
202
+ return { ok: false, minutes: null, kind: 'string' };
203
+ }
204
+
205
+ return { ok: false, minutes: null, kind: typeof value };
206
+ }
207
+
208
+ function parseStrictTimeStringToMinutes(value) {
209
+ if (typeof value !== 'string') return { ok: false, minutes: null, kind: 'non-string' };
210
+ const trimmed = value.trim();
211
+ if (TIME_HHMM_RE.test(trimmed)) {
212
+ const [h, m] = trimmed.split(':').map(Number);
213
+ return { ok: true, minutes: (h * 60) + m, kind: 'hhmm' };
214
+ }
215
+ if (TIME_HHMMSS_RE.test(trimmed)) {
216
+ const [h, m, s] = trimmed.split(':').map(Number);
217
+ return { ok: true, minutes: (h * 60) + m + (s / 60), kind: 'hhmmss' };
218
+ }
219
+ return { ok: false, minutes: null, kind: 'invalid' };
220
+ }
221
+
222
+ function isIsoDateTimeString(value) {
223
+ if (typeof value !== 'string') return false;
224
+ const trimmed = value.trim();
225
+ if (!trimmed) return false;
226
+ const date = new Date(trimmed);
227
+ return !Number.isNaN(date.getTime());
228
+ }
229
+
230
+ function parseTaskStart(value) {
231
+ if (typeof value === 'string') {
232
+ const trimmed = value.trim();
233
+
234
+ const strictTime = parseStrictTimeStringToMinutes(trimmed);
235
+ if (strictTime.ok) {
236
+ return { ok: true, kind: 'time', startMinutes: strictTime.minutes, raw: trimmed };
237
+ }
238
+
239
+ if (isIsoDateTimeString(trimmed)) {
240
+ const ms = new Date(trimmed).getTime();
241
+ return { ok: true, kind: 'datetime', startMillis: ms, raw: trimmed };
242
+ }
243
+
244
+ return { ok: false, kind: 'string', raw: trimmed };
245
+ }
246
+
247
+ if (isPlainObject(value)) {
248
+ const day = value.day;
249
+ const time = value.time;
250
+
251
+ if (!Number.isInteger(day) || day < 1) {
252
+ return { ok: false, kind: 'daytime', raw: value, reason: 'invalid_day' };
253
+ }
254
+
255
+ if (typeof time !== 'string') {
256
+ return { ok: false, kind: 'daytime', raw: value, reason: 'invalid_time' };
257
+ }
258
+
259
+ const strictTime = parseStrictTimeStringToMinutes(time.trim());
260
+ if (!strictTime.ok) {
261
+ return { ok: false, kind: 'daytime', raw: value, reason: 'invalid_time_format' };
262
+ }
263
+
264
+ return { ok: true, kind: 'daytime', day, time: time.trim(), startMinutes: ((day - 1) * 1440) + strictTime.minutes, raw: value };
265
+ }
266
+
267
+ return { ok: false, kind: typeof value, raw: value };
268
+ }
269
+
270
+ function parseObjectIdParts(objectId) {
271
+ const id = ensureString(objectId);
272
+ if (!id) return { ok: false, kind: 'empty' };
273
+ if (id.length > 250) return { ok: false, kind: 'too_long' };
274
+
275
+ if (PLAIN_ID_RE.test(id)) return { ok: true, kind: 'plain', id };
276
+
277
+ if (OBJECT_ID_RE.test(id)) {
278
+ const [namespace, local] = id.split(':');
279
+ return { ok: true, kind: 'namespaced', id, namespace, local };
280
+ }
281
+
282
+ return { ok: false, kind: 'format' };
283
+ }
284
+
285
+ function getBuiltInBaseType(type, typeDefinitions, seen = new Set()) {
286
+ const raw = safeTrim(type);
287
+ if (!raw) return '';
288
+ if (BUILTIN_TYPES.includes(raw)) return raw;
289
+ if (!typeDefinitions || !isPlainObject(typeDefinitions)) return '';
290
+ if (seen.has(raw)) return '';
291
+ seen.add(raw);
292
+ const def = typeDefinitions[raw];
293
+ if (!isPlainObject(def)) return '';
294
+ const extendsType = safeTrim(def.extends);
295
+ if (!extendsType) return '';
296
+ return getBuiltInBaseType(extendsType, typeDefinitions, seen);
297
+ }
298
+
299
+ function isPerformerType(type, typeDefinitions) {
300
+ const base = getBuiltInBaseType(type, typeDefinitions) || safeTrim(type);
301
+ return base === 'actor' || base === 'equipment' || base === 'service';
302
+ }
303
+
304
+ function isQuantifiableType(type, typeDefinitions) {
305
+ const base = getBuiltInBaseType(type, typeDefinitions) || safeTrim(type);
306
+ return base === 'resource' || base === 'product' || base === 'digital_object';
307
+ }
308
+
309
+ function isStatefulType(type, typeDefinitions) {
310
+ const base = getBuiltInBaseType(type, typeDefinitions) || safeTrim(type);
311
+ return base === 'actor' || base === 'equipment' || base === 'service' || base === 'display' || base === 'screen_element' || base === 'digital_object';
312
+ }
313
+
314
+ function validatePropertyOperator(metricBase, operatorValue, instanceParts, problems, contextBase) {
315
+ if (!isPlainObject(operatorValue)) {
316
+ problems.push(buildProblem(
317
+ `${metricBase}.invalid_operator`,
318
+ 'error',
319
+ 'Invalid Property Change Operator',
320
+ 'Property change operator must be an object.',
321
+ toJsonPointer(instanceParts),
322
+ { ...contextBase, operator: operatorValue },
323
+ ['Replace this operator with a valid object like { "set": ... } or { "delta": ... }.']
324
+ ));
325
+ return;
326
+ }
327
+
328
+ const hasFrom = Object.prototype.hasOwnProperty.call(operatorValue, 'from');
329
+ const hasTo = Object.prototype.hasOwnProperty.call(operatorValue, 'to');
330
+ const hasDelta = Object.prototype.hasOwnProperty.call(operatorValue, 'delta');
331
+ const hasSet = Object.prototype.hasOwnProperty.call(operatorValue, 'set');
332
+ const hasMultiply = Object.prototype.hasOwnProperty.call(operatorValue, 'multiply');
333
+ const hasAppend = Object.prototype.hasOwnProperty.call(operatorValue, 'append');
334
+ const hasRemove = Object.prototype.hasOwnProperty.call(operatorValue, 'remove');
335
+ const hasIncrement = Object.prototype.hasOwnProperty.call(operatorValue, 'increment');
336
+ const hasDecrement = Object.prototype.hasOwnProperty.call(operatorValue, 'decrement');
337
+
338
+ const keys = Object.keys(operatorValue);
339
+ const allowedKeys = ['from', 'to', 'delta', 'set', 'multiply', 'append', 'remove', 'increment', 'decrement'];
340
+ const unknownKeys = keys.filter((k) => !allowedKeys.includes(k));
341
+
342
+ if (unknownKeys.length > 0) {
343
+ problems.push(buildProblem(
344
+ `${metricBase}.unknown_operator`,
345
+ 'warning',
346
+ 'Unknown Property Change Operator',
347
+ `Property change operator includes unknown keys: ${unknownKeys.join(', ')}.`,
348
+ toJsonPointer(instanceParts),
349
+ { ...contextBase, unknown_keys: unknownKeys },
350
+ ['Remove unknown keys or replace them with supported operators (from/to, delta, set, multiply, append, remove, increment, decrement).']
351
+ ));
352
+ }
353
+
354
+ // Cannot combine from/to with delta (or multiply) for same property
355
+ if ((hasFrom || hasTo) && (hasDelta || hasMultiply || hasIncrement || hasDecrement)) {
356
+ problems.push(buildProblem(
357
+ `${metricBase}.conflicting_operator`,
358
+ 'error',
359
+ 'Conflicting Property Change Operators',
360
+ 'Cannot combine from/to with delta/multiply/increment/decrement for the same property.',
361
+ toJsonPointer(instanceParts),
362
+ { ...contextBase, operator: operatorValue },
363
+ ['Use either { "from": ..., "to": ... } for transitions, or use { "delta": ... } / { "set": ... } for direct changes.']
364
+ ));
365
+ }
366
+
367
+ if ((hasFrom || hasTo) && !(hasFrom && hasTo)) {
368
+ problems.push(buildProblem(
369
+ `${metricBase}.incomplete_transition`,
370
+ 'error',
371
+ 'Incomplete State Transition',
372
+ 'from/to transitions require both "from" and "to".',
373
+ toJsonPointer(instanceParts),
374
+ { ...contextBase, operator: operatorValue },
375
+ ['Provide both "from" and "to", or use { "set": ... } instead.']
376
+ ));
377
+ }
378
+
379
+ if (hasDelta && typeof operatorValue.delta !== 'number') {
380
+ problems.push(buildProblem(
381
+ `${metricBase}.invalid_delta`,
382
+ 'error',
383
+ 'Invalid Delta',
384
+ 'delta must be a number.',
385
+ toJsonPointer(instanceParts.concat(['delta'])),
386
+ { ...contextBase, delta: operatorValue.delta },
387
+ ['Use a numeric delta like { "delta": -1 } or { "delta": 2.5 }.']
388
+ ));
389
+ }
390
+
391
+ if (hasMultiply && typeof operatorValue.multiply !== 'number') {
392
+ problems.push(buildProblem(
393
+ `${metricBase}.invalid_multiply`,
394
+ 'error',
395
+ 'Invalid Multiply',
396
+ 'multiply must be a number.',
397
+ toJsonPointer(instanceParts.concat(['multiply'])),
398
+ { ...contextBase, multiply: operatorValue.multiply },
399
+ ['Use a numeric multiplier like { "multiply": 1.1 }.']
400
+ ));
401
+ }
402
+
403
+ if (hasIncrement && operatorValue.increment !== true) {
404
+ problems.push(buildProblem(
405
+ `${metricBase}.invalid_increment`,
406
+ 'error',
407
+ 'Invalid Increment',
408
+ 'increment must be true when present.',
409
+ toJsonPointer(instanceParts.concat(['increment'])),
410
+ { ...contextBase, increment: operatorValue.increment },
411
+ ['Use { "increment": true } or remove this operator.']
412
+ ));
413
+ }
414
+
415
+ if (hasDecrement && operatorValue.decrement !== true) {
416
+ problems.push(buildProblem(
417
+ `${metricBase}.invalid_decrement`,
418
+ 'error',
419
+ 'Invalid Decrement',
420
+ 'decrement must be true when present.',
421
+ toJsonPointer(instanceParts.concat(['decrement'])),
422
+ { ...contextBase, decrement: operatorValue.decrement },
423
+ ['Use { "decrement": true } or remove this operator.']
424
+ ));
425
+ }
426
+
427
+ if (hasSet && (hasDelta || hasMultiply || hasAppend || hasRemove || hasIncrement || hasDecrement || hasFrom || hasTo)) {
428
+ problems.push(buildProblem(
429
+ `${metricBase}.conflicting_set`,
430
+ 'warning',
431
+ 'Conflicting Set Operator',
432
+ 'set is typically used alone; combining it with other operators is unusual.',
433
+ toJsonPointer(instanceParts),
434
+ { ...contextBase, operator: operatorValue },
435
+ ['Prefer a single operator per property (use set alone, or remove set if delta/from-to is intended).']
436
+ ));
437
+ }
438
+ }
439
+
440
+ function validate(documentValue, options = {}) {
441
+ const problems = [];
442
+
443
+ const root = isPlainObject(documentValue) ? documentValue : null;
444
+ const simulation = getSimFromDocument(root);
445
+
446
+ if (!simulation) {
447
+ problems.push(buildProblem(
448
+ 'schema.integrity.missing_root',
449
+ 'error',
450
+ 'Missing Simulation Root',
451
+ "The root 'simulation' object is missing.",
452
+ '/simulation',
453
+ { expected: 'simulation' },
454
+ ['Wrap the document in { "simulation": { ... } }.', 'If this is a v1 document, run the migration tool first.']
455
+ ));
456
+ return { ok: false, problems };
457
+ }
458
+
459
+ const schemaVersion = safeTrim(simulation.schema_version);
460
+ if (!schemaVersion) {
461
+ problems.push(buildProblem(
462
+ 'schema.integrity.missing_version',
463
+ 'error',
464
+ 'Missing Schema Version',
465
+ "Missing required field 'simulation.schema_version'. Documents without schema_version are treated as WorkSpec v1.0 and are rejected.",
466
+ '/simulation/schema_version',
467
+ { field: 'schema_version' },
468
+ [
469
+ "Add \"schema_version\": \"2.0\" under simulation.",
470
+ 'Run the migration tool (Playground: Tools → Migrate v1 → v2) or CLI: workspec migrate <file> --out <output>.'
471
+ ]
472
+ ));
473
+ return { ok: false, problems };
474
+ }
475
+
476
+ if (!/^[0-9]+\.[0-9]+$/.test(schemaVersion)) {
477
+ problems.push(buildProblem(
478
+ 'schema.integrity.invalid_version',
479
+ 'error',
480
+ 'Invalid Schema Version',
481
+ `simulation.schema_version must be in Major.Minor format (example: "2.0"). Received "${schemaVersion}".`,
482
+ '/simulation/schema_version',
483
+ { value: schemaVersion },
484
+ ['Change schema_version to "2.0".']
485
+ ));
486
+ return { ok: false, problems };
487
+ }
488
+
489
+ if (!SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion)) {
490
+ problems.push(buildProblem(
491
+ 'schema.integrity.unsupported_version',
492
+ 'error',
493
+ 'Unsupported Schema Version',
494
+ `WorkSpec schema_version "${schemaVersion}" is not supported by this validator. Supported versions: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}.`,
495
+ '/simulation/schema_version',
496
+ { value: schemaVersion, supported: [...SUPPORTED_SCHEMA_VERSIONS] },
497
+ ['Use the supported schema version "2.0".']
498
+ ));
499
+ return { ok: false, problems };
500
+ }
501
+
502
+ // Required top-level sections
503
+ if (!isPlainObject(simulation.meta)) {
504
+ problems.push(buildProblem(
505
+ 'schema.integrity.missing_meta',
506
+ 'error',
507
+ 'Missing Meta Section',
508
+ "Missing required object 'simulation.meta'.",
509
+ '/simulation/meta',
510
+ { field: 'meta' },
511
+ ['Add simulation.meta with required fields: title, description, domain.']
512
+ ));
513
+ } else {
514
+ const meta = simulation.meta;
515
+ const missingFields = [];
516
+ for (const key of ['title', 'description', 'domain']) {
517
+ if (!safeTrim(meta[key])) missingFields.push(key);
518
+ }
519
+
520
+ if (missingFields.length > 0) {
521
+ problems.push(buildProblem(
522
+ 'schema.integrity.missing_meta_fields',
523
+ 'error',
524
+ 'Missing Meta Fields',
525
+ `simulation.meta is missing required field(s): ${missingFields.join(', ')}.`,
526
+ '/simulation/meta',
527
+ { missing: missingFields },
528
+ ['Add the missing meta fields: title, description, domain.']
529
+ ));
530
+ }
531
+
532
+ if (Object.prototype.hasOwnProperty.call(meta, 'article_title')) {
533
+ problems.push(buildProblem(
534
+ 'schema.integrity.disallowed_meta_field',
535
+ 'error',
536
+ 'Disallowed Meta Field',
537
+ "Legacy field 'meta.article_title' is not allowed in WorkSpec v2.0. Use 'meta.title' instead.",
538
+ '/simulation/meta/article_title',
539
+ { field: 'article_title' },
540
+ ['Remove meta.article_title.', 'Rename meta.article_title to meta.title.']
541
+ ));
542
+ }
543
+ }
544
+
545
+ if (!isPlainObject(simulation.config)) {
546
+ problems.push(buildProblem(
547
+ 'schema.integrity.missing_config',
548
+ 'error',
549
+ 'Missing Config Section',
550
+ "Missing required object 'simulation.config'.",
551
+ '/simulation/config',
552
+ { field: 'config' },
553
+ ['Add simulation.config with required fields: time_unit, start_time, end_time, currency, locale.']
554
+ ));
555
+ }
556
+
557
+ const config = isPlainObject(simulation.config) ? simulation.config : {};
558
+ const normalizedTimeUnit = normalizeTimeUnit(config.time_unit);
559
+ if (!normalizedTimeUnit) {
560
+ problems.push(buildProblem(
561
+ 'schema.integrity.invalid_time_unit',
562
+ 'error',
563
+ 'Invalid Time Unit',
564
+ "simulation.config.time_unit must be one of: seconds, minutes, hours.",
565
+ '/simulation/config/time_unit',
566
+ { value: config.time_unit },
567
+ ['Set time_unit to "seconds", "minutes", or "hours".']
568
+ ));
569
+ }
570
+
571
+ for (const key of ['start_time', 'end_time']) {
572
+ const raw = config[key];
573
+ if (typeof raw !== 'string' || !safeTrim(raw)) {
574
+ problems.push(buildProblem(
575
+ 'schema.integrity.missing_config_field',
576
+ 'error',
577
+ 'Missing Config Field',
578
+ `simulation.config.${key} is required.`,
579
+ toJsonPointer(['simulation', 'config', key]),
580
+ { field: key },
581
+ [`Add ${key} in HH:MM (or ISO 8601) format.`]
582
+ ));
583
+ }
584
+ }
585
+
586
+ for (const key of ['currency', 'locale']) {
587
+ const raw = config[key];
588
+ if (typeof raw !== 'string' || !safeTrim(raw)) {
589
+ problems.push(buildProblem(
590
+ 'schema.integrity.missing_config_field',
591
+ 'error',
592
+ 'Missing Config Field',
593
+ `simulation.config.${key} is required.`,
594
+ toJsonPointer(['simulation', 'config', key]),
595
+ { field: key },
596
+ [`Add ${key} (currency: ISO 4217, locale: BCP 47).`]
597
+ ));
598
+ }
599
+ }
600
+
601
+ // world/process structure
602
+ if (!isPlainObject(simulation.world)) {
603
+ problems.push(buildProblem(
604
+ 'schema.integrity.missing_world',
605
+ 'error',
606
+ 'Missing World Section',
607
+ "Missing required object 'simulation.world'.",
608
+ '/simulation/world',
609
+ { field: 'world' },
610
+ ['Add simulation.world with required world.objects and optional world.layout.']
611
+ ));
612
+ }
613
+
614
+ if (!isPlainObject(simulation.process)) {
615
+ problems.push(buildProblem(
616
+ 'schema.integrity.missing_process',
617
+ 'error',
618
+ 'Missing Process Section',
619
+ "Missing required object 'simulation.process'.",
620
+ '/simulation/process',
621
+ { field: 'process' },
622
+ ['Add simulation.process with required process.tasks and optional process.recipes.']
623
+ ));
624
+ }
625
+
626
+ const world = isPlainObject(simulation.world) ? simulation.world : {};
627
+ const process = isPlainObject(simulation.process) ? simulation.process : {};
628
+
629
+ const legacyLayout = isPlainObject(simulation.layout) ? simulation.layout : null;
630
+ const legacyObjects = Array.isArray(simulation.objects) ? simulation.objects : null;
631
+ const legacyTasks = Array.isArray(simulation.tasks) ? simulation.tasks : null;
632
+ const legacyRecipes = isPlainObject(simulation.recipes) ? simulation.recipes : null;
633
+
634
+ const objectsFromWorld = Array.isArray(world.objects) ? world.objects : null;
635
+ const tasksFromProcess = Array.isArray(process.tasks) ? process.tasks : null;
636
+ const layoutForLocations = isPlainObject(world.layout) ? world.layout : (legacyLayout || {});
637
+
638
+ const objects = (objectsFromWorld && objectsFromWorld.length > 0)
639
+ ? objectsFromWorld
640
+ : (legacyObjects || objectsFromWorld);
641
+ const tasks = (tasksFromProcess && tasksFromProcess.length > 0)
642
+ ? tasksFromProcess
643
+ : (legacyTasks || tasksFromProcess);
644
+
645
+ const objectsBasePtr = (objects === objectsFromWorld)
646
+ ? ['simulation', 'world', 'objects']
647
+ : ['simulation', 'objects'];
648
+ const tasksBasePtr = (tasks === tasksFromProcess)
649
+ ? ['simulation', 'process', 'tasks']
650
+ : ['simulation', 'tasks'];
651
+ const tasksBaseInstance = toJsonPointer(tasksBasePtr);
652
+
653
+ if (!objectsFromWorld) {
654
+ problems.push(buildProblem(
655
+ 'schema.integrity.invalid_world_objects',
656
+ 'error',
657
+ 'Invalid World Objects',
658
+ "simulation.world.objects must be an array.",
659
+ '/simulation/world/objects',
660
+ { field: 'world.objects', value_type: typeof world.objects },
661
+ ['Set simulation.world.objects to an array (use [] if empty).']
662
+ ));
663
+ }
664
+
665
+ if (!tasksFromProcess) {
666
+ problems.push(buildProblem(
667
+ 'schema.integrity.invalid_process_tasks',
668
+ 'error',
669
+ 'Invalid Process Tasks',
670
+ "simulation.process.tasks must be an array.",
671
+ '/simulation/process/tasks',
672
+ { field: 'process.tasks', value_type: typeof process.tasks },
673
+ ['Set simulation.process.tasks to an array (use [] if empty).']
674
+ ));
675
+ }
676
+
677
+ // Stop early if basic structure is invalid
678
+ if (!objects || !tasks || !normalizedTimeUnit) {
679
+ return { ok: problems.every((p) => p.severity !== 'error'), problems };
680
+ }
681
+
682
+ const typeDefinitions = isPlainObject(simulation.type_definitions) ? simulation.type_definitions : null;
683
+ const locationIds = new Set();
684
+ const locations = layoutForLocations?.locations;
685
+ if (Array.isArray(locations)) {
686
+ for (let i = 0; i < locations.length; i += 1) {
687
+ const loc = locations[i];
688
+ if (isPlainObject(loc) && safeTrim(loc.id)) {
689
+ locationIds.add(safeTrim(loc.id));
690
+ }
691
+ }
692
+ }
693
+
694
+ const objectIds = new Set();
695
+ const objectTypeById = new Map();
696
+ const objectById = new Map();
697
+
698
+ for (let i = 0; i < objects.length; i += 1) {
699
+ const obj = objects[i];
700
+ const objPtr = objectsBasePtr.concat([i]);
701
+
702
+ if (!isPlainObject(obj)) {
703
+ problems.push(buildProblem(
704
+ 'object.integrity.invalid_object',
705
+ 'error',
706
+ 'Invalid Object',
707
+ `Object at index ${i} must be an object.`,
708
+ toJsonPointer(objPtr),
709
+ { index: i },
710
+ ['Replace this entry with a valid object containing id, type, and name.']
711
+ ));
712
+ continue;
713
+ }
714
+
715
+ const rawId = ensureString(obj.id);
716
+ const rawType = safeTrim(obj.type);
717
+ const rawName = safeTrim(obj.name);
718
+
719
+ if (!rawId || !rawType || !rawName) {
720
+ const missing = [];
721
+ if (!rawId) missing.push('id');
722
+ if (!rawType) missing.push('type');
723
+ if (!rawName) missing.push('name');
724
+ problems.push(buildProblem(
725
+ 'object.integrity.missing_required_fields',
726
+ 'error',
727
+ 'Missing Object Fields',
728
+ `Object is missing required field(s): ${missing.join(', ')}.`,
729
+ toJsonPointer(objPtr),
730
+ { object_index: i, missing },
731
+ ['Ensure each object includes: id, type, name.']
732
+ ));
733
+ continue;
734
+ }
735
+
736
+ if (rawType.startsWith('_') || RESERVED_TYPE_NAMES.includes(rawType)) {
737
+ problems.push(buildProblem(
738
+ 'schema.integrity.disallowed_types',
739
+ 'error',
740
+ 'Disallowed Object Type',
741
+ `Object '${rawId}' uses reserved type '${rawType}', which is not allowed.`,
742
+ toJsonPointer(objPtr.concat(['type'])),
743
+ { object_id: rawId, type: rawType },
744
+ ['Use a non-reserved type name.', 'Avoid types: timeline_actors, any, unknown, and any type starting with underscore.']
745
+ ));
746
+ }
747
+
748
+ if (Object.prototype.hasOwnProperty.call(DISALLOWED_TYPE_ALIASES, rawType)) {
749
+ problems.push(buildProblem(
750
+ 'object.integrity.disallowed_type_alias',
751
+ 'error',
752
+ 'Disallowed Type Alias',
753
+ `Object '${rawId}' uses legacy type alias '${rawType}'.`,
754
+ toJsonPointer(objPtr.concat(['type'])),
755
+ { object_id: rawId, type: rawType, suggested_type: DISALLOWED_TYPE_ALIASES[rawType] },
756
+ [`Use canonical type '${DISALLOWED_TYPE_ALIASES[rawType]}' instead of '${rawType}'.`]
757
+ ));
758
+ }
759
+
760
+ const idParts = parseObjectIdParts(rawId);
761
+ if (!idParts.ok) {
762
+ problems.push(buildProblem(
763
+ 'object.integrity.invalid_object_id',
764
+ 'error',
765
+ 'Invalid Object ID',
766
+ `Object id '${rawId}' is invalid. IDs must be snake_case, optionally namespaced as '{type}:{id}', max 250 chars.`,
767
+ toJsonPointer(objPtr.concat(['id'])),
768
+ { object_id: rawId },
769
+ ['Use snake_case IDs like "head_baker".', 'Optional namespacing is allowed: "actor:head_baker".']
770
+ ));
771
+ } else if (idParts.kind === 'namespaced' && idParts.namespace !== rawType) {
772
+ problems.push(buildProblem(
773
+ 'object.integrity.namespace_mismatch',
774
+ 'error',
775
+ 'Namespaced ID Type Mismatch',
776
+ `Object id '${rawId}' is namespaced as '${idParts.namespace}', but object.type is '${rawType}'. Namespace must match type exactly.`,
777
+ toJsonPointer(objPtr.concat(['id'])),
778
+ { object_id: rawId, namespace: idParts.namespace, type: rawType },
779
+ [`Change id to '${rawType}:${idParts.local}'.`, `Or change type to '${idParts.namespace}'.`]
780
+ ));
781
+ }
782
+
783
+ if (objectIds.has(rawId)) {
784
+ problems.push(buildProblem(
785
+ 'object.integrity.duplicate_object_id',
786
+ 'error',
787
+ 'Duplicate Object ID',
788
+ `Duplicate object id '${rawId}'. Object IDs must be unique.`,
789
+ toJsonPointer(objPtr.concat(['id'])),
790
+ { object_id: rawId },
791
+ ['Rename one of the duplicate objects to a unique id.']
792
+ ));
793
+ } else {
794
+ objectIds.add(rawId);
795
+ }
796
+
797
+ if (!BUILTIN_TYPES.includes(rawType) && (!typeDefinitions || !isPlainObject(typeDefinitions[rawType]))) {
798
+ problems.push(buildProblem(
799
+ 'object.integrity.missing_type_definition',
800
+ 'error',
801
+ 'Missing Type Definition',
802
+ `Object '${rawId}' uses custom type '${rawType}' but no corresponding simulation.type_definitions['${rawType}'] entry exists.`,
803
+ toJsonPointer(objPtr.concat(['type'])),
804
+ { object_id: rawId, type: rawType },
805
+ ['Add a type definition under simulation.type_definitions.', `Or change object.type to a built-in type: ${BUILTIN_TYPES.join(', ')}.`]
806
+ ));
807
+ }
808
+
809
+ if (typeDefinitions && isPlainObject(typeDefinitions[rawType])) {
810
+ const def = typeDefinitions[rawType];
811
+ const extendsType = safeTrim(def.extends);
812
+ if (!extendsType) {
813
+ problems.push(buildProblem(
814
+ 'object.integrity.invalid_type_definition',
815
+ 'error',
816
+ 'Invalid Type Definition',
817
+ `Type definition '${rawType}' is missing required field 'extends'.`,
818
+ toJsonPointer(['simulation', 'type_definitions', rawType]),
819
+ { type: rawType },
820
+ ['Add "extends" (actor|equipment|resource|product|service|display|screen_element|digital_object).']
821
+ ));
822
+ }
823
+ }
824
+
825
+ if (safeTrim(obj.location) && locationIds.size > 0 && !locationIds.has(safeTrim(obj.location))) {
826
+ problems.push(buildProblem(
827
+ 'object.reference.invalid_location',
828
+ 'error',
829
+ 'Invalid Location Reference',
830
+ `Object '${rawId}' references unknown location '${obj.location}'.`,
831
+ toJsonPointer(objPtr.concat(['location'])),
832
+ { object_id: rawId, location: obj.location },
833
+ ['Fix the location id to one defined in simulation.world.layout.locations.', 'Or add this location to the layout.']
834
+ ));
835
+ }
836
+
837
+ const props = isPlainObject(obj.properties) ? obj.properties : {};
838
+ if (isQuantifiableType(rawType, typeDefinitions)) {
839
+ const q = props.quantity;
840
+ if (typeof q !== 'number' || !Number.isFinite(q) || q < 0) {
841
+ problems.push(buildProblem(
842
+ 'object.integrity.missing_required_properties',
843
+ 'error',
844
+ 'Missing Required Properties',
845
+ `Quantifiable object '${rawId}' must have numeric properties.quantity >= 0.`,
846
+ toJsonPointer(objPtr.concat(['properties', 'quantity'])),
847
+ { object_id: rawId, type: rawType, quantity: q },
848
+ ['Add properties.quantity as a non-negative number.']
849
+ ));
850
+ }
851
+ }
852
+
853
+ if (isStatefulType(rawType, typeDefinitions)) {
854
+ const state = props.state;
855
+ if (typeof state !== 'string' || !safeTrim(state)) {
856
+ problems.push(buildProblem(
857
+ 'object.integrity.missing_required_properties',
858
+ 'error',
859
+ 'Missing Required Properties',
860
+ `Stateful object '${rawId}' must have string properties.state.`,
861
+ toJsonPointer(objPtr.concat(['properties', 'state'])),
862
+ { object_id: rawId, type: rawType },
863
+ ['Add properties.state (example: "available", "running", "clean").']
864
+ ));
865
+ }
866
+ }
867
+
868
+ objectTypeById.set(rawId, rawType);
869
+ objectById.set(rawId, obj);
870
+ }
871
+
872
+ const taskIds = new Set();
873
+ const taskById = new Map();
874
+ const taskIndexById = new Map();
875
+ const taskTiming = new Map(); // id -> { startMinutes, endMinutes } (for time/daytime tasks)
876
+ const taskMillis = new Map(); // id -> { startMillis, endMillis } (for datetime tasks)
877
+
878
+ const referencedObjectIds = new Set();
879
+ const recipeDefinitions = isPlainObject(process.recipes)
880
+ ? process.recipes
881
+ : (legacyRecipes || null);
882
+
883
+ // Object lifecycle (create/delete): allow references to objects created via action:create.
884
+ // Temporal existence checks are performed later using chronological replay.
885
+ const createdObjectIds = new Set();
886
+ for (let i = 0; i < tasks.length; i += 1) {
887
+ const task = tasks[i];
888
+ if (!isPlainObject(task) || !Array.isArray(task.interactions)) continue;
889
+ for (let j = 0; j < task.interactions.length; j += 1) {
890
+ const interaction = task.interactions[j];
891
+ if (!isPlainObject(interaction)) continue;
892
+ if (safeTrim(interaction.action) !== 'create') continue;
893
+ if (!isPlainObject(interaction.object)) continue;
894
+ const createdId = safeTrim(interaction.object.id);
895
+ if (createdId) createdObjectIds.add(createdId);
896
+ }
897
+ }
898
+ const knownObjectIds = new Set([...objectIds, ...createdObjectIds]);
899
+
900
+ for (let i = 0; i < tasks.length; i += 1) {
901
+ const task = tasks[i];
902
+ const taskPtr = tasksBasePtr.concat([i]);
903
+
904
+ if (!isPlainObject(task)) {
905
+ problems.push(buildProblem(
906
+ 'task.integrity.invalid_task',
907
+ 'error',
908
+ 'Invalid Task',
909
+ `Task at index ${i} must be an object.`,
910
+ toJsonPointer(taskPtr),
911
+ { index: i },
912
+ ['Replace this entry with a valid task containing id, actor_id, start, duration.']
913
+ ));
914
+ continue;
915
+ }
916
+
917
+ const rawId = safeTrim(task.id);
918
+ const rawActorId = ensureString(task.actor_id);
919
+
920
+ if (!rawId) {
921
+ problems.push(buildProblem(
922
+ 'task.integrity.invalid_task_id',
923
+ 'error',
924
+ 'Invalid Task ID',
925
+ `Task at index ${i} is missing id.`,
926
+ toJsonPointer(taskPtr.concat(['id'])),
927
+ { task_index: i },
928
+ ['Add a unique snake_case id to this task.']
929
+ ));
930
+ continue;
931
+ }
932
+
933
+ if (!PLAIN_ID_RE.test(rawId)) {
934
+ problems.push(buildProblem(
935
+ 'task.integrity.invalid_task_id',
936
+ 'error',
937
+ 'Invalid Task ID',
938
+ `Task id '${rawId}' is invalid. Task IDs must be snake_case (no namespaces).`,
939
+ toJsonPointer(taskPtr.concat(['id'])),
940
+ { task_id: rawId },
941
+ ['Use snake_case IDs like "mix_dough".']
942
+ ));
943
+ }
944
+
945
+ if (taskIds.has(rawId)) {
946
+ problems.push(buildProblem(
947
+ 'task.integrity.duplicate_task_id',
948
+ 'error',
949
+ 'Duplicate Task ID',
950
+ `Duplicate task id '${rawId}'. Task IDs must be unique.`,
951
+ toJsonPointer(taskPtr.concat(['id'])),
952
+ { task_id: rawId },
953
+ ['Rename one of the duplicate tasks to a unique id.']
954
+ ));
955
+ } else {
956
+ taskIds.add(rawId);
957
+ }
958
+
959
+ taskById.set(rawId, task);
960
+ taskIndexById.set(rawId, i);
961
+
962
+ if (!rawActorId) {
963
+ problems.push(buildProblem(
964
+ 'task.reference.invalid_actor',
965
+ 'error',
966
+ 'Invalid Actor Reference',
967
+ `Task '${rawId}' is missing actor_id.`,
968
+ toJsonPointer(taskPtr.concat(['actor_id'])),
969
+ { task_id: rawId },
970
+ ['Set actor_id to an existing performer object id (actor, equipment, or service).']
971
+ ));
972
+ } else if (!knownObjectIds.has(rawActorId)) {
973
+ problems.push(buildProblem(
974
+ 'task.reference.invalid_actor',
975
+ 'error',
976
+ 'Invalid Actor Reference',
977
+ `Task '${rawId}' references unknown actor_id '${rawActorId}'.`,
978
+ toJsonPointer(taskPtr.concat(['actor_id'])),
979
+ { task_id: rawId, actor_id: rawActorId },
980
+ ['Fix the actor_id to match an object id in simulation.world.objects.', 'Add the missing object to world.objects.', 'Or create the object earlier via action:create.']
981
+ ));
982
+ } else if (objectTypeById.has(rawActorId) && !isPerformerType(objectTypeById.get(rawActorId), typeDefinitions)) {
983
+ problems.push(buildProblem(
984
+ 'task.reference.invalid_actor',
985
+ 'error',
986
+ 'Invalid Actor Reference',
987
+ `Task '${rawId}' actor_id '${rawActorId}' references an object that cannot perform tasks (type: '${objectTypeById.get(rawActorId)}').`,
988
+ toJsonPointer(taskPtr.concat(['actor_id'])),
989
+ { task_id: rawId, actor_id: rawActorId, type: objectTypeById.get(rawActorId) },
990
+ ['Use actor/equipment/service objects as actor_id.', 'Change the referenced object type to a performer type if appropriate.']
991
+ ));
992
+ }
993
+
994
+ const startParse = parseTaskStart(task.start);
995
+ if (!startParse.ok) {
996
+ const suggestions = [
997
+ 'Use strict time strings like "09:30" (zero-padded).',
998
+ 'Use "HH:MM:SS" if you need seconds.',
999
+ 'Use ISO 8601 date-time strings (e.g., "2026-02-03T09:30:00Z").',
1000
+ 'For multi-day: use { "day": 2, "time": "09:30" }.'
1001
+ ];
1002
+ problems.push(buildProblem(
1003
+ 'task.integrity.invalid_start_time',
1004
+ 'error',
1005
+ 'Invalid Start Time Format',
1006
+ `Task '${rawId}' has invalid start value.`,
1007
+ toJsonPointer(taskPtr.concat(['start'])),
1008
+ { task_id: rawId, value: task.start, reason: startParse.reason || startParse.kind },
1009
+ suggestions
1010
+ ));
1011
+ }
1012
+
1013
+ const durationParse = parseDurationToMinutes(task.duration, normalizedTimeUnit);
1014
+ if (!durationParse.ok) {
1015
+ problems.push(buildProblem(
1016
+ 'task.integrity.invalid_duration',
1017
+ 'error',
1018
+ 'Invalid Task Duration',
1019
+ `Task '${rawId}' has invalid duration '${task.duration}'. Duration must be a positive integer (uses config.time_unit), ISO 8601 (PT30M), or shorthand (30m, 1h, 1d, 10s, 1w, 1M).`,
1020
+ toJsonPointer(taskPtr.concat(['duration'])),
1021
+ { task_id: rawId, duration: task.duration },
1022
+ ['Use a positive integer for duration (e.g., 30).', 'Or use "PT30M" / "30m" / "1h".']
1023
+ ));
1024
+ }
1025
+
1026
+ if (startParse.ok && durationParse.ok) {
1027
+ if (startParse.kind === 'datetime') {
1028
+ const endMillis = startParse.startMillis + (durationParse.minutes * 60 * 1000);
1029
+ taskMillis.set(rawId, { startMillis: startParse.startMillis, endMillis, index: i });
1030
+ } else {
1031
+ const startMinutes = startParse.startMinutes;
1032
+ const endMinutes = startMinutes + durationParse.minutes;
1033
+ taskTiming.set(rawId, { startMinutes, endMinutes, index: i });
1034
+ }
1035
+ }
1036
+
1037
+ if (safeTrim(task.location) && locationIds.size > 0 && !locationIds.has(safeTrim(task.location))) {
1038
+ problems.push(buildProblem(
1039
+ 'object.reference.invalid_location',
1040
+ 'error',
1041
+ 'Invalid Location Reference',
1042
+ `Task '${rawId}' references unknown location '${task.location}'.`,
1043
+ toJsonPointer(taskPtr.concat(['location'])),
1044
+ { task_id: rawId, location: task.location },
1045
+ ['Fix the task location id to one defined in simulation.world.layout.locations.', 'Or add this location to the layout.']
1046
+ ));
1047
+ }
1048
+
1049
+ // depends_on validation (array or {all, any})
1050
+ const depends = task.depends_on;
1051
+ const depIds = [];
1052
+ if (Array.isArray(depends)) {
1053
+ depIds.push(...depends);
1054
+ } else if (isPlainObject(depends)) {
1055
+ if (Array.isArray(depends.all)) depIds.push(...depends.all);
1056
+ if (Array.isArray(depends.any)) depIds.push(...depends.any);
1057
+ } else if (depends !== undefined && depends !== null) {
1058
+ problems.push(buildProblem(
1059
+ 'task.reference.invalid_dependency',
1060
+ 'error',
1061
+ 'Invalid Dependency Format',
1062
+ `Task '${rawId}' depends_on must be an array of task ids or an object with "all"/"any" arrays.`,
1063
+ toJsonPointer(taskPtr.concat(['depends_on'])),
1064
+ { task_id: rawId, depends_on: depends },
1065
+ ['Use "depends_on": ["task_a", "task_b"] or { "all": [...], "any": [...] }.']
1066
+ ));
1067
+ }
1068
+
1069
+ for (const dep of depIds) {
1070
+ if (typeof dep !== 'string' || !safeTrim(dep)) {
1071
+ problems.push(buildProblem(
1072
+ 'task.reference.invalid_dependency',
1073
+ 'error',
1074
+ 'Invalid Dependency Reference',
1075
+ `Task '${rawId}' has a depends_on entry that is not a string id.`,
1076
+ toJsonPointer(taskPtr.concat(['depends_on'])),
1077
+ { task_id: rawId, dependency: dep },
1078
+ ['Replace this dependency entry with a valid task id string.']
1079
+ ));
1080
+ continue;
1081
+ }
1082
+
1083
+ if (safeTrim(dep) === rawId) {
1084
+ problems.push(buildProblem(
1085
+ 'task.reference.invalid_dependency',
1086
+ 'error',
1087
+ 'Self-Referencing Dependency',
1088
+ `Task '${rawId}' cannot depend on itself.`,
1089
+ toJsonPointer(taskPtr.concat(['depends_on'])),
1090
+ { task_id: rawId, dependency: dep },
1091
+ ['Remove the self-reference from depends_on.']
1092
+ ));
1093
+ }
1094
+ }
1095
+
1096
+ // Interactions validation
1097
+ const interactions = task.interactions;
1098
+ if (interactions !== undefined && !Array.isArray(interactions)) {
1099
+ problems.push(buildProblem(
1100
+ 'task.integrity.invalid_interactions',
1101
+ 'error',
1102
+ 'Invalid Interactions',
1103
+ `Task '${rawId}' interactions must be an array.`,
1104
+ toJsonPointer(taskPtr.concat(['interactions'])),
1105
+ { task_id: rawId },
1106
+ ['Set interactions to an array (use [] if none).']
1107
+ ));
1108
+ }
1109
+
1110
+ if (Array.isArray(interactions)) {
1111
+ for (let j = 0; j < interactions.length; j += 1) {
1112
+ const interaction = interactions[j];
1113
+ const interactionPtr = taskPtr.concat(['interactions', j]);
1114
+
1115
+ if (!isPlainObject(interaction)) {
1116
+ problems.push(buildProblem(
1117
+ 'task.integrity.invalid_interaction',
1118
+ 'error',
1119
+ 'Invalid Interaction',
1120
+ `Task '${rawId}' has an interaction at index ${j} that is not an object.`,
1121
+ toJsonPointer(interactionPtr),
1122
+ { task_id: rawId, interaction_index: j },
1123
+ ['Replace this entry with a valid interaction object.']
1124
+ ));
1125
+ continue;
1126
+ }
1127
+
1128
+ if (Object.prototype.hasOwnProperty.call(interaction, 'object_id')) {
1129
+ problems.push(buildProblem(
1130
+ 'task.integrity.legacy_field',
1131
+ 'error',
1132
+ 'Legacy Interaction Field',
1133
+ `Task '${rawId}' uses legacy field 'object_id'. WorkSpec v2 requires 'target_id'.`,
1134
+ toJsonPointer(interactionPtr.concat(['object_id'])),
1135
+ { task_id: rawId, legacy_field: 'object_id' },
1136
+ ['Rename object_id to target_id.']
1137
+ ));
1138
+ }
1139
+
1140
+ if (Object.prototype.hasOwnProperty.call(interaction, 'revert_after')) {
1141
+ problems.push(buildProblem(
1142
+ 'task.integrity.legacy_field',
1143
+ 'error',
1144
+ 'Legacy Interaction Field',
1145
+ `Task '${rawId}' uses legacy field 'revert_after'. WorkSpec v2 requires 'temporary'.`,
1146
+ toJsonPointer(interactionPtr.concat(['revert_after'])),
1147
+ { task_id: rawId, legacy_field: 'revert_after' },
1148
+ ['Rename revert_after to temporary.']
1149
+ ));
1150
+ }
1151
+
1152
+ const action = safeTrim(interaction.action);
1153
+ const targetId = ensureString(interaction.target_id);
1154
+
1155
+ if (action) {
1156
+ if (action !== 'create' && action !== 'delete') {
1157
+ problems.push(buildProblem(
1158
+ 'task.integrity.invalid_interaction_action',
1159
+ 'error',
1160
+ 'Invalid Interaction Action',
1161
+ `Task '${rawId}' has invalid interaction.action '${action}'. Supported actions: create, delete.`,
1162
+ toJsonPointer(interactionPtr.concat(['action'])),
1163
+ { task_id: rawId, action },
1164
+ ['Use action "create" or "delete".', 'Remove action to use property_changes interactions.']
1165
+ ));
1166
+ }
1167
+
1168
+ if (action === 'create') {
1169
+ if (!isPlainObject(interaction.object)) {
1170
+ problems.push(buildProblem(
1171
+ 'task.integrity.invalid_interaction',
1172
+ 'error',
1173
+ 'Invalid Create Interaction',
1174
+ `Task '${rawId}' create interaction requires an 'object' field.`,
1175
+ toJsonPointer(interactionPtr),
1176
+ { task_id: rawId, action },
1177
+ ['Add an object field containing the new object definition.']
1178
+ ));
1179
+ }
1180
+ }
1181
+
1182
+ if (action === 'delete') {
1183
+ if (!safeTrim(targetId)) {
1184
+ problems.push(buildProblem(
1185
+ 'task.integrity.invalid_interaction',
1186
+ 'error',
1187
+ 'Invalid Delete Interaction',
1188
+ `Task '${rawId}' delete interaction requires 'target_id'.`,
1189
+ toJsonPointer(interactionPtr),
1190
+ { task_id: rawId, action },
1191
+ ['Add target_id to indicate which object is deleted.']
1192
+ ));
1193
+ }
1194
+ }
1195
+ } else {
1196
+ // Property change interaction
1197
+ if (!safeTrim(targetId)) {
1198
+ problems.push(buildProblem(
1199
+ 'task.integrity.invalid_interaction',
1200
+ 'error',
1201
+ 'Invalid Interaction',
1202
+ `Task '${rawId}' interaction is missing target_id.`,
1203
+ toJsonPointer(interactionPtr.concat(['target_id'])),
1204
+ { task_id: rawId, interaction_index: j },
1205
+ ['Add target_id referencing an existing object.']
1206
+ ));
1207
+ }
1208
+
1209
+ if (!isPlainObject(interaction.property_changes)) {
1210
+ problems.push(buildProblem(
1211
+ 'task.integrity.invalid_interaction',
1212
+ 'error',
1213
+ 'Invalid Interaction',
1214
+ `Task '${rawId}' property-change interaction requires property_changes object.`,
1215
+ toJsonPointer(interactionPtr.concat(['property_changes'])),
1216
+ { task_id: rawId, interaction_index: j },
1217
+ ['Add property_changes with one or more property operations.']
1218
+ ));
1219
+ }
1220
+ }
1221
+
1222
+ if (safeTrim(targetId)) {
1223
+ referencedObjectIds.add(targetId);
1224
+ if (!knownObjectIds.has(targetId)) {
1225
+ problems.push(buildProblem(
1226
+ 'task.integrity.invalid_object_reference',
1227
+ 'error',
1228
+ 'Invalid Object Reference',
1229
+ `Task '${rawId}' references unknown object '${targetId}'.`,
1230
+ toJsonPointer(interactionPtr.concat(['target_id'])),
1231
+ { task_id: rawId, target_id: targetId },
1232
+ ['Fix target_id to match an existing object id in simulation.world.objects.', 'Or create the object earlier via action:create.']
1233
+ ));
1234
+ }
1235
+ }
1236
+
1237
+ if (isPlainObject(interaction.property_changes)) {
1238
+ for (const [propertyName, op] of Object.entries(interaction.property_changes)) {
1239
+ const operatorPtr = interactionPtr.concat(['property_changes', propertyName]);
1240
+ validatePropertyOperator(
1241
+ 'interaction.operator',
1242
+ op,
1243
+ operatorPtr,
1244
+ problems,
1245
+ { task_id: rawId, target_id: targetId, property: propertyName }
1246
+ );
1247
+ }
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ // Track actor_id as object reference for unused-resource detection
1253
+ if (rawActorId) referencedObjectIds.add(rawActorId);
1254
+ }
1255
+
1256
+ // Validate dependency references now that we have taskIds
1257
+ for (let i = 0; i < tasks.length; i += 1) {
1258
+ const task = tasks[i];
1259
+ if (!isPlainObject(task)) continue;
1260
+ const rawId = safeTrim(task.id);
1261
+ if (!rawId) continue;
1262
+ const depends = task.depends_on;
1263
+
1264
+ const collect = [];
1265
+ if (Array.isArray(depends)) collect.push(...depends);
1266
+ if (isPlainObject(depends)) {
1267
+ if (Array.isArray(depends.all)) collect.push(...depends.all);
1268
+ if (Array.isArray(depends.any)) collect.push(...depends.any);
1269
+ }
1270
+
1271
+ for (const dep of collect) {
1272
+ const depId = safeTrim(dep);
1273
+ if (!depId) continue;
1274
+ if (!taskIds.has(depId)) {
1275
+ problems.push(buildProblem(
1276
+ 'task.reference.invalid_dependency',
1277
+ 'error',
1278
+ 'Invalid Dependency Reference',
1279
+ `Task '${rawId}' depends_on references unknown task '${depId}'.`,
1280
+ toJsonPointer(tasksBasePtr.concat([i, 'depends_on'])),
1281
+ { task_id: rawId, dependency_id: depId },
1282
+ ['Fix the dependency id (typo), or add the missing task.']
1283
+ ));
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ // Circular dependency detection (graph on ALL dependencies; any/all are both edges)
1289
+ const graph = new Map(); // taskId -> Set(depId)
1290
+ for (const id of taskIds) {
1291
+ graph.set(id, new Set());
1292
+ }
1293
+ for (let i = 0; i < tasks.length; i += 1) {
1294
+ const task = tasks[i];
1295
+ if (!isPlainObject(task)) continue;
1296
+ const id = safeTrim(task.id);
1297
+ if (!id) continue;
1298
+ const deps = new Set();
1299
+ const depends = task.depends_on;
1300
+ if (Array.isArray(depends)) {
1301
+ depends.forEach((d) => { if (safeTrim(d)) deps.add(safeTrim(d)); });
1302
+ } else if (isPlainObject(depends)) {
1303
+ if (Array.isArray(depends.all)) depends.all.forEach((d) => { if (safeTrim(d)) deps.add(safeTrim(d)); });
1304
+ if (Array.isArray(depends.any)) depends.any.forEach((d) => { if (safeTrim(d)) deps.add(safeTrim(d)); });
1305
+ }
1306
+ graph.set(id, deps);
1307
+ }
1308
+
1309
+ const visitState = new Map(); // id -> 0 unvisited, 1 visiting, 2 done
1310
+ const stack = [];
1311
+
1312
+ function dfs(node) {
1313
+ visitState.set(node, 1);
1314
+ stack.push(node);
1315
+
1316
+ const deps = graph.get(node) || new Set();
1317
+ for (const dep of deps) {
1318
+ if (!graph.has(dep)) continue; // already reported missing
1319
+ const state = visitState.get(dep) || 0;
1320
+ if (state === 1) {
1321
+ const cycleStartIndex = stack.indexOf(dep);
1322
+ const cycle = stack.slice(cycleStartIndex).concat([dep]);
1323
+ problems.push(buildProblem(
1324
+ 'temporal.scheduling.circular_dependency',
1325
+ 'error',
1326
+ 'Circular Dependencies',
1327
+ `Circular dependency detected: ${cycle.join(' -> ')}`,
1328
+ tasksBaseInstance,
1329
+ { cycle },
1330
+ ['Break the cycle by removing or rewriting one of the depends_on references.']
1331
+ ));
1332
+ } else if (state === 0) {
1333
+ dfs(dep);
1334
+ }
1335
+ }
1336
+
1337
+ stack.pop();
1338
+ visitState.set(node, 2);
1339
+ }
1340
+
1341
+ for (const id of taskIds) {
1342
+ if ((visitState.get(id) || 0) === 0) dfs(id);
1343
+ }
1344
+
1345
+ // Object lifecycle semantics (best-effort):
1346
+ // - Objects created via action:create become valid targets after the create interaction runs.
1347
+ // - References to objects after action:delete must error.
1348
+ //
1349
+ // Evaluated by replaying create/delete interactions in chronological order (task start time).
1350
+ try {
1351
+ const tasksInOrderForLifecycle = [...taskTiming.entries()]
1352
+ .map(([taskId, timing]) => ({ taskId, ...timing }))
1353
+ .sort((a, b) => (a.startMinutes - b.startMinutes) || (a.index - b.index));
1354
+
1355
+ const existing = new Set(objectIds); // objects currently "alive" at this point in time
1356
+ const everDefined = new Set(objectIds); // enforce unique IDs across the whole simulation
1357
+ const deletedBy = new Map(); // objectId -> { task_id, start_minutes }
1358
+ let pendingEndEvents = []; // { time, kind: 'create'|'delete', object_id, task_id, order }
1359
+
1360
+ function applyEndEvents(upToTime) {
1361
+ if (pendingEndEvents.length === 0) return;
1362
+ const due = pendingEndEvents.filter((e) => typeof e.time === 'number' && e.time <= upToTime);
1363
+ if (due.length === 0) return;
1364
+ pendingEndEvents = pendingEndEvents.filter((e) => !(typeof e.time === 'number' && e.time <= upToTime));
1365
+
1366
+ due.sort((a, b) => (a.time - b.time) || (a.order - b.order));
1367
+ for (const event of due) {
1368
+ if (event.kind === 'create') {
1369
+ existing.add(event.object_id);
1370
+ deletedBy.delete(event.object_id);
1371
+ } else if (event.kind === 'delete') {
1372
+ existing.delete(event.object_id);
1373
+ deletedBy.set(event.object_id, { task_id: event.task_id, start_minutes: event.time });
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ for (const entry of tasksInOrderForLifecycle) {
1379
+ applyEndEvents(entry.startMinutes);
1380
+
1381
+ const task = taskById.get(entry.taskId);
1382
+ if (!task) continue;
1383
+ const taskIndex = taskIndexById.get(entry.taskId);
1384
+ const taskPtr = tasksBasePtr.concat([typeof taskIndex === 'number' ? taskIndex : entry.index]);
1385
+
1386
+ const actorId = safeTrim(task.actor_id);
1387
+ if (actorId && knownObjectIds.has(actorId) && !existing.has(actorId)) {
1388
+ const lastDelete = deletedBy.get(actorId);
1389
+ const detail = lastDelete
1390
+ ? `Task '${entry.taskId}' references '${actorId}' after it was deleted by task '${lastDelete.task_id}'.`
1391
+ : `Task '${entry.taskId}' references '${actorId}' before it is created.`;
1392
+
1393
+ problems.push(buildProblem(
1394
+ 'object.reference.lifecycle_violation',
1395
+ 'error',
1396
+ 'Object Lifecycle Violation',
1397
+ detail,
1398
+ toJsonPointer(taskPtr.concat(['actor_id'])),
1399
+ { task_id: entry.taskId, object_id: actorId, reference: 'actor_id', deleted_by: lastDelete ? lastDelete.task_id : null },
1400
+ ['Move the reference to after the object is created.', 'Or remove/replace the reference.', 'If this is meant to be permanent, remove temporary from the create/delete interaction.']
1401
+ ));
1402
+ }
1403
+
1404
+ if (!Array.isArray(task.interactions)) continue;
1405
+ for (let j = 0; j < task.interactions.length; j += 1) {
1406
+ const interaction = task.interactions[j];
1407
+ if (!isPlainObject(interaction)) continue;
1408
+ const interactionPtr = taskPtr.concat(['interactions', j]);
1409
+
1410
+ const action = safeTrim(interaction.action);
1411
+ const isTemporary = interaction.temporary === true;
1412
+
1413
+ if (action === 'create') {
1414
+ if (!isPlainObject(interaction.object)) continue;
1415
+ const createdId = safeTrim(interaction.object.id);
1416
+ if (!createdId) continue;
1417
+
1418
+ if (everDefined.has(createdId)) {
1419
+ problems.push(buildProblem(
1420
+ 'object.integrity.duplicate_object_id',
1421
+ 'error',
1422
+ 'Duplicate Object ID',
1423
+ `Task '${entry.taskId}' creates object id '${createdId}', but that id is already defined.`,
1424
+ toJsonPointer(interactionPtr.concat(['object', 'id'])),
1425
+ { task_id: entry.taskId, object_id: createdId },
1426
+ ['Use a unique id for the created object.', 'If you meant to modify an existing object, remove action:create and use property_changes.']
1427
+ ));
1428
+ } else {
1429
+ everDefined.add(createdId);
1430
+ }
1431
+
1432
+ existing.add(createdId);
1433
+ deletedBy.delete(createdId);
1434
+
1435
+ if (isTemporary && typeof entry.endMinutes === 'number' && Number.isFinite(entry.endMinutes)) {
1436
+ pendingEndEvents.push({
1437
+ time: entry.endMinutes,
1438
+ kind: 'delete',
1439
+ object_id: createdId,
1440
+ task_id: entry.taskId,
1441
+ order: (entry.index * 1000) + j
1442
+ });
1443
+ }
1444
+
1445
+ continue;
1446
+ }
1447
+
1448
+ const targetId = safeTrim(interaction.target_id);
1449
+ if (!targetId) continue;
1450
+
1451
+ if (knownObjectIds.has(targetId) && !existing.has(targetId)) {
1452
+ const lastDelete = deletedBy.get(targetId);
1453
+ const detail = lastDelete
1454
+ ? `Task '${entry.taskId}' references '${targetId}' after it was deleted by task '${lastDelete.task_id}'.`
1455
+ : `Task '${entry.taskId}' references '${targetId}' before it is created.`;
1456
+
1457
+ problems.push(buildProblem(
1458
+ 'object.reference.lifecycle_violation',
1459
+ 'error',
1460
+ 'Object Lifecycle Violation',
1461
+ detail,
1462
+ toJsonPointer(interactionPtr.concat(['target_id'])),
1463
+ { task_id: entry.taskId, object_id: targetId, reference: 'target_id', deleted_by: lastDelete ? lastDelete.task_id : null },
1464
+ ['Move the reference to after the object is created.', 'Or remove/replace the reference.', 'If this is meant to be permanent, remove temporary from the create/delete interaction.']
1465
+ ));
1466
+ }
1467
+
1468
+ if (action === 'delete') {
1469
+ if (existing.has(targetId)) {
1470
+ existing.delete(targetId);
1471
+ deletedBy.set(targetId, { task_id: entry.taskId, start_minutes: entry.startMinutes });
1472
+
1473
+ if (isTemporary && typeof entry.endMinutes === 'number' && Number.isFinite(entry.endMinutes)) {
1474
+ pendingEndEvents.push({
1475
+ time: entry.endMinutes,
1476
+ kind: 'create',
1477
+ object_id: targetId,
1478
+ task_id: entry.taskId,
1479
+ order: (entry.index * 1000) + j
1480
+ });
1481
+ }
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1486
+ } catch (error) {
1487
+ problems.push(buildProblem(
1488
+ 'object.reference.lifecycle_validation_error',
1489
+ 'warning',
1490
+ 'Lifecycle Validation Error',
1491
+ `Lifecycle validation encountered an internal error: ${error.message}`,
1492
+ tasksBaseInstance,
1493
+ { error: error.message },
1494
+ ['Retry validation.', 'If this persists, simplify create/delete interactions or report a bug.']
1495
+ ));
1496
+ }
1497
+
1498
+ // Temporal scheduling checks (only for time/daytime tasks where we have minutes)
1499
+ // 1) actor overlap
1500
+ const tasksByActor = new Map(); // actorId -> [{taskId, start, end}]
1501
+ for (const [taskId, timing] of taskTiming.entries()) {
1502
+ const task = taskById.get(taskId);
1503
+ if (!task) continue;
1504
+ const actorId = ensureString(task.actor_id);
1505
+ if (!safeTrim(actorId)) continue;
1506
+ if (!tasksByActor.has(actorId)) tasksByActor.set(actorId, []);
1507
+ tasksByActor.get(actorId).push({ taskId, start: timing.startMinutes, end: timing.endMinutes, index: timing.index });
1508
+ }
1509
+
1510
+ for (const [actorId, items] of tasksByActor.entries()) {
1511
+ items.sort((a, b) => (a.start - b.start) || (a.index - b.index));
1512
+ for (let i = 1; i < items.length; i += 1) {
1513
+ const prev = items[i - 1];
1514
+ const cur = items[i];
1515
+ if (cur.start < prev.end) {
1516
+ problems.push(buildProblem(
1517
+ 'temporal.scheduling.actor_overlap',
1518
+ 'error',
1519
+ 'Actor Overlap',
1520
+ `Actor '${actorId}' has overlapping tasks: '${prev.taskId}' overlaps '${cur.taskId}'.`,
1521
+ tasksBaseInstance,
1522
+ { actor_id: actorId, task_a: prev.taskId, task_b: cur.taskId },
1523
+ ['Adjust start times/durations to remove the overlap.', 'Assign one task to a different performer object.']
1524
+ ));
1525
+ }
1526
+ }
1527
+ }
1528
+
1529
+ // 2) dependency timing violations (best-effort)
1530
+ for (let i = 0; i < tasks.length; i += 1) {
1531
+ const task = tasks[i];
1532
+ if (!isPlainObject(task)) continue;
1533
+ const id = safeTrim(task.id);
1534
+ if (!id) continue;
1535
+ const timing = taskTiming.get(id);
1536
+ if (!timing) continue;
1537
+
1538
+ const depends = task.depends_on;
1539
+ const allDeps = [];
1540
+ const anyDeps = [];
1541
+ if (Array.isArray(depends)) {
1542
+ allDeps.push(...depends.map((d) => safeTrim(d)).filter(Boolean));
1543
+ } else if (isPlainObject(depends)) {
1544
+ if (Array.isArray(depends.all)) allDeps.push(...depends.all.map((d) => safeTrim(d)).filter(Boolean));
1545
+ if (Array.isArray(depends.any)) anyDeps.push(...depends.any.map((d) => safeTrim(d)).filter(Boolean));
1546
+ }
1547
+
1548
+ if (allDeps.length === 0 && anyDeps.length === 0) continue;
1549
+
1550
+ let requiredEnd = 0;
1551
+ if (allDeps.length > 0) {
1552
+ const ends = allDeps.map((depId) => taskTiming.get(depId)?.endMinutes).filter((v) => typeof v === 'number');
1553
+ if (ends.length > 0) requiredEnd = Math.max(requiredEnd, Math.max(...ends));
1554
+ }
1555
+ if (anyDeps.length > 0) {
1556
+ const ends = anyDeps.map((depId) => taskTiming.get(depId)?.endMinutes).filter((v) => typeof v === 'number');
1557
+ if (ends.length > 0) requiredEnd = Math.max(requiredEnd, Math.min(...ends));
1558
+ }
1559
+
1560
+ if (timing.startMinutes < requiredEnd) {
1561
+ problems.push(buildProblem(
1562
+ 'temporal.scheduling.dependency_violation',
1563
+ 'error',
1564
+ 'Dependency Violation',
1565
+ `Task '${id}' starts before its dependency condition is satisfied.`,
1566
+ toJsonPointer(tasksBasePtr.concat([i, 'start'])),
1567
+ { task_id: id, start_minutes: timing.startMinutes, required_end_minutes: requiredEnd },
1568
+ ['Move the task start time later.', 'Shorten dependency durations or adjust dependencies.']
1569
+ ));
1570
+ }
1571
+ }
1572
+
1573
+ // Recipe validation (optional warnings)
1574
+ if (recipeDefinitions) {
1575
+ for (let i = 0; i < tasks.length; i += 1) {
1576
+ const task = tasks[i];
1577
+ if (!isPlainObject(task)) continue;
1578
+ const taskId = safeTrim(task.id);
1579
+ if (!taskId) continue;
1580
+ if (!Array.isArray(task.interactions)) continue;
1581
+
1582
+ const produced = new Map(); // productId -> producedQuantity
1583
+ const consumed = new Map(); // resourceId -> consumedQuantity (positive number)
1584
+
1585
+ for (const interaction of task.interactions) {
1586
+ if (!isPlainObject(interaction) || safeTrim(interaction.action)) continue;
1587
+ const targetId = safeTrim(interaction.target_id);
1588
+ if (!targetId) continue;
1589
+ if (!isPlainObject(interaction.property_changes)) continue;
1590
+ const qChange = interaction.property_changes.quantity;
1591
+ if (!isPlainObject(qChange)) continue;
1592
+ if (Object.prototype.hasOwnProperty.call(qChange, 'delta') && typeof qChange.delta === 'number') {
1593
+ if (qChange.delta > 0) {
1594
+ produced.set(targetId, (produced.get(targetId) || 0) + qChange.delta);
1595
+ } else if (qChange.delta < 0) {
1596
+ consumed.set(targetId, (consumed.get(targetId) || 0) + Math.abs(qChange.delta));
1597
+ }
1598
+ }
1599
+ }
1600
+
1601
+ for (const [productId] of produced.entries()) {
1602
+ const recipe = recipeDefinitions[productId];
1603
+ if (!isPlainObject(recipe) || !isPlainObject(recipe.inputs)) continue;
1604
+ const missing = [];
1605
+ for (const [inputId, requiredQty] of Object.entries(recipe.inputs)) {
1606
+ const req = typeof requiredQty === 'number' ? requiredQty : null;
1607
+ if (req === null || req <= 0) continue;
1608
+ const used = consumed.get(inputId) || 0;
1609
+ if (used < req) missing.push(inputId);
1610
+ }
1611
+ if (missing.length > 0) {
1612
+ problems.push(buildProblem(
1613
+ 'recipe.compliance.missing_inputs',
1614
+ 'warning',
1615
+ 'Recipe Violation',
1616
+ `Task '${taskId}' produces '${productId}' but is missing required recipe inputs: ${missing.join(', ')}.`,
1617
+ toJsonPointer(tasksBasePtr.concat([i])),
1618
+ { task_id: taskId, product_id: productId, missing_inputs: missing },
1619
+ ['Add consumption interactions for the missing inputs.', 'Or update/remove the recipe if it does not apply.']
1620
+ ));
1621
+ }
1622
+ }
1623
+ }
1624
+ }
1625
+
1626
+ // Resource flow: negative stock (best-effort sequential application by start time)
1627
+ const quantities = new Map(); // objectId -> quantity
1628
+ for (let i = 0; i < objects.length; i += 1) {
1629
+ const obj = objects[i];
1630
+ if (!isPlainObject(obj)) continue;
1631
+ const id = safeTrim(obj.id);
1632
+ if (!id) continue;
1633
+ const type = safeTrim(obj.type);
1634
+ const props = isPlainObject(obj.properties) ? obj.properties : {};
1635
+ if (type === 'resource' || type === 'product') {
1636
+ if (typeof props.quantity === 'number' && Number.isFinite(props.quantity)) {
1637
+ quantities.set(id, props.quantity);
1638
+ }
1639
+ }
1640
+ }
1641
+
1642
+ const tasksInOrder = [...taskTiming.entries()]
1643
+ .map(([taskId, timing]) => ({ taskId, ...timing }))
1644
+ .sort((a, b) => (a.startMinutes - b.startMinutes) || (a.index - b.index));
1645
+
1646
+ for (const entry of tasksInOrder) {
1647
+ const task = taskById.get(entry.taskId);
1648
+ if (!task || !Array.isArray(task.interactions)) continue;
1649
+
1650
+ for (const interaction of task.interactions) {
1651
+ if (!isPlainObject(interaction) || safeTrim(interaction.action)) continue;
1652
+ const targetId = safeTrim(interaction.target_id);
1653
+ if (!targetId) continue;
1654
+ if (!isPlainObject(interaction.property_changes)) continue;
1655
+ const qOp = interaction.property_changes.quantity;
1656
+ if (!isPlainObject(qOp)) continue;
1657
+
1658
+ let delta = null;
1659
+ if (Object.prototype.hasOwnProperty.call(qOp, 'delta') && typeof qOp.delta === 'number' && Number.isFinite(qOp.delta)) {
1660
+ delta = qOp.delta;
1661
+ } else if (qOp.increment === true) {
1662
+ delta = 1;
1663
+ } else if (qOp.decrement === true) {
1664
+ delta = -1;
1665
+ }
1666
+
1667
+ if (delta === null) continue;
1668
+
1669
+ if (!quantities.has(targetId)) continue;
1670
+ const before = quantities.get(targetId);
1671
+ const after = before + delta;
1672
+ quantities.set(targetId, after);
1673
+
1674
+ if (after < 0) {
1675
+ problems.push(buildProblem(
1676
+ 'resource.flow.negative_stock',
1677
+ 'error',
1678
+ 'Negative Stock',
1679
+ `Resource '${targetId}' goes negative (${before} -> ${after}) in task '${entry.taskId}'.`,
1680
+ tasksBaseInstance,
1681
+ { task_id: entry.taskId, object_id: targetId, before, delta, after },
1682
+ ['Increase the starting quantity for this resource.', 'Reduce consumption deltas, or add production earlier.']
1683
+ ));
1684
+ }
1685
+ }
1686
+ }
1687
+
1688
+ // Economic: negative margin (warning)
1689
+ try {
1690
+ let totalLaborCost = 0;
1691
+ let totalMaterialCost = 0;
1692
+ let totalRevenue = 0;
1693
+
1694
+ for (let i = 0; i < tasksInOrder.length; i += 1) {
1695
+ const entry = tasksInOrder[i];
1696
+ const task = taskById.get(entry.taskId);
1697
+ if (!task) continue;
1698
+
1699
+ const durationMinutes = taskTiming.get(entry.taskId)?.endMinutes - taskTiming.get(entry.taskId)?.startMinutes;
1700
+ const hours = (typeof durationMinutes === 'number' && Number.isFinite(durationMinutes)) ? (durationMinutes / 60) : 0;
1701
+
1702
+ const actorId = safeTrim(task.actor_id);
1703
+ if (actorId && objectById.has(actorId)) {
1704
+ const performer = objectById.get(actorId);
1705
+ const props = isPlainObject(performer.properties) ? performer.properties : {};
1706
+ const costPerHour = typeof props.cost_per_hour === 'number' ? props.cost_per_hour : 0;
1707
+ const overheadRate = typeof props.overhead_rate === 'number' ? props.overhead_rate : 0;
1708
+ const effectiveCostPerHour = costPerHour * (1 + (Number.isFinite(overheadRate) ? overheadRate : 0));
1709
+ totalLaborCost += effectiveCostPerHour * hours;
1710
+
1711
+ const depreciationPerHour = typeof props.depreciation_per_hour === 'number' ? props.depreciation_per_hour : 0;
1712
+ totalLaborCost += depreciationPerHour * hours;
1713
+ }
1714
+
1715
+ if (!Array.isArray(task.interactions)) continue;
1716
+ for (const interaction of task.interactions) {
1717
+ if (!isPlainObject(interaction) || safeTrim(interaction.action)) continue;
1718
+ const targetId = safeTrim(interaction.target_id);
1719
+ if (!targetId || !objectById.has(targetId)) continue;
1720
+ const obj = objectById.get(targetId);
1721
+ const type = safeTrim(obj.type);
1722
+ const props = isPlainObject(obj.properties) ? obj.properties : {};
1723
+ const qOp = interaction.property_changes?.quantity;
1724
+ if (!isPlainObject(qOp) || typeof qOp.delta !== 'number') continue;
1725
+
1726
+ if (qOp.delta < 0 && type === 'resource') {
1727
+ const unitCost = typeof props.cost_per_unit === 'number' ? props.cost_per_unit : 0;
1728
+ totalMaterialCost += Math.abs(qOp.delta) * unitCost;
1729
+ }
1730
+
1731
+ if (qOp.delta > 0 && type === 'product') {
1732
+ const unitRevenue = typeof props.revenue_per_unit === 'number' ? props.revenue_per_unit : 0;
1733
+ totalRevenue += qOp.delta * unitRevenue;
1734
+ }
1735
+ }
1736
+ }
1737
+
1738
+ const grossProfit = totalRevenue - totalLaborCost - totalMaterialCost;
1739
+ if (Number.isFinite(grossProfit) && grossProfit < 0) {
1740
+ problems.push(buildProblem(
1741
+ 'economic.profitability.negative_margin',
1742
+ 'warning',
1743
+ 'Negative Profitability',
1744
+ `Simulation gross profit is negative (${grossProfit.toFixed(2)}).`,
1745
+ '/simulation',
1746
+ { total_labor_cost: totalLaborCost, total_material_cost: totalMaterialCost, total_revenue: totalRevenue, gross_profit: grossProfit },
1747
+ ['Adjust costs/revenue on objects, reduce task durations, or reduce resource consumption.', 'Confirm the units/currency settings are correct.']
1748
+ ));
1749
+ }
1750
+ } catch (error) {
1751
+ problems.push(buildProblem(
1752
+ 'system.error',
1753
+ 'warning',
1754
+ 'Economic Validation Error',
1755
+ `Economic validation failed: ${error && error.message ? error.message : String(error)}`,
1756
+ '/simulation',
1757
+ { error: error && error.message ? error.message : String(error) },
1758
+ ['Fix validation errors first, then re-run.', 'If this persists, simplify the document and try again.']
1759
+ ));
1760
+ }
1761
+
1762
+ // Unused resources (info)
1763
+ const allInteractionTargets = new Set([...referencedObjectIds]);
1764
+ if (recipeDefinitions) {
1765
+ for (const recipe of Object.values(recipeDefinitions)) {
1766
+ if (!isPlainObject(recipe) || !isPlainObject(recipe.inputs)) continue;
1767
+ for (const inputId of Object.keys(recipe.inputs)) {
1768
+ allInteractionTargets.add(inputId);
1769
+ }
1770
+ }
1771
+ }
1772
+
1773
+ for (let i = 0; i < objects.length; i += 1) {
1774
+ const obj = objects[i];
1775
+ if (!isPlainObject(obj)) continue;
1776
+ const id = safeTrim(obj.id);
1777
+ const type = safeTrim(obj.type);
1778
+ if (!id || type !== 'resource') continue;
1779
+ if (!allInteractionTargets.has(id)) {
1780
+ problems.push(buildProblem(
1781
+ 'object.optimization.unused_resource',
1782
+ 'info',
1783
+ 'Unused Resource',
1784
+ `Resource '${id}' is defined but never used by any task.`,
1785
+ toJsonPointer(objectsBasePtr.concat([i])),
1786
+ { object_id: id },
1787
+ ['Remove the unused resource, or add tasks/interactions that consume it.']
1788
+ ));
1789
+ }
1790
+ }
1791
+
1792
+ const ok = problems.every((p) => p.severity !== 'error');
1793
+ return { ok, problems };
1794
+ }
1795
+
1796
+ const api = {
1797
+ validate,
1798
+ parseDurationToMinutes,
1799
+ parseTaskStart,
1800
+ parseStrictTimeStringToMinutes,
1801
+ SUPPORTED_SCHEMA_VERSIONS,
1802
+ WORKSPEC_NAMESPACE
1803
+ };
1804
+
1805
+ if (typeof window !== 'undefined') {
1806
+ window.WorkSpecValidator = api;
1807
+ }
1808
+
1809
+ if (typeof module !== 'undefined' && module.exports) {
1810
+ module.exports = api;
1811
+ }
1812
+ })();