Files
FESADev/tests/unit/analysis/newton_iteration_log_test.cpp
T

712 lines
32 KiB
C++

#include "fesa/analysis/newton_iteration_log.h"
#include <algorithm>
#include <charconv>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <functional>
#include <limits>
#include <map>
#include <iterator>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <gtest/gtest.h>
namespace {
class TempDirectory {
public:
explicit TempDirectory(const std::string& label) {
const auto tick =
std::chrono::steady_clock::now().time_since_epoch().count();
path_ = std::filesystem::temp_directory_path() /
("fesa-newton-log-" + label + "-" + std::to_string(tick));
std::error_code error;
if (!std::filesystem::create_directory(path_, error) || error) {
throw std::runtime_error{"Unable to create Newton log test fixture."};
}
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
const std::filesystem::path& Path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
void ExpectLogOutputFailure(const fesa::Status& status) {
ASSERT_FALSE(status.IsOk());
ASSERT_EQ(status.Category(), fesa::FailureCategory::kOutput);
ASSERT_EQ(status.Diagnostics().size(), 1U);
EXPECT_EQ(status.Diagnostics().front().code, "log-output-failure");
}
std::vector<std::string> SplitTabs(const std::string& line) {
std::vector<std::string> fields;
std::size_t start = 0U;
while (start <= line.size()) {
const std::size_t end = line.find('\t', start);
if (end == std::string::npos) {
fields.push_back(line.substr(start));
return fields;
}
fields.push_back(line.substr(start, end - start));
start = end + 1U;
}
return fields;
}
const std::string& FieldValue(const std::vector<std::string>& fields,
const std::string_view label) {
const std::string prefix = std::string{label} + "=";
for (const std::string& field : fields) {
if (field.compare(0U, prefix.size(), prefix) == 0) {
return field;
}
}
throw std::runtime_error{"Newton log field is missing."};
}
std::string ValueAfterEquals(const std::string& field) {
return field.substr(field.find('=') + 1U);
}
fesa::Vector VectorFrom(const std::vector<double>& values) {
fesa::Vector result{values.size()};
for (std::size_t index = 0U; index < values.size(); ++index) {
result[index] = values[index];
}
return result;
}
fesa::NewtonChannelDecision MakeDecision(const std::string& identity) {
return {identity,
0.0012345678901234567,
1.2345678901234567,
fesa::NewtonConvergenceBranch::kRelative,
fesa::kNewtonRelativeThreshold,
fesa::NewtonChannelUnit::kDimensionless,
true};
}
fesa::NewtonIterationLogRecord CompleteRecord() {
return {"Step-1",
3U,
0.12345678901234567,
2U,
fesa::NewtonIterationStage::kConvergenceEvaluated,
{false, fesa::NewtonIterationStage::kFailed, "", ""},
"accept",
"accepted",
"none",
MakeDecision("residual_force"),
MakeDecision("residual_moment"),
MakeDecision("correction_translation"),
MakeDecision("correction_rotation"),
true};
}
std::vector<std::string> WriteAndRead(
const std::filesystem::path& path,
const fesa::NewtonIterationLogRecord& record) {
fesa::NewtonIterationLogWriter writer;
if (!writer.Open(path).IsOk() || !writer.Write(record).IsOk() ||
!writer.Flush().IsOk() || !writer.Close().IsOk()) {
throw std::runtime_error{"Unable to write Newton log test record."};
}
std::ifstream input{path, std::ios::binary};
if (!input.is_open()) {
throw std::runtime_error{"Unable to read Newton log test record."};
}
const std::string contents{std::istreambuf_iterator<char>{input},
std::istreambuf_iterator<char>{}};
if (contents.empty()) {
throw std::runtime_error{"Newton log test record is empty."};
}
EXPECT_EQ(contents.back(), '\n');
EXPECT_EQ(std::count(contents.begin(), contents.end(), '\n'), 1);
EXPECT_EQ(contents.find('\r'), std::string::npos);
EXPECT_FALSE(contents.size() >= 3U &&
static_cast<unsigned char>(contents[0U]) == 0xEFU &&
static_cast<unsigned char>(contents[1U]) == 0xBBU &&
static_cast<unsigned char>(contents[2U]) == 0xBFU);
return SplitTabs(contents.substr(0U, contents.size() - 1U));
}
// NSNR-LOG-001, NSNR-LOG-002, and NSNR-LOG-003
TEST(NewtonIterationLog, WritesOneLfTerminated39FieldRecordInApprovedOrder) {
TempDirectory directory{"complete"};
const auto fields =
WriteAndRead(directory.Path() / "model_log.txt", CompleteRecord());
const std::vector<std::string> labels{
"record", "schema", "increment", "target_factor", "iteration",
"last_completed_stage", "failing_stage", "residual_force_channel",
"residual_force_numerator_norm", "residual_force_denominator_scale",
"residual_force_branch", "residual_force_threshold",
"residual_force_threshold_unit", "residual_force_pass",
"residual_moment_channel", "residual_moment_numerator_norm",
"residual_moment_denominator_scale", "residual_moment_branch",
"residual_moment_threshold", "residual_moment_threshold_unit",
"residual_moment_pass", "correction_translation_channel",
"correction_translation_numerator_norm",
"correction_translation_denominator_scale",
"correction_translation_branch", "correction_translation_threshold",
"correction_translation_threshold_unit", "correction_translation_pass",
"correction_rotation_channel", "correction_rotation_numerator_norm",
"correction_rotation_denominator_scale", "correction_rotation_branch",
"correction_rotation_threshold", "correction_rotation_threshold_unit",
"correction_rotation_pass", "combined_pass", "transition", "outcome",
"diagnostic_code"};
ASSERT_EQ(fields.size(), labels.size());
for (std::size_t index = 0U; index < labels.size(); ++index) {
EXPECT_EQ(fields[index].substr(0U, fields[index].find('=')), labels[index]);
}
EXPECT_EQ(FieldValue(fields, "record"), "record=newton-iteration");
EXPECT_EQ(FieldValue(fields, "schema"), "schema=nsnr-log-v1");
}
// NSNR-LOG-004
TEST(NewtonIterationLog, FormatsFiniteNumbersForLocaleIndependentRoundTrip) {
TempDirectory directory{"numbers"};
const auto fields =
WriteAndRead(directory.Path() / "model_log.txt", CompleteRecord());
const std::string value = ValueAfterEquals(FieldValue(fields, "target_factor"));
double parsed = 0.0;
const auto result = std::from_chars(value.data(), value.data() + value.size(),
parsed, std::chars_format::general);
EXPECT_EQ(result.ec, std::errc{});
EXPECT_EQ(result.ptr, value.data() + value.size());
EXPECT_DOUBLE_EQ(parsed, 0.12345678901234567);
EXPECT_NE(value.find('.'), std::string::npos);
EXPECT_EQ(value.find(','), std::string::npos);
}
// NSNR-LOG-005 and NSNR-LOG-007
TEST(NewtonIterationLog, SerializesChannelDecisionsAndExplicitOutcomeFields) {
TempDirectory directory{"channels"};
const auto fields =
WriteAndRead(directory.Path() / "model_log.txt", CompleteRecord());
for (const std::string_view prefix :
{"residual_force", "residual_moment", "correction_translation",
"correction_rotation"}) {
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_channel"),
std::string{prefix} + "_channel=" + std::string{prefix});
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_numerator_norm"),
std::string{prefix} +
"_numerator_norm=0.0012345678901234567");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_denominator_scale"),
std::string{prefix} +
"_denominator_scale=1.2345678901234567");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_branch"),
std::string{prefix} + "_branch=relative");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_threshold"),
std::string{prefix} + "_threshold=0.01");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_threshold_unit"),
std::string{prefix} + "_threshold_unit=1");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_pass"),
std::string{prefix} + "_pass=1");
}
EXPECT_EQ(FieldValue(fields, "last_completed_stage"),
"last_completed_stage=convergence-gates");
EXPECT_EQ(FieldValue(fields, "failing_stage"), "failing_stage=none");
EXPECT_EQ(FieldValue(fields, "combined_pass"), "combined_pass=1");
EXPECT_EQ(FieldValue(fields, "transition"), "transition=accept");
EXPECT_EQ(FieldValue(fields, "outcome"), "outcome=accepted");
EXPECT_EQ(FieldValue(fields, "diagnostic_code"), "diagnostic_code=none");
}
// NSNR-LOG-006
TEST(NewtonIterationLog, SerializesEmptyChannelsAsTypedZeroScaleDecisions) {
TempDirectory directory{"zero-scale"};
auto record = CompleteRecord();
const std::vector<fesa::NewtonChannelUnit> units{
fesa::NewtonChannelUnit::kForce, fesa::NewtonChannelUnit::kForceLength,
fesa::NewtonChannelUnit::kLength, fesa::NewtonChannelUnit::kDimensionless};
fesa::NewtonChannelDecision* decisions[]{
&record.force_residual, &record.moment_residual,
&record.translation_correction, &record.rotation_correction};
for (std::size_t index = 0U; index < std::size(decisions); ++index) {
decisions[index]->numerator_norm = 0.0;
decisions[index]->denominator_scale = 0.0;
decisions[index]->branch = fesa::NewtonConvergenceBranch::kZeroScale;
decisions[index]->threshold = fesa::kNewtonZeroScaleThreshold;
decisions[index]->threshold_unit = units[index];
decisions[index]->passed = true;
}
const auto fields = WriteAndRead(directory.Path() / "model_log.txt", record);
for (const std::string_view prefix :
{"residual_force", "residual_moment", "correction_translation",
"correction_rotation"}) {
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_numerator_norm"),
std::string{prefix} + "_numerator_norm=0");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_denominator_scale"),
std::string{prefix} + "_denominator_scale=0");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_branch"),
std::string{prefix} + "_branch=zero-scale");
EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_threshold"),
std::string{prefix} + "_threshold=0.001");
}
EXPECT_EQ(FieldValue(fields, "residual_force_threshold_unit"),
"residual_force_threshold_unit=force");
EXPECT_EQ(FieldValue(fields, "residual_moment_threshold_unit"),
"residual_moment_threshold_unit=force*length");
EXPECT_EQ(FieldValue(fields, "correction_translation_threshold_unit"),
"correction_translation_threshold_unit=length");
EXPECT_EQ(FieldValue(fields, "correction_rotation_threshold_unit"),
"correction_rotation_threshold_unit=1");
}
// NSNR-LOG-008 and NSNR-LOG-009
TEST(NewtonIterationLog, EscapesStringsAndOmitsUnevaluatedFailureFields) {
TempDirectory directory{"failure"};
auto record = CompleteRecord();
record.last_completed_stage = fesa::NewtonIterationStage::kInternalAssembled;
record.failure = {true,
fesa::NewtonIterationStage::kExternalAssembled,
"load\\failure",
std::string{"external\tload\nmissing\r"} + '\0'};
record.transition = "fail\ttransition";
record.outcome = "failed\nrecord";
record.diagnostic_code = std::string{"external\\load\r"} + '\0';
const auto fields = WriteAndRead(directory.Path() / "model_log.txt", record);
const std::string not_evaluated =
"not-evaluated(external\\tload\\nmissing\\r\\0)";
for (std::size_t index = 7U; index < 36U; ++index) {
EXPECT_EQ(ValueAfterEquals(fields[index]), not_evaluated);
}
EXPECT_EQ(FieldValue(fields, "failing_stage"),
"failing_stage=load-boundary");
EXPECT_EQ(FieldValue(fields, "transition"),
"transition=fail\\ttransition");
EXPECT_EQ(FieldValue(fields, "outcome"), "outcome=failed\\nrecord");
EXPECT_EQ(FieldValue(fields, "diagnostic_code"),
"diagnostic_code=external\\\\load\\r\\0");
}
// NSNR-LOG-010
TEST(NewtonIterationLog, ReportsWriteFlushAndCloseStateFailures) {
TempDirectory directory{"failures"};
const auto path = directory.Path() / "model_log.txt";
fesa::NewtonIterationLogWriter writer;
ExpectLogOutputFailure(writer.Write(CompleteRecord()));
ExpectLogOutputFailure(writer.Flush());
ExpectLogOutputFailure(writer.Close());
fesa::NewtonIterationLogWriter invalid_open_writer;
ExpectLogOutputFailure(
invalid_open_writer.Open(directory.Path() / "missing" / "log.txt"));
fesa::NewtonIterationLogWriter closed_writer;
ASSERT_TRUE(closed_writer.Open(path).IsOk());
ASSERT_TRUE(closed_writer.Close().IsOk());
ExpectLogOutputFailure(closed_writer.Write(CompleteRecord()));
ExpectLogOutputFailure(closed_writer.Flush());
ExpectLogOutputFailure(closed_writer.Close());
}
// NSNR-LOG-011
TEST(NewtonIterationLog, LeavesZeroByteLogForNonNewtonControlledFailurePath) {
TempDirectory directory{"zero-byte"};
const auto path = directory.Path() / "model_log.txt";
fesa::NewtonIterationLogWriter writer;
ASSERT_TRUE(writer.Open(path).IsOk());
ASSERT_TRUE(writer.Close().IsOk());
EXPECT_TRUE(std::filesystem::exists(path));
EXPECT_EQ(std::filesystem::file_size(path), 0U);
}
// NSNR-LOG-012
TEST(NewtonIterationLog,
SerializesEvaluatorProducedExternalScaleResidualDecisions) {
TempDirectory directory{"external-scale"};
const fesa::NewtonConvergenceInput zero_external{
{"residual_force", VectorFrom({1.0e-3}), VectorFrom({2.0}),
VectorFrom({0.0})},
{"residual_moment", VectorFrom({1.0e-3}), VectorFrom({3.0}),
VectorFrom({0.0})},
{"correction_translation", VectorFrom({1.0e-2}), VectorFrom({1.0})},
{"correction_rotation", VectorFrom({1.0e-2}), VectorFrom({1.0})},
};
const auto zero_decisions = fesa::EvaluateNewtonConvergence(zero_external);
auto zero_record = CompleteRecord();
zero_record.force_residual = zero_decisions.force_residual;
zero_record.moment_residual = zero_decisions.moment_residual;
zero_record.translation_correction = zero_decisions.translation_correction;
zero_record.rotation_correction = zero_decisions.rotation_correction;
zero_record.combined_passed = zero_decisions.combined_passed;
const auto zero_fields =
WriteAndRead(directory.Path() / "zero_log.txt", zero_record);
ASSERT_EQ(zero_fields.size(), 39U);
EXPECT_EQ(FieldValue(zero_fields, "schema"), "schema=nsnr-log-v1");
EXPECT_EQ(FieldValue(zero_fields, "residual_force_denominator_scale"),
"residual_force_denominator_scale=0");
EXPECT_EQ(FieldValue(zero_fields, "residual_moment_denominator_scale"),
"residual_moment_denominator_scale=0");
EXPECT_EQ(FieldValue(zero_fields, "residual_force_branch"),
"residual_force_branch=zero-scale");
EXPECT_EQ(FieldValue(zero_fields, "residual_moment_branch"),
"residual_moment_branch=zero-scale");
EXPECT_EQ(FieldValue(zero_fields, "residual_force_threshold"),
"residual_force_threshold=0.001");
EXPECT_EQ(FieldValue(zero_fields, "residual_moment_threshold"),
"residual_moment_threshold=0.001");
EXPECT_EQ(FieldValue(zero_fields, "residual_force_threshold_unit"),
"residual_force_threshold_unit=force");
EXPECT_EQ(FieldValue(zero_fields, "residual_moment_threshold_unit"),
"residual_moment_threshold_unit=force*length");
const fesa::NewtonConvergenceInput nonzero_external{
{"residual_force", VectorFrom({2.0e-2}), VectorFrom({2.0}),
VectorFrom({1.0})},
{"residual_moment", VectorFrom({2.0e-2}), VectorFrom({1.0}),
VectorFrom({2.0})},
{"correction_translation", VectorFrom({1.0e-2}), VectorFrom({1.0})},
{"correction_rotation", VectorFrom({1.0e-2}), VectorFrom({1.0})},
};
const auto relative_decisions =
fesa::EvaluateNewtonConvergence(nonzero_external);
auto relative_record = CompleteRecord();
relative_record.force_residual = relative_decisions.force_residual;
relative_record.moment_residual = relative_decisions.moment_residual;
relative_record.translation_correction =
relative_decisions.translation_correction;
relative_record.rotation_correction = relative_decisions.rotation_correction;
relative_record.combined_passed = relative_decisions.combined_passed;
const auto relative_fields =
WriteAndRead(directory.Path() / "relative_log.txt", relative_record);
ASSERT_EQ(relative_fields.size(), 39U);
EXPECT_EQ(FieldValue(relative_fields, "residual_force_denominator_scale"),
"residual_force_denominator_scale=2");
EXPECT_EQ(FieldValue(relative_fields, "residual_moment_denominator_scale"),
"residual_moment_denominator_scale=2");
EXPECT_EQ(FieldValue(relative_fields, "residual_force_branch"),
"residual_force_branch=relative");
EXPECT_EQ(FieldValue(relative_fields, "residual_moment_branch"),
"residual_moment_branch=relative");
EXPECT_EQ(FieldValue(relative_fields, "residual_force_threshold"),
"residual_force_threshold=0.01");
EXPECT_EQ(FieldValue(relative_fields, "residual_moment_threshold"),
"residual_moment_threshold=0.01");
EXPECT_EQ(FieldValue(relative_fields, "residual_force_threshold_unit"),
"residual_force_threshold_unit=1");
EXPECT_EQ(FieldValue(relative_fields, "residual_moment_threshold_unit"),
"residual_moment_threshold_unit=1");
}
fesa::DynamicNewtonIterationLogRecord CompleteDynamicRecord() {
return {2U,
3U,
0.25,
"convergence",
"accepted",
"",
{2.0e-12, 0.0},
{2.0, 0.0},
{1.0e-11, 2.0e-11},
{3.0e-11, 4.0e-11},
{5.0e-11, 6.0e-11},
{7.0e-11, 8.0e-11},
true,
true};
}
std::string ReadLogBytes(const std::filesystem::path& path) {
std::ifstream input{path, std::ios::binary};
return std::string{std::istreambuf_iterator<char>{input},
std::istreambuf_iterator<char>{}};
}
TEST(DynamicNewtonIterationLog, WritesTimeAndAllTwoChannelMetrics) {
TempDirectory directory{"dynamic-complete"};
const auto path = directory.Path() / "dynamic_log.txt";
fesa::NewtonIterationLogWriter writer;
const auto record = CompleteDynamicRecord();
ASSERT_TRUE(writer.Open(path).IsOk());
ASSERT_TRUE(writer.Write(record).IsOk());
ASSERT_TRUE(writer.Flush().IsOk());
ASSERT_TRUE(writer.Close().IsOk());
const std::string bytes = ReadLogBytes(path);
ASSERT_FALSE(bytes.empty());
EXPECT_EQ(bytes.back(), '\n');
EXPECT_EQ(std::count(bytes.begin(), bytes.end(), '\n'), 1);
EXPECT_EQ(bytes.find('\r'), std::string::npos);
EXPECT_EQ(bytes.find("target_factor"), std::string::npos);
EXPECT_EQ(bytes.find("nsnr-log-v1"), std::string::npos);
const auto fields = SplitTabs(bytes.substr(0U, bytes.size() - 1U));
EXPECT_EQ(fields.size(), 24U);
EXPECT_EQ(FieldValue(fields, "record"), "record=dynamic-newton-iteration");
EXPECT_EQ(FieldValue(fields, "schema"), "schema=hht-log-v1");
EXPECT_EQ(FieldValue(fields, "increment"), "increment=2");
EXPECT_EQ(FieldValue(fields, "iteration"), "iteration=3");
EXPECT_EQ(FieldValue(fields, "time"), "time=0.25");
EXPECT_EQ(FieldValue(fields, "stage"), "stage=convergence");
EXPECT_EQ(FieldValue(fields, "residual_u_branch"),
"residual_u_branch=relative");
EXPECT_EQ(FieldValue(fields, "residual_phi_branch"),
"residual_phi_branch=zero-scale");
EXPECT_EQ(FieldValue(fields, "residual_pass"), "residual_pass=1");
EXPECT_EQ(FieldValue(fields, "correction_pass"), "correction_pass=1");
const std::vector<std::pair<std::string, double>> expected{
{"residual_u_numerator", 2.0e-12},
{"residual_u_scale", 2.0},
{"residual_phi_numerator", 0.0},
{"residual_phi_scale", 0.0},
{"coordinate_correction_u", 1.0e-11},
{"coordinate_correction_phi", 2.0e-11},
{"velocity_correction_u", 3.0e-11},
{"velocity_correction_phi", 4.0e-11},
{"acceleration_correction_u", 5.0e-11},
{"acceleration_correction_phi", 6.0e-11},
{"algorithmic_correction_u", 7.0e-11},
{"algorithmic_correction_phi", 8.0e-11}};
for (const auto& metric : expected) {
const auto text = ValueAfterEquals(FieldValue(fields, metric.first));
double value = 0.0;
const auto parsed =
std::from_chars(text.data(), text.data() + text.size(), value);
EXPECT_EQ(parsed.ec, std::errc{});
EXPECT_EQ(parsed.ptr, text.data() + text.size());
EXPECT_DOUBLE_EQ(value, metric.second);
}
}
TEST(DynamicNewtonIterationLog, EscapesFailureContextAndKeepsZeroScaleFailure) {
TempDirectory directory{"dynamic-failure"};
const auto path = directory.Path() / "dynamic_log.txt";
auto record = CompleteDynamicRecord();
record.stage = "mass\tcheck";
record.outcome = "failed\ntrial";
record.failure_code = "dynamic\\mass\rregularity";
record.residual_numerator = {1.0, 0.0};
record.residual_scale = {0.0, 0.0};
record.residual_pass = false;
record.correction_pass = false;
fesa::NewtonIterationLogWriter writer;
ASSERT_TRUE(writer.Open(path).IsOk());
ASSERT_TRUE(writer.Write(record).IsOk());
ASSERT_TRUE(writer.Close().IsOk());
const auto bytes = ReadLogBytes(path);
EXPECT_EQ(std::count(bytes.begin(), bytes.end(), '\n'), 1);
const auto fields = SplitTabs(bytes.substr(0U, bytes.size() - 1U));
EXPECT_EQ(FieldValue(fields, "stage"), "stage=mass\\tcheck");
EXPECT_EQ(FieldValue(fields, "outcome"), "outcome=failed\\ntrial");
EXPECT_EQ(FieldValue(fields, "failure_code"),
"failure_code=dynamic\\\\mass\\rregularity");
EXPECT_EQ(FieldValue(fields, "residual_u_numerator"),
"residual_u_numerator=1");
EXPECT_EQ(FieldValue(fields, "residual_u_scale"), "residual_u_scale=0");
EXPECT_EQ(FieldValue(fields, "residual_u_branch"),
"residual_u_branch=zero-scale");
EXPECT_EQ(FieldValue(fields, "residual_pass"), "residual_pass=0");
}
TEST(DynamicNewtonIterationLog,
InvalidEvidencePreservesFlushedPrefixAndFailsLifecycle) {
using Record = fesa::DynamicNewtonIterationLogRecord;
const double nan = std::numeric_limits<double>::quiet_NaN();
const double infinity = std::numeric_limits<double>::infinity();
const std::vector<std::function<void(Record&)>> mutations{
[nan](Record& record) { record.time = nan; },
[nan](Record& record) { record.residual_numerator[0U] = nan; },
[infinity](Record& record) { record.residual_scale[1U] = infinity; },
[nan](Record& record) { record.coordinate_correction[0U] = nan; },
[infinity](Record& record) { record.velocity_correction[1U] = infinity; },
[nan](Record& record) { record.acceleration_correction[0U] = nan; },
[infinity](Record& record) {
record.algorithmic_correction[1U] = infinity;
},
[](Record& record) { record.time = -0.1; },
[](Record& record) { record.residual_scale[0U] = -1.0; },
[](Record& record) { record.coordinate_correction[0U] = -1.0; },
[](Record& record) { record.stage.clear(); },
[](Record& record) { record.outcome.clear(); }};
TempDirectory directory{"dynamic-invalid"};
const auto path = directory.Path() / "dynamic_log.txt";
for (std::size_t index = 0U; index < mutations.size(); ++index) {
SCOPED_TRACE(index);
fesa::NewtonIterationLogWriter writer;
ASSERT_TRUE(writer.Open(path).IsOk());
ASSERT_TRUE(writer.Write(CompleteDynamicRecord()).IsOk());
ASSERT_TRUE(writer.Flush().IsOk());
const std::string prefix = ReadLogBytes(path);
auto invalid = CompleteDynamicRecord();
mutations[index](invalid);
ExpectLogOutputFailure(writer.Write(invalid));
ExpectLogOutputFailure(writer.Write(CompleteDynamicRecord()));
ExpectLogOutputFailure(writer.Flush());
ExpectLogOutputFailure(writer.Close());
EXPECT_EQ(ReadLogBytes(path), prefix);
}
fesa::NewtonIterationLogWriter unopened;
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