Skip to content

Solve-pipeline refactor (integration branch — do not merge yet) - #69

Draft
ciaranm wants to merge 29 commits into
masterfrom
pipeline/refactor
Draft

Solve-pipeline refactor (integration branch — do not merge yet)#69
ciaranm wants to merge 29 commits into
masterfrom
pipeline/refactor

Conversation

@ciaranm

@ciaranm ciaranm commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Long-lived integration branch for the solve-pipeline refactor. Plan and rationale in dev_docs/preprocessor-refactor.md.

Not for merging phase-by-phase. Phases accumulate as commits here; the branch merges to master only once the refactor (or a meaningful chunk) is complete. Kept as a draft PR so CI runs on every push (the workflow only triggers on PRs to master). Head 6a5d7a4, all 6 CI lanes green.

Motivation (the three problems this targets)

  1. The "before search" preprocessor generated huge proofs unnecessarily.
  2. It was dumber than it could be — work done up front that the search didn't need.
  3. New features were hard to add — the model and proof code were tangled.

Status — the canonical phases are complete

  • Phase 0 — proof-size measurement harness (proof_metrics).
  • Phase 1 — extract the SolveStep pipeline; search becomes the terminal step.
  • Phase 1b — unify SolveState (model + root domains + nogood store carried through the pipeline).
  • Phase 2 — one ShapeGraphSpec plan as the single source of truth for the supplemental graphs.
  • Phase 3 — proof layering + file split (3a ProcessedGraphsData; 3b HomomorphismProofs middle layer; 3c clique-size split; 3d OPB emission into the middle layer). homomorphism_model.cc ~1300 → ~830 lines.
  • Phase 4 — supplemental constraint-level subsumption (S2a). Graph-level inert/duplicate elimination was shelved as unsound — measurement showed the supplemental adjacency derivations are ~92–100% never explicitly cited yet stay RUP-load-bearing in search, so dropping whole graphs is unsound. What is sound and instance-independent is constraint subsumption: the adjacency constraints nest (distance3 ⊇ exact-path-1 ⊇ exact-path-2 …, same head), so emit only the strongest per head; a degree/NDS check that needs an elided one re-derives it as a one-step weakening of the kept constraint and deletes it again. Hidden --no-proof-supplemental-subsumption restores full emission. ~25–39% PBP shrink on preprocessing-heavy configs, counts unchanged.
  • Phase 5 — cost ordering (S2b). The loop-cancelled adjacency derivations (loop_fix_adjacencies) were emitted at the head of emit_model, so even a trivial refutation carried them. Deferred to the start of the search step, after the cheap concluding steps. Byte-identical for any instance that reaches search; an early conclusion now emits none (e.g. induced c3 → trident PBP 2439 → 9).
  • Phase 6 — staged solving (S3, --staged). A cheap first round (original graph only: degree + Hall, no NDS, no supplementals); only if a bounded search round does not conclude does it build the supplementals + NDS, re-filter the domains, and continue unbounded. Full proof support — the supplementals are derived mid-proof at the level-0 restart boundary. Sequential; decision and (see below) counting.

Follow-on work, also on this branch

  • Staged counting (without proof). Initially rejected because the Stage-1 → Stage-2 transition restart double-counted. The root cause was narrow: might_have_watches() was keyed on the user's restart schedule, so under the --restarts none that counting defaults to, the transition's resumption nogoods were never posted and Stage 2 re-explored the tree. One-line fix (params.staged || …), validated against the brute-force oracle over the random sweep. Under proof it is still gated (logging an enumeration across a restart — the same restriction as counting with restarts).
  • Lean the bottom Proof API (Phase 3 Option 2). The middle layer used to marshal vector<NamedVertex> to call hom-specific Proof methods. Proof now exposes generic emit primitives (emit_proof_line / emit_proof_directive, emit_model_constraint / emit_model_comment, variable_name, injectivity/at-most-one label and dedup-cache accessors) plus a shared AdjacencyProofLines cache, and every homomorphism-exclusive derivation (adjacency, exact-path, distance-3, extra-shape, loop-fix, transient weaken/forget) is emitted from HomomorphismProofs using plain indices. create_adjacency_constraint / start_adjacency_constraints_for are kept in Proof for the common-subgraph and clique solvers, which still use them; the homomorphism solver drives its own clean path.

Deferred / not done (a reviewer's map of the edges)

  • Staged counting under proof — needs the enumeration proof to survive a restart; blocked on the upstream VeriPB core id monotonicity bug (the same blocker as re-adding solution-blocking-nogood deletion, issue Enumeration proofs are linear in #solutions; re-add blocking-constraint deletion once upstream VeriPB bug is fixed #59).
  • Threaded staging; making --staged the default (wants budget tuning first).
  • The model-driven filter proofs (incompatible_by_degrees/_nds, emit_hall) and the clique-size proof still use the old Proof API — the latter is entangled with the shared clique-solver proof path (its translation state lives in Proof because the clique solver's own proof methods read it), so it is left on the old API deliberately.

Guardrail

Phases 1–3 are byte-identical (verified by the random correctness + proof sweeps, the cake gss → veripb → cake pipeline, and the proof_metrics matrix — 274 emitted .opb/.pbp files). Phases 4–6 change proofs deliberately, verified by re-running the sweeps under VeriPB with solution counts unchanged. Each Phase-3-Option-2 step is again byte-identical — it moves where a proof line is emitted, not what. Every push is green on all 6 CI lanes (ubuntu 24.04 / 26.04, macOS 15 / 26, Sanitize, Proof coverage).

🤖 Generated with Claude Code

Baseline instrumentation for the solve-pipeline refactor described in
dev_docs/preprocessor-refactor.md. Behaviour-neutral; no solver code changes.

- test-instances/proof_metrics.bash: deterministic fixed-matrix harness recording,
  per configuration, the supplemental-graph count and OPB/PBP line counts (plus
  solution count and nodes). Needs no VeriPB, so it is registered as the
  proof_metrics ctest unconditionally and runs in every lane -- under the sanitizers
  it doubles as a proof-emission smoke test across many feature combinations.
- random_proof_sweep.bash: additionally emits a per-instance proof-size TSV (at
  $RPS_METRICS, default <workdir>/proof_sweep_metrics.tsv) alongside its verify pass.
- dev_docs/preprocessor-refactor.md: the agreed refactor plan (unified SolveStep
  pipeline; OPB stays complete; S2a = static inertness elimination; phases 0-6).

These give the before/after numbers that Phases 1-3 must hold byte-identical and that
Phase 4 onward should shrink. 49/49 ctest; clean under ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ciaranm and others added 23 commits June 19, 2026 14:40
Pure refactor of solve_homomorphism_problem into a pipeline of SolveStep objects
over a shared SolveContext, run by a driver that stops at the first step to conclude
the problem. The top-level branches become steps -- PatternBiggerThanTargetStep,
TargetLoopShortcutStep, CliqueShortcutStep -- and the model build + search becomes
the terminal MainSolveStep, so "the search is just a step" is now structural.

The OPB model emission stays inline as setup for now (it becomes its own step in
Phase 3, when proof emission is co-located per step). The HomomorphismModel/searcher
boundary and the would-be unified SolveState are untouched -- that is Phase 1b.

No behaviour change: across the proof_metrics matrix and the 128-instance random
sweep, all 274 emitted proof files (.opb + .pbp) are byte-identical to before; 49/49
ctest; clean under ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace calculate_n_shape_graphs and the parallel if(supports_*) build sequence in
prepare() with a single ShapeGraphSpec plan (make_shape_graph_plan): the ordered list
of supplemental graphs the model has, with each entry's slot count. max_graphs is now
derived from the plan (number_of_shape_graphs) and prepare() builds by iterating it,
so the count, the build order, and per-graph applicability live in one place and can
no longer drift apart -- adding a supplemental graph is an entry in the plan plus a
case in the build switch, not edits to a separate count function kept in step by a
runtime consistency check.

The per-graph proof emission stays inline in the build switch for now; co-locating it
with each build (and splitting it out of this file) is Phase 3. The slot-index check
is kept as a cheap defensive invariant.

No behaviour change: across the proof_metrics matrix and the 128-instance random
sweep, all 274 emitted proof files are byte-identical; 49/49 ctest; clean under
ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The recoded bitset graphs and everything derived from them -- the per-graph
adjacency rows, degrees, loops, labels, compressed pattern adjacencies, the directed
flag, and the supplemental-graph names -- move out of the catch-all HomomorphismModel
Imp into a cohesive ProcessedGraphsData struct (new processed_graphs_data.hh). This
is the data the supplemental builders, the domain filters and the searcher all read;
Imp keeps the genuinely model-private remainder (the Proof, vertex proof names, the
clique-size caches, the less-than flags).

This is the enabler for moving the supplemental build + proof code into its own
translation unit (Phase 3b): that code will take ProcessedGraphsData rather than
needing the whole Imp. The dimensions (max_graphs / pattern_size / target_size) stay
on the model for now -- folding them in would mean non-inline accessors or churn in
the searcher's hot loop, which rides the later searcher-side restructuring; PGD-taking
code receives the stride as a parameter.

No behaviour change: a field reorganisation only. 274/274 proof files byte-identical;
49/49 ctest; clean under ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ee functions

Introduces the "solver-proofs" middle layer (solver core -> HomomorphismProofs ->
generic Proof writer):

- HomomorphismProofs (homomorphism_proofs.{hh,cc}) owns the vertex index->name mapping
  (moved out of the model's Imp) and the supplemental-graph proof derivations
  (exact-path / distance-3 / extra-shape), reading ProcessedGraphsData directly. For
  now it still calls today's generic Proof methods, so the NamedVertex marshalling
  lives here rather than in the model; leaning the Proof API so these emit directly
  (deleting the marshalling) is the next step.
- The supplemental graph *builds* become free functions over ProcessedGraphsData with
  no proof dependency (supplemental_graphs.{hh,cc}).
- prepare()'s plan switch is now build-then-prove one-liners. model.pattern_vertex_for_proof
  / target_vertex_for_proof forward to HomomorphismProofs, so the searcher's many call
  sites don't churn.

homomorphism_model.cc drops from ~1300 to ~1020 lines. No behaviour change: 274/274
emitted proof files byte-identical; 49/49 ctest; clean ASan/UBSan; new files are
clang-format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OPB model emission (CP variables, (local-)injectivity, adjacency / induced
non-edge constraints, the preserved set, finalise, loop-fix) was the last big block of
proof code living outside the middle layer -- it sat inline in solve_homomorphism_problem
because it runs before the model is built. It now becomes HomomorphismProofs::emit_model,
invoked by a new EmitProofModelStep (the pipeline's first step).

To let the one HomomorphismProofs span the whole solve, it is now created and owned by
the pipeline (a unique_ptr in solve_homomorphism_problem) rather than by the model: the
emit-model step and the model's later supplemental / filter proofs share it. The model
holds a non-owning pointer (passed to its constructor); HomomorphismProofs now holds a
shared_ptr<Proof> (also sets up the clique-proof extension in 3c).

With this, the solver core no longer emits any proof model directly -- HomomorphismProofs
is the model's proof interface for everything bar the per-prune filter calls (which still
go straight to Proof, to be routed later). No behaviour change: 274/274 proof files
byte-identical; 49/49 ctest; clean under ASan/UBSan; clang-format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- new clique_size_constraints.{hh,cc}: a CliqueSizeData struct (the lazily-computed
  clique-size caches, carved out of the model's Imp) plus the clique-size *computation*
  as free functions over CliqueSizeData + ProcessedGraphsData -- init_clique_size_data
  and check_clique_compatibility, with find_clique as a file-local helper. No proof
  dependency.
- the no-clique *proof* becomes HomomorphismProofs::prove_no_clique (it drives the
  clique solver with proof extension, which is why the middle layer now holds the
  shared_ptr<Proof> introduced in 3d). check_clique_compatibility calls it on a bound
  violation.
- HomomorphismModel::_check_clique_compatibility is now a one-line forward; Imp drops
  the clique cache fields in favour of `mutable CliqueSizeData clique_data;`, and the
  constructor's cache setup becomes init_clique_size_data.

homomorphism_model.cc is down to ~830 lines (from ~1300 at the start of Phase 3). No
behaviour change: 274/274 proof files byte-identical (incl. proof_cliques /
proof_hom_clique); 49/49 ctest; clean under ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…yout

prove_exact_path_graphs assumed exact-path graph g lived at slot g, and read slot 1
for the shared 2-path structure that justifies every exact-path graph. It now takes
explicit (index, slot) pairs plus the exact-path-1 slot, so the supplementals can be
renumbered when inert ones are eliminated (Phase 4b). The index (the path-count
threshold) and the slot are now distinct; the derivation is keyed on the slot (what the
searcher references).

Byte-identical prep: with no elimination the pairs are (1,1),(2,2),... and the
exact-path-1 slot is 1, so the emitted proof is unchanged -- 274/274 proof files
byte-identical; 49/49 ctest; clean under ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The supplemental adjacency constraints the proof emits are nested: for a
fixed head (p -> t, with neighbour q) the permitted target set shrinks as
the path-count threshold grows (exact-path-k+1 subset of exact-path-k, both
subset of distance3). A constraint with the same head but a smaller set
syntactically subsumes the wider ones, so emitting only the strongest leaves
the rest derivable -- and they are provably RUP-redundant, unlike the merely
unused-in-this-search constraints, so this is sound regardless of instance.

So emit only the set-minimal constraint per head: the exact-path prover keeps
just the highest index that holds for each pattern edge and reports the edges
it covered, and the distance-3 prover skips those (distance3 is always the
widest). The degree/NDS pigeonhole still needs the per-graph constraint for
the graph it reasons about; where that was elided, re-derive it on demand as a
one-step weakening of the kept stronger constraint (cited by label), then
delete it again -- cheaper than the original multi-line derivation even when
it is needed. The original-graph and distance-2/k4 graphs (which carry no
adjacency lines) are left untouched, matching the no-elision behaviour.

A hidden --no-proof-supplemental-subsumption flag turns the whole thing off,
emitting every constraint as before, for studying its effect (e.g. on proof
trimming).

Solution counts are unchanged and every proof still verifies (128-instance
random sweep + 49 ctests + sanitizers); PBP shrinks ~25-39% on
preprocessing-heavy configurations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…helved)

Update the preprocessor-refactor plan to match what shipped: Phase 4 became
constraint-level subsumption of supplemental adjacency constraints (emit only
the strongest per head; degree/NDS re-derives elided ones on demand), with the
originally-planned graph-level inert/duplicate elimination shelved as unsound
(the deadness is dynamic / RUP-load-bearing). Notes the hidden
--no-proof-supplemental-subsumption toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold the constraint model, the (root) domains and the nogood store into a
single SolveState carried through the solve pipeline, rather than as locals
scattered across the model build and the two solvers. This is the prerequisite
for staged solving (Phase 6): builder / filter steps can grow the carried model
and bounded-search steps can accumulate nogoods between rounds.

- New gss/innards/solve_state.hh holding { model, domains, watches }.
- SolveContext carries a SolveState; the main solve step builds the model into
  it (still lazily, after the cheap shortcut steps, so the max_graphs guard does
  not fire before a shortcut can conclude).
- The searcher no longer owns its nogood store: it takes a Watches & supplied by
  the caller. The sequential path passes state.watches; the threaded (terminal,
  unbounded) path keeps a per-thread vector<Watches>, since threaded staging is
  deferred.

Behaviour-neutral: 274/274 emitted proof files byte-identical, 49/49 ctest and
clean under ASan/UBSan (release and sanitize), and threaded SAT / counting /
triggered-restarts runs match the sequential results under the sanitizers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run cheap preprocessing first and only build the expensive supplemental graphs
if a bounded first search round does not conclude. New --staged flag (sequential,
decision search): Stage 1 filters on the original graph only (degree + Hall, no
NDS, no supplemental graphs) and searches under a small backtrack budget; if that
does not conclude, the driver builds the supplemental graphs + NDS, re-filters,
and continues unbounded. This skips the supplemental build/propagation cost --
and, under proof, their derivations -- on instances that crack cheaply.

- HomomorphismModel::prepare() split: Stage-1 work stays in prepare(); the
  supplemental build+derive+degrees moves to a new build_supplemental_graphs(),
  which prepare() still calls itself when not staged (unstaged byte-identical).
  initialise_domains() gains a stage1 flag (g=0 only, no NDS); new
  tighten_domains_with_supplementals() applies the deferred supplemental degree +
  NDS filtering to the existing domains, emitting only the new prunings.
- SequentialSolver drives the two stages over the carried SolveState (Phase 1b):
  Stage 1 under its own fixed-budget schedule; at the first restart it builds the
  supplementals (derived mid-proof at the level-0 restart boundary) and tightens,
  then switches to the user's schedule. Nogoods persist in state.watches.
- Full proof support: staged proofs verify under VeriPB; instances that conclude
  in Stage 1 emit no supplemental derivations (trident decision PBP 2168 -> 136).
- Guards: --staged is sequential-only, and not yet combined with counting (a
  mid-enumeration restart would recount explored subtrees). Hidden
  --staged-first-round-backtracks tunes the budget (default 100; tests use a tiny
  value to force the transition).

Validation: unstaged proofs byte-identical (274/274); staged proof sweep 160/160
verify; the random correctness sweep checks staging is satisfiability-neutral vs
unstaged; 49/49 ctest, release + ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cheap concluding steps (pattern-too-big, target-loop, clique) already run
before the supplemental builders, but the loop-cancelled adjacency derivations
(loop_fix_adjacencies) were emitted at the tail of emit_model -- the very first
step -- so even a trivial refutation carried them. Under --induced every
non-edge constraint has the target in its permitted set, so all of them get
rewritten: the induced c3 -> trident refutation (pattern bigger than target, a
one-line injectivity pigeonhole that cites no adjacency constraint) emitted 2430
dead @adj rup lines before concluding.

Move the derivation out of emit_model into a new
HomomorphismProofs::derive_loop_fixed_adjacencies(), called at the start of
MainSolveStep -- after the cheap steps, before prepare()'s first @adj-citing
pol. Nothing is emitted to the proof between the two points for an instance that
reaches search, so its proof is byte-identical; an instance that concludes early
omits the derivation. The OPB is untouched.

induced_unsat (c3 -> trident, --induced) PBP 2439 -> 9, still verifies; 273/274
proof-metrics + sweep files byte-identical; all 49 ctests pass (release +
ASan/UBSan), including the random proof sweep and the cake pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Staged counting double-counted: a 22-solution instance reported up to 41,
re-counting in Stage 2 the solutions already counted in Stage 1. The cause was
not a missing mechanism but a disabled one. might_have_watches(), which gates the
whole nogood/watch machinery (post_nogood, the watch-table allocation, and the
watch propagation in propagate()), returned params.restarts_schedule->might_restart()
-- keyed on the *user's* schedule. Counting defaults to --restarts none, so
might_have_watches was false and every post_nogood at the Stage-1 -> Stage-2
transition was a no-op. With no resumption nogoods, Stage 2 re-explored the whole
tree from the top, and since sequential counting does no per-solution dedup, it
counted every solution again.

Staging always performs at least one restart (the transition), independently of
the user's schedule, so it must enable watches: might_have_watches() now also
returns true when params.staged. The restart-resumption nogoods then cover the
Stage-1 region exactly, as they already do for ordinary restarts.

This only affects staged counting (staged decision defaults to Luby, so
might_have_watches was already true there; non-staged is unchanged), so all 274
existing proofs stay byte-identical. The staged-count guard is relaxed from
"staged + count" to "staged + count + proof" -- the non-proof case now works;
under proof it stays blocked (logging an enumeration across a restart is not yet
handled, same restriction as counting with restarts).

random_homomorphism_test now checks staged counting against the brute-force
oracle over the 1500-instance sweep (tiny first-round budget forces the transition
mid-enumeration): the distinct-set check catches a missed solution, the
solution_count check catches a double-counted one. 49/49 ctest release +
ASan/UBSan; 274/274 proofs byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The homomorphism adjacency / supplemental-graph derivations in Proof share three
maps -- the adjacency-line labels, their numeric line ids, and their permitted
target sets -- all keyed by (g, p, q, t) index. Group them into an
AdjacencyProofLines struct and expose it via Proof::adjacency_proof_lines().

This is a pure, byte-identical refactor (274/274 proofs unchanged; 49/49 ctest).
Its point is to decouple the cluster: with the shared state behind one accessor,
the derivation methods that read and write it can migrate out of Proof into the
HomomorphismProofs middle layer one at a time, each sharing this object, rather
than the whole ~600-line cluster having to move in a single atomic step. The end
state is a generic Proof that emits PB constraints without knowing about pattern
or target vertices (see dev_docs/preprocessor-refactor.md, Phase 3 Option 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ency managers

Add the generic low-level proof-emission primitives to Proof -- emit_proof_line
(write a constraint line, bump and return the line number), emit_proof_directive
(write a non-constraint line: comment, setlvl/wiplvl, del id; no counter),
current_proof_line, variable_name(p, t), and is_locally_injective -- so the
homomorphism derivations can emit through Proof rather than reaching into its
internals.

Move the first four adjacency-management methods out of Proof into the
HomomorphismProofs middle layer, each emitting via the new primitives over the
shared AdjacencyProofLines cache:

  - loop_fix_adjacencies      -> derive_loop_fixed_adjacencies (already the caller)
  - weaken_supplemental_adjacency -> inlined into ensure_supplemental_adjacency,
    which now builds the line straight from indices and the target row, deleting
    the vector<NamedVertex> target_set marshalling
  - forget_supplemental_adjacency -> inlined into forget_transient_supplemental_adjacencies
  - adjacency_line_exists     -> a direct cache lookup in the middle

Proof no longer knows about loop-cancellation, subsumption weakening, or transient
deletion; these now live where the vertex names and graph data already are.

Byte-identical: 274/274 proofs unchanged; the migrated transient-weaken path
(@g..adj.. ia .. : @kept + del id) verifies under VeriPB on dense instances;
49/49 ctest release + ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iddle

Move create_exact_path_graphs -- the largest supplemental derivation, the
intricate setlvl / pol s+ / ia chain that proves a G^[gx2] adjacency constraint
-- out of Proof and into HomomorphismProofs::emit_exact_path_graph. The middle's
prove_exact_path_graphs now builds plain index vectors (between_p_and_q, n_t,
d_n_t, two_away_from_t) instead of the vector<NamedVertex> /
vector<pair<NamedVertex, vector<NamedVertex>>> it used to marshal for the Proof
call, and emits through the generic primitives.

To support it, Proof gains read accessors for the model labels the derivation
cites -- injectivity_label, locally_injective_label, at_most_one_value_label --
and a generic dedup cache (cached_proof_line / cache_proof_line). Proof no longer
contains any exact-path-specific logic or the NamedVertex-vector signature.

Byte-identical: 274/274 proofs unchanged; the supplemental / distance3 / staged
configs (which derive these) verify under VeriPB via the sweep and cake tests;
49/49 ctest release + ASan/UBSan; clang-format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…middle

Move the three distance-3 (G^3) adjacency derivations -- the direct-edge,
single-intermediate, and two-vertex-path variants -- out of Proof into
HomomorphismProofs (emit_distance3_graph_distance_1 / _distance_2 /
emit_distance3_graph). prove_distance3_graphs now finds the pattern path and
builds the d1/d2/d3-from-t target sets as plain index containers (set<int> gives
the same order as the old set<NamedVertex>, since the name is a function of the
index) instead of marshalling NamedVertex, and emits through the generic
primitives. These variants needed no new Proof API beyond what the exact-path
migration already added.

With this, every homomorphism-exclusive supplemental derivation lives in the
middle layer; Proof keeps only the cross-solver / model-driven proof methods
(create_adjacency_constraint is shared with MCS; the degree/NDS/Hall filter
proofs are model-driven).

Byte-identical: 274/274 proofs unchanged; the reachable distance-3 variants
(direct edge and the full two-vertex path) verify under VeriPB on dense
instances; 49/49 ctest release + ASan/UBSan; clang-format clean. (The
single-intermediate variant is unreachable while exact-path graphs are on -- a
distance-2 pair always has a 2-path, so exact-path covers it and distance3 skips
it -- so it is a faithful translation rather than separately exercised.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_adjacency_constraint and start_adjacency_constraints_for are shared with
the common-subgraph (MCS) solver, so they cannot simply move out of Proof. To get
the subgraph solver clean without disturbing the others, give the homomorphism
solver its own path -- HomomorphismProofs::emit_adjacency_constraint, which builds
the @adj OPB line and records it (with its permitted set) in the shared adjacency
cache via the new generic emit_model_constraint / emit_model_comment primitives --
and leave Proof::create_adjacency_constraint / start_adjacency_constraints_for in
place for MCS (and clique) to keep using.

emit_model no longer marshals NamedVertex for its adjacency constraints. The
homomorphism solver now drives all of its supplemental and adjacency proof
emission through the middle layer; the only Proof methods it still calls are the
generic model-building / searcher-logging API shared by every solver.

Byte-identical: 274/274 proofs unchanged; the MCS and clique proof tests
(proof_mcs, proof_mcs_connected, proof_mcs_clique, proof_clique) still pass via
the retained API; 49/49 ctest release + ASan/UBSan; clang-format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…middle

Move hack_in_shape_graph (the assert-rule adjacency line for an --shape extra
shape graph) out of Proof into HomomorphismProofs::emit_shape_graph;
prove_extra_shape now builds an index vector and emits through the generic
primitives. It is homomorphism-only, so nothing is left behind.

Byte-identical: 274/274 proofs unchanged; extra_shapes_test passes and an --shape
proof still emits and verifies; 49/49 ctest release + ASan/UBSan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ciaranm

ciaranm commented Jun 21, 2026

Copy link
Copy Markdown
Owner Author

@ArthurGontierPro Give this branch a go, in particular using the "--staged" command line option. It restructures some of how we do the preprocessing and seems to produce less noisy proofs.

Building on the subsumption elision (Phase 4) and transient ensure/forget
(Phase 5) economy, defer the kept-strongest supplemental adjacency derivations
until first use rather than emitting them all during model build. A supplemental
for antecedent (p,t) is needed only when x_p_t's value is decided by adjacency
during search -- assigned true (forward-checking use) or removed from dom(p) by
forward-checking (the branch-consequence a later Hall-set confinement cites) --
plus the root degree/NDS reads, so a head never touched in search is never
emitted. This is orthogonal to subsumption (which picks the strongest per head)
and composes with it.

The prove_*_graphs builders keep their analysis (the subsumption covered set and
kept-slot map) but register a pending closure per kept constraint instead of
emitting; materialise_adjacency_for runs them on first use, driven from the
searcher's guessing / unit_propagating (assignment) and propagate_adjacency_
constraints (per removed value, gated on has_pending_supplementals). The
transient ensure_supplemental_adjacency now materialises the kept constraint it
weakens from, since it may still be pending when a degree/NDS check reads it. The
OPB is byte-identical and the search tree is unchanged.

Mid-search materialisation scratches one level above the search (active_level+1,
since wiplvl N wipes every level >= N), emits the persistent @Label at level 0,
and restores the active level once per batch.

Verified: ctest 49/49 (release and sanitize, incl proof_*, cake_* and
random_proof_sweep); a 300-instance distance-3/locally-injective/counting sweep
all verify; OPB byte-identical. On top of subsumption it roughly halves the
supplemental count and PBP line count again (e.g. seed-31 distance-3: 279 -> 117
supplementals, 3479 -> 1423 lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ciaranm and others added 2 commits July 1, 2026 23:32
… middle

Route Proof::initial_domain_is_empty through HomomorphismProofs so the
domain-initialisation filter conclusions log via the middle layer rather than
calling the generic Proof directly. It is a comment-only line, so the six
callers in homomorphism_model.cc now go through _imp->proofs and Proof loses
the method -- a first step towards moving the remaining model filter proofs
(incompatible_by_degrees/_nds/_loops, the Hall set/violator) off the generic
bottom, per dev_docs/preprocessor-refactor.md Phase 3 Option 2.

Byte-identical: unstaged corpus (137 opb / 137 pbp) unchanged, 49/49 ctest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route Proof::emit_hall_set_or_violator through HomomorphismProofs. The middle
method takes pattern/target vertex indices (not pre-built NamedVertex vectors)
and builds the comment and the pol over each lhs vertex's at-least-one-value
constraint and each rhs target's injectivity constraint via the generic
primitives, so the homomorphism naming stays in the middle layer.

Both callers move across: the root global-degree Hall filter and the
post-initialisation nonshrinking check in homomorphism_model.cc, and the
search-time all-different filter in cheap_all_different.cc, which now reaches
the middle via model->proofs() (so its proof shared_ptr is only the on/off
switch and drops out of the templated worker). Adds the at-least-one-value
label accessor to Proof (the sibling of at-most-one-value); Proof loses
emit_hall_set_or_violator and its last NamedVertex-vector filter method.

Byte-identical: unstaged corpus (137 opb / 137 pbp) unchanged, 49/49 ctest
release + sanitize (the search-time Hall path is exercised by the 128-instance
proof_random_sweep, including counting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ciaranm and others added 2 commits July 1, 2026 23:44
… middle

Move the last homomorphism-specific filter derivations off the generic Proof:
incompatible_by_loops, incompatible_by_degrees and incompatible_by_nds (with
their need_elimination helper) now live in HomomorphismProofs, taking vertex
indices and emitting through the generic primitives over the shared adjacency
cache and the (local-)injectivity / at-least-one-value labels.

The eliminations map -- which records, per already-refuted (p,t), the line that
refuted it, for the NDS pigeonhole to cite -- moves into the middle with them.
It was homomorphism-only bookkeeping: the sole other writer,
Proof::create_forbidden_assignment_constraint, is called only by the
common-subgraph solver, which never runs an NDS check and so never reads the
map back -- that write was dead, and is dropped. A solve is either homomorphism
or common-subgraph, never both, so nothing shares the map across the split.

With this, every reader of the adjacency-line cache is in the middle layer
(the degree/NDS pigeonholes were the last bottom consumers), setting up the
eventual move of AdjacencyProofLines ownership out of Proof.

Byte-identical: unstaged corpus (137 opb / 137 pbp) unchanged, 49/49 ctest
release + sanitize (the nds, induced-unsat and loopy-supplemental configs
exercise all three; proof_mcs* covers the forbidden-assignment change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the AdjacencyProofLines struct and its instance out of the generic Proof
and into HomomorphismProofs, which now owns it outright: the middle's model
emission and supplemental / degree / NDS derivations read and write a private
_adjacency member instead of reaching through Proof::adjacency_proof_lines().

This is the payoff of the step-3 filter migrations: with every reader of the
cache now in the middle, the only remaining bottom writer was the
common-subgraph solver's create_adjacency_constraint, whose writes nothing in
the MCS path ever read (MCS runs no degree/NDS pigeonhole) -- so those writes
are dropped as dead, and Proof loses the struct, the member and the
adjacency_proof_lines() accessor. The generic Proof no longer carries any
homomorphism adjacency state.

Byte-identical: unstaged corpus (137 opb / 137 pbp) unchanged, 49/49 ctest
release + sanitize (proof_mcs* covers the create_adjacency_constraint change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant