history_property: decouple instantiation from serializability via numsim_serialize ADL hook (fixes #16) - #17
Conversation
Critical review — findings & resolutionsA 3-lens parallel review (architect-reviewer + cpp-pro + code-reviewer) was run on this branch and folded into commit 2 ( HIGHA — ODR hazard (cpp-pro). B — Asymmetric hooks (architect). C — Dispatch precedence (architect + code-reviewer). MEDD — No I/O stream-state check (architect). E — No CI (code-reviewer, sharpened). The #16 regression is clang-specific (eager virtual instantiation per [temp.inst]/11); this repo has no F — G — LOW
Verification
|
petlenz
left a comment
There was a problem hiding this comment.
Inline anchors for the folded-in review findings (full summary in the PR comment + REVIEW-history-property.md).
| // T), so that EVERY translation unit instantiating history_property<T> sees it. | ||
| // has_numsim_serialize<T> is detected by ADL at the point of instantiation; if | ||
| // the hook were visible in some TUs but not others, the trait — and the | ||
| // serialize() virtual body that branches on it — would differ across TUs, a |
There was a problem hiding this comment.
Finding A (HIGH) — ODR. has_numsim_serialize<T> resolves by ADL at the point of instantiation, and it feeds the serialize() virtual body. If the hook is visible in some TUs but not others, the trait result — and the virtual's compiled body — diverge across TUs: a silent ODR violation, linker picks one vtable slot. Resolution: documented (here) that the hook must be declared in T's own header so every TU detects it identically. Not machine-enforceable; the doc is the mitigation.
| struct has_numsim_serialize : std::false_type {}; | ||
| template <typename T> | ||
| struct has_numsim_serialize< | ||
| T, std::enable_if_t<std::is_void_v<decltype(numsim_serialize( |
There was a problem hiding this comment.
Finding I (LOW). Original trait used std::void_t<decltype(...)>, which checks only well-formedness → a non-void numsim_serialize would be a false positive. Resolution: constrained to std::enable_if_t<std::is_void_v<decltype(...)>> so the hook's void return is part of detection.
| if constexpr (std::is_trivially_copyable_v<T>) { | ||
| static_assert(!has_numsim_serialize<T>::value, | ||
| "history_property<T>: T is trivially copyable AND provides a " | ||
| "numsim_serialize hook — ambiguous (the raw-byte path would " |
There was a problem hiding this comment.
Finding C (HIGH) — silent precedence. is_trivially_copyable is checked before the hook, so a trivially-copyable T that also provides a hook would have it silently ignored (raw bytes win) — the exact POD-portability case a hook exists for. Resolution: this static_assert rejects the ambiguous combination loudly. For T = double the trait is false, so it only fires on the genuine ambiguity. Sabotage-verified.
| if (!os) | ||
| throw std::runtime_error( | ||
| "history_property::serialize: stream write failed"); | ||
| } else if constexpr (has_numsim_serialize<T>::value) { |
There was a problem hiding this comment.
Finding D (MED). Raw os.write failure was unchecked. Resolution: throw on a failed write so a serialize error is as loud as a missing hook.
| static_assert(has_numsim_deserialize<T>::value, | ||
| "history_property<T>: T provides numsim_serialize but not " | ||
| "numsim_deserialize — state could be written and never read " | ||
| "back. Provide both hooks or neither."); |
There was a problem hiding this comment.
Finding B (HIGH) — asymmetric hooks. serialize/deserialize detect hooks independently, so a serialize-only type could write state it can never read back. Resolution: this static_assert (inside the taken hook branch) enforces both-or-neither. Because it sits in an if constexpr branch, a type with neither hook still compiles (throws at call time) — so the #16 fix is preserved; only the exactly-one-hook case fails to build. Sabotage-verified.
| if (!is) | ||
| throw std::runtime_error( | ||
| "history_property::deserialize: stream read failed or truncated"); | ||
| } else if constexpr (has_numsim_deserialize<T>::value) { |
There was a problem hiding this comment.
Finding D (MED). A truncated/failed is.read previously left m_old/m_new indeterminate, indistinguishable from success. Resolution: throw on a short/failed read so a corrupt restore fails loudly.
| // copy-assignment (e.g. one holding a std::vector, under allocation failure) | ||
| // would call std::terminate here. Such a T is now instantiable (the old | ||
| // static_assert that blocked it is gone), so this precondition is on the | ||
| // value type, not enforced at compile time. |
There was a problem hiding this comment.
Finding F (MED) — accepted by proof. commit()/revert() are noexcept and now reachable for a T with throwing copy-assignment (e.g. std::vector-backed under OOM) → std::terminate. A static_assert(is_nothrow_copy_assignable_v<T>) is not a valid fix: these are virtuals pulled into the vtable, so it would fire at class instantiation and re-block history_property<T> exactly like the original #16 static_assert (confirmed empirically). Base contract is noexcept (an override can't widen). The intended types — scalars, tmech::tensor (noexcept ops) — are safe, so this is documented as a value-type precondition.
What
history_property<T>::serialize/deserializecarried astatic_assert(is_trivially_copyable_v<T>)inside the virtual bodies. Because a static_assert in a virtual is evaluated when the class specialization completes,history_property<T>was uninstantiable for any non-trivially-copyableT(e.g.tmech::tensor, which is copyable+assignable but has user-provided special members) even whenserializewas never called — and it diverged clang-vs-gcc per [temp.inst]/11 (clang eagerly instantiates unused virtuals for the vtable; gcc may defer). This is numsim-core#16.The fix replaces the static_assert with
if constexprdispatch:T→ raw bytes (unchanged fast path),Twith an ADLnumsim_serialize/numsim_deserializehook → call the hook,std::runtime_errorat call time only (never blocks instantiation).Serializability is a precondition of the serialize operation, not of being held as history — so its absence is a runtime error, not a compile-time barrier.
Commits
if constexprdispatch + thenumsim_serialize/numsim_deserializeADL customization point +has_numsim_serialize/has_numsim_deserializedetection traits.REVIEW-history-property.md) — closes silent-correctness holes the tests cannot catch by construction:static_assertinside the takenif constexprbranch, so input_property<T>::wire instantiates history_property<T> triviality static_assert — breaks tmech tensors under clang-19 #16 stays fixed;T's own header so every TU detects it identically);void-return-constrained detection traits;#include <utility>; tightened test throw-message assertions; hardenedclone()test.Test plan
tests/history_property/included).history_property<tmech::tensor<double,3,2>>(explicittemplate classforcing all virtuals) compiles and runs under clang — the exact input_property<T>::wire instantiates history_property<T> triviality static_assert — breaks tmech tensors under clang-19 #16 failure, resolved.Follow-up (not in this push)
A CI workflow (gcc-14 + clang-19 matrix) is ready locally but blocked on the pushing token's missing
workflowscope; it will be added so the clang-specific regression is guarded automatically. Until then this repo has no CI, so the dual-compiler verification above was run by hand.