Connect HHT field convergence to production acceptance and v2 logs

This commit is contained in:
KOKO\Mimi
2026-10-19 02:49:53 +09:00
parent 0e392a7364
commit dd54b0ae1e
9 changed files with 811 additions and 23 deletions
@@ -669,4 +669,39 @@ TEST(HhtDynamicAlgebra, RejectsAdmissibleNullRankChangeBeforeAcceptingFrame) {
EXPECT_EQ(frame.hidden_constraint[0], 23.0);
}
TEST(HhtEndpointFields, MeasuresActualFullChartAcrossNonlinearRootAndPartialBoundary) {
const auto fixture = MakeFixture(1U, true);
auto trial = EmptyTrial(fixture), old = EmptyTrial(fixture);
trial.residual = fesa::Vector{24U};
fesa::Vector previous{24U};
// Independent nonlinear graph q_phiZ=p^2: p .1 -> .2. The
// previous tangent lift is .02, while the actual endpoint change is .03.
previous[0] = .1; previous[5] = .01;
trial.generalized_coordinates[0] = .2;
trial.generalized_coordinates[5] = .04;
trial.residual[0] = 3; trial.residual[5] = -5;
// Large constrained values and spatial support moments are not free
// generalized endpoint evidence and cannot change either scalar maximum.
trial.generalized_coordinates[3] = 1000;
trial.residual[3] = 2000;
trial.reaction = fesa::Vector{24U}; trial.reaction[5] = 3000;
const auto result = fesa::hht_internal::MeasureEndpointFields(fixture.dofs, trial, old, previous);
ASSERT_TRUE(result.HasValue());
EXPECT_TRUE(result.Value()[0].active); EXPECT_TRUE(result.Value()[1].active);
EXPECT_DOUBLE_EQ(result.Value()[0].residual, 3);
EXPECT_DOUBLE_EQ(result.Value()[1].residual, 5);
EXPECT_DOUBLE_EQ(result.Value()[0].increment, .2);
EXPECT_DOUBLE_EQ(result.Value()[1].increment, .04);
EXPECT_DOUBLE_EQ(*result.Value()[0].correction, .2 - .1);
EXPECT_DOUBLE_EQ(*result.Value()[1].correction, .04 - .01);
EXPECT_NE(*result.Value()[1].correction, 2 * .1 * (.2 - .1));
const auto initial = fesa::hht_internal::MeasureEndpointFields(fixture.dofs, trial, old, std::nullopt);
ASSERT_TRUE(initial.HasValue());
EXPECT_FALSE(initial.Value()[0].correction.has_value());
EXPECT_FALSE(initial.Value()[1].correction.has_value());
previous[0] = -std::numeric_limits<double>::max();
trial.generalized_coordinates[0] = std::numeric_limits<double>::max();
EXPECT_FALSE(fesa::hht_internal::MeasureEndpointFields(fixture.dofs, trial, old, previous).HasValue());
}
} // namespace
@@ -8,9 +8,16 @@
#include <fstream>
#include <iterator>
#include <optional>
#include <sstream>
#include <map>
#include <string>
#include "fesa/analysis/newton_iteration_log.h"
#include "fesa/analysis/analysis_model.h"
#include "analysis/hht_dynamic_internal.h"
#include "analysis/hht_reduced_integrator.h"
#include "analysis/hht_reduced_procedure.h"
#include "analysis/equilibrium_algorithm.h"
#include "fesa/assembly/parallel_for.h"
#include "fesa/solvers/linear/mkl_pardiso_solver.h"
#include "fesa/solvers/linear/mkl_pardiso_tangent_solver.h"
@@ -569,3 +576,355 @@ TEST(HhtDynamicProcedure,
}
} // namespace
namespace {
std::map<std::string, std::string> LogFields(const std::string& line) {
std::map<std::string, std::string> fields;
std::istringstream input(line);
std::string token;
while (std::getline(input, token, '\t')) {
const auto equal = token.find('=');
if (equal != std::string::npos) fields.emplace(token.substr(0, equal), token.substr(equal + 1));
}
return fields;
}
}
namespace {
fesa::ModelDefinition SymmetricExtension() {
auto definition = MakeDefinition(false);
definition.dynamic_step->initial_increment = 0.1;
definition.dynamic_step->time_period = 0.1;
definition.dynamic_step->increment_count = 1U;
definition.node_sets.push_back({"Left", std::nullopt, {0U, 3U}, {"dynamic-state.inp", 30U}});
definition.node_sets.push_back({"Right", std::nullopt, {1U, 2U}, {"dynamic-state.inp", 31U}});
definition.dynamic_step->boundaries = {
{"All", 2, 6, 0.0, {"dynamic-state.inp", 32U}}};
definition.dynamic_step->loads = {
{"Left", 1, -1.0, {"dynamic-state.inp", 33U}},
{"Right", 1, 1.0, {"dynamic-state.inp", 34U}}};
return definition;
}
}
TEST(HhtDynamicProcedure, FieldPolicyActuallyTerminatesSymmetricExtensionBeforeStrict) {
auto definition = SymmetricExtension();
fesa::SerialParallelFor parallel;
auto run = RunRigidTranslation(definition, parallel, "field-policy-extension");
ASSERT_TRUE(run.HasValue()) << (run.HasValue() ? "" : FailureText(run.GetStatus()));
const auto& frame = run.Value().execution.state.CommittedState();
ASSERT_GT(frame.iteration_count, 0U);
ASSERT_LE(frame.iteration_count, 100U);
const auto& text = run.Value().log;
const auto accepted = text.rfind("record=dynamic-newton-iteration");
ASSERT_NE(accepted, std::string::npos);
const auto line = text.substr(accepted);
EXPECT_NE(line.find("\tschema=hht-log-v2\t"), std::string::npos);
EXPECT_NE(line.find("\tpolicy_id=abaqus2025-field-on-fesa-v1\t"), std::string::npos);
EXPECT_NE(line.find("\tstep=Transient Load\t"), std::string::npos);
EXPECT_NE(line.find("\tfield_pass=1\taccepted=1\t"), std::string::npos);
EXPECT_NE(line.find("\tstrict_correction_pass=0"), std::string::npos);
for (const auto value : frame.global_equilibrium) EXPECT_LE(std::abs(value), 1e-8);
auto fields = LogFields(line.substr(0, line.find('\n')));
double rho = 0, increment = 0, flux = 4.0;
for (std::size_t node = 0; node < 4; ++node) {
rho = (std::max)(rho, std::abs(frame.residual[6 * node]));
increment = (std::max)(increment, std::abs(frame.generalized_coordinates[6 * node]));
for (std::size_t c = 0; c < 3; ++c)
flux += std::abs(frame.internal_force[6 * node + c] + frame.inertia_force[6 * node + c]);
}
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_residual_numerator")), rho);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_increment_norm")), increment);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_spatial_flux")), flux / 24.0);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_time_average_flux")), flux / 24.0);
EXPECT_LE(rho, .005 * std::stod(fields.at("force_residual_scale")));
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_residual_threshold")), .005);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_correction_threshold")), .01);
EXPECT_EQ(fields.at("force_residual_pass"), "1");
EXPECT_EQ(fields.at("force_correction_pass"), "1");
const double c = std::stod(fields.at("force_correction_numerator"));
const auto& estimated = fields.at("force_estimated_correction");
EXPECT_TRUE(c <= .01 * increment ||
(estimated != "not-evaluated" && std::stod(estimated) <= .01 * increment));
EXPECT_EQ(fields.at("force_branch"), "normal");
EXPECT_EQ(fields.at("moment_branch"), "not-evaluated");
// Symmetric monotone extension has q=(-D,+D,+D,-D). This independent
// endpoint oracle includes the selected iteration-zero start, not the lift.
std::istringstream records(text);
std::string row;
double previous_D = 0;
bool first = true;
while (std::getline(records, row)) {
const auto values = LogFields(row);
if (values.at("schema") != "hht-log-v2") continue;
const double D = std::stod(values.at("force_increment_norm"));
if (first) {
EXPECT_EQ(values.at("force_correction_numerator"), "not-evaluated");
EXPECT_EQ(values.at("force_estimated_correction"), "not-evaluated");
EXPECT_EQ(values.at("field_pass"), "0");
} else {
EXPECT_NEAR(std::stod(values.at("force_correction_numerator")),
std::abs(D - previous_D), 1e-16);
}
previous_D = D;
first = false;
}
}
TEST(HhtDynamicProcedure, FieldPassDoesNotBypassPhysicalAcceptanceAndLogsActualCause) {
auto definition = MakeDefinition(false);
definition.materials[0].youngs_modulus = 1e9;
definition.dynamic_step->increment_count = 1;
definition.dynamic_step->time_period = definition.dynamic_step->initial_increment;
definition.node_sets[0].node_indices = {0U, 3U};
definition.node_sets[1].node_indices = {2U};
definition.dynamic_step->boundaries = {{"All", 1, 6, 0, {"dynamic-state.inp", 11U}}};
definition.dynamic_step->loads = {{"Loaded", 3, -40000, {"dynamic-state.inp", 12U}}};
auto domain = fesa::Domain::Create(definition);
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
fesa::MklPardisoTangentSolver tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-post-gate.log";
ASSERT_TRUE(log.Open(path).IsOk());
auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent).Execute(domain.Value(), log);
ASSERT_TRUE(log.Close().IsOk());
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close();
std::filesystem::remove(path);
ASSERT_FALSE(result.HasValue());
const auto row = text.substr(text.rfind("record="));
const auto fields = LogFields(row.substr(0, row.find('\n')));
EXPECT_EQ(fields.at("field_pass"), "1");
EXPECT_EQ(fields.at("accepted"), "0");
EXPECT_EQ(fields.at("outcome"), "failed");
const auto cause = std::find_if(result.GetStatus().Diagnostics().begin(),
result.GetStatus().Diagnostics().end(), [](const auto& d) {
return d.code != "dynamic-procedure-context";
});
ASSERT_NE(cause, result.GetStatus().Diagnostics().end());
EXPECT_EQ(fields.at("failure_code"), cause->code) << FailureText(result.GetStatus());
EXPECT_EQ(text.find("\taccepted=1"), std::string::npos);
}
TEST(HhtDynamicProcedure, AcceptedFluxHistoryCountsEachIncrementExactlyOnce) {
auto definition = SymmetricExtension();
definition.dynamic_step->time_period = .2;
definition.dynamic_step->increment_count = 2;
fesa::SerialParallelFor parallel;
auto run = RunRigidTranslation(definition, parallel, "field-history");
ASSERT_TRUE(run.HasValue()) << (run.HasValue() ? "" : FailureText(run.GetStatus()));
const auto& frames = run.Value().execution.state.AcceptedFrames();
ASSERT_EQ(frames.size(), 3U);
double sum = 0, maximum_sum = 0;
std::size_t n = 0;
std::istringstream input(run.Value().log);
std::string row;
while (std::getline(input, row)) {
const auto fields = LogFields(row);
if (fields.at("schema") != "hht-log-v2" || fields.at("accepted") != "1") continue;
++n;
const auto& frame = frames[n];
std::vector<double> flux;
for (std::size_t node = 0; node < 4; ++node)
for (std::size_t c = 0; c < 3; ++c)
flux.push_back(std::abs(frame.internal_force[6 * node + c] + frame.inertia_force[6 * node + c]));
for (int node = 0; node < 4; ++node) {
flux.push_back(1); flux.push_back(0); flux.push_back(0);
}
const double m = *std::max_element(flux.begin(), flux.end());
const double H = n == 1 ? 0 : maximum_sum / static_cast<double>(n - 1);
double selected_sum = 0;
std::size_t count = 0;
for (double x : flux)
if (m < .1 * H || x >= 1e-5 * H) { selected_sum += x; ++count; }
ASSERT_GT(count, 0U);
const double spatial = selected_sum / static_cast<double>(count);
sum += spatial; maximum_sum += m;
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_spatial_flux")), spatial);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_time_average_flux")), sum / static_cast<double>(n));
EXPECT_EQ(std::stoul(fields.at("iteration")), frame.iteration_count);
}
EXPECT_EQ(n, 2U);
}
TEST(HhtDynamicProcedure, LoadFluxPreservesSourceCancellationAndExplicitZeroEntries) {
auto definition = MakeDefinition(true);
definition.dynamic_step->loads = {
{"Loaded", 1, 3, {"dynamic-state.inp", 30U}},
{"Loaded", 1, -3, {"dynamic-state.inp", 31U}},
{"Loaded", 2, 0, {"dynamic-state.inp", 32U}}};
auto domain = fesa::Domain::Create(definition);
ASSERT_TRUE(domain.HasValue());
auto model = fesa::AnalysisModel::Create(domain.Value());
ASSERT_TRUE(model.HasValue());
auto dofs = fesa::DofManager::Create(model.Value());
ASSERT_TRUE(dofs.HasValue());
std::array<std::vector<fesa::hht_field_convergence::FluxOccurrence>, 2> flux;
const auto loads = fesa::hht_internal::AssembleLoads(domain.Value(), dofs.Value(), &flux);
ASSERT_TRUE(loads.HasValue());
EXPECT_DOUBLE_EQ(loads.Value()[0][0], 0);
EXPECT_DOUBLE_EQ(loads.Value()[1][0], 6);
ASSERT_EQ(flux[0].size(), 9U);
ASSERT_EQ(flux[1].size(), 9U);
const std::array<double, 9> expected{3, 0, 0, -3, 0, 0, 0, 0, 0};
for (std::size_t i = 0; i < expected.size(); ++i) {
EXPECT_DOUBLE_EQ(flux[0][i].internal, expected[i]);
EXPECT_DOUBLE_EQ(flux[0][i].inertia, 0);
EXPECT_DOUBLE_EQ(flux[1][i].internal, 0);
}
}
namespace {
// All-fixed fixture has no private coordinates. This adapter exercises the
// real production acceptance/output transaction without a test solver path.
class EmptyCoordinateIntegrator final : public fesa::hht_reduced_internal::ShellHhtIntegrator {
public:
fesa::hht_reduced_internal::ReducedShellPredictor Predict(
const fesa::hht_reduced_internal::ReducedHistory& history, double,
const fesa::HhtParameters&) const override {
EXPECT_EQ(history.p.Size(), 0U);
return {};
}
void EvaluateRates(const fesa::hht_reduced_internal::ReducedShellRateRequest&,
fesa::Vector& v, fesa::Vector& a) const override {
EXPECT_EQ(v.Size(), 0U); EXPECT_EQ(a.Size(), 0U);
}
fesa::Result<fesa::hht_reduced_internal::ReconstructedRates> ReconstructPhysicalRates(
const fesa::hht_reduced_internal::FixedChart& chart,
const fesa::hht_reduced_internal::EmbeddingRoot& root,
const fesa::Vector& v, const fesa::Vector& a,
fesa::TangentSolver& solver) const override {
return fesa::hht_reduced_internal::ReconstructPhysicalRates(chart, root, v, a, solver);
}
};
class CloseAtCandidate final : public fesa::EquilibriumAlgorithm {
public:
explicit CloseAtCandidate(fesa::NewtonIterationLogWriter& log) : log_(log) {}
fesa::Status SolveCurrentStep(std::uint32_t first, std::uint32_t last,
const Evaluation& evaluate) override {
EXPECT_EQ(first, 0U); EXPECT_EQ(last, 100U);
auto value = evaluate(first);
if (!value.HasValue()) return value.GetStatus();
EXPECT_TRUE(value.Value());
return log_.Close();
}
private:
fesa::NewtonIterationLogWriter& log_;
};
}
TEST(HhtDynamicProcedure, FailedAcceptanceOutputPublishesNoCandidateAndFreshRetryIsUnchanged) {
auto domain = fesa::Domain::Create(MakeDefinition());
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
fesa::MklPardisoTangentSolver tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-output-fail.log";
ASSERT_TRUE(log.Open(path).IsOk());
EmptyCoordinateIntegrator integrator;
CloseAtCandidate algorithm(log);
const auto result = fesa::ExecuteReducedHht(domain.Value(), log, parallel, mass, tangent, integrator, algorithm);
ASSERT_FALSE(result.HasValue());
EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kOutput);
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close(); std::filesystem::remove(path);
EXPECT_EQ(text.find("accepted=1"), std::string::npos);
auto first = RunRigidTranslation(MakeDefinition(), parallel, "field-retry");
auto second = RunRigidTranslation(MakeDefinition(), parallel, "field-retry-repeat");
ASSERT_TRUE(first.HasValue()); ASSERT_TRUE(second.HasValue());
ExpectSameDynamicHistory(first.Value(), second.Value());
}
namespace {
class FailNextSelectedTrial final : public fesa::TangentSolver {
public:
fesa::Status FactorizeFresh(const fesa::SparseMatrix& matrix) override {
if (outer_solved_) return fesa::Status::Failure(fesa::FailureCategory::kSolver,
{{fesa::Severity::kError, "injected-next-trial", {}, "DYNAMIC", {}, "Fail before the next selected field evaluation."}});
large_ = matrix.Rows() > 4;
return solver_.FactorizeFresh(matrix);
}
fesa::Status SolveCorrection(const fesa::Vector& rhs, fesa::Vector& solution) const override {
auto status = solver_.SolveCorrection(rhs, solution);
if (status.IsOk() && large_) outer_solved_ = true;
return status;
}
private:
fesa::MklPardisoTangentSolver solver_;
bool large_ = false;
mutable bool outer_solved_ = false;
};
}
TEST(HhtDynamicProcedure, FailureBeforeNextEvaluationDoesNotReusePreviousFieldEvidence) {
auto domain = fesa::Domain::Create(MakeFiniteRotationDefinition());
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
FailNextSelectedTrial tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-next-fail.log";
ASSERT_TRUE(log.Open(path).IsOk());
const auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent).Execute(domain.Value(), log);
ASSERT_TRUE(log.Close().IsOk());
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close(); std::filesystem::remove(path);
ASSERT_FALSE(result.HasValue());
const auto row = text.substr(text.rfind("record="));
const auto fields = LogFields(row.substr(0, row.find('\n')));
EXPECT_EQ(fields.at("failure_code"), "injected-next-trial") << FailureText(result.GetStatus());
EXPECT_EQ(fields.at("iteration"), "1");
EXPECT_EQ(fields.at("force_residual_numerator"), "not-evaluated");
EXPECT_EQ(fields.at("moment_residual_scale"), "not-evaluated");
EXPECT_EQ(fields.at("field_pass"), "not-evaluated");
EXPECT_EQ(fields.at("accepted"), "0");
}
namespace {
class EvaluateEndpoint100 final : public fesa::EquilibriumAlgorithm {
public:
fesa::Status SolveCurrentStep(std::uint32_t first, std::uint32_t last,
const Evaluation& evaluate) override {
EXPECT_EQ(first, 0U); EXPECT_EQ(last, 100U);
auto value = evaluate(last);
if (!value.HasValue()) return value.GetStatus();
EXPECT_TRUE(value.Value());
return fesa::Status::Ok();
}
};
}
TEST(HhtDynamicProcedure, FieldAcceptanceEvaluatesProductionEndpoint100BeforeCorrectionBudget) {
auto domain = fesa::Domain::Create(MakeDefinition());
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
fesa::MklPardisoTangentSolver tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-endpoint100.log";
ASSERT_TRUE(log.Open(path).IsOk());
EmptyCoordinateIntegrator integrator;
EvaluateEndpoint100 algorithm;
// Tests the real endpoint callback at the inclusive boundary. No physical
// trajectory is forced through 100 corrections; all fixed coordinates stay zero.
const auto result = fesa::ExecuteReducedHht(domain.Value(), log, parallel, mass, tangent, integrator, algorithm);
ASSERT_TRUE(log.Close().IsOk());
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close(); std::filesystem::remove(path);
ASSERT_TRUE(result.HasValue()) << (result.HasValue() ? "" : FailureText(result.GetStatus()));
ASSERT_EQ(result.Value().state.AcceptedFrames().size(), 3U);
EXPECT_EQ(result.Value().state.AcceptedFrames()[1].iteration_count, 100U);
EXPECT_EQ(result.Value().state.AcceptedFrames()[2].iteration_count, 100U);
const auto row = text.substr(text.rfind("record="));
const auto fields = LogFields(row.substr(0, row.find('\n')));
EXPECT_EQ(fields.at("iteration"), "100");
EXPECT_EQ(fields.at("field_pass"), "1");
EXPECT_EQ(fields.at("accepted"), "1");
}
@@ -8,6 +8,7 @@
#include <fstream>
#include <functional>
#include <limits>
#include <map>
#include <iterator>
#include <stdexcept>
#include <string>
@@ -562,4 +563,149 @@ TEST(DynamicNewtonIterationLog,
ExpectLogOutputFailure(unopened.Write(CompleteDynamicRecord()));
}
TEST(DynamicFieldIterationLog, KeepsOrderedUnitsMissingEvidenceAndEscapedSourceName) {
TempDirectory directory{"field-v2"};
const auto path = directory.Path() / "log.txt";
auto record = CompleteDynamicRecord();
record.policy_id = "abaqus2025-field-on-fesa-v1";
record.step_name = "\xED\x95\x9C\xEA\xB8\x80=Step\\tail\tline\nend\r";
record.stage = "evaluation";
record.outcome = "candidate";
record.fields[0].residual_numerator = 1.2345678901234567;
record.fields[0].increment_norm = 0;
fesa::NewtonIterationLogWriter writer;
ASSERT_TRUE(writer.Open(path).IsOk());
ASSERT_TRUE(writer.Write(record).IsOk());
ASSERT_TRUE(writer.Close().IsOk());
const auto text = ReadLogBytes(path);
EXPECT_EQ(std::count(text.begin(), text.end(), '\n'), 1);
EXPECT_EQ(text.find('\r'), std::string::npos);
const auto fields = SplitTabs(text.substr(0, text.size() - 1));
const std::vector<std::string> base{"record", "schema", "policy_id", "step", "increment", "time", "iteration", "stage", "outcome", "failure_code", "field_pass", "accepted"};
for (std::size_t i = 0; i < base.size(); ++i)
EXPECT_EQ(fields[i].substr(0, fields[i].find('=')), base[i]);
const std::vector<std::string> suffixes{
"residual_numerator", "residual_scale", "spatial_flux", "time_average_flux",
"correction_numerator", "increment_norm", "estimated_correction",
"residual_threshold", "correction_threshold", "residual_unit", "correction_unit",
"branch", "residual_pass", "correction_pass", "field_pass"};
for (std::size_t c = 0; c < 2; ++c)
for (std::size_t i = 0; i < suffixes.size(); ++i)
EXPECT_EQ(fields[base.size() + c * suffixes.size() + i].substr(
0, fields[base.size() + c * suffixes.size() + i].find('=')),
(c == 0 ? "force_" : "moment_") + suffixes[i]);
EXPECT_EQ(FieldValue(fields, "step"), "step=\xED\x95\x9C\xEA\xB8\x80=Step\\\\tail\\tline\\nend\\r");
EXPECT_EQ(FieldValue(fields, "force_correction_numerator"), "force_correction_numerator=not-evaluated");
EXPECT_EQ(FieldValue(fields, "force_increment_norm"), "force_increment_norm=0");
EXPECT_EQ(FieldValue(fields, "strict_residual_pass"), "strict_residual_pass=not-evaluated");
EXPECT_EQ(FieldValue(fields, "moment_correction_unit"), "moment_correction_unit=rad");
EXPECT_DOUBLE_EQ(std::stod(ValueAfterEquals(FieldValue(fields, "force_residual_numerator"))),
*record.fields[0].residual_numerator);
}
TEST(DynamicFieldIterationLog, InvalidFieldEvidenceFailsWithoutWritingPartialRecord) {
TempDirectory directory{"field-v2-invalid"};
const auto path = directory.Path() / "log.txt";
for (int kind = 0; kind < 4; ++kind) {
SCOPED_TRACE(kind);
fesa::NewtonIterationLogWriter writer;
ASSERT_TRUE(writer.Open(path).IsOk());
auto record = CompleteDynamicRecord();
record.policy_id = "abaqus2025-field-on-fesa-v1";
ASSERT_TRUE(writer.Write(record).IsOk());
ASSERT_TRUE(writer.Flush().IsOk());
const auto prefix = ReadLogBytes(path);
if (kind == 0) record.fields[0].branch = "unknown";
if (kind == 1) record.fields[0].estimated_correction = std::numeric_limits<double>::infinity();
if (kind == 2) record.fields[1].residual_scale = -1;
if (kind == 3) record.policy_id = "unknown";
ExpectLogOutputFailure(writer.Write(record));
ExpectLogOutputFailure(writer.Flush());
EXPECT_EQ(ReadLogBytes(path), prefix);
}
}
// Independent strict consumer: production has a writer, no public log reader.
bool ReadFieldV2(const std::string& bytes) {
if (bytes.empty() || bytes.back() != '\n' || bytes.find('\r') != std::string::npos ||
std::count(bytes.begin(), bytes.end(), '\n') != 1) return false;
std::map<std::string, std::string> values;
for (const auto& field : SplitTabs(bytes.substr(0, bytes.size() - 1))) {
const auto equal = field.find('=');
if (equal == std::string::npos || !values.emplace(field.substr(0, equal), field.substr(equal + 1)).second)
return false;
}
for (const auto* key : {"record", "schema", "policy_id", "step", "increment", "time", "iteration", "stage", "outcome", "failure_code", "field_pass", "accepted"})
if (!values.count(key)) return false;
if (values["record"] != "dynamic-newton-iteration" || values["schema"] != "hht-log-v2" ||
values["policy_id"] != "abaqus2025-field-on-fesa-v1") return false;
const auto numeric = [](const std::string& text, bool missing) {
if (missing && text == "not-evaluated") return true;
double value = 0;
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() && std::isfinite(value) && value >= 0;
};
const auto boolean = [](const std::string& text, bool missing) {
return text == "0" || text == "1" || (missing && text == "not-evaluated");
};
for (const auto* key : {"increment", "iteration"}) {
std::uint64_t value = 0;
const auto& text = values[key];
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()) return false;
}
if (!numeric(values["time"], false) || !boolean(values["field_pass"], true) || !boolean(values["accepted"], false)) return false;
for (const auto* prefix : {"force_", "moment_"}) {
for (const auto* suffix : {"residual_numerator", "residual_scale", "spatial_flux", "time_average_flux", "correction_numerator", "increment_norm", "estimated_correction", "residual_threshold", "correction_threshold"}) {
const auto found = values.find(std::string(prefix) + suffix);
if (found == values.end() || !numeric(found->second, true)) return false;
}
for (const auto* suffix : {"residual_pass", "correction_pass", "field_pass"}) {
const auto found = values.find(std::string(prefix) + suffix);
if (found == values.end() || !boolean(found->second, true)) return false;
}
const auto branch = values.find(std::string(prefix) + "branch");
if (branch == values.end() || (branch->second != "normal" && branch->second != "zero-flux" &&
branch->second != "linear" && branch->second != "nonquadratic" && branch->second != "not-evaluated")) return false;
if (values[std::string(prefix) + "residual_unit"] != (std::string(prefix) == "force_" ? "force" : "moment") ||
values[std::string(prefix) + "correction_unit"] != (std::string(prefix) == "force_" ? "length" : "rad")) return false;
}
for (const auto& entry : values)
for (std::size_t i = 0; i < entry.second.size(); ++i)
if (entry.second[i] == '\\') {
if (++i == entry.second.size() || (entry.second[i] != '\\' && entry.second[i] != 't' && entry.second[i] != 'n' && entry.second[i] != 'r')) return false;
}
return true;
}
TEST(DynamicFieldIterationLog, IndependentReaderRejectsMalformedVersionedRecords) {
TempDirectory directory{"field-reader"};
const auto path = directory.Path() / "log.txt";
fesa::NewtonIterationLogWriter writer;
ASSERT_TRUE(writer.Open(path).IsOk());
auto record = CompleteDynamicRecord();
record.policy_id = "abaqus2025-field-on-fesa-v1";
ASSERT_TRUE(writer.Write(record).IsOk());
ASSERT_TRUE(writer.Close().IsOk());
const auto valid = ReadLogBytes(path);
ASSERT_TRUE(ReadFieldV2(valid));
const auto replaced = [&](const std::string& from, const std::string& to) {
auto text = valid;
const auto start = text.find(from);
EXPECT_NE(start, std::string::npos);
text.replace(start, from.size(), to);
return text;
};
EXPECT_FALSE(ReadFieldV2(replaced("schema=hht-log-v2", "schema=hht-log-v1")));
EXPECT_FALSE(ReadFieldV2(replaced("\tincrement=2", "\tincrement=-1")));
EXPECT_FALSE(ReadFieldV2(replaced("\ttime=0.25", "\ttime=nan")));
EXPECT_FALSE(ReadFieldV2(replaced("\ttime=0.25", "\ttime=inf")));
EXPECT_FALSE(ReadFieldV2(replaced("force_residual_numerator=not-evaluated", "force_residual_numerator=1junk")));
EXPECT_FALSE(ReadFieldV2(replaced("force_branch=not-evaluated", "force_branch=unknown")));
EXPECT_FALSE(ReadFieldV2(replaced("\tfield_pass=not-evaluated", "\tfield_pass=true")));
EXPECT_FALSE(ReadFieldV2(replaced("\tstep=", "\tstep=bad\\x")));
EXPECT_FALSE(ReadFieldV2(replaced("\taccepted=0", "\taccepted=0\taccepted=0")));
EXPECT_FALSE(ReadFieldV2(replaced("\tforce_increment_norm=not-evaluated", "")));
}
} // namespace