Skip to content

Commit d764b37

Browse files
committed
Round-8 review fixes: merge_add double-consume, tensor zero-child regression
Round 8 (adversarial probe of the round-7 delta) confirmed two regressions introduced by the round-7 negation probe in merge_add: - Double-consume (wrong value): when the lhs held an exact {t, -t} pair, t consumed the rhs child -t via the negation probe and then -t direct-matched the same rhs child again — (5x-5x) + (y-5x) evaluated to y-10x. Matches are now gated on the used-set in both probe paths. The enabler is closed too: child insertion in merge_add and merge_and_finish now goes through add_insert_signed, which combines an entry with an existing exact match or exact negation until no collision remains and zero-filters the result, so an add can no longer hold an exact {t, -t} pair at all ((2x-5x)+3x now collapses to the zero singleton at construction). - Tensor zero child: the shared merge_add gained cancellation power in round 7, but only the scalar/t2s dispatch site got the zero filter — the tensor caller inserted fully-cancelled combines as literal 0{2} children ((A+B)+(C-A) -> "0{2}+B+C", breaking identity and round-trip cancellation). The tensor site now passes the is_same<tensor_zero> filter and collapses degenerate results the same way the scalar path does; the filter-less merge_add overload is gone so no caller can opt out silently. Regression tests in CoreBugFixTest (RoundEightReview suite). Refs #340. Signed-off-by: petlenz <peterlenz89.pl@gmail.com>
1 parent 001164d commit d764b37

4 files changed

Lines changed: 94 additions & 13 deletions

File tree

include/numsim_cas/core/simplifier/simplifier_add.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,11 @@ class n_ary_add_dispatch
325325
typename Traits::add_type &add,
326326
expr_holder_t combined) {
327327
if (!is_same<typename Traits::zero_type>(combined)) {
328-
add.merge_or_insert(std::move(combined));
328+
// signed insert: the combined term may exactly negate an existing
329+
// child; a plain insert would leave a {t,-t} pair (round-8 review)
330+
add_insert_signed(add, std::move(combined), [](expr_holder_t const &e) {
331+
return is_same<typename Traits::zero_type>(e);
332+
});
329333
}
330334
add.invalidate_hash();
331335
if (add.size() == 0) {

include/numsim_cas/functions.h

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,41 @@
66

77
namespace numsim::cas {
88

9+
/// Insert `entry` into an add-semantics tree, combining with an existing
10+
/// exact match or exact negation until no collision remains; `is_zero`
11+
/// filters fully-cancelled results so {t, -t} pairs never coexist
12+
/// (round-8 review: such a pair downstream caused a double-consume merge).
13+
template <typename Derived, typename IsZero>
14+
constexpr inline void
15+
add_insert_signed(n_ary_tree<Derived> &tree,
16+
expression_holder<typename Derived::expr_t> entry,
17+
IsZero &&is_zero) {
18+
while (entry.is_valid() && !is_zero(entry)) {
19+
auto pos = tree.find_like(entry);
20+
if (pos == tree.symbol_map().end()) {
21+
auto neg{-entry};
22+
pos = tree.find_like(neg);
23+
if (pos != tree.symbol_map().end() && !(pos->second == neg)) {
24+
pos = tree.symbol_map().end();
25+
}
26+
}
27+
if (pos == tree.symbol_map().end()) {
28+
tree.merge_or_insert(std::move(entry));
29+
return;
30+
}
31+
auto next = pos->second + entry;
32+
tree.symbol_map().erase(pos);
33+
entry = std::move(next);
34+
}
35+
}
36+
937
/// merge two n_ary_trees
1038
/// --> add (x+y+z) + (x+a) --> 2*x+y+z+a --> mul
1139
/// `is_zero` filters fully-cancelled combines (x + (-x)); negation pairs
1240
/// share no hash, so a failed find_like retries with -child (round-7).
41+
/// A matched rhs child is consumed exactly once: a second lhs child must
42+
/// not re-match it (round-8 review: 5x and -(5x) both consuming -(5x)
43+
/// turned y-5x into y-10x).
1344
template <typename Derived, typename IsZero>
1445
constexpr inline void merge_add(n_ary_tree<Derived> const &lhs,
1546
n_ary_tree<Derived> const &rhs,
@@ -30,40 +61,37 @@ constexpr inline void merge_add(n_ary_tree<Derived> const &lhs,
3061
expr_set<expression_holder<expr_t>> used_expr;
3162
for (auto &child : lhs.symbol_map() | std::views::values) {
3263
auto pos{rhs.find_like(child)};
64+
if (pos != rhs.symbol_map().end() && used_expr.count(pos->second)) {
65+
pos = rhs.symbol_map().end();
66+
}
3367
if (pos == rhs.symbol_map().end()) {
3468
// only an exact -child cancels; like-terms of -child would nest adds
3569
auto neg_child{-child};
3670
pos = rhs.find_like(neg_child);
37-
if (pos != rhs.symbol_map().end() && !(pos->second == neg_child)) {
71+
if (pos != rhs.symbol_map().end() &&
72+
(!(pos->second == neg_child) || used_expr.count(pos->second))) {
3873
pos = rhs.symbol_map().end();
3974
}
4075
}
4176
if (pos != rhs.symbol_map().end()) {
4277
used_expr.insert(pos->second);
4378
auto combined{child + pos->second};
4479
if (!is_zero(combined)) {
45-
result.merge_or_insert(std::move(combined));
80+
add_insert_signed(result, std::move(combined), is_zero);
4681
}
4782
} else {
48-
result.merge_or_insert(child);
83+
add_insert_signed(result, child, is_zero);
4984
}
5085
}
5186
if (used_expr.size() != rhs.size()) {
5287
for (auto &child : rhs.symbol_map() | std::views::values) {
5388
if (!used_expr.count(child)) {
54-
result.merge_or_insert(child);
89+
add_insert_signed(result, child, is_zero);
5590
}
5691
}
5792
}
5893
}
5994

60-
template <typename Derived>
61-
constexpr inline void merge_add(n_ary_tree<Derived> const &lhs,
62-
n_ary_tree<Derived> const &rhs,
63-
n_ary_tree<Derived> &result) {
64-
merge_add(lhs, rhs, result, [](auto const &) { return false; });
65-
}
66-
6795
} // namespace numsim::cas
6896

6997
#endif // FUNCTIONS_H

src/numsim_cas/tensor/simplifier/tensor_simplifier_add.cpp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,18 @@ n_ary_add::dispatch([[maybe_unused]] Expr const &rhs) {
7474
n_ary_add::dispatch(tensor_add const &rhs) {
7575
auto expr{make_expression<tensor_add>(rhs.dim(), rhs.rank())};
7676
auto &add{expr.template get<tensor_add>()};
77-
merge_add(m_lhs_node, rhs, add);
77+
// zero filter + collapse: childwise combines may fully cancel
78+
// ((A+B)+(C-A) left a literal 0{2} child, round-8 review)
79+
merge_add(m_lhs_node, rhs, add,
80+
[](expr_holder_t const &e) { return is_same<tensor_zero>(e); });
81+
add.invalidate_hash();
7882
add.recompute_space();
83+
if (add.size() == 0) {
84+
return tensor_traits::zero(m_lhs);
85+
}
86+
if (add.size() == 1 && !add.coeff().is_valid()) {
87+
return add.symbol_map().begin()->second;
88+
}
7989
return expr;
8090
}
8191

tests/CoreBugFixTest.h

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,45 @@ TEST(RoundSevenReview, AddCancelsAgainstNegativeChild) {
14461446
EXPECT_TRUE(*f == *(trace(A) + w(c4))) << to_string(f);
14471447
}
14481448

1449+
// Round-8 review: regressions from the round-7 negation probe.
1450+
1451+
// R8-1: merge_add consumed the same rhs child twice when the lhs held an
1452+
// exact {t,-t} pair — 5x neg-matched -(5x), then -(5x) direct-matched it
1453+
// again, turning y-5x into y-10x. Also kills the enabler: signed inserts
1454+
// keep {t,-t} from coexisting at all.
1455+
TEST(RoundEightReview, MergeAddNoDoubleConsume) {
1456+
auto [x, y] = make_scalar_variable("x", "y");
1457+
auto f = (2.0 * x + (-(5.0 * x))) + 3.0 * x;
1458+
EXPECT_TRUE(is_same<scalar_zero>(f)) << to_string(f);
1459+
scalar_evaluator<double> ev;
1460+
ev.set(x, 1.0);
1461+
ev.set(y, 0.0);
1462+
EXPECT_DOUBLE_EQ(ev.apply(f + (y - 5.0 * x)), -5.0);
1463+
// an add manually holding the exact pair must still merge correctly
1464+
auto pair_add = make_expression<scalar_add>();
1465+
auto &pa = pair_add.get<scalar_add>();
1466+
pa.push_back(5.0 * x);
1467+
pa.push_back(-(5.0 * x));
1468+
auto g = pair_add + (y - 5.0 * x);
1469+
EXPECT_DOUBLE_EQ(ev.apply(g), -5.0) << to_string(g); // was -10
1470+
}
1471+
1472+
// R8-2: the tensor add-merge got round-7's cancellation power without the
1473+
// zero filter — (A+B)+(C-A) held a literal 0{2} child.
1474+
TEST(RoundEightReview, TensorMergeAddZeroFiltered) {
1475+
auto [A, B, C] = make_tensor_variable(std::tuple{"A", std::size_t{3}, 2},
1476+
std::tuple{"B", std::size_t{3}, 2},
1477+
std::tuple{"C", std::size_t{3}, 2});
1478+
auto e1 = (A + B) + (C - A);
1479+
EXPECT_TRUE(*e1 == *(B + C)) << to_string(e1);
1480+
EXPECT_EQ(to_string(e1).find("0{2}"), std::string::npos) << to_string(e1);
1481+
auto e2 = e1 - (B + C);
1482+
EXPECT_TRUE(is_same<tensor_zero>(e2)) << to_string(e2);
1483+
// full cancellation collapses to the zero singleton, not an empty add
1484+
auto e3 = (A + B) + ((-A) + (-B));
1485+
EXPECT_TRUE(is_same<tensor_zero>(e3)) << to_string(e3);
1486+
}
1487+
14491488
} // namespace numsim::cas
14501489

14511490
#endif // COREBUGFIXTEST_H

0 commit comments

Comments
 (0)