Skip to content

Commit 32671fb

Browse files
authored
Merge pull request #47 from NumSim-Stack/phase-1.2-pass-framework
Phase 1.2: pass framework (PassManager + SymbolValidationPass + CodeEmitPass)
2 parents 36a72ba + a7db8a8 commit 32671fb

8 files changed

Lines changed: 916 additions & 77 deletions

File tree

REVIEW-stack-42-47.md

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Critical Review — PR Stack #42#47
2+
3+
**Scope:** Phase 1.1 emit-stub completion (#42, #44, #45, #46) + Phase 1.2 pass framework (#47).
4+
**Lenses (parallel, independent):** architect-reviewer · cpp-pro · code-reviewer.
5+
**Earlier reviews accounted for:** `REVIEW.md` (520f99b multi-lens review) + cpp-pro pass at `2c87949` + 5 nits fixed in `84d149d`. None of those findings re-flagged here.
6+
7+
---
8+
9+
## Verdict
10+
11+
**No critical merge blockers.** Recommended merge order: **#42#44#45#46#47** as-is. One Major finding (M1 — keyword reject) should land *inside* #47 before merge OR immediately after as #47a, because once the pass framework is the single validation gate, leaving the gap open is worse than the pre-1.2 state.
12+
13+
The biggest forward-look risk is **M4 + M5 together** — the first mutating pass and the first non-CodeEmit emit pass will both rip the current architecture. Recommend a follow-up refactor PR before Phase 2 work begins.
14+
15+
---
16+
17+
## Critical
18+
19+
None.
20+
21+
---
22+
23+
## Major
24+
25+
### M1 — Identifier validator accepts C++ keywords; locale-sensitive char-class checks
26+
**Files:** `include/numsim_codegen/passes/symbol_validation_pass.h:40-54`
27+
**Convergence:** cpp-pro M1 + code-reviewer M2.
28+
29+
`is_valid_cxx_identifier` green-lights `class`, `if`, `template`, `auto`, `for`, `do`, `return` — correct for *form* (matches `[A-Za-z_][A-Za-z0-9_]*`), wrong for *usability*. The check also uses `std::isalpha` / `std::isalnum` which are locale-sensitive: Turkish locale fails `'I'`; locales that treat accented chars as alpha accept `"épsilon"`.
30+
31+
The pass **replaces** the pre-1.2 `validate()` body, so there is no second guard. A bad name slips through to the compile-check driver / consumer compiler, defeating the point of upstream validation.
32+
33+
**Mitigation:**
34+
- Replace `std::isalpha` / `std::isalnum` with explicit ASCII range comparisons (`'A' <= c && c <= 'Z'`, etc.) — locale-immune.
35+
- Hard-code a `static constexpr std::array` of the ~85 reserved C++ keywords; reject on `std::binary_search` hit after the char-class check.
36+
- Add `RejectsRecipeWithKeywordName` test.
37+
38+
**Should land inside #47 before merge OR as #47a immediately after.**
39+
40+
### M2 — Duplicated projector preset matching across two sites
41+
**Files:** `include/numsim_codegen/code_emit/tensor_code_emit.h:197-217` and `:481-507`
42+
**Source:** code-reviewer M1.
43+
44+
Four `holds_alternative` ladders for (Symmetric/Skew × AnyTrace/Vol/Dev) appear identically in the `tensor_projector` materialise path (#44) and again in `projector_short_circuit_fn` (#45), with parallel return values (formula string vs. unary op name).
45+
46+
Risk: adding `P_harm` support or fixing a tag-name typo upstream needs lockstep edits to both sites. The projector-short-circuit test (`InnerProductWithPSymEmitsTmechSym`) won't catch drift in the materialised path.
47+
48+
**Mitigation:** extract a private helper that classifies `(perm, trace)``ProjectorKind` enum/optional, then dispatch both call sites off it. ~30-line follow-up.
49+
50+
### M3 — t2s callback wiring is a runtime trap, not a compile trap
51+
**Files:** `include/numsim_codegen/code_emit/tensor_code_emit.h:382-394`, `include/numsim_codegen/recipe.h:527-528`
52+
**Convergence:** code-reviewer M3 + architect m1.
53+
54+
The t2s callback gets wired only inside `CodeEmitPass::run`. Any future pass / test fixture / backend that constructs `TensorCodeEmit` directly silently lacks the callback until the first `tensor_to_scalar_with_tensor_mul` hits at runtime. The throw exists (`T2sWithTensorMulThrowsWhenCallbackUnset` covers it), but the throw *is* the failure mode being flagged.
55+
56+
**Mitigation:** take `T2sApply` in `TensorCodeEmit`'s constructor — moves the diagnostic from runtime throw to compile error at all call sites. One additional line at the call site (the lambda is already built).
57+
58+
### M4 — `PassContext` const-binds `ConstitutiveModel`; will force Phase 2 breakage
59+
**Files:** `include/numsim_codegen/passes/pass.h:20-24`
60+
**Convergence:** architect M1 + cpp-pro M2.
61+
62+
Phase 2's `TimeIntegrationPass` / `KuhnTuckerLoweringPass` need to rewrite expressions. Today's `ConstitutiveModel const &` leaves three bad options when Phase 2 arrives:
63+
- (a) flip to `ConstitutiveModel &` — every pass that captured `pctx.model` by `auto const &` silently becomes mutable; friend boundaries shift.
64+
- (b) parallel `ConstitutiveModel *mutable_model` slot — bifurcates the API.
65+
- (c) introduce an explicit IR layer — large refactor.
66+
67+
Plus a subtler issue: `validate()` and `emit_compute_function()` each construct independent `PassContext{*this, CodeGenContext{}, ...}`. Currently harmless (SymbolValidationPass doesn't touch ctx), but a future pass that writes to `pctx.ctx` for downstream caching will silently see nothing if a client calls `validate()` separately.
68+
69+
**Mitigation:** insert a thin `RecipeView` handle that exposes the const surface today and gains mutable views later without breaking signatures. <100 LOC today; 500+ after passes proliferate. Also document the validate-vs-emit context isolation, or expose a single `run_pipeline()` entry-point.
70+
71+
### M5 — `friend class CodeEmitPass` is a one-way ratchet
72+
**Files:** `include/numsim_codegen/recipe.h:293`
73+
**Source:** architect M2.
74+
75+
Every future pass needing framing access (`TangentEmitPass`, `MoosePropertyEmitPass`, `StateVarEmitPass`, `AbaqusEmitPass`) must either be friended or route through `CodeEmitPass`. O(passes) friends on the user-facing recipe class.
76+
77+
The friend was justified as *"the rendering ABI is the framework's contract, not the user's surface"* — internally consistent, but Phase 3 (Abaqus / standalone / per-target emit) explicitly contradicts the single-emitter assumption.
78+
79+
**Mitigation:** extract `render_compute_function` + `param_decl` + `output_decl` + `tensor_arg_count` into a free function in `recipe_render.h` taking `ConstitutiveModel const &` through public accessors. Recipe stays a data class; rendering becomes its own seam.
80+
81+
### M6 — Phase 1.2 framework has zero non-trivial consumer
82+
**Files:** conceptual — `include/numsim_codegen/passes/` directory as a whole
83+
**Source:** architect M3.
84+
85+
`SymbolValidationPass` does identifier syntax + leaf-declaration checks. Both could have stayed inside `validate()` as free functions; the pass-framework right now is **zero observable behaviour change**. The design hasn't been stress-tested by a real pass that needs the context. The first non-trivial consumer (Phase 2 `TimeIntegrationPass`) is also the first redesign trigger.
86+
87+
Closely related: the deferred D3' `tensor_space` consistency validation, which this PR was supposed to deliver per the roadmap.
88+
89+
**Mitigation:** land a thin `TensorSpaceConsistencyPass` stub that walks `tensor_space` annotations on declared symbols (no expression-level inference yet). Exercises the postcondition wiring with a *second* validator and exposes any `PassContext` shape weaknesses before Phase 2 rewrite-passes commit you to the current layout.
90+
91+
---
92+
93+
## Minor
94+
95+
### m1 — Chained nested string expressions in `simple_outer_product` / `tensor_mul`
96+
**Files:** `include/numsim_codegen/code_emit/tensor_code_emit.h:262-272`, `:331-338`
97+
**Source:** cpp-pro m2.
98+
99+
For N children, the accumulated `acc` string is O(N)-deep nested function call, never registered as a temp until the final `register_temp`. Child strings are temps, but partial-product intermediates aren't. No CSE across two recipes that share a partial product. N≤4 in practice for elasticity. Readability/scalability note only — no UB, no string-ref invalidation.
100+
101+
### m2 — `tensor_mul` rank arithmetic can underflow `size_t`
102+
**Files:** `include/numsim_codegen/code_emit/tensor_code_emit.h:337`
103+
**Source:** cpp-pro m3.
104+
105+
`acc_rank = acc_rank + rhs_rank - 2` wraps if `rhs_rank < 2`. Guarded by upstream cas today; UBSAN would catch a future regression. Add a defensive `if (acc_rank < 2 || rhs_rank < 2) throw` for explicit-assumption documentation.
106+
107+
### m3 — `PassManager::run` uses `std::set<string_view>` of vector-returned views
108+
**Files:** `include/numsim_codegen/passes/pass_manager.h:36`
109+
**Convergence:** cpp-pro n2 + architect m2.
110+
111+
`postconditions()` returns `vector<string_view>` by value; views point at static literals in concrete passes today. A future pass returning views into temporary `std::string` members would dangle silently.
112+
113+
**Mitigation:** document "postcondition string_views must have static storage duration" in the `Pass` interface, or switch the set to `std::string` (one allocation per condition; negligible in a build step).
114+
115+
### m4 — Numerical correctness coverage gap (acknowledged)
116+
**Files:** `tests/TensorCodeEmitTest.cpp` throughout
117+
**Source:** code-reviewer m1.
118+
119+
String-containment tests verify structure, not math. Acknowledged interim tech debt — the compile-check driver picks it up when a recipe lands. Two specific risk areas worth filing tickets for now: (i) `tensor_mul` 3-tensor accumulator rank arithmetic at `tensor_code_emit.h:337`; (ii) `permute_indices_wrapper` rank-4 patterns (the test asserts string round-trip, not that `{3,4,1,2}` is the elasticity major/minor swap actually intended).
120+
121+
### m5 — `code_emit_pass.h` missing the include-order comment that `symbol_validation_pass.h` has
122+
**Files:** `include/numsim_codegen/passes/code_emit_pass.h`
123+
**Convergence:** cpp-pro m4 + code-reviewer n2.
124+
125+
`symbol_validation_pass.h:5-8` documents "recipe.h must be included elsewhere in the TU before `run()` is instantiated"; `code_emit_pass.h` has the same constraint but no comment. Per code-reviewer n2 the comment is also slightly misleading since `run()` is virtual and instantiated where it's *defined* (recipe.h). Reconcile: rewrite both as "definition lives in recipe.h."
126+
127+
### m6 — Two throw messages end with "open an issue" rather than naming the workaround
128+
**Files:** `include/numsim_codegen/code_emit/tensor_code_emit.h:311-315` (`tensor_mul` coeff), `:218-223` (projector unsupported preset)
129+
**Source:** code-reviewer m2.
130+
131+
Both throws punt to "file an issue" / "is not yet supported". Add the concrete workaround inline: *"rewrite as `tensor_scalar_mul`"* / *"use `P_devi()` instead of `P_harm`"*. The P_harm test already asserts P_dev appears in the message but the throw text doesn't actually suggest that substitution.
132+
133+
---
134+
135+
## Nit
136+
137+
- **n1 — Dead `NUMSIM_CODEGEN_TENSOR_STUB` macro** at `tensor_code_emit.h:140-150`. Zero remaining invocations. (code-reviewer n1)
138+
- **n2 — `m_t2s_apply` default-init signaling** at `tensor_code_emit.h:513`. Add `// intentionally empty; wired by CodeEmitPass` or `{nullptr}`. (cpp-pro n1)
139+
- **n3 — `is_valid_cxx_identifier` cohesion** at `symbol_validation_pass.h:40`. Could be a free function in a utilities header. (cpp-pro n3)
140+
- **n4 — Dim=0 divide-by-zero risk in projector emit** at `tensor_code_emit.h:191`. Cheap pre-check. (code-reviewer n4)
141+
- **n5 — Verbose `const std::string` locals in projector emit** at `tensor_code_emit.h:185-191`. Style only. (cpp-pro n4)
142+
143+
---
144+
145+
## PR sequencing
146+
147+
**Merge order: `#42 → #44 → #45 → #46 → #47` as-is. No splits or reordering.**
148+
149+
**Pre-merge ask:** fold **M1** into #47 before merge, or commit to #47a immediately after.
150+
151+
**Single follow-up refactor PR**, bundled, before any Phase 2 work begins:
152+
- **M2** (DRY projector classification helper)
153+
- **M3** (constructor-inject `T2sApply`)
154+
- **M4** (`RecipeView` to decouple `PassContext` from `ConstitutiveModel` directly)
155+
- **M5** (extract `render_compute_function``recipe_render.h`; drop the `friend`)
156+
- **M6** (stub `TensorSpaceConsistencyPass`)
157+
158+
Minor and nit items can be batched separately or rolled into the refactor PR.
159+
160+
---
161+
162+
## Forward look
163+
164+
The biggest architectural risk carried into Phase 2 is **M4 + M5 together**. The first mutating pass (`TimeIntegrationPass`) will either break every existing pass signature or force the recipe to grow a second mutable surface; the first non-CodeEmit emit pass (`AbaqusEmitPass` / `TangentEmitPass`) will trigger the friend ratchet. Both are <100 LOC refactors today and 500+ after passes proliferate. The Phase 1.2 framework, as it stands, has not yet been stress-tested by a real consumer (M6) — landing the `TensorSpaceConsistencyPass` stub the roadmap actually called for would be the cheapest way to validate the design before Phase 2 hardens it.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#ifndef NUMSIM_CODEGEN_CODE_EMIT_PASS_H
2+
#define NUMSIM_CODEGEN_CODE_EMIT_PASS_H
3+
4+
#include <numsim_codegen/passes/pass.h>
5+
// NOTE: `run()` is defined in recipe.h after ConstitutiveModel is complete.
6+
// Any TU instantiating this pass must include recipe.h; in practice
7+
// recipe.h is the only constructor site.
8+
9+
namespace numsim::codegen {
10+
11+
// Phase 1.2 CodeEmitPass.
12+
//
13+
// Drives the existing scalar/tensor/tensor-to-scalar visitor pipeline:
14+
// 1. Register symbol-name → expression mappings with the CodeGenContext.
15+
// 2. Walk each output expression, accumulating statements + the final RHS.
16+
// 3. Render the framing (function signature + body + output writes) via
17+
// ConstitutiveModel::render_compute_function.
18+
//
19+
// Result lands in `pctx.compute_function_source`.
20+
//
21+
// Preconditions: "symbols-declared" + "identifiers-valid" (i.e.
22+
// SymbolValidationPass must have run first). If you add a pass that
23+
// transforms expressions (e.g. a future TimeIntegrationPass), register it
24+
// AFTER SymbolValidationPass but BEFORE CodeEmitPass.
25+
class CodeEmitPass final : public Pass {
26+
public:
27+
[[nodiscard]] auto name() const -> std::string_view override {
28+
return "CodeEmit";
29+
}
30+
[[nodiscard]] auto preconditions() const
31+
-> std::vector<std::string_view> override {
32+
return {"symbols-declared", "identifiers-valid"};
33+
}
34+
[[nodiscard]] auto postconditions() const
35+
-> std::vector<std::string_view> override {
36+
return {"compute-function-emitted"};
37+
}
38+
void run(PassContext &pctx) override; // defined in recipe.h after class.
39+
};
40+
41+
} // namespace numsim::codegen
42+
43+
#endif // NUMSIM_CODEGEN_CODE_EMIT_PASS_H
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#ifndef NUMSIM_CODEGEN_PASS_H
2+
#define NUMSIM_CODEGEN_PASS_H
3+
4+
#include <numsim_codegen/code_emit/codegen_context.h>
5+
6+
#include <optional>
7+
#include <string>
8+
#include <string_view>
9+
#include <vector>
10+
11+
namespace numsim::codegen {
12+
13+
class ConstitutiveModel;
14+
15+
// Shared state for a single PassManager invocation. Passes read the
16+
// (read-only) recipe, mutate the codegen context, and deposit their final
17+
// products into the output slots below. Phase 1.2 only needs one output
18+
// slot (the rendered compute function); Phase 2/3 will add more (tangent
19+
// source, state-variable wiring, etc.).
20+
struct PassContext {
21+
ConstitutiveModel const &model;
22+
CodeGenContext ctx;
23+
std::optional<std::string> compute_function_source;
24+
};
25+
26+
// Abstract base for a single codegen pass.
27+
//
28+
// Passes advertise their pre/postconditions as string tags. PassManager
29+
// verifies, before running each pass, that every declared precondition has
30+
// been advertised as a postcondition by some earlier pass — catching
31+
// ill-ordered pipelines at run time rather than producing silently-wrong
32+
// output.
33+
//
34+
// Phase 1.2 ships two concrete passes (symbol validation + code emit).
35+
// Future phases register additional passes (time integration, tangent,
36+
// Kuhn-Tucker lowering, …) into the same framework.
37+
class Pass {
38+
public:
39+
virtual ~Pass() = default;
40+
[[nodiscard]] virtual auto name() const -> std::string_view = 0;
41+
[[nodiscard]] virtual auto preconditions() const
42+
-> std::vector<std::string_view> {
43+
return {};
44+
}
45+
[[nodiscard]] virtual auto postconditions() const
46+
-> std::vector<std::string_view> {
47+
return {};
48+
}
49+
virtual void run(PassContext &ctx) = 0;
50+
};
51+
52+
} // namespace numsim::codegen
53+
54+
#endif // NUMSIM_CODEGEN_PASS_H
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#ifndef NUMSIM_CODEGEN_PASS_MANAGER_H
2+
#define NUMSIM_CODEGEN_PASS_MANAGER_H
3+
4+
#include <numsim_codegen/passes/pass.h>
5+
6+
#include <memory>
7+
#include <set>
8+
#include <stdexcept>
9+
#include <string>
10+
#include <utility>
11+
#include <vector>
12+
13+
namespace numsim::codegen {
14+
15+
// Runs a sequence of passes against a shared PassContext.
16+
//
17+
// Ordering is registration order — the PassManager does NOT topologically
18+
// reorder. The pre/postcondition check exists only to fail loudly on
19+
// misordered pipelines, not to silently fix them. Forcing the caller to
20+
// register in the right order keeps debugging predictable: a pipeline that
21+
// looks wrong on the page is wrong; one that looks right is right.
22+
class PassManager {
23+
public:
24+
void add(std::unique_ptr<Pass> p) {
25+
if (!p) {
26+
throw std::invalid_argument("PassManager::add: null pass");
27+
}
28+
m_passes.push_back(std::move(p));
29+
}
30+
31+
template <class P, class... Args> void emplace(Args &&...args) {
32+
add(std::make_unique<P>(std::forward<Args>(args)...));
33+
}
34+
35+
void run(PassContext &ctx) const {
36+
std::set<std::string_view> satisfied;
37+
for (auto const &p : m_passes) {
38+
for (auto pre : p->preconditions()) {
39+
if (!satisfied.contains(pre)) {
40+
throw std::runtime_error(
41+
"PassManager: pass '" + std::string{p->name()} +
42+
"' requires precondition '" + std::string{pre} +
43+
"' but no earlier pass advertised that postcondition.");
44+
}
45+
}
46+
p->run(ctx);
47+
for (auto post : p->postconditions()) {
48+
satisfied.insert(post);
49+
}
50+
}
51+
}
52+
53+
[[nodiscard]] auto size() const noexcept -> std::size_t {
54+
return m_passes.size();
55+
}
56+
57+
// Read-only access for inspection (debugging, test harnesses, future
58+
// recipe-IR explorers).
59+
[[nodiscard]] auto passes() const noexcept
60+
-> std::vector<std::unique_ptr<Pass>> const & {
61+
return m_passes;
62+
}
63+
64+
private:
65+
std::vector<std::unique_ptr<Pass>> m_passes;
66+
};
67+
68+
} // namespace numsim::codegen
69+
70+
#endif // NUMSIM_CODEGEN_PASS_MANAGER_H

0 commit comments

Comments
 (0)