compute_cube computed a wrong c.cnt for non-weighted counting modes (--mode 0).
The root cause was that c.cnf did not cover all required indep-support vars, leaving
some vars free in the cube. Because the blocking clause only pins the vars in c.cnf,
it banned more models than c.cnt claimed, causing an undercount (and an overcount
after an attempted fix via ancestor-multiplication).
Two additional bugs were found in the process:
include_solutionfor!is_indeplevels did not propagate UNSAT (zero count) correctly: it always setbranch_mc = fg->one()even whensolutions = nullptr.init_decision_stack()passed the (stale)is_indepmember variable to the root level 0, which could leave that level withis_indep = false. The root level must always beis_indep = trueso it properly accumulates the final projected count (especially important for the EXIT-state cube count with--satsolver 0).
At restart time (restart_if_needed), Ganak walks the decision stack from the deepest
level to level 1. For each level and branch side (0 = left, 1 = right), it calls
compute_cube(cube, side) to capture the counted subspace. Each cube is then:
- Added to
mini_cubeswith its countcube.cnt. - Converted to a blocking clause
sat_solver->add_clause(ganak_to_cms_cl(c.cnf)). This clause is satisfied when at least one pinned var differs from its cube value, so it bans exactly those models where ALL pinned vars match the cube assignment.
The invariant: cube.cnt must equal the number of models banned by the blocking
clause. Too small → permanent undercount. Too large → overcounting (double-count).
check_count_norestart_cms(cube) independently verifies the count by forcing the
cube vars as unit clauses and enumerating satisfying indep-support assignments.
compute_cube builds c.cnf from:
- Decision literals — all trail decisions for vars
< opt_indep_support_end. - Unprocessed component vars — indep vars (
< indep_support_end) from component ranges[remaining_comps_ofs_, unprocessed_comps_end_)at each decision level, taken from the current SAT model.
What was missing: Indep vars (< indep_support_end) that are:
- In already-processed sub-components (not in any unprocessed range), AND
- Not in the trail as decisions (e.g., implied/propagated, or solved by cache hit).
These vars ended up free in c.cnf. The blocking clause then banned all satisfying
assignments for those free vars, but c.cnt did not account for them.
indep_support_end: 17 (indep vars = 1..16)
Cube CNF: 20 -8 -9 -4 10 -1 -2 -3 5 -11 -12 -13 -14 -15 -16
Vars 6 and 7 were absent from the cube — they lived in already-processed sub-components
and were implied rather than decided. The blocking clause banned all 3 satisfying
assignments for {6, 7} given the 14 pinned vars, but c.cnt was wrong.
Original code (c.cnt = fg->one()): claimed 1, actual was 3 → undercount by 2.
First fix attempt (ancestor branch_mc multiplication): 96 × 3 × 4 × 2 = 2304.
Wrong because branch_mc values are aggregate counts accumulated over many different
assignments in the DPLL tree — not the count for the one specific assignment pinned
by the cube. The specific assignment from the SAT model was already fully pinned for
all the processed sub-comp vars (they were in the trail as decisions), so each
contributed 1 to the actual count, not their historical aggregate count.
After the dedup pass, scan all vars v in [1, indep_support_end) and add any not
yet present, taken from the SAT model:
{
set<uint32_t> seen_vars;
auto it = std::remove_if(c.cnf.begin(), c.cnf.end(),
[&seen_vars](const Lit& l) { return !seen_vars.insert(l.var()).second; });
c.cnf.erase(it, c.cnf.end());
for (uint32_t v = 1; v < indep_support_end; v++) {
if (seen_vars.count(v)) continue;
c.cnf.push_back(Lit(v, sat_solver->get_model()[v-1] == CMSat::l_False));
}
}With all indep vars pinned, the blocking clause bans exactly one indep-support
assignment. For non-weighted, c.cnt = fg->one() is now correct.
The old code for !is_indep levels:
if (!is_indep) branch_mc[act_branch] = fg->one(); // BUG: ignores UNSAT children
else { /* proper accumulation */ }For non-indep levels, Ganak's projected counting intentionally tracks only satisfiability
(0/1), not the exact count — the non-indep variable assignment does not affect the
projected count over indep vars when no indep vars remain in the component. However,
the old code incorrectly set branch_mc = fg->one() even when solutions = nullptr
(child returned UNSAT). This meant a branch where the child component was UNSAT would
be treated as SAT, causing over-counting.
Fix: propagate zero correctly for the UNSAT case, while preserving the 0/1 invariant:
if (!is_indep) {
if (cnt_is_zero(solutions)) {
branch_zero[act_branch] = true;
branch_mc[act_branch] = nullptr;
} else {
branch_mc[act_branch] = fg->one();
}
} else {
/* existing indep accumulation logic */
}init_decision_stack() pushed the root level (level 0) with the stale is_indep
member variable:
decisions.push_back(StackLevel(1, 2, is_indep, tstamp, fg)); // BUG: is_indep may be falseLevel 0 is a dummy root that accumulates the final total count from all level-1
sub-problems. It must always have is_indep = true so that include_solution properly
accumulates child counts (products of component sub-counts) rather than collapsing to
0/1. When is_indep = false, include_solution discards the actual count and stores 1,
making total_model_count() at the EXIT state return 1 regardless of the true count.
This was the reason a check_count_norestart_cms fallback existed at the EXIT state —
it patched over the wrong total_model_count() with a slow one-by-one enumeration.
Fix: always use is_indep = true for the root level:
decisions.push_back(StackLevel(1, 2, true, tstamp, fg)); // root always accumulates countWith this fix, the EXIT-state cube's c.cnt = decisions.top().total_model_count() is
correct even for --satsolver 0, and the check_count_norestart_cms production
fallback (which was the slow debug-only enumeration) is no longer needed.
-
Initial report: performance bug — some counted subspaces were not collected as cubes, causing re-exploration after each restart.
-
Code review of
restart_if_neededandcompute_cube: foundc.cnt = fg->one()applied unconditionally for non-weighted, regardless of how many models the cube's partial assignment covers. Wrote analysis todocuments/restart_cube_analysis.md. -
First fix attempt: removed the
c.cnt = fg->one()override; used ancestorbranch_mcmultiplication instead. -
Test failure:
CHECK_COUNTassertion triggered:recorded c.cnt : 2304 (= 96 × 3 × 4 × 2 from branch_mc products) actual check : 3Debug showed: all "excluded comp" vars were covered (0 uncovered), free indep vars were
{6, 7}with 3 satisfying assignments.branch_mcaggregate values were wrong because they represent counts over many different DPLL-tree assignments, not the one specific SAT model assignment pinned by the cube. -
Root cause identified: the cube was not covering all indep vars. Vars 6 and 7 (in processed sub-components, not decided) were absent from
c.cnf. Under the cube's partial pinning, the blocking clause banned 3 models butc.cntclaimed 1 (or 2304). -
Correct fix for compute_cube: pin ALL indep vars from the SAT model in
c.cnfduring cube construction. With full coverage,c.cnt = fg->one()is exact (Fix 1). -
Second regression (
fuzzTest_2574.cnf):assert(*decisions.top().total_model_count() == *cnt_cmp)failed incheck_count(). This led to investigation ofinclude_solutionfor!is_indeplevels and the UNSAT propagation bug (Fix 2), plus the discovery thatinit_decision_stack()was initializing the root level withis_indep = false(Fix 3). The slowcheck_count_norestart_cmsfallback in the EXIT state was a symptom of this root-levelis_indepbug and was removed once Fix 3 was in place.