@geotechcli/core 0.4.112 → 0.4.114

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.
@@ -1,5 +1,6 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { calculateLateralEarthPressure } from '../geo/lateral-earth-pressure.js';
2
- import { buildPlaneStrainRectangularMesh, runPlaneStrainBiotConsolidation, runPlaneStrainDruckerPragerLoadSteps, runPlaneStrainQuad4Assembly, runPlaneStrainSteadySeepage, } from './plane-strain-assembly.js';
3
+ import { buildPlaneStrainRectangularMesh, runPlaneStrainBiotConsolidation, runPlaneStrainDruckerPragerBiotPressureReplay, runPlaneStrainDruckerPragerLoadSteps, runPlaneStrainQuad4Assembly, runPlaneStrainSteadySeepage, } from './plane-strain-assembly.js';
3
4
  import { runFemSupportMemberDesignCheck } from './support-design.js';
4
5
  export const DEFAULT_FEM_CONVERGENCE_POLICY = {
5
6
  schemaVersion: 'fem-convergence-policy.v1',
@@ -161,6 +162,33 @@ function round(value, digits = 6) {
161
162
  const scale = 10 ** digits;
162
163
  return Math.round(value * scale) / scale;
163
164
  }
165
+ function canonicalFemBenchmarkJson(value) {
166
+ if (value === null || value === undefined)
167
+ return 'null';
168
+ if (typeof value === 'number') {
169
+ if (!Number.isFinite(value))
170
+ throw new Error('Cannot hash non-finite FEM benchmark value.');
171
+ return JSON.stringify(round(value, 12));
172
+ }
173
+ if (typeof value === 'string' || typeof value === 'boolean') {
174
+ return JSON.stringify(value);
175
+ }
176
+ if (Array.isArray(value)) {
177
+ return `[${value.map((item) => canonicalFemBenchmarkJson(item)).join(',')}]`;
178
+ }
179
+ if (typeof value === 'object') {
180
+ const entries = Object.entries(value)
181
+ .filter(([, itemValue]) => itemValue !== undefined)
182
+ .sort(([left], [right]) => left.localeCompare(right));
183
+ return `{${entries
184
+ .map(([key, itemValue]) => `${JSON.stringify(key)}:${canonicalFemBenchmarkJson(itemValue)}`)
185
+ .join(',')}}`;
186
+ }
187
+ return JSON.stringify(String(value));
188
+ }
189
+ function hashFemBenchmarkPayload(value) {
190
+ return createHash('sha256').update(canonicalFemBenchmarkJson(value)).digest('hex');
191
+ }
164
192
  function isNonEmptyString(value) {
165
193
  return typeof value === 'string' && value.trim().length > 0;
166
194
  }
@@ -183,6 +211,63 @@ function hasReferenceSolverCitation(citation) {
183
211
  function hasValidSha256(value) {
184
212
  return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
185
213
  }
214
+ function hasSolverRunMetadata(value) {
215
+ if (value == null || typeof value !== 'object')
216
+ return false;
217
+ const metadata = value;
218
+ return isNonEmptyString(metadata.name) &&
219
+ isNonEmptyString(metadata.version) &&
220
+ (metadata.solverType === 'geotechcli-kernel' ||
221
+ metadata.solverType === 'published-reference' ||
222
+ metadata.solverType === 'commercial-solver' ||
223
+ metadata.solverType === 'open-source-solver' ||
224
+ metadata.solverType === 'deterministic-fixture');
225
+ }
226
+ function hasFiniteSeriesStatistics(value) {
227
+ if (value == null || typeof value !== 'object')
228
+ return false;
229
+ const statistics = value;
230
+ return Number.isFinite(statistics.min) &&
231
+ Number.isFinite(statistics.max) &&
232
+ Number.isFinite(statistics.final) &&
233
+ (statistics.mean == null || Number.isFinite(statistics.mean));
234
+ }
235
+ function hasValidSeriesSummary(value) {
236
+ if (value == null || typeof value !== 'object')
237
+ return false;
238
+ const summary = value;
239
+ const pointCount = summary.pointCount;
240
+ const maxAbsoluteError = summary.maxAbsoluteError;
241
+ const maxRelativeError = summary.maxRelativeError;
242
+ const rmsError = summary.rmsError;
243
+ return isNonEmptyString(summary.xQuantity) &&
244
+ isNonEmptyString(summary.xUnit) &&
245
+ isNonEmptyString(summary.yQuantity) &&
246
+ isNonEmptyString(summary.yUnit) &&
247
+ typeof pointCount === 'number' &&
248
+ Number.isInteger(pointCount) &&
249
+ pointCount > 0 &&
250
+ hasFiniteSeriesStatistics(summary.actual) &&
251
+ hasFiniteSeriesStatistics(summary.expected) &&
252
+ typeof maxAbsoluteError === 'number' &&
253
+ Number.isFinite(maxAbsoluteError) &&
254
+ maxAbsoluteError >= 0 &&
255
+ typeof maxRelativeError === 'number' &&
256
+ Number.isFinite(maxRelativeError) &&
257
+ maxRelativeError >= 0 &&
258
+ (rmsError == null || (Number.isFinite(rmsError) && rmsError >= 0)) &&
259
+ hasValidSha256(summary.seriesHashSha256);
260
+ }
261
+ function seriesSummarySatisfiesRequirementTolerance(summary, requirement) {
262
+ if (requirement.toleranceType === 'absolute') {
263
+ return summary.maxAbsoluteError <= requirement.tolerance;
264
+ }
265
+ if (requirement.toleranceType === 'relative') {
266
+ return summary.maxRelativeError <= requirement.tolerance;
267
+ }
268
+ return summary.maxAbsoluteError <= requirement.tolerance ||
269
+ summary.maxRelativeError <= requirement.tolerance;
270
+ }
186
271
  function copyExternalBenchmarkReference(reference) {
187
272
  return {
188
273
  ...reference,
@@ -210,6 +295,24 @@ function copyExternalBenchmarkQuantityRequirement(requirement) {
210
295
  function copyExternalBenchmarkComparisonResult(result) {
211
296
  return {
212
297
  ...result,
298
+ candidateSolver: result.candidateSolver != null
299
+ ? { ...result.candidateSolver }
300
+ : result.candidateSolver,
301
+ ...(result.referenceSolver ? { referenceSolver: { ...result.referenceSolver } } : {}),
302
+ ...(result.seriesSummary
303
+ ? {
304
+ seriesSummary: {
305
+ ...result.seriesSummary,
306
+ actual: result.seriesSummary.actual != null
307
+ ? { ...result.seriesSummary.actual }
308
+ : result.seriesSummary.actual,
309
+ expected: result.seriesSummary.expected != null
310
+ ? { ...result.seriesSummary.expected }
311
+ : result.seriesSummary.expected,
312
+ ...(result.seriesSummary.notes ? { notes: [...result.seriesSummary.notes] } : {}),
313
+ },
314
+ }
315
+ : {}),
213
316
  ...(result.notes ? { notes: [...result.notes] } : {}),
214
317
  };
215
318
  }
@@ -223,6 +326,47 @@ export function buildFemExternalBenchmarkAcceptanceContract(options = {}) {
223
326
  const blockerCodes = [];
224
327
  const referenceById = new Map(references.map((reference) => [reference.id, reference]));
225
328
  const quantityById = new Map(requiredQuantities.map((requirement) => [requirement.id, requirement]));
329
+ function referenceRequiresSolverRunMetadata(reference) {
330
+ return reference?.sourceType === 'commercial-solver' || reference?.sourceType === 'open-source-solver';
331
+ }
332
+ function comparisonIsAcceptedForRequirement(result, requirement, reference, sourceType) {
333
+ if (!reference)
334
+ return false;
335
+ if (sourceType && reference.sourceType !== sourceType)
336
+ return false;
337
+ if (result.quantityRequirementId !== requirement.id || !result.accepted)
338
+ return false;
339
+ if (result.comparisonKind !== 'scalar' && result.comparisonKind !== 'series-summary')
340
+ return false;
341
+ if (result.comparisonKind === 'series-summary' && !hasValidSeriesSummary(result.seriesSummary))
342
+ return false;
343
+ if (result.comparisonKind === 'series-summary' &&
344
+ result.seriesSummary &&
345
+ !seriesSummarySatisfiesRequirementTolerance(result.seriesSummary, requirement)) {
346
+ return false;
347
+ }
348
+ if (!isNonEmptyString(result.metricName))
349
+ return false;
350
+ if (!hasSolverRunMetadata(result.candidateSolver))
351
+ return false;
352
+ if (referenceRequiresSolverRunMetadata(reference) && !hasSolverRunMetadata(result.referenceSolver))
353
+ return false;
354
+ if (result.referenceSolver != null && !hasSolverRunMetadata(result.referenceSolver))
355
+ return false;
356
+ if (!Number.isFinite(result.actual) || !Number.isFinite(result.expected))
357
+ return false;
358
+ if (!hasValidSha256(result.evidenceHashSha256) || !hasValidSha256(result.resultHashSha256))
359
+ return false;
360
+ if (result.quantity !== requirement.quantity || result.unit !== requirement.unit)
361
+ return false;
362
+ if (result.toleranceType !== requirement.toleranceType || result.tolerance > requirement.tolerance) {
363
+ return false;
364
+ }
365
+ const tolerance = evaluateFemTolerance(requirement.quantity, result.actual, result.expected, requirement.toleranceType === 'relative' ? 0 : requirement.tolerance, requirement.toleranceType === 'absolute'
366
+ ? { unit: requirement.unit }
367
+ : { relativeTolerance: requirement.tolerance, unit: requirement.unit });
368
+ return tolerance.accepted;
369
+ }
226
370
  if (references.length === 0) {
227
371
  blockerCodes.push('external-benchmark-reference-corpus-missing');
228
372
  }
@@ -321,6 +465,12 @@ export function buildFemExternalBenchmarkAcceptanceContract(options = {}) {
321
465
  if (!isNonEmptyString(result.caseId)) {
322
466
  blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.case-id-missing`);
323
467
  }
468
+ if (result.comparisonKind !== 'scalar' && result.comparisonKind !== 'series-summary') {
469
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.comparison-kind-invalid`);
470
+ }
471
+ if (!isNonEmptyString(result.metricName)) {
472
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.metric-name-missing`);
473
+ }
324
474
  if (!isNonEmptyString(result.quantity)) {
325
475
  blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.quantity-missing`);
326
476
  }
@@ -341,7 +491,32 @@ export function buildFemExternalBenchmarkAcceptanceContract(options = {}) {
341
491
  if (!hasValidSha256(result.evidenceHashSha256)) {
342
492
  blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.evidence-hash-missing`);
343
493
  }
494
+ if (!hasValidSha256(result.resultHashSha256)) {
495
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.result-hash-missing`);
496
+ }
497
+ if (!hasSolverRunMetadata(result.candidateSolver)) {
498
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.candidate-solver-metadata-missing`);
499
+ }
500
+ const reference = referenceById.get(result.referenceId);
501
+ if (referenceRequiresSolverRunMetadata(reference) && !hasSolverRunMetadata(result.referenceSolver)) {
502
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.reference-solver-metadata-missing`);
503
+ }
504
+ else if (result.referenceSolver != null && !hasSolverRunMetadata(result.referenceSolver)) {
505
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.reference-solver-metadata-invalid`);
506
+ }
507
+ if (result.comparisonKind === 'series-summary' && !hasValidSeriesSummary(result.seriesSummary)) {
508
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.series-summary-invalid`);
509
+ }
510
+ else if (result.seriesSummary != null && !hasValidSeriesSummary(result.seriesSummary)) {
511
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.series-summary-invalid`);
512
+ }
344
513
  const requirement = quantityById.get(result.quantityRequirementId);
514
+ if (requirement &&
515
+ result.comparisonKind === 'series-summary' &&
516
+ hasValidSeriesSummary(result.seriesSummary) &&
517
+ !seriesSummarySatisfiesRequirementTolerance(result.seriesSummary, requirement)) {
518
+ blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.series-tolerance-exceeded`);
519
+ }
345
520
  if (requirement) {
346
521
  if (result.quantity !== requirement.quantity) {
347
522
  blockerCodes.push(`external-benchmark.comparison-results.${resultCode}.quantity-mismatch`);
@@ -371,37 +546,60 @@ export function buildFemExternalBenchmarkAcceptanceContract(options = {}) {
371
546
  }
372
547
  }
373
548
  }
549
+ const acceptedComparisons = comparisonResults
550
+ .map((result) => ({
551
+ result,
552
+ requirement: quantityById.get(result.quantityRequirementId),
553
+ reference: referenceById.get(result.referenceId),
554
+ }))
555
+ .filter((item) => item.requirement != null &&
556
+ item.reference != null &&
557
+ comparisonIsAcceptedForRequirement(item.result, item.requirement, item.reference));
558
+ const partiallyCoveredRequiredQuantityIds = [];
559
+ const fullyCoveredRequiredQuantityIds = [];
374
560
  for (const requirement of requiredQuantities) {
375
561
  if (!isNonEmptyString(requirement.id))
376
562
  continue;
563
+ const acceptedSourceTypesForRequirement = new Set(acceptedComparisons
564
+ .filter((item) => item.requirement.id === requirement.id)
565
+ .map((item) => item.reference.sourceType));
566
+ if (acceptedSourceTypesForRequirement.size > 0) {
567
+ partiallyCoveredRequiredQuantityIds.push(requirement.id);
568
+ }
569
+ if (requirement.requiredReferenceSourceTypes.length > 0 &&
570
+ requirement.requiredReferenceSourceTypes.every((sourceType) => acceptedSourceTypesForRequirement.has(sourceType))) {
571
+ fullyCoveredRequiredQuantityIds.push(requirement.id);
572
+ }
377
573
  for (const sourceType of requirement.requiredReferenceSourceTypes) {
378
- const hasAcceptedComparison = comparisonResults.some((result) => {
379
- const reference = referenceById.get(result.referenceId);
380
- if (!reference || reference.sourceType !== sourceType)
381
- return false;
382
- if (result.quantityRequirementId !== requirement.id || !result.accepted)
383
- return false;
384
- if (!Number.isFinite(result.actual) || !Number.isFinite(result.expected))
385
- return false;
386
- if (!hasValidSha256(result.evidenceHashSha256))
387
- return false;
388
- if (result.quantity !== requirement.quantity || result.unit !== requirement.unit)
389
- return false;
390
- if (result.toleranceType !== requirement.toleranceType || result.tolerance > requirement.tolerance) {
391
- return false;
392
- }
393
- const tolerance = evaluateFemTolerance(requirement.quantity, result.actual, result.expected, requirement.toleranceType === 'relative' ? 0 : requirement.tolerance, requirement.toleranceType === 'absolute'
394
- ? { unit: requirement.unit }
395
- : { relativeTolerance: requirement.tolerance, unit: requirement.unit });
396
- return tolerance.accepted;
397
- });
574
+ const hasAcceptedComparison = acceptedComparisons.some((item) => item.requirement.id === requirement.id &&
575
+ item.reference.sourceType === sourceType);
398
576
  if (!hasAcceptedComparison) {
399
577
  blockerCodes.push(`external-benchmark.required-quantities.${requirement.id}.accepted-comparison-missing.${sourceType}`);
400
578
  }
401
579
  }
402
580
  }
581
+ const acceptedSourceTypes = new Set(acceptedComparisons.map((item) => item.reference.sourceType));
582
+ const coverageSummary = {
583
+ schemaVersion: 'fem-external-benchmark-coverage.v1',
584
+ acceptedComparisonCount: acceptedComparisons.length,
585
+ acceptedPublishedComparisonCount: acceptedComparisons
586
+ .filter((item) => item.reference.sourceType === 'published-source').length,
587
+ acceptedCommercialComparisonCount: acceptedComparisons
588
+ .filter((item) => item.reference.sourceType === 'commercial-solver').length,
589
+ acceptedOpenSourceComparisonCount: acceptedComparisons
590
+ .filter((item) => item.reference.sourceType === 'open-source-solver').length,
591
+ requiredQuantityCount: requiredQuantities.length,
592
+ partiallyCoveredRequiredQuantityIds,
593
+ fullyCoveredRequiredQuantityIds,
594
+ missingRequiredSourceTypes: REQUIRED_EXTERNAL_BENCHMARK_SOURCE_TYPES
595
+ .filter((sourceType) => !acceptedSourceTypes.has(sourceType)),
596
+ };
597
+ if (coverageSummary.fullyCoveredRequiredQuantityIds.length < requiredQuantities.length) {
598
+ blockerCodes.push('external-benchmark-comparison-results-missing');
599
+ }
403
600
  const uniqueBlockerCodes = [...new Set(blockerCodes)];
404
601
  const productionReadinessBlocked = uniqueBlockerCodes.length > 0;
602
+ const hasPartialAcceptedEvidence = coverageSummary.acceptedComparisonCount > 0;
405
603
  return {
406
604
  schemaVersion: 'fem-external-benchmark-acceptance.v2',
407
605
  status: productionReadinessBlocked ? 'blocked' : 'accepted-comparisons-ready',
@@ -410,12 +608,226 @@ export function buildFemExternalBenchmarkAcceptanceContract(options = {}) {
410
608
  references,
411
609
  requiredQuantities,
412
610
  comparisonResults,
611
+ coverageSummary,
413
612
  blockerCodes: uniqueBlockerCodes,
414
613
  acceptanceStatement: productionReadinessBlocked
415
- ? 'External benchmark acceptance is incomplete; production readiness remains blocked until source citations, commercial solver references, and accepted comparison results cover every required FEM quantity.'
614
+ ? hasPartialAcceptedEvidence
615
+ ? `Partial external benchmark evidence is present (${coverageSummary.acceptedComparisonCount} accepted comparison summaries), but production readiness remains blocked until published and commercial solver references and accepted comparison results cover every required FEM quantity.`
616
+ : 'External benchmark acceptance is incomplete; production readiness remains blocked until source citations, commercial solver references, and accepted comparison results cover every required FEM quantity.'
416
617
  : 'External benchmark references and comparison results cover the required FEM quantities; this still does not approve production design without solver-route and reviewer-workflow acceptance.',
417
618
  };
418
619
  }
620
+ function femSeriesStatistics(values) {
621
+ if (values.length === 0)
622
+ throw new Error('FEM benchmark series must include at least one value.');
623
+ const sum = values.reduce((total, value) => total + value, 0);
624
+ return {
625
+ min: round(Math.min(...values), 10),
626
+ max: round(Math.max(...values), 10),
627
+ final: round(values[values.length - 1], 10),
628
+ mean: round(sum / values.length, 10),
629
+ };
630
+ }
631
+ function buildFemExternalBenchmarkSeriesSummary(input) {
632
+ if (input.points.length === 0) {
633
+ throw new Error('FEM external benchmark comparison series must include at least one point.');
634
+ }
635
+ const points = input.points.map((point) => ({
636
+ x: round(point.x, 10),
637
+ actual: round(point.actual, 10),
638
+ expected: round(point.expected, 10),
639
+ }));
640
+ const actual = points.map((point) => point.actual);
641
+ const expected = points.map((point) => point.expected);
642
+ const errors = points.map((point) => Math.abs(point.actual - point.expected));
643
+ const relativeErrors = points.map((point) => Math.abs(point.actual - point.expected) / Math.max(Math.abs(point.expected), 1e-12));
644
+ const rmsError = Math.sqrt(errors.reduce((sum, error) => sum + error * error, 0) / errors.length);
645
+ return {
646
+ xQuantity: input.xQuantity,
647
+ xUnit: input.xUnit,
648
+ yQuantity: input.yQuantity,
649
+ yUnit: input.yUnit,
650
+ pointCount: points.length,
651
+ actual: femSeriesStatistics(actual),
652
+ expected: femSeriesStatistics(expected),
653
+ maxAbsoluteError: round(Math.max(...errors), 10),
654
+ maxRelativeError: round(Math.max(...relativeErrors), 10),
655
+ rmsError: round(rmsError, 10),
656
+ seriesHashSha256: hashFemBenchmarkPayload({
657
+ schemaVersion: 'fem-external-benchmark-series.v1',
658
+ xQuantity: input.xQuantity,
659
+ xUnit: input.xUnit,
660
+ yQuantity: input.yQuantity,
661
+ yUnit: input.yUnit,
662
+ points,
663
+ }),
664
+ ...(input.notes ? { notes: [...input.notes] } : {}),
665
+ };
666
+ }
667
+ function externalBenchmarkFinalAccepted(input) {
668
+ return evaluateFemTolerance(input.quantity, input.actual, input.expected, input.toleranceType === 'relative' ? 0 : input.tolerance, input.toleranceType === 'absolute'
669
+ ? { unit: input.unit }
670
+ : { relativeTolerance: input.tolerance, unit: input.unit }).accepted;
671
+ }
672
+ function buildDefaultPublishedExternalBenchmarkComparisonResults(input) {
673
+ const consolidationRequirement = DEFAULT_EXTERNAL_BENCHMARK_REQUIRED_QUANTITIES
674
+ .find((requirement) => requirement.id === 'consolidation-settlement-time-curve');
675
+ const biotRequirement = DEFAULT_EXTERNAL_BENCHMARK_REQUIRED_QUANTITIES
676
+ .find((requirement) => requirement.id === 'biot-pore-pressure-dissipation');
677
+ if (!consolidationRequirement || !biotRequirement) {
678
+ throw new Error('Default FEM external benchmark quantity requirements are missing.');
679
+ }
680
+ const consolidationPoints = input.consolidation.steps.map((step) => ({
681
+ x: step.timeFactor,
682
+ actual: step.degreeOfConsolidation,
683
+ expected: step.referenceDegreeOfConsolidation,
684
+ }));
685
+ const consolidationSeries = buildFemExternalBenchmarkSeriesSummary({
686
+ xQuantity: 'time factor',
687
+ xUnit: 'Tv',
688
+ yQuantity: 'average degree of consolidation',
689
+ yUnit: 'ratio',
690
+ points: consolidationPoints,
691
+ notes: [
692
+ 'Backward-Euler drainage-column series compared with the Terzaghi average-consolidation Fourier-series reference.',
693
+ ],
694
+ });
695
+ const consolidationActual = input.consolidation.finalStep.degreeOfConsolidation;
696
+ const consolidationExpected = input.consolidation.finalStep.referenceDegreeOfConsolidation;
697
+ const consolidationEvidencePayload = {
698
+ schemaVersion: 'fem-external-benchmark-evidence.v1',
699
+ caseId: 'terzaghi-1d-backward-euler-tv-0-197',
700
+ sourceId: 'terzaghi-1943-theoretical-soil-mechanics',
701
+ method: input.consolidation.method,
702
+ drainagePathM: input.consolidation.drainagePathM,
703
+ nodeCount: input.consolidation.nodeCount,
704
+ finalTimeFactor: input.consolidation.finalStep.timeFactor,
705
+ seriesHashSha256: consolidationSeries.seriesHashSha256,
706
+ };
707
+ const consolidationResultPayload = {
708
+ actual: consolidationActual,
709
+ expected: consolidationExpected,
710
+ maxAbsoluteError: consolidationSeries.maxAbsoluteError,
711
+ maxRelativeError: consolidationSeries.maxRelativeError,
712
+ resultSeriesHashSha256: consolidationSeries.seriesHashSha256,
713
+ };
714
+ const consolidationAccepted = externalBenchmarkFinalAccepted({
715
+ actual: consolidationActual,
716
+ expected: consolidationExpected,
717
+ tolerance: consolidationRequirement.tolerance,
718
+ toleranceType: consolidationRequirement.toleranceType,
719
+ unit: consolidationRequirement.unit,
720
+ quantity: consolidationRequirement.quantity,
721
+ }) && seriesSummarySatisfiesRequirementTolerance(consolidationSeries, consolidationRequirement);
722
+ const biotCvPerSecond = input.biotTerzaghiHydraulicConductivityMPerS / input.biotTerzaghiSpecificStorage1PerM;
723
+ const biotPoints = input.biotTerzaghi.timeSteps.map((step) => {
724
+ const timeFactor = step.timeSeconds * biotCvPerSecond;
725
+ return {
726
+ x: timeFactor,
727
+ actual: step.pressureDiagnostics.averagePorePressureKpa,
728
+ expected: input.biotTerzaghiInitialPressureKpa * (1 - terzaghiAverageConsolidation(timeFactor)),
729
+ };
730
+ });
731
+ const biotSeries = buildFemExternalBenchmarkSeriesSummary({
732
+ xQuantity: 'time factor',
733
+ xUnit: 'Tv',
734
+ yQuantity: 'average excess pore pressure',
735
+ yUnit: 'kPa',
736
+ points: biotPoints,
737
+ notes: [
738
+ 'Alpha-zero Quad4 Biot u-p pressure-diffusion series compared with the Terzaghi drained-column analytical curve.',
739
+ ],
740
+ });
741
+ const biotActual = input.biotTerzaghi.pressureDiagnostics.averagePorePressureKpa;
742
+ const biotExpected = biotPoints[biotPoints.length - 1].expected;
743
+ const biotEvidencePayload = {
744
+ schemaVersion: 'fem-external-benchmark-evidence.v1',
745
+ caseId: 'quad4-biot-alpha-zero-terzaghi-top-drained-tv-0-197',
746
+ sourceId: 'biot-1941-three-dimensional-consolidation',
747
+ method: input.biotTerzaghi.method,
748
+ pressureKind: input.biotTerzaghi.numericalContract.pressureKind,
749
+ pressureUnit: input.biotTerzaghi.numericalContract.pressureUnit,
750
+ timeStepCount: input.biotTerzaghi.timeSteps.length,
751
+ transientAcceptance: input.biotTerzaghi.transientAcceptance,
752
+ finalTimeFactor: round(biotPoints[biotPoints.length - 1].x, 10),
753
+ seriesHashSha256: biotSeries.seriesHashSha256,
754
+ };
755
+ const biotResultPayload = {
756
+ actual: biotActual,
757
+ expected: biotExpected,
758
+ maxAbsoluteError: biotSeries.maxAbsoluteError,
759
+ maxRelativeError: biotSeries.maxRelativeError,
760
+ resultSeriesHashSha256: biotSeries.seriesHashSha256,
761
+ };
762
+ const biotAccepted = externalBenchmarkFinalAccepted({
763
+ actual: biotActual,
764
+ expected: biotExpected,
765
+ tolerance: biotRequirement.tolerance,
766
+ toleranceType: biotRequirement.toleranceType,
767
+ unit: biotRequirement.unit,
768
+ quantity: biotRequirement.quantity,
769
+ }) && seriesSummarySatisfiesRequirementTolerance(biotSeries, biotRequirement);
770
+ return [
771
+ {
772
+ id: 'published-terzaghi-1d-consolidation-tv-0-197',
773
+ quantityRequirementId: consolidationRequirement.id,
774
+ referenceId: 'terzaghi-1943-theoretical-soil-mechanics',
775
+ caseId: 'terzaghi-1d-backward-euler-tv-0-197',
776
+ comparisonKind: 'series-summary',
777
+ metricName: 'averageDegreeOfConsolidationTimeCurve',
778
+ quantity: consolidationRequirement.quantity,
779
+ unit: consolidationRequirement.unit,
780
+ actual: round(consolidationActual, 10),
781
+ expected: round(consolidationExpected, 10),
782
+ tolerance: consolidationRequirement.tolerance,
783
+ toleranceType: consolidationRequirement.toleranceType,
784
+ accepted: consolidationAccepted,
785
+ candidateSolver: {
786
+ name: 'geotechCLI FEM evidence suite',
787
+ version: 'strong-beta',
788
+ solverType: 'geotechcli-kernel',
789
+ analysisProcedure: 'backward-Euler 1D Terzaghi consolidation time stepper',
790
+ elementType: '1D drainage-column finite-difference grid',
791
+ runId: 'terzaghi-1d-backward-euler-tv-0-197',
792
+ },
793
+ evidenceHashSha256: hashFemBenchmarkPayload(consolidationEvidencePayload),
794
+ resultHashSha256: hashFemBenchmarkPayload(consolidationResultPayload),
795
+ seriesSummary: consolidationSeries,
796
+ notes: [
797
+ 'Generated published-source comparison record only; commercial solver comparison remains missing.',
798
+ ],
799
+ },
800
+ {
801
+ id: 'published-biot-alpha-zero-terzaghi-pressure-dissipation-tv-0-197',
802
+ quantityRequirementId: biotRequirement.id,
803
+ referenceId: 'biot-1941-three-dimensional-consolidation',
804
+ caseId: 'quad4-biot-alpha-zero-terzaghi-top-drained-tv-0-197',
805
+ comparisonKind: 'series-summary',
806
+ metricName: 'averageExcessPorePressureDissipationTimeCurve',
807
+ quantity: biotRequirement.quantity,
808
+ unit: biotRequirement.unit,
809
+ actual: round(biotActual, 10),
810
+ expected: round(biotExpected, 10),
811
+ tolerance: biotRequirement.tolerance,
812
+ toleranceType: biotRequirement.toleranceType,
813
+ accepted: biotAccepted,
814
+ candidateSolver: {
815
+ name: 'geotechCLI FEM evidence suite',
816
+ version: 'strong-beta',
817
+ solverType: 'geotechcli-kernel',
818
+ analysisProcedure: 'linear-elastic Quad4 Biot u-p backward-Euler pressure diffusion',
819
+ elementType: 'Quad4 plane-strain u-p evidence mesh',
820
+ runId: 'quad4-biot-alpha-zero-terzaghi-top-drained-tv-0-197',
821
+ },
822
+ evidenceHashSha256: hashFemBenchmarkPayload(biotEvidencePayload),
823
+ resultHashSha256: hashFemBenchmarkPayload(biotResultPayload),
824
+ seriesSummary: biotSeries,
825
+ notes: [
826
+ 'Generated published-source comparison record for an alpha-zero Biot pressure-diffusion specialization; commercial solver comparison remains missing.',
827
+ ],
828
+ },
829
+ ];
830
+ }
419
831
  export function evaluateFemTolerance(quantity, actual, expected, absoluteTolerance, options = {}) {
420
832
  const error = Math.abs(actual - expected);
421
833
  const relativeError = Math.abs(expected) > 0 ? error / Math.abs(expected) : error;
@@ -1638,6 +2050,36 @@ export function runFemEngineeringEvidenceSuite(policy = DEFAULT_FEM_CONVERGENCE_
1638
2050
  biotTerzaghi.transientAcceptance.monotonicMaxPressureEnvelope
1639
2051
  ? 1
1640
2052
  : 0, 1, 0, 'Top-drained alpha-zero Biot Terzaghi fixture must pass the stricter drained-dissipation transient acceptance gate.'));
2053
+ const biotPressureReplay = runPlaneStrainDruckerPragerBiotPressureReplay({
2054
+ mechanicalModel: {
2055
+ schemaVersion: 'fem-plane-strain-model.v1',
2056
+ nodes: biotTerzaghiMesh.nodes,
2057
+ elements: biotTerzaghiMesh.elements,
2058
+ materials: [{
2059
+ id: 'soil',
2060
+ elasticModulusKpa: 30_000,
2061
+ poissonRatio: 0.3,
2062
+ frictionAngleDeg: 35,
2063
+ cohesionKpa: 10_000,
2064
+ dilationAngleDeg: 0,
2065
+ biotCoefficient: 0.8,
2066
+ }],
2067
+ boundaryConditions: biotTerzaghiBottomNodes.flatMap((node) => [
2068
+ { nodeId: node.id, dof: 'ux' },
2069
+ { nodeId: node.id, dof: 'uy' },
2070
+ ]),
2071
+ policy,
2072
+ },
2073
+ biotResult: biotTerzaghi,
2074
+ solverOptions: { loadStepFractions: [1] },
2075
+ });
2076
+ benchmarks.push(benchmark('quad4-plane-strain-dp-sequential-biot-pressure-replay-audit', 'seepage-pore-pressure-coupling', 'internal-balance', 'sequentialPressureReplayAccepted', biotPressureReplay.converged &&
2077
+ biotPressureReplay.pressureReplayAudit.mode === 'sequential-one-way-biot-pressure-replay' &&
2078
+ biotPressureReplay.pressureReplayAudit.sourceTransientAccepted &&
2079
+ biotPressureReplay.hydroMechanicalCoupling?.porePressureDofCount === 0 &&
2080
+ biotPressureReplay.hydroMechanicalCoupling.maxAbsAppliedEffectiveStressReductionKpa > 0
2081
+ ? 1
2082
+ : 0, 1, 0, 'Sequential pressure replay must feed an accepted linear Biot u-p pressure frame into the Drucker-Prager effective-stress residual while auditing that no pore-pressure DOFs or monolithic Biot-plastic tangent are assembled.'));
1641
2083
  const coupling = runHydroMechanicalCoupling1D({
1642
2084
  totalVerticalStressKpa: 200,
1643
2085
  porePressureBeforeKpa: 80,
@@ -1776,7 +2218,15 @@ export function runFemEngineeringEvidenceSuite(policy = DEFAULT_FEM_CONVERGENCE_
1776
2218
  .filter((item) => item.status === 'accepted')
1777
2219
  .map((item) => item.feature))];
1778
2220
  const status = benchmarks.every((item) => item.status === 'accepted') ? 'kernel-verified' : 'blocked';
1779
- const externalBenchmarkAcceptance = buildFemExternalBenchmarkAcceptanceContract();
2221
+ const externalBenchmarkAcceptance = buildFemExternalBenchmarkAcceptanceContract({
2222
+ comparisonResults: buildDefaultPublishedExternalBenchmarkComparisonResults({
2223
+ consolidation,
2224
+ biotTerzaghi,
2225
+ biotTerzaghiInitialPressureKpa,
2226
+ biotTerzaghiHydraulicConductivityMPerS,
2227
+ biotTerzaghiSpecificStorage1PerM,
2228
+ }),
2229
+ });
1780
2230
  return {
1781
2231
  schemaVersion: 'fem-engineering-evidence.v1',
1782
2232
  status,