@geotechcli/core 0.4.77 → 0.4.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/agents/data-tools.js +38 -1
  2. package/dist/agents/data-tools.js.map +1 -1
  3. package/dist/agents/fem-artifact-guards.d.ts +14 -0
  4. package/dist/agents/fem-artifact-guards.d.ts.map +1 -0
  5. package/dist/agents/fem-artifact-guards.js +53 -0
  6. package/dist/agents/fem-artifact-guards.js.map +1 -0
  7. package/dist/agents/fem-tools.js +86 -1
  8. package/dist/agents/fem-tools.js.map +1 -1
  9. package/dist/agents/filesystem-tools.js +13 -0
  10. package/dist/agents/filesystem-tools.js.map +1 -1
  11. package/dist/agents/provider-operating-contract.d.ts +3 -3
  12. package/dist/agents/provider-operating-contract.d.ts.map +1 -1
  13. package/dist/agents/provider-operating-contract.js +10 -46
  14. package/dist/agents/provider-operating-contract.js.map +1 -1
  15. package/dist/agents/runtime-bootstrap.d.ts +1 -0
  16. package/dist/agents/runtime-bootstrap.d.ts.map +1 -1
  17. package/dist/agents/runtime-bootstrap.js +1 -0
  18. package/dist/agents/runtime-bootstrap.js.map +1 -1
  19. package/dist/agents/safety.d.ts.map +1 -1
  20. package/dist/agents/safety.js +33 -0
  21. package/dist/agents/safety.js.map +1 -1
  22. package/dist/agents/signal-tools.d.ts +2 -0
  23. package/dist/agents/signal-tools.d.ts.map +1 -0
  24. package/dist/agents/signal-tools.js +96 -0
  25. package/dist/agents/signal-tools.js.map +1 -0
  26. package/dist/agents/swarm-planner.js +1 -1
  27. package/dist/agents/swarm-planner.js.map +1 -1
  28. package/dist/agents/swarm.d.ts +1 -0
  29. package/dist/agents/swarm.d.ts.map +1 -1
  30. package/dist/agents/swarm.js +202 -31
  31. package/dist/agents/swarm.js.map +1 -1
  32. package/dist/fem/ground-model-draft.d.ts +14 -0
  33. package/dist/fem/ground-model-draft.d.ts.map +1 -1
  34. package/dist/fem/ground-model-draft.js +86 -21
  35. package/dist/fem/ground-model-draft.js.map +1 -1
  36. package/dist/fem/index.d.ts +1 -1
  37. package/dist/fem/index.d.ts.map +1 -1
  38. package/dist/fem/index.js +1 -1
  39. package/dist/fem/index.js.map +1 -1
  40. package/dist/fem/routing.d.ts +15 -1
  41. package/dist/fem/routing.d.ts.map +1 -1
  42. package/dist/fem/routing.js +192 -14
  43. package/dist/fem/routing.js.map +1 -1
  44. package/dist/fem/validation.d.ts.map +1 -1
  45. package/dist/fem/validation.js +715 -30
  46. package/dist/fem/validation.js.map +1 -1
  47. package/dist/fem/webgl.d.ts.map +1 -1
  48. package/dist/fem/webgl.js +24 -8
  49. package/dist/fem/webgl.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/ingest/document-evidence-packet.d.ts +603 -45
  55. package/dist/ingest/document-evidence-packet.d.ts.map +1 -1
  56. package/dist/ingest/document-evidence-packet.js +145 -5
  57. package/dist/ingest/document-evidence-packet.js.map +1 -1
  58. package/dist/ingest/geotech-benchmark-corpus.d.ts +108 -0
  59. package/dist/ingest/geotech-benchmark-corpus.d.ts.map +1 -0
  60. package/dist/ingest/geotech-benchmark-corpus.js +423 -0
  61. package/dist/ingest/geotech-benchmark-corpus.js.map +1 -0
  62. package/dist/ingest/geotech-document-benchmark.d.ts +133 -0
  63. package/dist/ingest/geotech-document-benchmark.d.ts.map +1 -1
  64. package/dist/ingest/geotech-document-benchmark.js +370 -2
  65. package/dist/ingest/geotech-document-benchmark.js.map +1 -1
  66. package/dist/ingest/geotech-document.d.ts +3 -0
  67. package/dist/ingest/geotech-document.d.ts.map +1 -1
  68. package/dist/ingest/geotech-document.js +7 -0
  69. package/dist/ingest/geotech-document.js.map +1 -1
  70. package/dist/ingest/index.d.ts +2 -1
  71. package/dist/ingest/index.d.ts.map +1 -1
  72. package/dist/ingest/index.js +1 -0
  73. package/dist/ingest/index.js.map +1 -1
  74. package/dist/ingest/job-store.d.ts.map +1 -1
  75. package/dist/ingest/job-store.js +193 -0
  76. package/dist/ingest/job-store.js.map +1 -1
  77. package/dist/ingest/job-worker.d.ts.map +1 -1
  78. package/dist/ingest/job-worker.js +5 -0
  79. package/dist/ingest/job-worker.js.map +1 -1
  80. package/dist/ingest/page-evidence-cache.d.ts +6 -2
  81. package/dist/ingest/page-evidence-cache.d.ts.map +1 -1
  82. package/dist/ingest/page-evidence-cache.js +226 -4
  83. package/dist/ingest/page-evidence-cache.js.map +1 -1
  84. package/dist/ingest/pdf.d.ts.map +1 -1
  85. package/dist/ingest/pdf.js +2 -2
  86. package/dist/ingest/pdf.js.map +1 -1
  87. package/dist/ingest/review-store.d.ts +3 -0
  88. package/dist/ingest/review-store.d.ts.map +1 -1
  89. package/dist/ingest/review-store.js +28 -0
  90. package/dist/ingest/review-store.js.map +1 -1
  91. package/dist/llm/capabilities.d.ts +6 -1
  92. package/dist/llm/capabilities.d.ts.map +1 -1
  93. package/dist/llm/capabilities.js +66 -0
  94. package/dist/llm/capabilities.js.map +1 -1
  95. package/dist/llm/index.d.ts +2 -2
  96. package/dist/llm/index.d.ts.map +1 -1
  97. package/dist/llm/index.js +1 -1
  98. package/dist/llm/index.js.map +1 -1
  99. package/dist/llm/types.d.ts +20 -0
  100. package/dist/llm/types.d.ts.map +1 -1
  101. package/dist/llm/types.js.map +1 -1
  102. package/dist/meta/metadata.json +1 -1
  103. package/dist/report/ingest-dossier.d.ts.map +1 -1
  104. package/dist/report/ingest-dossier.js +13 -1
  105. package/dist/report/ingest-dossier.js.map +1 -1
  106. package/dist/signal/index.d.ts +95 -0
  107. package/dist/signal/index.d.ts.map +1 -0
  108. package/dist/signal/index.js +375 -0
  109. package/dist/signal/index.js.map +1 -0
  110. package/dist/verifier/findings.d.ts +1 -1
  111. package/dist/verifier/findings.d.ts.map +1 -1
  112. package/dist/verifier/findings.js +329 -0
  113. package/dist/verifier/findings.js.map +1 -1
  114. package/dist/vision/ocr.d.ts +2 -0
  115. package/dist/vision/ocr.d.ts.map +1 -1
  116. package/dist/vision/ocr.js +78 -2
  117. package/dist/vision/ocr.js.map +1 -1
  118. package/dist/vision/preprocess.d.ts +65 -0
  119. package/dist/vision/preprocess.d.ts.map +1 -1
  120. package/dist/vision/preprocess.js +620 -7
  121. package/dist/vision/preprocess.js.map +1 -1
  122. package/dist/workspace/project-workflow-executor.d.ts +1 -1
  123. package/dist/workspace/project-workflow-executor.d.ts.map +1 -1
  124. package/dist/workspace/project-workflow-executor.js +216 -4
  125. package/dist/workspace/project-workflow-executor.js.map +1 -1
  126. package/dist/workspace/project-workflow-router.d.ts.map +1 -1
  127. package/dist/workspace/project-workflow-router.js +41 -1
  128. package/dist/workspace/project-workflow-router.js.map +1 -1
  129. package/package.json +1 -1
@@ -17,6 +17,34 @@ function isRecord(value) {
17
17
  function isNonEmptyString(value) {
18
18
  return typeof value === 'string' && value.trim().length > 0;
19
19
  }
20
+ const FEM_WEBGL_UINT16_INDEX_LIMIT = 65_535;
21
+ const FEM_MAX_PREVIEW_MESH_NODES = FEM_WEBGL_UINT16_INDEX_LIMIT + 1;
22
+ function isFiniteNumber(value) {
23
+ return typeof value === 'number' && Number.isFinite(value);
24
+ }
25
+ function pushFiniteNumberFinding(findings, value, code, label, options = {}) {
26
+ if (!isFiniteNumber(value)) {
27
+ findings.push(finding('blocker', `${code}.non-finite`, `${label} must be a finite number.`));
28
+ return false;
29
+ }
30
+ if (options.positive && value <= 0) {
31
+ findings.push(finding('blocker', `${code}.positive`, `${label} must be positive.`));
32
+ return false;
33
+ }
34
+ if (options.nonNegative && value < 0) {
35
+ findings.push(finding('blocker', `${code}.non-negative`, `${label} must be non-negative.`));
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+ function pushPositiveNumberFindings(findings, entries) {
41
+ for (const [value, code, label] of entries) {
42
+ pushFiniteNumberFinding(findings, value, code, label, { positive: true });
43
+ }
44
+ }
45
+ function arraysApproximatelyEqual(left, right, tolerance) {
46
+ return left.length === right.length && left.every((value, index) => Math.abs(value - right[index]) <= tolerance);
47
+ }
20
48
  function isFemAnalysisCaseShape(value) {
21
49
  if (!isRecord(value))
22
50
  return false;
@@ -28,8 +56,10 @@ function isFemAnalysisCaseShape(value) {
28
56
  (isRecord(geometry.raft) || isRecord(geometry.excavation) || isRecord(geometry.tunnel)) &&
29
57
  isRecord(mesh) &&
30
58
  isRecord(groundwater) &&
59
+ isRecord(value.units) &&
31
60
  Array.isArray(value.materials) &&
32
61
  Array.isArray(value.loads) &&
62
+ Array.isArray(value.boundaryConditions) &&
33
63
  Array.isArray(value.assumptions) &&
34
64
  Array.isArray(value.limitations) &&
35
65
  Array.isArray(value.evidenceRefs));
@@ -45,6 +75,84 @@ function pushUniqueStringFinding(findings, seen, value, code, label) {
45
75
  seen.add(value);
46
76
  return value;
47
77
  }
78
+ function validateEvidenceRefs(findings, evidenceRefs, prefix) {
79
+ if (!Array.isArray(evidenceRefs)) {
80
+ findings.push(finding('blocker', `${prefix}.evidence-refs-invalid`, 'Evidence refs must be an array.'));
81
+ return;
82
+ }
83
+ const evidenceIds = new Set();
84
+ for (const [index, evidence] of evidenceRefs.entries()) {
85
+ const itemPrefix = `${prefix}.evidenceRefs.${index}`;
86
+ if (!isRecord(evidence)) {
87
+ findings.push(finding('blocker', `${itemPrefix}.shape-invalid`, 'Evidence ref must be an object.'));
88
+ continue;
89
+ }
90
+ pushUniqueStringFinding(findings, evidenceIds, evidence.id, `${itemPrefix}.id`, 'Evidence ref id');
91
+ if (evidence.source != null && !isNonEmptyString(evidence.source)) {
92
+ findings.push(finding('blocker', `${itemPrefix}.source.invalid`, 'Evidence ref source must be a non-empty string when present.'));
93
+ }
94
+ if (evidence.page != null && (typeof evidence.page !== 'number' || !Number.isInteger(evidence.page) || evidence.page < 1)) {
95
+ findings.push(finding('blocker', `${itemPrefix}.page.invalid`, 'Evidence ref page must be an integer greater than or equal to 1 when present.'));
96
+ }
97
+ if (evidence.note != null && !isNonEmptyString(evidence.note)) {
98
+ findings.push(finding('blocker', `${itemPrefix}.note.invalid`, 'Evidence ref note must be a non-empty string when present.'));
99
+ }
100
+ }
101
+ }
102
+ function validateAssumptions(findings, assumptions, prefix, options = {}) {
103
+ if (!Array.isArray(assumptions)) {
104
+ findings.push(finding('blocker', `${prefix}.assumptions-invalid`, 'Assumptions must be an array.'));
105
+ return;
106
+ }
107
+ const validConfidence = new Set(['measured', 'inferred', 'review']);
108
+ const assumptionIds = new Set();
109
+ for (const [index, assumption] of assumptions.entries()) {
110
+ const itemPrefix = `${prefix}.assumptions.${index}`;
111
+ if (!isRecord(assumption)) {
112
+ findings.push(finding('blocker', `${itemPrefix}.shape-invalid`, 'Assumption must be an object.'));
113
+ continue;
114
+ }
115
+ const id = pushUniqueStringFinding(findings, assumptionIds, assumption.id, `${itemPrefix}.id`, 'Assumption id');
116
+ const parameter = isNonEmptyString(assumption.parameter) ? assumption.parameter : undefined;
117
+ if (!parameter) {
118
+ findings.push(finding('blocker', `${itemPrefix}.parameter.missing`, 'Assumption parameter must be a non-empty string.'));
119
+ }
120
+ if (!(typeof assumption.value === 'number' && Number.isFinite(assumption.value)) &&
121
+ !isNonEmptyString(assumption.value)) {
122
+ findings.push(finding('blocker', `${itemPrefix}.value.invalid`, 'Assumption value must be a finite number or non-empty string.'));
123
+ }
124
+ if (assumption.unit != null && !isNonEmptyString(assumption.unit)) {
125
+ findings.push(finding('blocker', `${itemPrefix}.unit.invalid`, 'Assumption unit must be a non-empty string when present.'));
126
+ }
127
+ if (!isNonEmptyString(assumption.basis)) {
128
+ findings.push(finding('blocker', `${itemPrefix}.basis.missing`, 'Assumption basis must be a non-empty string.'));
129
+ }
130
+ if (!validConfidence.has(String(assumption.confidence))) {
131
+ findings.push(finding('blocker', `${itemPrefix}.confidence.invalid`, `Unsupported assumption confidence: ${String(assumption.confidence)}.`));
132
+ }
133
+ if (typeof assumption.reviewRequired !== 'boolean') {
134
+ findings.push(finding('blocker', `${itemPrefix}.review-required.invalid`, 'Assumption reviewRequired must be boolean.'));
135
+ }
136
+ if (options.emitReviewFindings && id && parameter && (assumption.reviewRequired === true || assumption.confidence === 'review')) {
137
+ findings.push(finding('review', `assumption.${id}`, `${parameter} is an engineering assumption requiring review.`));
138
+ }
139
+ }
140
+ }
141
+ function validateLimitations(findings, limitations, prefix) {
142
+ if (!Array.isArray(limitations)) {
143
+ findings.push(finding('blocker', `${prefix}.limitations-invalid`, 'Limitations must be an array.'));
144
+ return;
145
+ }
146
+ if (limitations.length === 0) {
147
+ findings.push(finding('blocker', `${prefix}.limitations-missing`, 'Experimental FEM artifacts must include at least one limitation.'));
148
+ return;
149
+ }
150
+ for (const [index, limitation] of limitations.entries()) {
151
+ if (!isNonEmptyString(limitation)) {
152
+ findings.push(finding('blocker', `${prefix}.limitations.${index}.missing`, 'Limitation entries must be non-empty strings.'));
153
+ }
154
+ }
155
+ }
48
156
  function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNodeCount) {
49
157
  const { resultFields, steps, datasets } = manifest;
50
158
  if (resultFields != null && !Array.isArray(resultFields)) {
@@ -59,15 +167,49 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
59
167
  if (!Array.isArray(resultFields) && !Array.isArray(steps) && !Array.isArray(datasets)) {
60
168
  return;
61
169
  }
170
+ if (!Array.isArray(resultFields) || !Array.isArray(steps) || !Array.isArray(datasets)) {
171
+ findings.push(finding('blocker', 'result.metadata.incomplete', 'Result metadata must include resultFields, steps, and datasets together, or omit all three for legacy manifests.'));
172
+ }
62
173
  const validFieldLocations = new Set(['surface_nodes', 'outline_nodes', 'envelope']);
63
174
  const validFieldQuantities = new Set(['displacement', 'reaction', 'load', 'stage_count']);
175
+ const validFieldComponents = new Set(['x', 'y', 'z', 'magnitude']);
64
176
  const validDatasetSources = new Set(['visualization.disp', 'visualization.frame', 'envelope']);
65
177
  const fieldIds = new Set();
66
178
  const stepIds = new Set();
179
+ const stepIndexes = new Set();
67
180
  const datasetIds = new Set();
68
- const excavationStageIds = new Set(manifest.analysisCase.geometry.excavation?.stages.map((stage) => stage.id) ?? []);
181
+ const excavationStages = manifest.analysisCase.geometry.excavation?.stages ?? [];
182
+ const excavationStageIds = new Set(excavationStages.map((stage) => stage.id));
183
+ const excavationStageById = new Map(excavationStages.map((stage, index) => [stage.id, { stage, index }]));
69
184
  const stepIdByIndex = new Map();
185
+ const stepLabelByIndex = new Map();
70
186
  const fieldLocations = new Map();
187
+ const fieldInfoById = new Map();
188
+ const frameByKey = new Map();
189
+ const referencedFieldIds = new Set();
190
+ const referencedStepIds = new Set();
191
+ const envelopeValueByFieldId = new Map([
192
+ ['max_settlement', manifest.envelope.maxSettlementMm],
193
+ ['min_settlement', manifest.envelope.minSettlementMm],
194
+ ['total_load', manifest.envelope.totalLoadKn],
195
+ ['reaction', manifest.envelope.reactionKn],
196
+ ['reaction_balance_ratio', manifest.envelope.reactionBalanceRatio],
197
+ ['max_surface_settlement', manifest.envelope.maxSurfaceSettlementMm],
198
+ ['max_horizontal_displacement', manifest.envelope.maxHorizontalDisplacementMm],
199
+ ['max_wall_deflection', manifest.envelope.maxWallDeflectionMm],
200
+ ['max_basal_heave', manifest.envelope.maxBasalHeaveMm],
201
+ ['total_excavated_weight', manifest.envelope.totalExcavatedWeightKn],
202
+ ['support_reaction', manifest.envelope.supportReactionKn],
203
+ ['boundary_reaction', manifest.envelope.boundaryReactionKn],
204
+ ['stage_count', manifest.envelope.stageCount],
205
+ ['tunnel_diameter', manifest.envelope.tunnelDiameterM],
206
+ ['tunnel_axis_depth', manifest.envelope.tunnelAxisDepthM],
207
+ ['volume_loss', manifest.envelope.volumeLossPercent],
208
+ ['trough_width', manifest.envelope.troughWidthM],
209
+ ['influence_width', manifest.envelope.influenceWidthM],
210
+ ['settlement_volume', manifest.envelope.settlementVolumeM3],
211
+ ['settlement_volume_per_m', manifest.envelope.settlementVolumePerM],
212
+ ].filter((entry) => isFiniteNumber(entry[1])));
71
213
  if (Array.isArray(resultFields)) {
72
214
  for (const [index, fieldInfo] of resultFields.entries()) {
73
215
  const id = pushUniqueStringFinding(findings, fieldIds, fieldInfo.id, `result.fields.${index}.id`, 'Result field id');
@@ -83,8 +225,37 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
83
225
  if (!validFieldQuantities.has(fieldInfo.quantity)) {
84
226
  findings.push(finding('blocker', `result.fields.${index}.quantity.invalid`, `Unsupported result field quantity: ${String(fieldInfo.quantity)}.`));
85
227
  }
86
- if (id)
228
+ if (fieldInfo.component != null && !validFieldComponents.has(String(fieldInfo.component))) {
229
+ findings.push(finding('blocker', `result.fields.${index}.component.invalid`, `Unsupported result field component: ${String(fieldInfo.component)}.`));
230
+ }
231
+ if (fieldInfo.location !== 'envelope' && fieldInfo.quantity !== 'displacement') {
232
+ findings.push(finding('blocker', `result.fields.${index}.node-quantity-invalid`, 'Surface and outline node result fields must describe displacement quantities.'));
233
+ }
234
+ if (fieldInfo.location !== 'envelope' && fieldInfo.quantity === 'displacement' && fieldInfo.component == null) {
235
+ findings.push(finding('blocker', `result.fields.${index}.component.missing`, 'Node displacement result fields must include a displacement component.'));
236
+ }
237
+ if (fieldInfo.quantity !== 'displacement' && fieldInfo.component != null) {
238
+ findings.push(finding('blocker', `result.fields.${index}.component-unexpected`, 'Only displacement result fields may include a component.'));
239
+ }
240
+ if (fieldInfo.quantity === 'displacement' && fieldInfo.unit !== 'mm') {
241
+ findings.push(finding('blocker', `result.fields.${index}.unit.displacement-invalid`, 'Displacement result fields must use mm units.'));
242
+ }
243
+ if ((fieldInfo.quantity === 'reaction' || fieldInfo.quantity === 'load') && fieldInfo.unit !== 'kN') {
244
+ findings.push(finding('blocker', `result.fields.${index}.unit.force-invalid`, 'Reaction and load result fields must use kN units.'));
245
+ }
246
+ if (fieldInfo.quantity === 'stage_count' && fieldInfo.unit !== 'count') {
247
+ findings.push(finding('blocker', `result.fields.${index}.unit.stage-count-invalid`, 'Stage count result fields must use count units.'));
248
+ }
249
+ if (fieldInfo.signConvention != null && !isNonEmptyString(fieldInfo.signConvention)) {
250
+ findings.push(finding('blocker', `result.fields.${index}.sign-convention.invalid`, 'Result field sign convention must be a non-empty string when present.'));
251
+ }
252
+ if (id && fieldInfo.location === 'envelope' && !envelopeValueByFieldId.has(id)) {
253
+ findings.push(finding('blocker', `result.fields.${index}.envelope-field-unmapped`, `Envelope result field ${id} must map to a known result envelope value.`));
254
+ }
255
+ if (id) {
87
256
  fieldLocations.set(id, fieldInfo.location);
257
+ fieldInfoById.set(id, fieldInfo);
258
+ }
88
259
  }
89
260
  }
90
261
  if (Array.isArray(steps)) {
@@ -97,7 +268,12 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
97
268
  findings.push(finding('blocker', `result.steps.${index}.index.invalid`, 'Result step index must be an integer greater than or equal to zero.'));
98
269
  }
99
270
  else if (id) {
271
+ if (stepIndexes.has(step.index)) {
272
+ findings.push(finding('blocker', `result.steps.${index}.index.duplicate`, `Result step index ${step.index} is duplicated.`));
273
+ }
274
+ stepIndexes.add(step.index);
100
275
  stepIdByIndex.set(step.index, id);
276
+ stepLabelByIndex.set(step.index, step.label);
101
277
  }
102
278
  if (step.analysisStageId && !excavationStageIds.has(step.analysisStageId)) {
103
279
  findings.push(finding('blocker', `result.steps.${index}.stage-unknown`, `Result step references unknown excavation stage ${step.analysisStageId}.`));
@@ -105,6 +281,44 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
105
281
  if (step.depthM != null && (!Number.isFinite(step.depthM) || step.depthM < 0)) {
106
282
  findings.push(finding('blocker', `result.steps.${index}.depth-invalid`, 'Result step depth must be finite and non-negative when present.'));
107
283
  }
284
+ if (manifest.analysisCase.objective === 'excavation_deformation') {
285
+ if (!step.analysisStageId) {
286
+ findings.push(finding('blocker', `result.steps.${index}.stage-required`, 'Excavation result steps must reference an analysis stage.'));
287
+ }
288
+ else {
289
+ const stageInfo = excavationStageById.get(step.analysisStageId);
290
+ if (stageInfo) {
291
+ if (step.label !== stageInfo.stage.label) {
292
+ findings.push(finding('blocker', `result.steps.${index}.stage-label-mismatch`, 'Excavation result step label must match the embedded analysis stage label.'));
293
+ }
294
+ if (step.depthM == null) {
295
+ findings.push(finding('blocker', `result.steps.${index}.stage-depth-required`, 'Excavation result steps must carry the analysis stage depth.'));
296
+ }
297
+ else if (Number.isFinite(step.depthM) && !isApproxEqual(step.depthM, stageInfo.stage.depthM, 1e-6)) {
298
+ findings.push(finding('blocker', `result.steps.${index}.stage-depth-mismatch`, 'Excavation result step depth must match the embedded analysis stage depth.'));
299
+ }
300
+ if (Number.isInteger(step.index) && step.index !== stageInfo.index) {
301
+ findings.push(finding('blocker', `result.steps.${index}.stage-index-mismatch`, 'Excavation result step index must match the embedded analysis stage order.'));
302
+ }
303
+ }
304
+ }
305
+ }
306
+ else {
307
+ if (step.analysisStageId != null) {
308
+ findings.push(finding('blocker', `result.steps.${index}.stage-unexpected`, 'Non-staged FEM result steps must not reference analysis stages.'));
309
+ }
310
+ if (step.depthM != null) {
311
+ findings.push(finding('blocker', `result.steps.${index}.depth-unexpected`, 'Non-staged FEM result steps must not carry staged excavation depths.'));
312
+ }
313
+ }
314
+ }
315
+ }
316
+ if (Array.isArray(manifest.visualization.frames)) {
317
+ for (const frame of manifest.visualization.frames) {
318
+ const stepId = stepIdByIndex.get(frame.stageIndex ?? 0);
319
+ if (isNonEmptyString(frame.field) && stepId) {
320
+ frameByKey.set(`${frame.field}:${stepId}`, frame);
321
+ }
108
322
  }
109
323
  }
110
324
  if (Array.isArray(datasets)) {
@@ -113,9 +327,15 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
113
327
  if (!fieldIds.has(dataset.fieldId)) {
114
328
  findings.push(finding('blocker', `result.datasets.${index}.field-unknown`, `Result dataset references unknown field ${String(dataset.fieldId)}.`));
115
329
  }
330
+ else {
331
+ referencedFieldIds.add(dataset.fieldId);
332
+ }
116
333
  if (dataset.stepId != null && !stepIds.has(dataset.stepId)) {
117
334
  findings.push(finding('blocker', `result.datasets.${index}.step-unknown`, `Result dataset references unknown step ${String(dataset.stepId)}.`));
118
335
  }
336
+ else if (dataset.stepId != null) {
337
+ referencedStepIds.add(dataset.stepId);
338
+ }
119
339
  if (!Array.isArray(dataset.values) || dataset.values.length === 0) {
120
340
  findings.push(finding('blocker', `result.datasets.${index}.values.empty`, 'Result dataset values must be a non-empty array.'));
121
341
  continue;
@@ -134,6 +354,27 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
134
354
  findings.push(finding('blocker', `result.datasets.${index}.source.invalid`, `Unsupported result dataset source: ${String(dataset.source)}.`));
135
355
  }
136
356
  const location = fieldLocations.get(dataset.fieldId);
357
+ if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && dataset.stepId == null) {
358
+ findings.push(finding('blocker', `result.datasets.${index}.step-required`, 'Visualization datasets must reference a result step.'));
359
+ }
360
+ if ((dataset.source === 'visualization.disp' || dataset.source === 'visualization.frame') && location === 'envelope') {
361
+ findings.push(finding('blocker', `result.datasets.${index}.visualization-field-invalid`, 'Visualization datasets must reference surface or outline node result fields, not envelope fields.'));
362
+ }
363
+ if (dataset.source === 'envelope' && location !== 'envelope') {
364
+ findings.push(finding('blocker', `result.datasets.${index}.envelope-field-invalid`, 'Envelope datasets must reference an envelope result field.'));
365
+ }
366
+ if (dataset.source === 'visualization.disp' && !arraysApproximatelyEqual(dataset.values, manifest.visualization.disp, 1e-12)) {
367
+ findings.push(finding('blocker', `result.datasets.${index}.disp-values-mismatch`, 'Visualization displacement dataset values must match the manifest visualization displacement array.'));
368
+ }
369
+ if (dataset.source === 'visualization.frame' && dataset.stepId != null) {
370
+ const frame = frameByKey.get(`${dataset.fieldId}:${dataset.stepId}`);
371
+ if (!frame) {
372
+ findings.push(finding('blocker', `result.datasets.${index}.frame-missing`, 'Visualization frame dataset has no matching frame payload.'));
373
+ }
374
+ else if (!arraysApproximatelyEqual(dataset.values, frame.disp, 1e-12)) {
375
+ findings.push(finding('blocker', `result.datasets.${index}.frame-values-mismatch`, 'Visualization frame dataset values must match the referenced frame displacement array.'));
376
+ }
377
+ }
137
378
  const valueCount = dataset.values.length / dataset.stride;
138
379
  if (location === 'surface_nodes' && valueCount !== nodeCount) {
139
380
  findings.push(finding('blocker', `result.datasets.${index}.surface-count-mismatch`, 'Surface-node dataset values must match visualization base node count.'));
@@ -144,18 +385,53 @@ function validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNo
144
385
  if (dataset.source === 'envelope' && dataset.values.length !== 1) {
145
386
  findings.push(finding('blocker', `result.datasets.${index}.envelope-count-invalid`, 'Envelope datasets must contain exactly one value.'));
146
387
  }
388
+ if (dataset.source === 'envelope' && dataset.values.length === 1) {
389
+ const expectedEnvelopeValue = envelopeValueByFieldId.get(dataset.fieldId);
390
+ const fieldInfo = fieldInfoById.get(dataset.fieldId);
391
+ if (!isFiniteNumber(expectedEnvelopeValue)) {
392
+ findings.push(finding('blocker', `result.datasets.${index}.envelope-field-unmapped`, `Envelope dataset field ${dataset.fieldId} must map to a known result envelope value.`));
393
+ }
394
+ else if (!isApproxEqual(dataset.values[0], expectedEnvelopeValue, 1e-6)) {
395
+ findings.push(finding('blocker', `result.datasets.${index}.envelope-values-mismatch`, `Envelope dataset ${fieldInfo?.label ?? dataset.fieldId} must match the result envelope value.`));
396
+ }
397
+ }
398
+ }
399
+ }
400
+ if (Array.isArray(resultFields) && Array.isArray(datasets)) {
401
+ for (const [index, fieldInfo] of resultFields.entries()) {
402
+ if (isNonEmptyString(fieldInfo.id) && !referencedFieldIds.has(fieldInfo.id)) {
403
+ findings.push(finding('blocker', `result.fields.${index}.dataset-missing`, `Result field ${fieldInfo.id} has no backing dataset metadata.`));
404
+ }
405
+ }
406
+ }
407
+ if (Array.isArray(steps) && Array.isArray(datasets)) {
408
+ for (const [index, step] of steps.entries()) {
409
+ if (isNonEmptyString(step.id) && !referencedStepIds.has(step.id)) {
410
+ findings.push(finding('blocker', `result.steps.${index}.dataset-missing`, `Result step ${step.id} has no backing dataset metadata.`));
411
+ }
147
412
  }
148
413
  }
149
414
  if (Array.isArray(resultFields) && Array.isArray(steps) && Array.isArray(datasets) && Array.isArray(manifest.visualization.frames)) {
150
415
  const datasetKeys = new Set(datasets.map((dataset) => `${dataset.fieldId}:${dataset.stepId ?? ''}`));
151
416
  for (const [index, frame] of manifest.visualization.frames.entries()) {
152
- if (!fieldIds.has(frame.field)) {
417
+ const fieldInfo = fieldInfoById.get(frame.field);
418
+ if (!fieldInfo) {
153
419
  findings.push(finding('blocker', `result.frames.${index}.field-metadata-missing`, `Frame field ${frame.field} has no resultFields metadata.`));
154
420
  }
421
+ else if (isNonEmptyString(frame.fieldLabel) && frame.fieldLabel !== fieldInfo.label) {
422
+ findings.push(finding('blocker', `result.frames.${index}.field-label-mismatch`, `Frame field label must match result field metadata for ${frame.field}.`));
423
+ }
155
424
  const stepId = stepIdByIndex.get(frame.stageIndex ?? 0);
156
- if (stepId && !datasetKeys.has(`${frame.field}:${stepId}`)) {
425
+ if (!stepId) {
426
+ findings.push(finding('blocker', `result.frames.${index}.stage-unknown`, 'Frame stage index must reference a known result step.'));
427
+ }
428
+ else if (!datasetKeys.has(`${frame.field}:${stepId}`)) {
157
429
  findings.push(finding('blocker', `result.frames.${index}.dataset-missing`, `Frame ${frame.field}/${stepId} has no matching dataset metadata.`));
158
430
  }
431
+ const stepLabel = stepLabelByIndex.get(frame.stageIndex ?? 0);
432
+ if (stepLabel && isNonEmptyString(frame.stageLabel) && frame.stageLabel !== stepLabel) {
433
+ findings.push(finding('blocker', `result.frames.${index}.stage-label-mismatch`, 'Frame stage label must match result step metadata.'));
434
+ }
159
435
  }
160
436
  }
161
437
  }
@@ -165,7 +441,11 @@ function isFemResultManifestShape(value) {
165
441
  const visualization = value.visualization;
166
442
  const envelope = value.envelope;
167
443
  const mesh = value.mesh;
444
+ const backend = value.backend;
445
+ const validation = value.validation;
168
446
  return (isFemAnalysisCaseShape(value.analysisCase) &&
447
+ isRecord(backend) &&
448
+ isRecord(validation) &&
169
449
  isRecord(envelope) &&
170
450
  isRecord(mesh) &&
171
451
  isRecord(visualization) &&
@@ -188,11 +468,21 @@ export function validateFemAnalysisCase(caseFile) {
188
468
  ]);
189
469
  }
190
470
  const { domain, raft, excavation, tunnel } = caseFile.geometry;
191
- const material = caseFile.materials[0];
192
- const load = caseFile.loads[0];
193
471
  if (caseFile.schemaVersion !== 'fem-analysis-case.v0') {
194
472
  findings.push(finding('blocker', 'schema.unsupported', 'Only fem-analysis-case.v0 is supported.'));
195
473
  }
474
+ if (!isNonEmptyString(caseFile.caseId)) {
475
+ findings.push(finding('blocker', 'case-id.missing', 'FEM analysis case id must be a non-empty string.'));
476
+ }
477
+ if (!isNonEmptyString(caseFile.title)) {
478
+ findings.push(finding('blocker', 'title.missing', 'FEM analysis case title must be a non-empty string.'));
479
+ }
480
+ if (!isNonEmptyString(caseFile.createdBy)) {
481
+ findings.push(finding('blocker', 'created-by.missing', 'FEM analysis case creator must be a non-empty string.'));
482
+ }
483
+ if (!isNonEmptyString(caseFile.createdAt) || Number.isNaN(Date.parse(caseFile.createdAt))) {
484
+ findings.push(finding('blocker', 'created-at.invalid', 'FEM analysis case createdAt must be an ISO-compatible timestamp.'));
485
+ }
196
486
  if (!caseFile.experimental) {
197
487
  findings.push(finding('blocker', 'mode.experimental-required', 'FEM cases must be explicitly marked experimental.'));
198
488
  }
@@ -208,6 +498,21 @@ export function validateFemAnalysisCase(caseFile) {
208
498
  if (caseFile.objective === 'tunnel_volume_loss_settlement' && caseFile.analysisType !== 'empirical_3d_settlement_surface') {
209
499
  findings.push(finding('blocker', 'analysis.unsupported', `Unsupported analysis type: ${caseFile.analysisType}.`));
210
500
  }
501
+ if (caseFile.geometry.domain.type !== 'box') {
502
+ findings.push(finding('blocker', 'geometry.domain.type-invalid', `Unsupported domain type: ${String(caseFile.geometry.domain.type)}.`));
503
+ }
504
+ if (caseFile.units.length !== 'm' ||
505
+ caseFile.units.force !== 'kN' ||
506
+ caseFile.units.stress !== 'kPa' ||
507
+ caseFile.units.density !== 'kN/m3' ||
508
+ caseFile.units.displacement !== 'mm') {
509
+ findings.push(finding('blocker', 'units.unsupported', 'FEM cases must use m, kN, kPa, kN/m3, and mm units. Values must be converted before preview execution.'));
510
+ }
511
+ pushPositiveNumberFindings(findings, [
512
+ [domain.lengthM, 'geometry.domain.length', 'Domain length'],
513
+ [domain.widthM, 'geometry.domain.width', 'Domain width'],
514
+ [domain.depthM, 'geometry.domain.depth', 'Domain depth'],
515
+ ]);
211
516
  if (domain.lengthM <= 0 || domain.widthM <= 0 || domain.depthM <= 0) {
212
517
  findings.push(finding('blocker', 'geometry.domain-invalid', 'Domain dimensions must be positive.'));
213
518
  }
@@ -216,6 +521,16 @@ export function validateFemAnalysisCase(caseFile) {
216
521
  findings.push(finding('blocker', 'geometry.raft-missing', 'Foundation-settlement cases require raft geometry.'));
217
522
  }
218
523
  else {
524
+ if (raft.type !== 'raft') {
525
+ findings.push(finding('blocker', 'geometry.raft.type-invalid', `Unsupported raft geometry type: ${String(raft.type)}.`));
526
+ }
527
+ pushPositiveNumberFindings(findings, [
528
+ [raft.lengthM, 'geometry.raft.length', 'Raft length'],
529
+ [raft.widthM, 'geometry.raft.width', 'Raft width'],
530
+ [raft.thicknessM, 'geometry.raft.thickness', 'Raft thickness'],
531
+ ]);
532
+ pushFiniteNumberFinding(findings, raft.centerXM, 'geometry.raft.center-x', 'Raft center X');
533
+ pushFiniteNumberFinding(findings, raft.centerYM, 'geometry.raft.center-y', 'Raft center Y');
219
534
  if (raft.lengthM <= 0 || raft.widthM <= 0 || raft.thicknessM <= 0) {
220
535
  findings.push(finding('blocker', 'geometry.raft-invalid', 'Raft dimensions must be positive.'));
221
536
  }
@@ -232,6 +547,20 @@ export function validateFemAnalysisCase(caseFile) {
232
547
  findings.push(finding('blocker', 'geometry.excavation-missing', 'Excavation-deformation cases require excavation geometry.'));
233
548
  }
234
549
  else {
550
+ if (excavation.type !== 'braced_excavation') {
551
+ findings.push(finding('blocker', 'geometry.excavation.type-invalid', `Unsupported excavation geometry type: ${String(excavation.type)}.`));
552
+ }
553
+ pushPositiveNumberFindings(findings, [
554
+ [excavation.lengthM, 'geometry.excavation.length', 'Excavation length'],
555
+ [excavation.widthM, 'geometry.excavation.width', 'Excavation width'],
556
+ [excavation.finalDepthM, 'geometry.excavation.final-depth', 'Excavation final depth'],
557
+ [excavation.wallToeDepthM, 'geometry.excavation.wall-toe-depth', 'Excavation wall toe depth'],
558
+ ]);
559
+ pushFiniteNumberFinding(findings, excavation.centerXM, 'geometry.excavation.center-x', 'Excavation center X');
560
+ pushFiniteNumberFinding(findings, excavation.centerYM, 'geometry.excavation.center-y', 'Excavation center Y');
561
+ if (!['diaphragm_wall', 'secant_pile_wall', 'soldier_pile_lagging', 'unsupported_screening'].includes(excavation.wallType)) {
562
+ findings.push(finding('blocker', 'geometry.excavation.wall-type-invalid', `Unsupported excavation wall type: ${String(excavation.wallType)}.`));
563
+ }
235
564
  if (excavation.lengthM <= 0 ||
236
565
  excavation.widthM <= 0 ||
237
566
  excavation.finalDepthM <= 0 ||
@@ -248,7 +577,12 @@ export function validateFemAnalysisCase(caseFile) {
248
577
  findings.push(finding('blocker', 'stages.missing', 'Excavation cases require at least one construction stage.'));
249
578
  }
250
579
  let previousDepth = 0;
251
- for (const stage of excavation.stages) {
580
+ const stageIds = new Set();
581
+ for (const [index, stage] of excavation.stages.entries()) {
582
+ pushUniqueStringFinding(findings, stageIds, stage.id, `stages.${index}.id`, 'Excavation stage id');
583
+ if (!isNonEmptyString(stage.label)) {
584
+ findings.push(finding('blocker', `stages.${index}.label.missing`, 'Excavation stage label must be a non-empty string.'));
585
+ }
252
586
  if (!Number.isFinite(stage.depthM) || stage.depthM <= previousDepth || stage.depthM > excavation.finalDepthM) {
253
587
  findings.push(finding('blocker', 'stages.depth-invalid', 'Excavation stage depths must increase and stay within the final excavation depth.'));
254
588
  break;
@@ -270,6 +604,18 @@ export function validateFemAnalysisCase(caseFile) {
270
604
  findings.push(finding('blocker', 'geometry.tunnel-missing', 'Tunnel volume-loss settlement cases require tunnel geometry.'));
271
605
  }
272
606
  else {
607
+ if (tunnel.type !== 'tunnel') {
608
+ findings.push(finding('blocker', 'geometry.tunnel.type-invalid', `Unsupported tunnel geometry type: ${String(tunnel.type)}.`));
609
+ }
610
+ pushPositiveNumberFindings(findings, [
611
+ [tunnel.diameterM, 'geometry.tunnel.diameter', 'Tunnel diameter'],
612
+ [tunnel.axisDepthM, 'geometry.tunnel.axis-depth', 'Tunnel axis depth'],
613
+ [tunnel.lengthM, 'geometry.tunnel.length', 'Tunnel length'],
614
+ [tunnel.volumeLossPercent, 'geometry.tunnel.volume-loss', 'Tunnel volume loss'],
615
+ [tunnel.troughWidthParameterK, 'geometry.tunnel.trough-width-factor', 'Tunnel trough-width factor'],
616
+ ]);
617
+ pushFiniteNumberFinding(findings, tunnel.centerXM, 'geometry.tunnel.center-x', 'Tunnel center X');
618
+ pushFiniteNumberFinding(findings, tunnel.centerYM, 'geometry.tunnel.center-y', 'Tunnel center Y');
273
619
  if (tunnel.diameterM <= 0 ||
274
620
  tunnel.axisDepthM <= 0 ||
275
621
  tunnel.lengthM <= 0 ||
@@ -296,46 +642,153 @@ export function validateFemAnalysisCase(caseFile) {
296
642
  findings.push(finding('review', 'tunnel.empirical-preview', 'Tunnel preview uses an empirical Gaussian settlement surface from prescribed volume loss; it is not a tunnel lining, face-stability, or coupled FEM solver.'));
297
643
  }
298
644
  }
299
- if (!material) {
645
+ if (caseFile.materials.length === 0) {
300
646
  findings.push(finding('blocker', 'material.missing', 'At least one material is required.'));
301
647
  }
302
648
  else {
303
- if (material.model !== 'linear_elastic') {
304
- findings.push(finding('blocker', 'material.unsupported', `Unsupported material model: ${material.model}.`));
649
+ const materialIds = new Set();
650
+ for (const [index, material] of caseFile.materials.entries()) {
651
+ const prefix = index === 0 ? 'material' : `material.${index}`;
652
+ if (!isRecord(material)) {
653
+ findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Material must be an object.'));
654
+ continue;
655
+ }
656
+ pushUniqueStringFinding(findings, materialIds, material.id, `${prefix}.id`, 'Material id');
657
+ if (!isNonEmptyString(material.name)) {
658
+ findings.push(finding('blocker', `${prefix}.name.missing`, 'Material name must be a non-empty string.'));
659
+ }
660
+ if (material.model !== 'linear_elastic') {
661
+ findings.push(finding('blocker', `${prefix}.unsupported`, `Unsupported material model: ${String(material.model)}.`));
662
+ }
663
+ if (!Number.isFinite(material.elasticModulusKpa) || material.elasticModulusKpa <= 0) {
664
+ findings.push(finding('blocker', `${prefix}.elastic-modulus-invalid`, 'Elastic modulus must be positive.'));
665
+ }
666
+ if (!Number.isFinite(material.poissonRatio) || material.poissonRatio <= 0 || material.poissonRatio >= 0.5) {
667
+ findings.push(finding('blocker', `${prefix}.poisson-invalid`, 'Poisson ratio must be between 0 and 0.5.'));
668
+ }
669
+ if (!Number.isFinite(material.unitWeightKnM3) || material.unitWeightKnM3 <= 0) {
670
+ findings.push(finding('blocker', `${prefix}.unit-weight-invalid`, 'Unit weight must be positive.'));
671
+ }
672
+ validateEvidenceRefs(findings, material.evidenceRefs, prefix);
673
+ validateAssumptions(findings, material.assumptions, prefix);
305
674
  }
306
- if (!Number.isFinite(material.elasticModulusKpa) || material.elasticModulusKpa <= 0) {
307
- findings.push(finding('blocker', 'material.elastic-modulus-invalid', 'Elastic modulus must be positive.'));
675
+ }
676
+ if (caseFile.objective === 'tunnel_volume_loss_settlement' && caseFile.loads.length > 0) {
677
+ findings.push(finding('blocker', 'load.unsupported-for-objective', 'Tunnel volume-loss settlement previews use explicit volume loss and must not include pressure loads.'));
678
+ }
679
+ if (caseFile.loads.length === 0) {
680
+ if (caseFile.objective === 'foundation_settlement') {
681
+ findings.push(finding('blocker', 'load.missing', 'A raft pressure load is required.'));
308
682
  }
309
- if (!Number.isFinite(material.poissonRatio) || material.poissonRatio <= 0 || material.poissonRatio >= 0.5) {
310
- findings.push(finding('blocker', 'material.poisson-invalid', 'Poisson ratio must be between 0 and 0.5.'));
683
+ else if (caseFile.objective === 'excavation_deformation') {
684
+ findings.push(finding('blocker', 'load.missing', 'An excavation surcharge/load assumption is required.'));
311
685
  }
312
686
  }
313
- if (!load) {
314
- if (caseFile.objective !== 'tunnel_volume_loss_settlement') {
315
- findings.push(finding('blocker', 'load.missing', caseFile.objective === 'foundation_settlement' ? 'A raft pressure load is required.' : 'An excavation surcharge/load assumption is required.'));
687
+ else {
688
+ const loadIds = new Set();
689
+ for (const [index, load] of caseFile.loads.entries()) {
690
+ const prefix = index === 0 ? 'load' : `load.${index}`;
691
+ if (!isRecord(load)) {
692
+ findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Load must be an object.'));
693
+ continue;
694
+ }
695
+ pushUniqueStringFinding(findings, loadIds, load.id, `${prefix}.id`, 'Load id');
696
+ if (load.type !== 'uniform_pressure') {
697
+ findings.push(finding('blocker', `${prefix}.type-invalid`, `Unsupported load type: ${String(load.type)}.`));
698
+ }
699
+ if (!Number.isFinite(load.pressureKpa) || load.pressureKpa <= 0) {
700
+ findings.push(finding('blocker', `${prefix}.pressure-invalid`, 'Uniform pressure must be positive.'));
701
+ }
702
+ if (caseFile.objective === 'foundation_settlement' && load.target !== 'raft') {
703
+ findings.push(finding('blocker', `${prefix}.target-invalid`, 'Foundation-settlement load must target the raft.'));
704
+ }
705
+ if (caseFile.objective === 'excavation_deformation' && load.target !== 'excavation_surcharge') {
706
+ findings.push(finding('blocker', `${prefix}.target-invalid`, 'Excavation-deformation load must target excavation_surcharge.'));
707
+ }
708
+ validateEvidenceRefs(findings, load.evidenceRefs, prefix);
709
+ validateAssumptions(findings, load.assumptions, prefix);
316
710
  }
317
711
  }
318
- else if (!Number.isFinite(load.pressureKpa) || load.pressureKpa <= 0) {
319
- findings.push(finding('blocker', 'load.pressure-invalid', 'Uniform pressure must be positive.'));
320
- }
321
- else if (caseFile.objective === 'foundation_settlement' && load.target !== 'raft') {
322
- findings.push(finding('blocker', 'load.target-invalid', 'Foundation-settlement load must target the raft.'));
712
+ const { divisionsX, divisionsY, divisionsZ } = caseFile.mesh;
713
+ if (caseFile.mesh.elementType !== 'hex8') {
714
+ findings.push(finding('blocker', 'mesh.element-type-invalid', `Unsupported mesh element type: ${String(caseFile.mesh.elementType)}.`));
323
715
  }
324
- else if (caseFile.objective === 'excavation_deformation' && load.target !== 'excavation_surcharge') {
325
- findings.push(finding('blocker', 'load.target-invalid', 'Excavation-deformation load must target excavation_surcharge.'));
716
+ if (!Number.isInteger(divisionsX) ||
717
+ !Number.isInteger(divisionsY) ||
718
+ !Number.isInteger(divisionsZ)) {
719
+ findings.push(finding('blocker', 'mesh.divisions-integer', 'Mesh divisions must be finite integers.'));
326
720
  }
327
- const { divisionsX, divisionsY, divisionsZ } = caseFile.mesh;
328
721
  if (divisionsX < 2 || divisionsY < 2 || divisionsZ < 1) {
329
722
  findings.push(finding('blocker', 'mesh.too-coarse', 'Mesh divisions must be at least 2 x 2 x 1.'));
330
723
  }
331
- if (caseFile.groundwater.reviewRequired) {
332
- findings.push(finding('review', 'groundwater.review-required', caseFile.groundwater.note));
724
+ if (Number.isInteger(divisionsX) && Number.isInteger(divisionsY) && Number.isInteger(divisionsZ)) {
725
+ const nodeCount = (divisionsX + 1) * (divisionsY + 1) * (divisionsZ + 1);
726
+ if (!Number.isFinite(nodeCount) || nodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
727
+ findings.push(finding('blocker', 'mesh.preview-node-limit', `Experimental FEM/WebGL previews are capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
728
+ }
729
+ }
730
+ if (caseFile.boundaryConditions.length === 0) {
731
+ findings.push(finding('blocker', 'boundary.missing', 'At least one boundary condition is required.'));
333
732
  }
334
- for (const assumption of caseFile.assumptions) {
335
- if (assumption.reviewRequired || assumption.confidence === 'review') {
336
- findings.push(finding('review', `assumption.${assumption.id}`, `${assumption.parameter} is an engineering assumption requiring review.`));
733
+ else {
734
+ const boundaryIds = new Set();
735
+ let hasFixedBase = false;
736
+ let hasSideRollers = false;
737
+ for (const [index, boundary] of caseFile.boundaryConditions.entries()) {
738
+ const prefix = index === 0 ? 'boundary' : `boundary.${index}`;
739
+ if (!isRecord(boundary)) {
740
+ findings.push(finding('blocker', `${prefix}.shape-invalid`, 'Boundary condition must be an object.'));
741
+ continue;
742
+ }
743
+ pushUniqueStringFinding(findings, boundaryIds, boundary.id, `${prefix}.id`, 'Boundary condition id');
744
+ if (boundary.type === 'fixed_base')
745
+ hasFixedBase = true;
746
+ if (boundary.type === 'side_rollers')
747
+ hasSideRollers = true;
748
+ if (boundary.type !== 'fixed_base' && boundary.type !== 'side_rollers') {
749
+ findings.push(finding('blocker', `${prefix}.type-invalid`, `Unsupported boundary condition type: ${String(boundary.type)}.`));
750
+ }
751
+ if (!isNonEmptyString(boundary.description)) {
752
+ findings.push(finding('blocker', `${prefix}.description.missing`, 'Boundary condition description must be a non-empty string.'));
753
+ }
754
+ }
755
+ if (!hasFixedBase) {
756
+ findings.push(finding('blocker', 'boundary.fixed-base-missing', 'FEM preview cases require a fixed-base boundary condition.'));
337
757
  }
758
+ if (!hasSideRollers) {
759
+ findings.push(finding('blocker', 'boundary.side-rollers-missing', 'FEM preview cases require side-roller boundary conditions.'));
760
+ }
761
+ }
762
+ if (!['not_modelled', 'below_domain', 'specified'].includes(caseFile.groundwater.condition)) {
763
+ findings.push(finding('blocker', 'groundwater.condition-invalid', `Unsupported groundwater condition: ${String(caseFile.groundwater.condition)}.`));
764
+ }
765
+ if (!isNonEmptyString(caseFile.groundwater.note)) {
766
+ findings.push(finding('blocker', 'groundwater.note.missing', 'Groundwater note must be a non-empty string.'));
767
+ }
768
+ if (typeof caseFile.groundwater.reviewRequired !== 'boolean') {
769
+ findings.push(finding('blocker', 'groundwater.review-required.invalid', 'Groundwater reviewRequired must be boolean.'));
770
+ }
771
+ if (caseFile.groundwater.depthM != null) {
772
+ pushFiniteNumberFinding(findings, caseFile.groundwater.depthM, 'groundwater.depth', 'Groundwater depth', { nonNegative: true });
773
+ }
774
+ if (caseFile.groundwater.condition === 'specified' && caseFile.groundwater.depthM == null) {
775
+ findings.push(finding('blocker', 'groundwater.depth-required', 'Specified groundwater conditions require a groundwater depth.'));
776
+ }
777
+ if (caseFile.groundwater.condition === 'specified' && caseFile.groundwater.depthM != null && caseFile.groundwater.depthM > domain.depthM) {
778
+ findings.push(finding('blocker', 'groundwater.depth-outside-domain', 'Specified groundwater depth must fall within the model domain. Use below_domain for deeper groundwater.'));
779
+ }
780
+ if (caseFile.groundwater.condition === 'below_domain' && caseFile.groundwater.depthM != null && caseFile.groundwater.depthM <= domain.depthM) {
781
+ findings.push(finding('blocker', 'groundwater.below-domain-depth-invalid', 'Below-domain groundwater depth must be deeper than the model domain when supplied.'));
782
+ }
783
+ if (caseFile.groundwater.condition === 'not_modelled' && caseFile.groundwater.depthM != null) {
784
+ findings.push(finding('blocker', 'groundwater.depth-unused', 'Do not provide groundwater depth when groundwater is not modelled.'));
338
785
  }
786
+ if (caseFile.groundwater.reviewRequired) {
787
+ findings.push(finding('review', 'groundwater.review-required', caseFile.groundwater.note));
788
+ }
789
+ validateAssumptions(findings, caseFile.assumptions, 'case', { emitReviewFindings: true });
790
+ validateEvidenceRefs(findings, caseFile.evidenceRefs, 'case');
791
+ validateLimitations(findings, caseFile.limitations, 'case');
339
792
  return summary(findings);
340
793
  }
341
794
  function pushFiniteArrayFindings(findings, name, values, multipleOf) {
@@ -363,6 +816,155 @@ function pushIndexArrayFindings(findings, name, values, nodeCount, multipleOf) {
363
816
  findings.push(finding('blocker', `result.${name}.index-invalid`, `${name} references node index ${value}, outside the 0-${Math.max(0, nodeCount - 1)} range.`));
364
817
  return;
365
818
  }
819
+ if (value > FEM_WEBGL_UINT16_INDEX_LIMIT) {
820
+ findings.push(finding('blocker', `result.${name}.webgl-index-limit`, `${name} references node index ${value}, above the WebGL 1 unsigned-short index limit.`));
821
+ return;
822
+ }
823
+ }
824
+ }
825
+ function validateEmbeddedValidationSummary(findings, manifestValidation, caseValidation) {
826
+ if (!isRecord(manifestValidation)) {
827
+ findings.push(finding('blocker', 'result.validation.shape-invalid', 'Embedded validation summary must be an object.'));
828
+ return;
829
+ }
830
+ if (!['ready', 'review', 'blocked'].includes(manifestValidation.status)) {
831
+ findings.push(finding('blocker', 'result.validation.status-invalid', `Unsupported embedded validation status: ${String(manifestValidation.status)}.`));
832
+ }
833
+ if (!Number.isInteger(manifestValidation.blockers) || manifestValidation.blockers < 0) {
834
+ findings.push(finding('blocker', 'result.validation.blockers-invalid', 'Embedded validation blocker count must be a non-negative integer.'));
835
+ }
836
+ if (!Number.isInteger(manifestValidation.reviewItems) || manifestValidation.reviewItems < 0) {
837
+ findings.push(finding('blocker', 'result.validation.review-items-invalid', 'Embedded validation review item count must be a non-negative integer.'));
838
+ }
839
+ if (!Array.isArray(manifestValidation.findings)) {
840
+ findings.push(finding('blocker', 'result.validation.findings-invalid', 'Embedded validation findings must be an array.'));
841
+ return;
842
+ }
843
+ if (manifestValidation.status !== caseValidation.status) {
844
+ findings.push(finding('blocker', 'result.validation.status-mismatch', 'Embedded validation status must match the embedded analysis-case validation.'));
845
+ }
846
+ if (manifestValidation.blockers !== caseValidation.blockers) {
847
+ findings.push(finding('blocker', 'result.validation.blockers-mismatch', 'Embedded validation blocker count must match the embedded analysis-case validation.'));
848
+ }
849
+ if (manifestValidation.reviewItems !== caseValidation.reviewItems) {
850
+ findings.push(finding('blocker', 'result.validation.review-items-mismatch', 'Embedded validation review item count must match the embedded analysis-case validation.'));
851
+ }
852
+ const manifestCodes = manifestValidation.findings.map((item) => isRecord(item) ? String(item.code) : '');
853
+ const caseCodes = caseValidation.findings.map((item) => item.code);
854
+ if (manifestCodes.length !== caseCodes.length ||
855
+ manifestCodes.some((code, index) => code !== caseCodes[index])) {
856
+ findings.push(finding('blocker', 'result.validation.findings-mismatch', 'Embedded validation finding codes must match the embedded analysis-case validation.'));
857
+ }
858
+ }
859
+ function isApproxEqual(actual, expected, tolerance) {
860
+ return Math.abs(actual - expected) <= tolerance;
861
+ }
862
+ function pushApproximateMatchFinding(findings, actual, expected, code, label, tolerance) {
863
+ if (!isFiniteNumber(actual) || !Number.isFinite(expected))
864
+ return;
865
+ if (!isApproxEqual(actual, expected, tolerance)) {
866
+ findings.push(finding('blocker', code, `${label} must match the embedded FEM analysis case within ${tolerance}.`));
867
+ }
868
+ }
869
+ function validateResultEnvelopeSemantics(findings, manifest) {
870
+ const { envelope, analysisCase } = manifest;
871
+ const maxSettlementOk = pushFiniteNumberFinding(findings, envelope.maxSettlementMm, 'result.envelope.max-settlement', 'Envelope max settlement', { nonNegative: true });
872
+ const minSettlementOk = pushFiniteNumberFinding(findings, envelope.minSettlementMm, 'result.envelope.min-settlement', 'Envelope min settlement', { nonNegative: true });
873
+ pushFiniteNumberFinding(findings, envelope.totalLoadKn, 'result.envelope.total-load', 'Envelope total load', { nonNegative: true });
874
+ pushFiniteNumberFinding(findings, envelope.reactionKn, 'result.envelope.reaction', 'Envelope reaction', { nonNegative: true });
875
+ pushFiniteNumberFinding(findings, envelope.reactionBalanceRatio, 'result.envelope.reaction-balance-ratio', 'Envelope reaction balance ratio', { positive: true });
876
+ if (maxSettlementOk && minSettlementOk && envelope.minSettlementMm > envelope.maxSettlementMm) {
877
+ findings.push(finding('blocker', 'result.envelope.settlement-range-invalid', 'Envelope minimum settlement cannot exceed maximum settlement.'));
878
+ }
879
+ const expectedBackendByObjective = new Map([
880
+ ['foundation_settlement', 'builtin-elastic3d-demo'],
881
+ ['excavation_deformation', 'builtin-staged-excavation-demo'],
882
+ ['tunnel_volume_loss_settlement', 'builtin-tunnel-volume-loss-demo'],
883
+ ]);
884
+ const expectedBackend = expectedBackendByObjective.get(analysisCase.objective);
885
+ if (expectedBackend && manifest.backend.id !== expectedBackend) {
886
+ findings.push(finding('blocker', 'result.backend.objective-mismatch', 'Result backend must match the embedded FEM objective.'));
887
+ }
888
+ if (analysisCase.objective === 'foundation_settlement') {
889
+ const raft = analysisCase.geometry.raft;
890
+ if (!raft)
891
+ return;
892
+ const totalLoadKn = analysisCase.loads
893
+ .filter((load) => load.target === 'raft')
894
+ .reduce((total, load) => total + load.pressureKpa * raft.lengthM * raft.widthM, 0);
895
+ const tolerance = Math.max(0.01, Math.abs(totalLoadKn) * 0.0001);
896
+ pushApproximateMatchFinding(findings, envelope.totalLoadKn, totalLoadKn, 'result.envelope.foundation-total-load-mismatch', 'Foundation total load', tolerance);
897
+ pushApproximateMatchFinding(findings, envelope.reactionKn, totalLoadKn, 'result.envelope.foundation-reaction-mismatch', 'Foundation reaction', tolerance);
898
+ if (envelope.maxSurfaceSettlementMm != null) {
899
+ findings.push(finding('blocker', 'result.envelope.foundation-extra-surface-settlement', 'Foundation result envelopes must not expose excavation/tunnel surface-settlement fields.'));
900
+ }
901
+ if (envelope.stageCount != null) {
902
+ findings.push(finding('blocker', 'result.envelope.foundation-extra-stage-count', 'Foundation result envelopes must not expose staged-excavation fields.'));
903
+ }
904
+ return;
905
+ }
906
+ if (analysisCase.objective === 'excavation_deformation') {
907
+ const excavation = analysisCase.geometry.excavation;
908
+ const upperMaterial = analysisCase.materials[0];
909
+ if (!excavation || !upperMaterial)
910
+ return;
911
+ const expectedWeightKn = excavation.lengthM * excavation.widthM * excavation.finalDepthM * upperMaterial.unitWeightKnM3;
912
+ const expectedWeightTolerance = Math.max(0.01, Math.abs(expectedWeightKn) * 0.0001);
913
+ const maxSurfaceSettlementMm = envelope.maxSurfaceSettlementMm;
914
+ const maxSurfaceOk = pushFiniteNumberFinding(findings, maxSurfaceSettlementMm, 'result.envelope.max-surface-settlement', 'Envelope max surface settlement', { nonNegative: true });
915
+ pushFiniteNumberFinding(findings, envelope.maxHorizontalDisplacementMm, 'result.envelope.max-horizontal-displacement', 'Envelope max horizontal displacement', { nonNegative: true });
916
+ pushFiniteNumberFinding(findings, envelope.maxWallDeflectionMm, 'result.envelope.max-wall-deflection', 'Envelope max wall deflection', { nonNegative: true });
917
+ pushFiniteNumberFinding(findings, envelope.maxBasalHeaveMm, 'result.envelope.max-basal-heave', 'Envelope max basal heave', { nonNegative: true });
918
+ pushFiniteNumberFinding(findings, envelope.totalExcavatedWeightKn, 'result.envelope.total-excavated-weight', 'Envelope total excavated weight', { nonNegative: true });
919
+ pushFiniteNumberFinding(findings, envelope.supportReactionKn, 'result.envelope.support-reaction', 'Envelope support reaction', { nonNegative: true });
920
+ pushFiniteNumberFinding(findings, envelope.boundaryReactionKn, 'result.envelope.boundary-reaction', 'Envelope boundary reaction', { nonNegative: true });
921
+ const stageCountOk = pushFiniteNumberFinding(findings, envelope.stageCount, 'result.envelope.stage-count', 'Envelope stage count', { positive: true });
922
+ if (stageCountOk && !Number.isInteger(envelope.stageCount)) {
923
+ findings.push(finding('blocker', 'result.envelope.excavation-stage-count-integer', 'Excavation stage count must be an integer.'));
924
+ }
925
+ if (stageCountOk && envelope.stageCount !== excavation.stages.length) {
926
+ findings.push(finding('blocker', 'result.envelope.excavation-stage-count-mismatch', 'Excavation envelope stage count must match the embedded construction stages.'));
927
+ }
928
+ pushApproximateMatchFinding(findings, envelope.totalExcavatedWeightKn, expectedWeightKn, 'result.envelope.excavation-weight-mismatch', 'Excavation total excavated weight', expectedWeightTolerance);
929
+ pushApproximateMatchFinding(findings, envelope.totalLoadKn, expectedWeightKn, 'result.envelope.excavation-total-load-mismatch', 'Excavation total load', expectedWeightTolerance);
930
+ pushApproximateMatchFinding(findings, envelope.reactionKn, expectedWeightKn, 'result.envelope.excavation-reaction-mismatch', 'Excavation reaction', expectedWeightTolerance);
931
+ if (isFiniteNumber(envelope.supportReactionKn) && isFiniteNumber(envelope.boundaryReactionKn)) {
932
+ pushApproximateMatchFinding(findings, envelope.supportReactionKn + envelope.boundaryReactionKn, expectedWeightKn, 'result.envelope.excavation-reaction-components-mismatch', 'Excavation support plus boundary reactions', expectedWeightTolerance);
933
+ }
934
+ if (maxSettlementOk && maxSurfaceOk) {
935
+ pushApproximateMatchFinding(findings, envelope.maxSettlementMm, maxSurfaceSettlementMm, 'result.envelope.excavation-max-settlement-mismatch', 'Excavation max settlement', 0.001);
936
+ }
937
+ return;
938
+ }
939
+ if (analysisCase.objective === 'tunnel_volume_loss_settlement') {
940
+ const tunnel = analysisCase.geometry.tunnel;
941
+ if (!tunnel)
942
+ return;
943
+ const troughWidthM = tunnel.axisDepthM * tunnel.troughWidthParameterK;
944
+ const tunnelAreaM2 = Math.PI * (tunnel.diameterM / 2) ** 2;
945
+ const settlementVolumePerM = (tunnel.volumeLossPercent / 100) * tunnelAreaM2;
946
+ const settlementVolumeM3 = settlementVolumePerM * tunnel.lengthM;
947
+ const maxSurfaceSettlementMm = envelope.maxSurfaceSettlementMm;
948
+ const maxSurfaceOk = pushFiniteNumberFinding(findings, maxSurfaceSettlementMm, 'result.envelope.max-surface-settlement', 'Envelope max surface settlement', { nonNegative: true });
949
+ pushFiniteNumberFinding(findings, envelope.tunnelDiameterM, 'result.envelope.tunnel-diameter', 'Envelope tunnel diameter', { positive: true });
950
+ pushFiniteNumberFinding(findings, envelope.tunnelAxisDepthM, 'result.envelope.tunnel-axis-depth', 'Envelope tunnel axis depth', { positive: true });
951
+ pushFiniteNumberFinding(findings, envelope.volumeLossPercent, 'result.envelope.volume-loss', 'Envelope volume loss', { positive: true });
952
+ pushFiniteNumberFinding(findings, envelope.troughWidthM, 'result.envelope.trough-width', 'Envelope trough width', { positive: true });
953
+ pushFiniteNumberFinding(findings, envelope.influenceWidthM, 'result.envelope.influence-width', 'Envelope influence width', { positive: true });
954
+ pushFiniteNumberFinding(findings, envelope.settlementVolumeM3, 'result.envelope.settlement-volume', 'Envelope settlement volume', { nonNegative: true });
955
+ pushFiniteNumberFinding(findings, envelope.settlementVolumePerM, 'result.envelope.settlement-volume-per-m', 'Envelope settlement volume per metre', { nonNegative: true });
956
+ pushApproximateMatchFinding(findings, envelope.totalLoadKn, 0, 'result.envelope.tunnel-total-load-invalid', 'Tunnel empirical total load', 0.001);
957
+ pushApproximateMatchFinding(findings, envelope.reactionKn, 0, 'result.envelope.tunnel-reaction-invalid', 'Tunnel empirical reaction', 0.001);
958
+ pushApproximateMatchFinding(findings, envelope.tunnelDiameterM, tunnel.diameterM, 'result.envelope.tunnel-diameter-mismatch', 'Tunnel diameter envelope value', 0.001);
959
+ pushApproximateMatchFinding(findings, envelope.tunnelAxisDepthM, tunnel.axisDepthM, 'result.envelope.tunnel-axis-depth-mismatch', 'Tunnel axis depth envelope value', 0.001);
960
+ pushApproximateMatchFinding(findings, envelope.volumeLossPercent, tunnel.volumeLossPercent, 'result.envelope.tunnel-volume-loss-mismatch', 'Tunnel volume loss envelope value', 0.001);
961
+ pushApproximateMatchFinding(findings, envelope.troughWidthM, troughWidthM, 'result.envelope.tunnel-trough-width-mismatch', 'Tunnel trough width envelope value', 0.001);
962
+ pushApproximateMatchFinding(findings, envelope.influenceWidthM, troughWidthM * 6, 'result.envelope.tunnel-influence-width-mismatch', 'Tunnel influence width envelope value', 0.001);
963
+ pushApproximateMatchFinding(findings, envelope.settlementVolumePerM, settlementVolumePerM, 'result.envelope.tunnel-settlement-volume-per-m-mismatch', 'Tunnel settlement volume per metre', 0.0001);
964
+ pushApproximateMatchFinding(findings, envelope.settlementVolumeM3, settlementVolumeM3, 'result.envelope.tunnel-settlement-volume-mismatch', 'Tunnel settlement volume', Math.max(0.001, Math.abs(settlementVolumeM3) * 0.0001));
965
+ if (maxSettlementOk && maxSurfaceOk) {
966
+ pushApproximateMatchFinding(findings, envelope.maxSettlementMm, maxSurfaceSettlementMm, 'result.envelope.tunnel-max-settlement-mismatch', 'Tunnel max settlement', 0.001);
967
+ }
366
968
  }
367
969
  }
368
970
  export function validateFemResultManifest(manifest) {
@@ -375,9 +977,40 @@ export function validateFemResultManifest(manifest) {
375
977
  if (manifest.schemaVersion !== 'fem-result-manifest.v0') {
376
978
  findings.push(finding('blocker', 'result.schema.unsupported', 'Only fem-result-manifest.v0 is supported.'));
377
979
  }
980
+ if (!isNonEmptyString(manifest.caseId)) {
981
+ findings.push(finding('blocker', 'result.case-id.missing', 'Result manifest caseId must be a non-empty string.'));
982
+ }
983
+ else if (manifest.caseId !== manifest.analysisCase.caseId) {
984
+ findings.push(finding('blocker', 'result.case-id.mismatch', 'Result manifest caseId must match the embedded analysis case.'));
985
+ }
986
+ if (!isNonEmptyString(manifest.title)) {
987
+ findings.push(finding('blocker', 'result.title.missing', 'Result manifest title must be a non-empty string.'));
988
+ }
989
+ if (!isNonEmptyString(manifest.generatedAt) || Number.isNaN(Date.parse(manifest.generatedAt))) {
990
+ findings.push(finding('blocker', 'result.generated-at.invalid', 'Result manifest generatedAt must be an ISO-compatible timestamp.'));
991
+ }
992
+ validateAssumptions(findings, manifest.assumptions, 'result');
993
+ validateLimitations(findings, manifest.limitations, 'result');
378
994
  if (!manifest.analysisCase.experimental) {
379
995
  findings.push(finding('blocker', 'result.experimental-required', 'FEM result manifests must be tied to an experimental case.'));
380
996
  }
997
+ const validBackendIds = new Set([
998
+ 'builtin-elastic3d-demo',
999
+ 'builtin-staged-excavation-demo',
1000
+ 'builtin-tunnel-volume-loss-demo',
1001
+ ]);
1002
+ if (!validBackendIds.has(manifest.backend.id)) {
1003
+ findings.push(finding('blocker', 'result.backend.id-invalid', `Unsupported FEM result backend: ${String(manifest.backend.id)}.`));
1004
+ }
1005
+ if (!isNonEmptyString(manifest.backend.label)) {
1006
+ findings.push(finding('blocker', 'result.backend.label.missing', 'Result backend label must be a non-empty string.'));
1007
+ }
1008
+ if (manifest.backend.deterministic !== true) {
1009
+ findings.push(finding('blocker', 'result.backend.deterministic-required', 'FEM result backends must be deterministic for strong-beta preview artifacts.'));
1010
+ }
1011
+ if (!isNonEmptyString(manifest.backend.version)) {
1012
+ findings.push(finding('blocker', 'result.backend.version.missing', 'Result backend version must be a non-empty string.'));
1013
+ }
381
1014
  for (const [key, value] of Object.entries(manifest.envelope)) {
382
1015
  if (!Number.isFinite(value)) {
383
1016
  findings.push(finding('blocker', `result.envelope.${key}.non-finite`, `Envelope value ${key} must be finite.`));
@@ -386,6 +1019,7 @@ export function validateFemResultManifest(manifest) {
386
1019
  if (manifest.envelope.reactionBalanceRatio < 0.95 || manifest.envelope.reactionBalanceRatio > 1.05) {
387
1020
  findings.push(finding('review', 'result.envelope.balance-review', 'Reaction balance is outside the 0.95-1.05 review band.'));
388
1021
  }
1022
+ validateResultEnvelopeSemantics(findings, manifest);
389
1023
  const { visualization } = manifest;
390
1024
  pushFiniteArrayFindings(findings, 'base', visualization.base, 3);
391
1025
  pushFiniteArrayFindings(findings, 'disp', visualization.disp, 3);
@@ -393,6 +1027,9 @@ export function validateFemResultManifest(manifest) {
393
1027
  pushFiniteArrayFindings(findings, 'outlineBase', visualization.outlineBase, 3);
394
1028
  pushFiniteArrayFindings(findings, 'outlineDisp', visualization.outlineDisp, 3);
395
1029
  const nodeCount = visualization.base.length / 3;
1030
+ if (nodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
1031
+ findings.push(finding('blocker', 'result.visualization.webgl-node-limit', `FEM/WebGL preview visualization is capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
1032
+ }
396
1033
  if (visualization.disp.length / 3 !== nodeCount) {
397
1034
  findings.push(finding('blocker', 'result.disp.node-count-mismatch', 'Displacement vectors must match base nodes.'));
398
1035
  }
@@ -401,13 +1038,60 @@ export function validateFemResultManifest(manifest) {
401
1038
  }
402
1039
  pushIndexArrayFindings(findings, 'tri', visualization.tri, nodeCount, 3);
403
1040
  pushIndexArrayFindings(findings, 'edge', visualization.edge, nodeCount, 2);
1041
+ const expectedMeshNodes = (manifest.analysisCase.mesh.divisionsX + 1) *
1042
+ (manifest.analysisCase.mesh.divisionsY + 1) *
1043
+ (manifest.analysisCase.mesh.divisionsZ + 1);
1044
+ const expectedMeshElements = manifest.analysisCase.mesh.divisionsX *
1045
+ manifest.analysisCase.mesh.divisionsY *
1046
+ manifest.analysisCase.mesh.divisionsZ;
1047
+ const meshDivisions = manifest.mesh.divisions;
1048
+ if (!Number.isInteger(manifest.mesh.nodes) || manifest.mesh.nodes !== expectedMeshNodes) {
1049
+ findings.push(finding('blocker', 'result.mesh.nodes-mismatch', 'Result mesh node count must match the embedded analysis case mesh divisions.'));
1050
+ }
1051
+ if (!Number.isInteger(manifest.mesh.elements) || manifest.mesh.elements !== expectedMeshElements) {
1052
+ findings.push(finding('blocker', 'result.mesh.elements-mismatch', 'Result mesh element count must match the embedded analysis case mesh divisions.'));
1053
+ }
1054
+ if (manifest.mesh.elementType !== manifest.analysisCase.mesh.elementType) {
1055
+ findings.push(finding('blocker', 'result.mesh.element-type-mismatch', 'Result mesh element type must match the embedded analysis case.'));
1056
+ }
1057
+ if (!Array.isArray(meshDivisions) ||
1058
+ meshDivisions.length !== 3 ||
1059
+ meshDivisions[0] !== manifest.analysisCase.mesh.divisionsX ||
1060
+ meshDivisions[1] !== manifest.analysisCase.mesh.divisionsY ||
1061
+ meshDivisions[2] !== manifest.analysisCase.mesh.divisionsZ) {
1062
+ findings.push(finding('blocker', 'result.mesh.divisions-mismatch', 'Result mesh divisions must match the embedded analysis case.'));
1063
+ }
1064
+ if (!Number.isInteger(manifest.mesh.visualizationNodes) || manifest.mesh.visualizationNodes !== nodeCount) {
1065
+ findings.push(finding('blocker', 'result.mesh.visualization-nodes-mismatch', 'Result visualization node count must match the visualization base array.'));
1066
+ }
1067
+ if (!Number.isInteger(manifest.mesh.visualizationTriangles) || manifest.mesh.visualizationTriangles !== visualization.tri.length / 3) {
1068
+ findings.push(finding('blocker', 'result.mesh.visualization-triangles-mismatch', 'Result visualization triangle count must match the visualization tri array.'));
1069
+ }
1070
+ if (!Number.isInteger(manifest.mesh.visualizationEdges) || manifest.mesh.visualizationEdges !== visualization.edge.length / 2) {
1071
+ findings.push(finding('blocker', 'result.mesh.visualization-edges-mismatch', 'Result visualization edge count must match the visualization edge array.'));
1072
+ }
404
1073
  const outlineNodeCount = visualization.outlineBase.length / 3;
1074
+ if (outlineNodeCount > FEM_MAX_PREVIEW_MESH_NODES) {
1075
+ findings.push(finding('blocker', 'result.outline.webgl-node-limit', `FEM/WebGL outline visualization is capped at ${FEM_MAX_PREVIEW_MESH_NODES} nodes.`));
1076
+ }
405
1077
  if (visualization.outlineDisp.length / 3 !== outlineNodeCount) {
406
1078
  findings.push(finding('blocker', 'result.outline.node-count-mismatch', 'Outline displacement vectors must match outline nodes.'));
407
1079
  }
408
1080
  pushIndexArrayFindings(findings, 'outlineIdx', visualization.outlineIdx, outlineNodeCount, 2);
409
1081
  if (Array.isArray(visualization.frames)) {
410
1082
  for (const [index, frame] of visualization.frames.entries()) {
1083
+ if (!isNonEmptyString(frame.field)) {
1084
+ findings.push(finding('blocker', `result.frames.${index}.field.missing`, 'Frame field must be a non-empty string.'));
1085
+ }
1086
+ if (!isNonEmptyString(frame.fieldLabel)) {
1087
+ findings.push(finding('blocker', `result.frames.${index}.field-label.missing`, 'Frame field label must be a non-empty string.'));
1088
+ }
1089
+ if (frame.stageIndex != null && (!Number.isInteger(frame.stageIndex) || frame.stageIndex < 0)) {
1090
+ findings.push(finding('blocker', `result.frames.${index}.stage-index.invalid`, 'Frame stage index must be an integer greater than or equal to zero when present.'));
1091
+ }
1092
+ if (frame.stageLabel != null && !isNonEmptyString(frame.stageLabel)) {
1093
+ findings.push(finding('blocker', `result.frames.${index}.stage-label.missing`, 'Frame stage label must be a non-empty string when present.'));
1094
+ }
411
1095
  pushFiniteArrayFindings(findings, `frames.${index}.disp`, frame.disp, 3);
412
1096
  pushFiniteArrayFindings(findings, `frames.${index}.color`, frame.color, 3);
413
1097
  if (frame.disp.length / 3 !== nodeCount) {
@@ -420,6 +1104,7 @@ export function validateFemResultManifest(manifest) {
420
1104
  }
421
1105
  validateOptionalResultMetadata(findings, manifest, nodeCount, outlineNodeCount);
422
1106
  const caseValidation = validateFemAnalysisCase(manifest.analysisCase);
1107
+ validateEmbeddedValidationSummary(findings, manifest.validation, caseValidation);
423
1108
  findings.push(...caseValidation.findings);
424
1109
  return summary(findings);
425
1110
  }