Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions include/numsim_codegen/passes/internal/pass_bodies.h
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ inline void SymbolValidationPass::run(PassContext &pctx) {
T,
cas::expression_holder<cas::scalar_expression>>) {
lc.collect_scalar(e);
} else if constexpr (
std::is_same_v<T, cas::expression_holder<
cas::tensor_to_scalar_expression>>) {
lc.collect_t2s(e); // #142: scalar-from-tensor output
} else {
lc.collect_tensor(e);
}
Expand Down Expand Up @@ -580,6 +584,10 @@ inline void CodeEmitPass::run(PassContext &pctx) {
T,
cas::expression_holder<cas::scalar_expression>>) {
return pipeline.scalar().apply(e);
} else if constexpr (
std::is_same_v<T, cas::expression_holder<
cas::tensor_to_scalar_expression>>) {
return pipeline.t2s().apply(e); // #142: scalar-from-tensor output
} else {
return pipeline.tensor().apply(e);
}
Expand Down
33 changes: 32 additions & 1 deletion include/numsim_codegen/recipe.h
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,21 @@ 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 &<name>_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 };

std::string name;
Kind kind;
std::variant<
cas::expression_holder<cas::scalar_expression>,
cas::expression_holder<cas::tensor_expression>>
cas::expression_holder<cas::tensor_expression>,
cas::expression_holder<cas::tensor_to_scalar_expression>>
expr;
std::size_t dim = 0;
std::size_t rank = 0;
Expand Down Expand Up @@ -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 &<name>_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<cas::tensor_to_scalar_expression> 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`
Expand Down
27 changes: 27 additions & 0 deletions src/targets/numsim_material.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,20 @@ std::vector<EmittedFile> 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<cas::tensor_to_scalar_expression>>(
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);
Expand Down Expand Up @@ -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<cas::tensor_to_scalar_expression>>(
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);
Expand Down
9 changes: 8 additions & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,25 @@ 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 &<name>_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 $<TARGET_FILE:generate_compile_check_recipe>
${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}
DEPENDS generate_compile_check_recipe
COMMENT "Generating compile-check headers via numsim-codegen"
VERBATIM)
Expand All @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions tests/NumSimMaterialTargetTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<scalar_constant>(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.
Expand Down
63 changes: 63 additions & 0 deletions tests/RecipeTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 &<name>_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<cas::tensor>("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
Expand Down
36 changes: 36 additions & 0 deletions tests/generated/compile_check_driver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <array>
Expand Down Expand Up @@ -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 &<name>_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<double, 3, 2> 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<double, 3, 2> 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
Expand Down
33 changes: 28 additions & 5 deletions tests/generated/generate_compile_check_recipe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <numsim_codegen/numsim_codegen.h>

Expand All @@ -24,6 +25,7 @@
#include <numsim_cas/tensor/tensor_std.h>
#include <numsim_cas/tensor_to_scalar/tensor_dot.h>
#include <numsim_cas/tensor_to_scalar/tensor_norm.h>
#include <numsim_cas/tensor_to_scalar/tensor_to_scalar_operators.h>
#include <numsim_cas/tensor_to_scalar/tensor_to_scalar_std.h>
#include <numsim_cas/tensor_to_scalar/tensor_trace.h>

Expand Down Expand Up @@ -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]
<< " <CompileCheck.h> <HardeningCheck.h> <NewtonCheck.h>"
" <AutocatalyticCheck.h> <CoupledCheck.h> <PiecewiseCheck.h>"
" <PiecewiseT2sCheck.h> <TangentCheck.h>\n";
" <PiecewiseT2sCheck.h> <TangentCheck.h> <T2sOutputCheck.h>\n";
return 1;
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 &<name>_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<tensor_trace>(eps));
auto dot = make_expression<tensor_dot>(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;
}
Loading