nx_fof_cnf.nx source
↩ module page · 404 lines · 16683 B
1// nx_fof_cnf.nx -- FOF -> CNF conversion pipeline.
2//
3// Per Vampire-displacement roadmap Phase 2. Closes the FOF axis end-
4// to-end: a parsed *Fof tree (from nx_fof_parse) becomes an array of
5// *Clause that the saturation loop can consume. Required for the
6// CASC FOF division -- competition problems are stated in FOF and
7// must be CNF-converted before resolution + paramodulation can fire.
8//
9// Standard pipeline:
10// 1. eliminate IFF A<=>B => (A=>B) & (B=>A)
11// 2. eliminate IMP A=>B => ~A | B
12// 3. NNF (push NEG) ~~A=>A; De Morgan; swap quantifiers
13// 4. Skolemize ?[X]:F => F[X := sk_N(U_1..U_k)] where U_i
14// are the universal vars in scope
15// 5. drop FORALL all remaining vars become implicitly free
16// 6. distribute & / | A|(B&C) => (A|B)&(A|C) (may iterate)
17// 7. extract clauses AND-of-OR-of-literals -> array of *Clause
18//
19// Each pipeline stage is a pure function -- callers can inspect
20// intermediate forms. Top-level entry nx_fof_to_cnf chains them.
21
22// nx_safety_envelope:
23// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
24// sil_target: SIL1
25// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
26// verdict: NOT_YET_EVALUATED
27
28import "nx_syscalls.nx"
29import "nx_runtime.nx"
30import "nx_tier.nx"
31import "nx_result.nx"
32import "nx_unify.nx"
33import "nx_resolution.nx"
34import "nx_tptp_symtab.nx"
35import "nx_fof.nx"
36
37// Skolem functions get sym_ids in this range -- we assume user
38// symbols stay below NX_TPTP_SK_BASE. 256 capacity (NX_TPTP_SYM_MAX)
39// gives plenty of headroom in practice.
40const NX_TPTP_SK_BASE: nx_int = 900000
41
42// ===== Stage 1: eliminate IFF ======================================
43// A <=> B => (A => B) & (B => A)
44func nx_fof_elim_iff(f: *Fof) -> *Fof {
45 if f.kind == NX_FOF_ATOM { return f }
46 if f.kind == NX_FOF_NEG {
47 return nx_fof_neg(nx_fof_elim_iff(f.left))
48 }
49 if f.kind == NX_FOF_FORALL {
50 return nx_fof_quantified(NX_FOF_FORALL, f.var_id, nx_fof_elim_iff(f.left))
51 }
52 if f.kind == NX_FOF_EXISTS {
53 return nx_fof_quantified(NX_FOF_EXISTS, f.var_id, nx_fof_elim_iff(f.left))
54 }
55 let l: *Fof = nx_fof_elim_iff(f.left)
56 let r: *Fof = nx_fof_elim_iff(f.right)
57 if f.kind == NX_FOF_IFF {
58 // Build (l => r) & (r => l) -- use elim_iff result, no need to
59 // re-eliminate since the rewrite produces only AND/IMP nodes.
60 let l_imp_r: *Fof = nx_fof_binary(NX_FOF_IMP, l, r)
61 let r_imp_l: *Fof = nx_fof_binary(NX_FOF_IMP, r, l)
62 return nx_fof_binary(NX_FOF_AND, l_imp_r, r_imp_l)
63 }
64 return nx_fof_binary(f.kind, l, r)
65}
66
67// ===== Stage 2: eliminate IMP ======================================
68// A => B => ~A | B
69func nx_fof_elim_imp(f: *Fof) -> *Fof {
70 if f.kind == NX_FOF_ATOM { return f }
71 if f.kind == NX_FOF_NEG {
72 return nx_fof_neg(nx_fof_elim_imp(f.left))
73 }
74 if f.kind == NX_FOF_FORALL {
75 return nx_fof_quantified(NX_FOF_FORALL, f.var_id, nx_fof_elim_imp(f.left))
76 }
77 if f.kind == NX_FOF_EXISTS {
78 return nx_fof_quantified(NX_FOF_EXISTS, f.var_id, nx_fof_elim_imp(f.left))
79 }
80 let l: *Fof = nx_fof_elim_imp(f.left)
81 let r: *Fof = nx_fof_elim_imp(f.right)
82 if f.kind == NX_FOF_IMP {
83 return nx_fof_binary(NX_FOF_OR, nx_fof_neg(l), r)
84 }
85 return nx_fof_binary(f.kind, l, r)
86}
87
88// ===== Stage 3: NNF (push negations inward) =======================
89// ~~A => A
90// ~(A & B) => ~A | ~B
91// ~(A | B) => ~A & ~B
92// ~∀x.A => ∃x.~A
93// ~∃x.A => ∀x.~A
94// ~atom => ~atom (literal; cannot push further)
95//
96// Pre: input has no IFF or IMP (run elim_iff + elim_imp first).
97func nx_fof_to_nnf(f: *Fof) -> *Fof {
98 if f.kind == NX_FOF_ATOM { return f }
99 if f.kind == NX_FOF_AND {
100 return nx_fof_binary(NX_FOF_AND, nx_fof_to_nnf(f.left), nx_fof_to_nnf(f.right))
101 }
102 if f.kind == NX_FOF_OR {
103 return nx_fof_binary(NX_FOF_OR, nx_fof_to_nnf(f.left), nx_fof_to_nnf(f.right))
104 }
105 if f.kind == NX_FOF_FORALL {
106 return nx_fof_quantified(NX_FOF_FORALL, f.var_id, nx_fof_to_nnf(f.left))
107 }
108 if f.kind == NX_FOF_EXISTS {
109 return nx_fof_quantified(NX_FOF_EXISTS, f.var_id, nx_fof_to_nnf(f.left))
110 }
111 // f.kind == NEG -- look at the child to apply the right rule.
112 let inner: *Fof = f.left
113 if inner.kind == NX_FOF_NEG {
114 // ~~A -> A
115 return nx_fof_to_nnf(inner.left)
116 }
117 if inner.kind == NX_FOF_AND {
118 // ~(A & B) -> ~A | ~B
119 return nx_fof_binary(NX_FOF_OR,
120 nx_fof_to_nnf(nx_fof_neg(inner.left)),
121 nx_fof_to_nnf(nx_fof_neg(inner.right)))
122 }
123 if inner.kind == NX_FOF_OR {
124 // ~(A | B) -> ~A & ~B
125 return nx_fof_binary(NX_FOF_AND,
126 nx_fof_to_nnf(nx_fof_neg(inner.left)),
127 nx_fof_to_nnf(nx_fof_neg(inner.right)))
128 }
129 if inner.kind == NX_FOF_FORALL {
130 // ~∀x.A -> ∃x.~A
131 return nx_fof_quantified(NX_FOF_EXISTS, inner.var_id,
132 nx_fof_to_nnf(nx_fof_neg(inner.left)))
133 }
134 if inner.kind == NX_FOF_EXISTS {
135 // ~∃x.A -> ∀x.~A
136 return nx_fof_quantified(NX_FOF_FORALL, inner.var_id,
137 nx_fof_to_nnf(nx_fof_neg(inner.left)))
138 }
139 // ~atom -- leave as NEG of ATOM.
140 return f
141}
142
143// ===== Term-level variable substitution ============================
144// Returns a new Term with every occurrence of var `var_id` replaced by
145// `replacement`. Recursive; doesn't modify the input.
146func nx_term_subst_var(t: *Term, var_id: nx_int, replacement: *Term) -> *Term {
147 if t.kind == NX_TERM_VAR {
148 if t.sym == var_id { return replacement }
149 return t
150 }
151 if t.kind == NX_TERM_CONST { return t }
152 // APP: rebuild with substituted children.
153 if t.n_args == 0 { return t }
154 let new_args: *Term = (sys_mmap((t.n_args * NX_TERM_BYTES) as i64)) as *Term
155 var any_changed: nx_int = 0
156 var i: nx_int = 0
157 while i < t.n_args {
158 let child: *Term = nx_term_arg(t, i)
159 let new_child: *Term = nx_term_subst_var(child, var_id, replacement)
160 let dest: *Term = ((new_args as nx_int) + (i * NX_TERM_BYTES)) as *Term
161 dest.kind = new_child.kind
162 dest.sym = new_child.sym
163 dest.n_args = new_child.n_args
164 dest.args = new_child.args
165 if (new_child as nx_int) != (child as nx_int) { any_changed = 1 }
166 i = i + 1
167 }
168 if any_changed == 1 { return nx_term_app(t.sym, t.n_args, new_args) }
169 return t
170}
171
172// ===== Fof-level variable substitution =============================
173// Walk the formula tree replacing var_id in every embedded atom.
174// Skips occurrences inside a quantifier that re-binds var_id (variable
175// shadowing -- though in proper NNF after skolemize this shouldn't
176// arise, the safety check keeps us correct under future restructure).
177func nx_fof_subst_var(f: *Fof, var_id: nx_int, replacement: *Term) -> *Fof {
178 if f.kind == NX_FOF_ATOM {
179 return nx_fof_atom(nx_term_subst_var(f.atom, var_id, replacement))
180 }
181 if f.kind == NX_FOF_NEG {
182 return nx_fof_neg(nx_fof_subst_var(f.left, var_id, replacement))
183 }
184 if f.kind == NX_FOF_FORALL {
185 if f.var_id == var_id { return f } // shadowed
186 return nx_fof_quantified(NX_FOF_FORALL, f.var_id,
187 nx_fof_subst_var(f.left, var_id, replacement))
188 }
189 if f.kind == NX_FOF_EXISTS {
190 if f.var_id == var_id { return f }
191 return nx_fof_quantified(NX_FOF_EXISTS, f.var_id,
192 nx_fof_subst_var(f.left, var_id, replacement))
193 }
194 return nx_fof_binary(f.kind,
195 nx_fof_subst_var(f.left, var_id, replacement),
196 nx_fof_subst_var(f.right, var_id, replacement))
197}
198
199// ===== Stage 4: Skolemization =====================================
200// Walk the NNF formula; track universal vars in scope. At each
201// existential, build a Skolem-function term applied to the universals
202// in scope, substitute it for the existential's bound var, drop the
203// quantifier.
204//
205// Universal-vars-in-scope passed as a flat array + count; capped at
206// NX_TPTP_VAR_MAX (already 64). *next_sk_id is the running Skolem
207// function counter -- shared across the recursive walk.
208const NX_FOF_SK_MAX_UNIV: nx_int = 64
209
210func nx_fof_skolem_walk(f: *Fof, univ_vars: *nx_int, n_univ: nx_int,
211 next_sk_id: *nx_int) -> *Fof {
212 if f.kind == NX_FOF_ATOM { return f }
213 if f.kind == NX_FOF_NEG {
214 return nx_fof_neg(nx_fof_skolem_walk(f.left, univ_vars, n_univ, next_sk_id))
215 }
216 if f.kind == NX_FOF_AND {
217 return nx_fof_binary(NX_FOF_AND,
218 nx_fof_skolem_walk(f.left, univ_vars, n_univ, next_sk_id),
219 nx_fof_skolem_walk(f.right, univ_vars, n_univ, next_sk_id))
220 }
221 if f.kind == NX_FOF_OR {
222 return nx_fof_binary(NX_FOF_OR,
223 nx_fof_skolem_walk(f.left, univ_vars, n_univ, next_sk_id),
224 nx_fof_skolem_walk(f.right, univ_vars, n_univ, next_sk_id))
225 }
226 if f.kind == NX_FOF_FORALL {
227 // Push f.var_id onto the universal stack, recurse on body.
228 if n_univ >= NX_FOF_SK_MAX_UNIV { return f } // cap; treat as opaque
229 univ_vars[n_univ] = f.var_id
230 let body: *Fof = nx_fof_skolem_walk(f.left, univ_vars, n_univ + 1, next_sk_id)
231 return nx_fof_quantified(NX_FOF_FORALL, f.var_id, body)
232 }
233 if f.kind == NX_FOF_EXISTS {
234 // Build sk_N(U_1, ..., U_k) and substitute for f.var_id.
235 let sk_id: nx_int = NX_TPTP_SK_BASE + next_sk_id[0]
236 next_sk_id[0] = next_sk_id[0] + 1
237 var sk_term: *Term = 0 as *Term
238 if n_univ == 0 {
239 // No universals in scope -- Skolem is a constant.
240 sk_term = nx_term_const(sk_id)
241 } else {
242 let args: *Term = (sys_mmap((n_univ * NX_TERM_BYTES) as i64)) as *Term
243 var i: nx_int = 0
244 while i < n_univ {
245 let dest: *Term = ((args as nx_int) + (i * NX_TERM_BYTES)) as *Term
246 dest.kind = NX_TERM_VAR
247 dest.sym = univ_vars[i]
248 dest.n_args = 0
249 dest.args = 0 as *Term
250 i = i + 1
251 }
252 sk_term = nx_term_app(sk_id, n_univ, args)
253 }
254 // Substitute and continue skolemizing the body (which may have
255 // nested existentials).
256 let body_sub: *Fof = nx_fof_subst_var(f.left, f.var_id, sk_term)
257 return nx_fof_skolem_walk(body_sub, univ_vars, n_univ, next_sk_id)
258 }
259 return f // defensive
260}
261
262func nx_fof_skolemize(f: *Fof) -> *Fof {
263 let univ_vars: *nx_int = (sys_mmap((NX_FOF_SK_MAX_UNIV * 8) as i64)) as *nx_int
264 let next_sk_id: *nx_int = (sys_mmap(8)) as *nx_int
265 next_sk_id[0] = 0
266 return nx_fof_skolem_walk(f, univ_vars, 0, next_sk_id)
267}
268
269// ===== Stage 5: drop universal quantifiers =========================
270// After skolemize, only FORALL nodes remain (no EXISTS). Drop each --
271// the bound var becomes implicitly universally quantified at the
272// clause level (standard CNF convention).
273func nx_fof_drop_forall(f: *Fof) -> *Fof {
274 if f.kind == NX_FOF_FORALL { return nx_fof_drop_forall(f.left) }
275 if f.kind == NX_FOF_ATOM { return f }
276 if f.kind == NX_FOF_NEG { return nx_fof_neg(nx_fof_drop_forall(f.left)) }
277 if f.kind == NX_FOF_AND {
278 return nx_fof_binary(NX_FOF_AND, nx_fof_drop_forall(f.left), nx_fof_drop_forall(f.right))
279 }
280 if f.kind == NX_FOF_OR {
281 return nx_fof_binary(NX_FOF_OR, nx_fof_drop_forall(f.left), nx_fof_drop_forall(f.right))
282 }
283 return f
284}
285
286// ===== Stage 6: distribute & over | ================================
287// A | (B & C) => (A | B) & (A | C)
288// (A & B) | C => (A | C) & (B | C)
289// Iterate to fixpoint. Worst case 2^n size blowup but acceptable for
290// CASC-Easy problems; Tseitin transformation would avoid it but
291// requires fresh propositional vars and is queued.
292func nx_fof_distribute_step(f: *Fof) -> *Fof {
293 if f.kind == NX_FOF_ATOM { return f }
294 if f.kind == NX_FOF_NEG { return nx_fof_neg(nx_fof_distribute_step(f.left)) }
295 let l: *Fof = nx_fof_distribute_step(f.left)
296 let r: *Fof = nx_fof_distribute_step(f.right)
297 if f.kind == NX_FOF_AND { return nx_fof_binary(NX_FOF_AND, l, r) }
298 if f.kind == NX_FOF_OR {
299 // (A & B) | C -> (A|C) & (B|C)
300 if l.kind == NX_FOF_AND {
301 return nx_fof_binary(NX_FOF_AND,
302 nx_fof_distribute_step(nx_fof_binary(NX_FOF_OR, l.left, r)),
303 nx_fof_distribute_step(nx_fof_binary(NX_FOF_OR, l.right, r)))
304 }
305 // A | (B & C) -> (A|B) & (A|C)
306 if r.kind == NX_FOF_AND {
307 return nx_fof_binary(NX_FOF_AND,
308 nx_fof_distribute_step(nx_fof_binary(NX_FOF_OR, l, r.left)),
309 nx_fof_distribute_step(nx_fof_binary(NX_FOF_OR, l, r.right)))
310 }
311 return nx_fof_binary(NX_FOF_OR, l, r)
312 }
313 return f
314}
315
316// ===== Stage 7: extract clauses ====================================
317// After distribution, formula is AND of (OR of literals). Walk:
318// AND(L, R) -> emit clauses from L and R
319// OR or atom -> flatten into one clause
320//
321// `out_clauses` is a caller-allocated array; `out_n` is updated.
322func nx_fof_collect_lits(f: *Fof, c: *Clause) -> nx_int {
323 if f.kind == NX_FOF_OR {
324 let lc: nx_int = nx_fof_collect_lits(f.left, c)
325 if lc != 0 { return lc }
326 return nx_fof_collect_lits(f.right, c)
327 }
328 if f.kind == NX_FOF_ATOM {
329 let _r: *NxResult = nx_clause_add(c, nx_lit_make(NX_LIT_POS, f.atom))
330 return 0
331 }
332 if f.kind == NX_FOF_NEG {
333 if f.left.kind == NX_FOF_ATOM {
334 let _r: *NxResult = nx_clause_add(c, nx_lit_make(NX_LIT_NEG, f.left.atom))
335 return 0
336 }
337 return 0 - 1 // ill-formed: NEG of non-atom past NNF
338 }
339 return 0 - 1
340}
341
342func nx_fof_extract_clauses(f: *Fof, out_clauses: *Clause, out_n: *nx_int,
343 cap: nx_int) -> nx_int {
344 if f.kind == NX_FOF_AND {
345 if nx_fof_extract_clauses(f.left, out_clauses, out_n, cap) != 0 { return 0 - 1 }
346 return nx_fof_extract_clauses(f.right, out_clauses, out_n, cap)
347 }
348 // Leaf: this whole subformula is one clause (OR or atom or NEG-atom).
349 if out_n[0] >= cap { return 0 - 1 }
350 let c: *Clause = nx_clause_new()
351 let cr: nx_int = nx_fof_collect_lits(f, c)
352 if cr != 0 { return cr }
353 let dest: *Clause = ((out_clauses as nx_int) + (out_n[0] * NX_CLAUSE_BYTES)) as *Clause
354 dest.n_lits = c.n_lits
355 dest.lits = c.lits
356 out_n[0] = out_n[0] + 1
357 return 0
358}
359
360// ===== Top-level pipeline ==========================================
361// Returns 0 on success, negative on failure. out_clauses is a
362// caller-allocated array of *Clause; out_n is the count populated.
363func nx_fof_to_cnf(f: *Fof, out_clauses: *Clause, out_n: *nx_int,
364 cap: nx_int) -> nx_int {
365 let f1: *Fof = nx_fof_elim_iff(f)
366 let f2: *Fof = nx_fof_elim_imp(f1)
367 let f3: *Fof = nx_fof_to_nnf(f2)
368 let f4: *Fof = nx_fof_skolemize(f3)
369 let f5: *Fof = nx_fof_drop_forall(f4)
370 let f6: *Fof = nx_fof_distribute_step(f5)
371 out_n[0] = 0
372 return nx_fof_extract_clauses(f6, out_clauses, out_n, cap)
373}
374
375// Tseitin-based pipeline: linear-size CNF instead of exponential
376// distribution. Useful for formulas with deeply nested <=> or
377// alternating &/| that would blow up exponentially under naive
378// distribution. Requires nx_fof_tseitin which lives in
379// nx_fof_tseitin.nx -- caller imports that module + calls this
380// wrapper which composes the elim/NNF/skolem stages with Tseitin.
381//
382// Caller imports nx_fof_tseitin.nx for nx_fof_to_cnf_tseitin().
383// This wrapper just composes the pre-Tseitin stages here.
384func nx_fof_to_nnf_quantifier_free(f: *Fof) -> *Fof {
385 let f1: *Fof = nx_fof_elim_iff(f)
386 let f2: *Fof = nx_fof_elim_imp(f1)
387 let f3: *Fof = nx_fof_to_nnf(f2)
388 let f4: *Fof = nx_fof_skolemize(f3)
389 return nx_fof_drop_forall(f4)
390}
391
392// Count IFF + IMP nodes in a Fof tree. Used by the CNF strategy
393// chooser: many of these means the distribute pipeline will explode
394// exponentially, so Tseitin (linear-size) is preferable.
395func nx_fof_count_iff_imp(f: *Fof) -> nx_int {
396 if f.kind == NX_FOF_ATOM { return 0 }
397 if f.kind == NX_FOF_NEG { return nx_fof_count_iff_imp(f.left) }
398 if f.kind == NX_FOF_FORALL { return nx_fof_count_iff_imp(f.left) }
399 if f.kind == NX_FOF_EXISTS { return nx_fof_count_iff_imp(f.left) }
400 var n: nx_int = 0
401 if f.kind == NX_FOF_IFF { n = 1 }
402 if f.kind == NX_FOF_IMP { n = 1 }
403 return n + nx_fof_count_iff_imp(f.left) + nx_fof_count_iff_imp(f.right)
404}