Skip to content

Commit 38d395e

Browse files
Fix determinism issues in sorting, proposal seeding, and rounding
B3: argsort_by_first broke ties on the primary key nondeterministically (std::sort is unstable). Add a secondary tie-break by original index so the ordering is a total order. This makes the label-multiset downsample argmax and restrict_set truncation deterministic on count ties (smaller id wins). B4: GreedyAdditiveMulticutProposalGenerator derived its per-call seed as seed_ + call_count_, which collided with the fusion-move driver's per-slot seed + slot offset (different (slot, iteration) cells computed identical proposals). Mix the packed (seed, counter) pair through a SplitMix64 finalizer to remove the linearity. B7: WatershedProposalGenerator::reset re-seeded the RNG but left the cached normal-distribution deviate, so a reset generator was not identical to a fresh one. Add noise_.reset(). flow: round_to_flat_index used std::nearbyint (FP-rounding-mode dependent, half-to-even); switch to floor(x + 0.5) to match the half-up convention used in affine.hxx and watershed.hxx. Add a determinism regression test for the downsample count-tie case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 552249c commit 38d395e

5 files changed

Lines changed: 55 additions & 4 deletions

File tree

include/bioimage_cpp/flow/flow_density.hxx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ inline std::ptrdiff_t round_to_flat_index(
109109
} else if (clipped > grid.upper[axis]) {
110110
clipped = grid.upper[axis];
111111
}
112-
const auto coord = static_cast<std::ptrdiff_t>(std::nearbyint(clipped));
112+
// Round half up, matching the nearest-neighbor convention in
113+
// transformation/affine.hxx and segmentation/watershed.hxx.
114+
// std::nearbyint would honor the FP rounding mode (round-half-to-even).
115+
const auto coord = static_cast<std::ptrdiff_t>(std::floor(clipped + 0.5f));
113116
flat += coord * grid.strides[axis];
114117
}
115118
return flat;

include/bioimage_cpp/graph/proposal_generators/greedy_additive_multicut.hxx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public:
5454
weight_stop_,
5555
node_num_stop_,
5656
true,
57-
seed_ + static_cast<int>(call_count_),
57+
mixed_seed(seed_, call_count_),
5858
sigma_
5959
);
6060
++call_count_;
@@ -65,6 +65,23 @@ public:
6565
}
6666

6767
private:
68+
// Mix the base seed and the call counter so proposals from different
69+
// (slot, iteration) cells never realign. A plain `seed_ + call_count_`
70+
// collided with the per-slot `seed + slot` offset applied by the fusion-move
71+
// driver (slot 0 at iteration i+1 reused the seed of slot 1 at iteration i),
72+
// recomputing identical proposals and shrinking effective diversity. A
73+
// SplitMix64 finalizer over the packed (seed, counter) pair removes the
74+
// linearity.
75+
static int mixed_seed(const int base, const std::size_t counter) {
76+
std::uint64_t z =
77+
(static_cast<std::uint64_t>(static_cast<std::uint32_t>(base)) << 32) |
78+
static_cast<std::uint64_t>(static_cast<std::uint32_t>(counter));
79+
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
80+
z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
81+
z = z ^ (z >> 31);
82+
return static_cast<int>(static_cast<std::uint32_t>(z));
83+
}
84+
6885
const UndirectedGraph &graph_;
6986
std::vector<double> edge_costs_;
7087
double sigma_;

include/bioimage_cpp/graph/proposal_generators/watershed.hxx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ public:
109109

110110
void reset() override {
111111
generator_.seed(static_cast<std::mt19937::result_type>(seed_));
112+
// std::normal_distribution caches a second Box-Muller deviate; clear it
113+
// so a reset generator is bytewise identical to a freshly constructed one.
114+
noise_.reset();
112115
}
113116

114117
private:

include/bioimage_cpp/label_multiset/multiset.hxx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,22 @@ template <class V1, class V2>
2424
inline void argsort_by_first(V1 &v1, V2 &v2, const bool ascending = true) {
2525
std::vector<std::size_t> idx(v1.size());
2626
std::iota(idx.begin(), idx.end(), 0);
27+
// Break ties on the primary key by original index so the ordering is a
28+
// total order. std::sort is unstable, so without this tie-break equal keys
29+
// (e.g. equal counts when sorting label entries) would be reordered
30+
// nondeterministically across runs / STL implementations.
2731
if (ascending) {
2832
std::sort(idx.begin(), idx.end(),
29-
[&v1](std::size_t a, std::size_t b) { return v1[a] < v1[b]; });
33+
[&v1](std::size_t a, std::size_t b) {
34+
if (v1[a] != v1[b]) return v1[a] < v1[b];
35+
return a < b;
36+
});
3037
} else {
3138
std::sort(idx.begin(), idx.end(),
32-
[&v1](std::size_t a, std::size_t b) { return v1[a] > v1[b]; });
39+
[&v1](std::size_t a, std::size_t b) {
40+
if (v1[a] != v1[b]) return v1[a] > v1[b];
41+
return a < b;
42+
});
3343
}
3444
reorder_inplace(v1, idx);
3545
reorder_inplace(v2, idx);

tests/label_multiset/test_downsample.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,21 @@ def test_downsample_restrict_set_keeps_top_k():
123123
assert ids.tolist() == [0]
124124
assert counts.tolist() == [15]
125125
assert ms_top1.argmax.tolist() == [0]
126+
127+
128+
def test_downsample_restrict_set_count_tie_is_deterministic():
129+
# Two labels with equal counts (8 each). With restrict_set=1 and a count
130+
# tie, the argmax / retained entry must be deterministic: the smaller id
131+
# wins (the sort breaks ties by id). Run twice to confirm stability.
132+
labels = np.empty((4, 4), dtype=np.uint64)
133+
labels[:2, :] = 1
134+
labels[2:, :] = 2
135+
ms_full = multiset_from_labels(labels, (1, 1))
136+
b = Blocking([0, 0], [4, 4], [4, 4])
137+
results = []
138+
for _ in range(2):
139+
ms_top1 = downsample_multiset(ms_full, b, restrict_set=1)
140+
ids, counts = ms_top1.entry(0)
141+
results.append((ms_top1.argmax.tolist(), ids.tolist(), counts.tolist()))
142+
assert results[0] == results[1]
143+
assert results[0] == ([1], [1], [8])

0 commit comments

Comments
 (0)