From 5faae3142551e9f92c8e868c854cc7487b5dc6f3 Mon Sep 17 00:00:00 2001 From: petlenz Date: Sun, 2 Aug 2026 23:12:39 +0200 Subject: [PATCH] Add tensor_to_scalar add_output overload (#142) --- .../passes/internal/pass_bodies.h | 8 +++ include/numsim_codegen/recipe.h | 33 +++++++++- src/targets/numsim_material.cpp | 27 ++++++++ tests/CMakeLists.txt | 9 ++- tests/NumSimMaterialTargetTest.cpp | 16 +++++ tests/RecipeTest.cpp | 63 +++++++++++++++++++ tests/generated/compile_check_driver.cpp | 36 +++++++++++ .../generate_compile_check_recipe.cpp | 33 ++++++++-- 8 files changed, 218 insertions(+), 7 deletions(-) diff --git a/include/numsim_codegen/passes/internal/pass_bodies.h b/include/numsim_codegen/passes/internal/pass_bodies.h index 306d4a1..dc96c0d 100644 --- a/include/numsim_codegen/passes/internal/pass_bodies.h +++ b/include/numsim_codegen/passes/internal/pass_bodies.h @@ -99,6 +99,10 @@ inline void SymbolValidationPass::run(PassContext &pctx) { T, cas::expression_holder>) { lc.collect_scalar(e); + } else if constexpr ( + std::is_same_v>) { + lc.collect_t2s(e); // #142: scalar-from-tensor output } else { lc.collect_tensor(e); } @@ -580,6 +584,10 @@ inline void CodeEmitPass::run(PassContext &pctx) { T, cas::expression_holder>) { return pipeline.scalar().apply(e); + } else if constexpr ( + std::is_same_v>) { + return pipeline.t2s().apply(e); // #142: scalar-from-tensor output } else { return pipeline.tensor().apply(e); } diff --git a/include/numsim_codegen/recipe.h b/include/numsim_codegen/recipe.h index 025b5a8..f683617 100644 --- a/include/numsim_codegen/recipe.h +++ b/include/numsim_codegen/recipe.h @@ -267,6 +267,12 @@ struct NewtonOptions { }; // Declaration of a computed output that the generated function emits. +// +// #142: the expr variant carries a third alternative — a tensor_to_scalar +// expression (e.g. trace(ε), von-Mises √(1.5·dev:dev)). Such an output is +// SCALAR-valued (Kind::Scalar, `double &_out` in the signature); only +// its expression domain differs, so every variant visitor must route it +// through the t2s emitter/collector rather than the scalar one. struct OutputDecl { enum class Kind { Scalar, Tensor }; @@ -274,7 +280,8 @@ struct OutputDecl { Kind kind; std::variant< cas::expression_holder, - cas::expression_holder> + cas::expression_holder, + cas::expression_holder> expr; std::size_t dim = 0; std::size_t rank = 0; @@ -617,6 +624,30 @@ class ConstitutiveModel { m_outputs.push_back(std::move(decl)); } + // #142: scalar-from-tensor output — a tensor_to_scalar expression such as + // trace(ε), a dissipation density, or a von-Mises measure. The output is + // SCALAR-valued (Kind::Scalar, `double &_out` out-parameter); only + // the expression domain differs from the plain-scalar overload. A role + // carrying a non-scalar expected_rank (e.g. roles::Stress) is rejected + // here: this output can never satisfy it, and name-based find_output_by_role + // would silently mis-route a rank-2 consumer to a scalar. + void add_output(std::string name, + cas::expression_holder expr, + Role role = roles::Other) { + validate_role_attributes(role); + if (role.expected_rank.has_value() && *role.expected_rank != 0) { + throw std::runtime_error(std::format( + "ConstitutiveModel '{}': output '{}' is a tensor_to_scalar " + "expression (scalar-valued) but role '{}' expects rank {}. Use a " + "rank-0 role (e.g. roles::Dissipation) or roles::Other.", + m_name, name, role.name, *role.expected_rank)); + } + assert_output_name_available(name); // PR #78 review #2 + OutputDecl decl{name, OutputDecl::Kind::Scalar, expr, 0, 0, + std::move(role)}; + m_outputs.push_back(std::move(decl)); + } + // Capacity hint ahead of a batch of `add_output` calls (cross-cutting // review MAJOR 4). A mutating pass that synthesises one output per // evolution equation calls this once before its loop so `m_outputs` diff --git a/src/targets/numsim_material.cpp b/src/targets/numsim_material.cpp index 640a103..b58b9b9 100644 --- a/src/targets/numsim_material.cpp +++ b/src/targets/numsim_material.cpp @@ -429,6 +429,20 @@ std::vector emit_residual_material(ConstitutiveModel const &model) "' collides with an emitted member, the state, a parameter, a " "tensor input, or an internal variable; rename it."); } + // #142: tensor_to_scalar outputs are Kind::Scalar but hold the t2s + // variant alternative — the scalar arm below would std::get the wrong + // alternative. Their leaf-scoping + JSON property wiring on this target + // is not implemented yet; reject loudly rather than emit a partial + // material (never bad_variant_access, never silent drop). + if (std::holds_alternative< + cas::expression_holder>( + o.expr)) { + throw std::runtime_error( + "NumSimMaterialTarget: output '" + o.name + + "' is a tensor_to_scalar expression, which this target does not " + "support yet. Use the standalone / MOOSE targets for " + "scalar-from-tensor outputs (#142)."); + } CodeGenContext oc; CodeEmitPipeline op(oc); register_all(oc); @@ -1052,6 +1066,19 @@ auto NumSimMaterialTarget::emit(ConstitutiveModel const &model) const "tensor input; rename it."); } + // #142: see the residual-path guard — a tensor_to_scalar output is + // Kind::Scalar but holds the t2s alternative; reject loudly rather than + // bad_variant_access in the scalar arm below. + if (std::holds_alternative< + cas::expression_holder>( + o.expr)) { + throw std::runtime_error( + "NumSimMaterialTarget: output '" + o.name + + "' is a tensor_to_scalar expression, which this target does not " + "support yet. Use the standalone / MOOSE targets for " + "scalar-from-tensor outputs (#142)."); + } + CodeGenContext oc; CodeEmitPipeline op(oc); register_scalars(oc); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f0aa040..c8590a8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -84,12 +84,17 @@ set(GENERATED_PIECEWISE_T2S_HEADER # Verification spine (#90): nonlinear-stress consistent-tangent FD compile-check. set(GENERATED_TANGENT_HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/TangentCheck.h) +# #142: tensor_to_scalar OUTPUT compile-check — scalar-from-tensor outputs +# (trace, von-Mises-like) emitted as `double &_out` parameters. +set(GENERATED_T2S_OUTPUT_HEADER + ${CMAKE_CURRENT_BINARY_DIR}/generated/T2sOutputCheck.h) add_custom_command( OUTPUT ${GENERATED_HEADER} ${GENERATED_HARDENING_HEADER} ${GENERATED_NEWTON_HEADER} ${GENERATED_AUTOCAT_HEADER} ${GENERATED_COUPLED_HEADER} ${GENERATED_PIECEWISE_HEADER} ${GENERATED_PIECEWISE_T2S_HEADER} ${GENERATED_TANGENT_HEADER} + ${GENERATED_T2S_OUTPUT_HEADER} COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/generated COMMAND $ @@ -97,6 +102,7 @@ add_custom_command( ${GENERATED_NEWTON_HEADER} ${GENERATED_AUTOCAT_HEADER} ${GENERATED_COUPLED_HEADER} ${GENERATED_PIECEWISE_HEADER} ${GENERATED_PIECEWISE_T2S_HEADER} ${GENERATED_TANGENT_HEADER} + ${GENERATED_T2S_OUTPUT_HEADER} DEPENDS generate_compile_check_recipe COMMENT "Generating compile-check headers via numsim-codegen" VERBATIM) @@ -120,7 +126,8 @@ add_executable(compile_check_driver ${GENERATED_COUPLED_HEADER} ${GENERATED_PIECEWISE_HEADER} ${GENERATED_PIECEWISE_T2S_HEADER} - ${GENERATED_TANGENT_HEADER}) + ${GENERATED_TANGENT_HEADER} + ${GENERATED_T2S_OUTPUT_HEADER}) target_include_directories(compile_check_driver PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated ${eigen3_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/support) diff --git a/tests/NumSimMaterialTargetTest.cpp b/tests/NumSimMaterialTargetTest.cpp index 768c816..198dac5 100644 --- a/tests/NumSimMaterialTargetTest.cpp +++ b/tests/NumSimMaterialTargetTest.cpp @@ -193,6 +193,22 @@ TEST(NumSimMaterialTarget, EmitsTensorStressOutput) { EXPECT_NE(h.find("void update_stress() {"), std::string::npos) << h; } +// #142: a tensor_to_scalar output (scalar-from-tensor, e.g. trace(strain)) is +// expressible on the recipe but this target's output wiring does not support +// it yet — the guard must reject LOUDLY (never bad_variant_access, never a +// silently dropped output). +TEST(NumSimMaterialTarget, RejectsTensorToScalarOutput) { + ConstitutiveModel m("WithT2sOutput"); + auto K = m.add_parameter("K", -1.0); + auto a = m.add_scalar_state_variable("a", make_expression(0.0)); + m.add_scalar_evolution_equation(a, K * a.current); + auto eps = m.add_tensor_input("strain", 3, 2, roles::Strain); + m.add_output("tr_strain", trace(eps)); + auto const msg = emit_throw_message(m); + EXPECT_NE(msg.find("tensor_to_scalar"), std::string::npos) << msg; + EXPECT_NE(msg.find("tr_strain"), std::string::npos) << msg; +} + // A tensor input named like a synthesized member must be rejected (it would // emit a duplicate `m_rate` member). The recipe permits the name (it isn't a // recipe-reserved word); the emitter's uniqueness guard catches it. diff --git a/tests/RecipeTest.cpp b/tests/RecipeTest.cpp index 9e50e67..f73f9eb 100644 --- a/tests/RecipeTest.cpp +++ b/tests/RecipeTest.cpp @@ -94,6 +94,69 @@ TEST(Recipe, MultipleOutputsShareCseAcrossBody) { << src; } +// ─── #142: tensor_to_scalar outputs (scalar-from-tensor) ───────────────────── + +// A t2s expression (trace(ε)) declared as an output is SCALAR-valued: the +// generated signature carries `double &_out` and the body routes +// through the t2s emitter (tmech::trace). +TEST(Recipe, TensorToScalarOutputEmitsScalarSignature) { + ConstitutiveModel m("TraceOut"); + auto eps = m.add_tensor_input("eps", 3, 2, roles::Strain); + m.add_output("tr_eps", trace(eps)); + + ASSERT_EQ(m.outputs().size(), 1u); + EXPECT_EQ(m.outputs()[0].kind, OutputDecl::Kind::Scalar); + + auto src = m.emit_compute_function(); + EXPECT_NE(src.find("double &tr_eps_out"), std::string::npos) + << "got:\n" << src; + EXPECT_NE(src.find("tmech::trace"), std::string::npos) << "got:\n" << src; + EXPECT_NE(src.find("tr_eps_out = "), std::string::npos) << "got:\n" << src; +} + +// Duplicate-name rejection covers the new overload too. +TEST(Recipe, TensorToScalarOutputRejectsDuplicateName) { + ConstitutiveModel m("DupT2s"); + auto eps = m.add_tensor_input("eps", 3, 2, roles::Strain); + m.add_output("q", trace(eps)); + EXPECT_THROW(m.add_output("q", trace(eps)), std::runtime_error); + // ...and against an existing symbol name. + EXPECT_THROW(m.add_output("eps", trace(eps)), std::runtime_error); +} + +// Role attribute check: a rank-0 role passes; a role expecting rank 2 can +// never be satisfied by a scalar-valued output and throws at add time. +TEST(Recipe, TensorToScalarOutputRoleRankCheck) { + ConstitutiveModel m("RoleT2s"); + auto eps = m.add_tensor_input("eps", 3, 2, roles::Strain); + m.add_output("diss", trace(eps), roles::Dissipation); // expected_rank == 0 + try { + m.add_output("bad", trace(eps), roles::Stress); // expected_rank == 2 + FAIL() << "expected throw on rank-2 role for a t2s (scalar) output"; + } catch (std::exception const &e) { + EXPECT_NE(std::string(e.what()).find("rank"), std::string::npos) + << e.what(); + } + // The rejected output must not have been recorded. + EXPECT_EQ(m.outputs().size(), 1u); +} + +// A t2s output referencing an undeclared tensor leaf is caught by +// SymbolValidationPass at emit time (the same guarantee scalar/tensor +// outputs have — the new variant alternative must not bypass it). +TEST(Recipe, TensorToScalarOutputValidatesLeaves) { + ConstitutiveModel m("T2sLeaves"); + auto bogus = cas::make_expression("undeclared_eps", 3, 2); + m.add_output("tr", trace(bogus)); + try { + m.validate(); + FAIL() << "expected throw on undeclared tensor leaf in a t2s output"; + } catch (std::exception const &e) { + EXPECT_NE(std::string(e.what()).find("undeclared_eps"), std::string::npos) + << e.what(); + } +} + // ─── Phase D: implicit residual equations (strain-coupled state) ───────────── // A scalar state defined by an implicit residual R(z, ε)=0. Unlike a rate, the diff --git a/tests/generated/compile_check_driver.cpp b/tests/generated/compile_check_driver.cpp index 27a381d..88a7c65 100644 --- a/tests/generated/compile_check_driver.cpp +++ b/tests/generated/compile_check_driver.cpp @@ -13,6 +13,7 @@ #include "NewtonCheck.h" #include "PiecewiseCheck.h" #include "PiecewiseT2sCheck.h" +#include "T2sOutputCheck.h" #include "TangentCheck.h" #include "numerical_tangent_verifier.h" #include @@ -287,6 +288,41 @@ TEST(CompileCheckGenerated, PiecewiseT2sSelectsBranchAndCompilesVsTmech) { } } +// #142: tensor_to_scalar OUTPUTS. The T2sOutputCheck recipe declares two +// scalar-from-tensor outputs — tr_eps = trace(eps) and +// vm_like = sqrt(w·(ε:ε)) — which pre-#142 were inexpressible (add_output +// took only scalar/tensor). Both must surface as `double &_out` +// out-parameters (that they do is proven by the call compiling) and carry +// the hand-computed values. +TEST(CompileCheckGenerated, TensorToScalarOutputsMatchHandComputedValues) { + double const w = 1.5; + tmech::tensor eps; // zero-initialised + eps(0, 0) = 1.0; + eps(1, 1) = 2.0; + eps(2, 2) = -0.5; + eps(0, 1) = eps(1, 0) = 0.25; + + double tr_out = 0.0; + double vm_out = 0.0; + // Generated signature: (w, eps, tr_eps_out, vm_like_out). + T2sOutputCheck_compute(w, eps, tr_out, vm_out); + + // tr = 1.0 + 2.0 - 0.5 = 2.5 + EXPECT_NEAR(tr_out, 2.5, 1e-12); + + // vm_like = sqrt(w * eps:eps), eps:eps = 1 + 4 + 0.25 + 2*0.25^2 = 5.375 + double const dot = 1.0 + 4.0 + 0.25 + 2.0 * 0.25 * 0.25; + EXPECT_NEAR(vm_out, std::sqrt(w * dot), 1e-12); +} + +TEST(CompileCheckGenerated, TensorToScalarOutputsOnZeroStrain) { + tmech::tensor eps; // zero-initialised + double tr_out = 1.0, vm_out = 1.0; + T2sOutputCheck_compute(1.5, eps, tr_out, vm_out); + EXPECT_NEAR(tr_out, 0.0, 1e-12); + EXPECT_NEAR(vm_out, 0.0, 1e-12); +} + // Verification spine (numsim-codegen#90, item 1). SCOPE: this verifies the // EXPLICIT tangent term ∂σ/∂ε (= cas::diff(tensor,tensor)) only. TangentCheck // has no state variable / no local Newton, so the strain-coupled implicit diff --git a/tests/generated/generate_compile_check_recipe.cpp b/tests/generated/generate_compile_check_recipe.cpp index 3ca0436..67574a5 100644 --- a/tests/generated/generate_compile_check_recipe.cpp +++ b/tests/generated/generate_compile_check_recipe.cpp @@ -13,6 +13,7 @@ // 6. PiecewiseCheck — tensor_if_then_else emission vs tmech // 7. PiecewiseT2sCheck — tensor_to_scalar_if_then_else (subterm) // 8. TangentCheck — FD consistent-tangent verification (#90) +// 9. T2sOutputCheck — tensor_to_scalar OUTPUT (scalar-from-tensor, #142) #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include @@ -52,11 +54,11 @@ auto write_single_file(numsim::codegen::ConstitutiveModel const &model, } // namespace int main(int argc, char *argv[]) { - if (argc < 9) { + if (argc < 10) { std::cerr << "usage: " << argv[0] << " " " " - " \n"; + " \n"; return 1; } @@ -178,9 +180,10 @@ int main(int argc, char *argv[]) { // // The SIBLING node `tensor_to_scalar_if_then_else` — cond/then/else ALL // t2s — was implemented in the same catch-up but had no end-to-end test - // (a t2s output is inexpressible: `add_output` takes only scalar/tensor). - // A t2s if_then_else is only reachable as a SUBTERM, so we lift it into a - // tensor output via `tensor_to_scalar_with_tensor_mul`: + // (a t2s output was inexpressible pre-#142: `add_output` took only + // scalar/tensor). This recipe keeps the historical SUBTERM shape — the + // t2s if_then_else lifted into a tensor output via + // `tensor_to_scalar_with_tensor_mul`: // sigma = (trace(eps) != 0 ? trace(eps) : norm(eps)) * eps // The condition is itself t2s (`trace(eps)`), exercising the t2s emitter's // condition path (NOT the scalar one) — the one subtlety this node has. @@ -223,5 +226,25 @@ int main(int argc, char *argv[]) { if (int rc = write_single_file(model, argv[8]); rc != 0) return rc; } + // ── Recipe 9: tensor_to_scalar OUTPUT (#142) ───────────────────────── + // + // Scalar-from-tensor outputs (von Mises, dissipation) were inexpressible + // before #142 — `add_output` took only scalar/tensor expressions. Two t2s + // outputs from one recipe exercise both the plain t2s leaf function and + // t2s arithmetic + a t2s std function: + // tr_eps = trace(eps) (t2s leaf node) + // vm_like = sqrt(w · (ε : ε)) (scalar·t2s mul under a t2s sqrt) + // Both must surface as `double &_out` parameters. The driver checks + // hand-computed values on a known strain. + { + ConstitutiveModel model("T2sOutputCheck"); + auto w = model.add_parameter("w", 1.5); + auto eps = model.add_tensor_input("eps", 3, 2, roles::Strain); + model.add_output("tr_eps", make_expression(eps)); + auto dot = make_expression(eps); // ε:ε + model.add_output("vm_like", sqrt(w * dot), roles::Dissipation); + if (int rc = write_single_file(model, argv[9]); rc != 0) return rc; + } + return 0; }