Only record implied literals of the current component - #22
Open
rmanhaeve wants to merge 1 commit into
Open
Conversation
The compiled d-DNNF could be non-decomposable: some AND nodes had children whose variable sets overlap. The written formula stays logically equivalent to the input CNF, so the search's own model count is unaffected, but any consumer that relies on decomposability -- i.e. any (weighted) model counter reading the .nnf -- silently returns a wrong result. ProbLog reported a probability of 156.13 for a model whose answer is 0.6 (ML-KULeuven/problog#113). Cause: BCP records every implied literal as a child of the current decision level's AND node. Conflict clauses are deliberately left out of the component decomposition, which makes them the only clauses whose unit propagation can take the first step across a component boundary (two unassigned variables sharing an unsatisfied original clause are in the same component by construction). Once a sibling component's variable has been assigned that way, ordinary propagation continues inside the sibling and everything it implies is recorded in this branch as well. An implied literal is now only recorded when its variable belongs to the component the current decision level is refining. Out-of-component implied literals may be dropped: the components are variable-disjoint, so such a literal is entailed by the sibling component on its own and that component records it; if the current branch is unsatisfiable it compiles to bottom regardless. Literals implied by a conflict clause over the current component's own variables are still recorded, since nothing else constrains them -- skipping those instead loses information and overcounts. Membership is tested with a stamp per decision level, marked when the level is pushed or exposed by a pop, so the test is O(1) and the added marking is proportional to work the component analysis already does. The search itself is untouched: the guard only decides what is recorded, never what is assigned, so solution counts, heuristics, learning and the component cache are unchanged. On a 20 variable, 26 clause reduction of the reported instance dsharp reports 798 solutions while its own .nnf has 854 models; with this change both are 798. On the reported instance the .nnf now counts 39708868943559345451429439397652844050730171778133720697929728 models, matching c2d and D4. Across 109 CNFs run with ProbLog's flags (-smoothNNF -disableAllLits) no output is non-decomposable any more, every .nnf model count agrees with the search count, and run time and representation size are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WwfmE52z7BJmmaewvNzaVY
rmanhaeve
marked this pull request as ready for review
September 8, 2026 12:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DSHARP can emit a
.nnfthat is not decomposable: an AND node whose children share a variable. The formula stays logically equivalent to the input, so the search's own count is right — but decomposability is the contract of the output format, so every model counter reading the.nnfreturns a wrong number, silently. Found via ML-KULeuven/problog#113, where ProbLog reported a probability of156.13for a model whose answer is0.6.Reproducer
20 variables, 26 clauses, reduced from the ProbLog instance:
798 is correct — an independent BDD over the CNF agrees. With this PR both numbers are 798 and no node is non-decomposable.
check_ddnnf.py— self-contained, no dependenciesCause
CMainSolver::BCPrecords every implied literal under the current decision level's AND node. That is only correct for literals of the component that level refines, and two things get past it.Conflict clauses are excluded from the component decomposition, and that exclusion is exactly what lets them cross a component boundary: two unassigned variables sharing an unsatisfied original clause are in the same component by construction, so only a learned clause can take the first step across. Ordinary propagation then continues inside the sibling, and is recorded here too.
Both stages are visible in the reproducer. At level 3 the component being refined is
{16,18,19,20}and a sibling is{3,4,5,6,7,8,11}. The learned clause(-18 ∨ 1 ∨ -2 ∨ 3)has-18falsified at level 3,1and-2falsified at level 1, and3free — so it implies3, which belongs to the sibling. Then the original clauses(-3 ∨ 4)and(-6 ∨ -3)propagate4and-6. All three land as literal children of one AND node, beside an OR node covering{3,4,6,16,18,19,20}.Instrumented to report every literal recorded outside the current component, and excluding implicit BCP's tentative rounds (whose recordings are detached again), every surviving crossing is initiated by a conflict clause: 2 of 2 rounds on a 53-variable reduction, 26 of 26 on the ProbLog instance. No original clause ever initiates one.
This is also the one step the paper states without proof — "the addition of conflict clauses during the solving procedure does not change the structure of the d-DNNF". True of the represented function; not of decomposability.
Why membership in the component is the right test
Let
Rbe the component the level refines; the residual original formula splits aspsi_A ∧ psi_Bover disjoint variable sets.Out-of-component literals can be dropped. If BCP implies
lwithvar(l) ∉ R, the residual entailsl, since every clause DSHARP propagates with is entailed by the input. Ifvar(l)occurs in no unsatisfied original clause, the residual is invariant under flipping it, so it must be unsatisfiable and the branch compiles to ⊥ regardless. Otherwisevar(l)lies in a siblingpsi_B: fix any model ofpsi_A, and disjointness givespsi_B ⊨ l, so that component's own node already forces it.In-component ones cannot. If
var(l) ∈ Rit is assigned, so it appears in no sub-component ofR; nothing else records it and smoothing would hand it a free choice.So the criterion is membership, not provenance — a conflict clause implying a literal over
R's own variables must still be recorded. That rules out the obvious one-liner, measurably: skipping conflict-clause implications gives a wrong count on 9 of 65 instances, against 5 unpatched and 0 here, always overcounting.-noCAis sound but still running after 300 s where this takes 0.53 s. Putting conflict clauses into the decomposition would collapse the decomposition. Post-hoc DAG repair is not a local edit — 256 of 261 offending children are shared, some with 349 parents.The change
+64/−8in four files.CDecisionStackstamps the variables of the component the top level refines, on push, on the pop that exposes a level, and once aftermakeCompIdFromActGraph.varInTOSRefCompis then an O(1) comparison, permissive while no component information exists so preprocessing is unchanged.mayRecordImpliedLitguards the five recording sites inBCPand the one inimplicitBCP.The search is untouched: the guard decides only what is recorded, never what is assigned, so solution counts, heuristics, conflict analysis and the component cache are unchanged. 0.531 s against 0.534 s, and 234150 edges against 234169.
Validation
-smoothNNF -disableAllLits: 6 wrong unpatched, 0 patched, and no solution count changed anywhere.39708868943559345451429439397652844050730171778133720697929728, matching c2d, D4 and DSHARP's own search count; 75 non-decomposable nodes become 0, and ProbLog answers0.6.exp-testing/counts and run times unchanged, two fewer edges.Worth noting the failure is not always out of range: sweeping the ProbLog model from 1 to 45 steps, unpatched DSHARP is wrong at 21 steps (
502.16) and at 23 steps (0.95024115) — a plausible-looking probability that no sanity check catches.Disclosure
Investigated and prepared with Claude Code (Claude Opus 5), working from ML-KULeuven/problog#113. The tracing, the reduction, the patch and the validation harness are AI-assisted work, done under my direction. Please weigh the argument rather than my word for it: the reproducer and checker above need nothing from me but the CNF, and the soundness argument is short enough to check by hand.
Fixes the compiler side of ML-KULeuven/problog#113. Happy to send a
-verifyflag separately — smoothing the emitted.nnfand comparing its count against the search count is one linear pass, and would have caught this immediately.