#include "fesa/analysis/newton_iteration_log.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 SplitTabs(const std::string& line) { std::vector 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& 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& 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 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{input}, std::istreambuf_iterator{}}; 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(contents[0U]) == 0xEFU && static_cast(contents[1U]) == 0xBBU && static_cast(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 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 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{input}, std::istreambuf_iterator{}}; } 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> 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::quiet_NaN(); const double infinity = std::numeric_limits::infinity(); const std::vector> 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 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 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::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 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