Foundationপ্রথম নীতি থেকে
LEVEL 13অ্যাডভান্সড~১৬ ঘণ্টাPython

SAT Solver (DPLL)

SAT Solver (DPLL)

Unit propagation, pure literal elimination, আর backtracking দিয়ে নিজের হাতে একটা DPLL SAT solver — তারপর random 3-SAT-এ satisfiability threshold-এর কাছে benchmark করে দেখুন কেন 'easy-hard-easy' প্যাটার্নটা আজও গবেষণার বিষয়।

মাইলস্টোন

আগে যা পড়া দরকার

কেন এই প্রজেক্ট

আগের দুইটা লেসনে আপনি দেখেছেন SAT NP-complete — শুধু আরেকটা কঠিন সমস্যা না, বরং সেই “প্রথম ইট” যার উপর হাজার হাজার অন্য সমস্যার কাঠিন্য দাঁড়িয়ে (Cook-Levin theorem, reduction transitivity)। কিন্তু তত্ত্বটা জানা আর একটা solver নিজে হাতে বানানো সম্পূর্ণ ভিন্ন অভিজ্ঞতা — কারণ solver বানাতে গেলেই আপনি সরাসরি মুখোমুখি হবেন সেই জিনিসটার যা NP-completeness-এর সংজ্ঞাতেই আড়াল হয়ে থাকে: worst-case exponential হওয়া সত্ত্বেও, বাস্তব instance-এ কেন এত দ্রুত সমাধান পাওয়া যায়?

DPLL (Davis-Putnam-Logemann-Loveland) algorithm — ১৯৬২ সালের, আজও প্রতিটা আধুনিক SAT solver-এর কঙ্কাল — এই প্রশ্নের একটা ব্যবহারিক উত্তর। আপনি দেখবেন কীভাবে দুইটা সরল কৌশল (unit propagation, pure literal elimination) exponential search tree-র বিশাল অংশ কেটে ফেলে দিতে পারে, বিনা কোনো তাত্ত্বিক গ্যারান্টি লঙ্ঘন করে। আর শেষে, random 3-SAT instance-এ benchmark চালিয়ে নিজের চোখে দেখবেন সেই বিখ্যাত “easy-hard-easy” প্যাটার্ন — যেখানে সবচেয়ে কঠিন instance পাওয়া যায় ঠিক সেই বিন্দুতে যেখানে satisfiable আর unsatisfiable instance-এর সংখ্যা প্রায় সমান।

ধাপে ধাপে

১. CNF representation

Clause-দের একটা list হিসেবে রাখুন, প্রতিটা clause literal-দের একটা list (positive int = variable true, negative = variable false):

# (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ x2) ∧ (x3)
clauses = [[1, -2, 3], [-1, 2], [3]]

DIMACS format পার্স করুন (standard SAT competition input format):

def parse_dimacs(text: str) -> tuple[list[list[int]], int]:
    clauses, n_vars = [], 0
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("c"):
            continue
        if line.startswith("p"):
            _, _, nv, _ = line.split()
            n_vars = int(nv)
            continue
        lits = [int(x) for x in line.split() if x != "0"]
        if lits:
            clauses.append(lits)
    return clauses, n_vars

Checkpoint: একটা ছোট হাতে-লেখা DIMACS ফাইল পার্স করে print করুন, লাইন সংখ্যা আর clause সংখ্যা মিলছে কি না দেখুন।

২. Unit propagation

একটা clause-এ যদি ঠিক একটা literal বাকি থাকে (বাকিগুলো ইতিমধ্যে false হয়ে গেছে assignment-এর কারণে), সেই literal-কে বাধ্যতামূলকভাবে true সেট করতে হবে — নাহলে clause-টা unsatisfied হয়ে যাবে।

def unit_propagate(clauses, assignment):
    """assignment: {var: bool}। বদলানো clauses আর assignment ফেরত দেয়,
    conflict হলে None।"""
    changed = True
    while changed:
        changed = False
        for clause in clauses:
            unassigned = [l for l in clause if abs(l) not in assignment]
            if not unassigned:
                # সব literal assigned — satisfied কি না চেক
                if not any((assignment[abs(l)] if l > 0 else not assignment[abs(l)])
                           for l in clause):
                    return None  # conflict — empty/falsified clause
                continue
            if len(unassigned) == 1:
                lit = unassigned[0]
                assignment[abs(lit)] = (lit > 0)
                changed = True
    return assignment

কেন এটা এত গুরুত্বপূর্ণ: বাস্তব CNF formula-য় unit propagation একাই প্রায়ই assignment-এর বড় অংশ নির্ধারণ করে দেয় — কোনো branching (guess) ছাড়াই। এটাকে বলা হয় cascade — একটা variable সেট হলে কিছু clause এক-literal হয়ে যায়, তারা আরেকটা variable সেট করতে বাধ্য করে, চলতেই থাকে।

Checkpoint: একটা “chain” formula বানান যেখানে একটা variable সেট করলে ডমিনো-র মতো বাকি সবগুলো cascade হয়ে সেট হয়ে যায় (যেমন (x1) ∧ (¬x1 ∨ x2) ∧ (¬x2 ∨ x3) ∧ ...) — verify করুন পুরো assignment একটা মাত্র unit_propagate call-এই বের হয়ে যায়, কোনো branching ছাড়া।

৩. Pure literal elimination

যদি একটা variable x পুরো formula-তে সবসময় একই polarity-তে দেখা যায় (শুধু x, কখনো ¬x নয় — বা উল্টো), তাহলে সেটাকে সেই polarity-তে সেট করে দেওয়া নিরাপদ — কোনো clause এতে খারাপ হবে না (কারণ যে clause-গুলোতে এটা আছে, সবগুলোই এই মানে satisfied হয়ে যাবে; যেগুলোতে নেই, তাদের উপর কোনো প্রভাব নেই)।

def pure_literal_eliminate(clauses, assignment):
    counts = {}
    for clause in clauses:
        for lit in clause:
            if abs(lit) not in assignment:
                counts.setdefault(abs(lit), set()).add(lit > 0)
    for var, polarities in counts.items():
        if len(polarities) == 1:  # শুধু এক polarity-তেই দেখা যায়
            assignment[var] = polarities.pop()
    return assignment

Checkpoint: একটা formula বানান যেখানে x5 শুধু positive literal হিসেবে ৪টা clause-এ আছে — pure literal elimination-এর পরে x5 অবশ্যই True সেট হবে, আর সেই ৪টা clause সব satisfied বলে চিহ্নিত হবে, কোনো search ছাড়াই।

মূল recursive algorithm — unit propagation আর pure literal elimination দিয়ে যতটা সম্ভব সরলীকরণের পর, যদি এখনো অমীমাংসিত variable থাকে, একটা বেছে নিয়ে দুটো branch (true/false) চেষ্টা করুন:

def dpll(clauses, assignment):
    assignment = unit_propagate(clauses, dict(assignment))
    if assignment is None:
        return None  # conflict
    assignment = pure_literal_eliminate(clauses, assignment)

    unassigned = {abs(l) for c in clauses for l in c} - set(assignment)
    if not unassigned:
        return assignment  # সব variable set, কোনো conflict নেই — SAT!

    var = next(iter(unassigned))  # branching heuristic — এখানে সরল, পরে উন্নত করুন
    for value in (True, False):
        new_assignment = dict(assignment)
        new_assignment[var] = value
        result = dpll(clauses, new_assignment)
        if result is not None:
            return result
    return None  # দুটো branch-ই ব্যর্থ — UNSAT এই সাব-ট্রিতে


def solve(clauses):
    result = dpll(clauses, {})
    return result  # None = UNSAT, dict = satisfying assignment

লক্ষ্য করুন কাঠামোটা — এটা এই module-এর “NP-completeness ও Cook-Levin” লেসনের NTM-দৃষ্টির সাথে হুবহু মেলে: প্রতিটা branch একটা computation tree-র node, আর কোনো branch satisfying assignment-এ পৌঁছালেই আমরা “accept” করি। DPLL এই tree-টাকেই prune করে — পুরোটা explore না করেই বড় অংশ বাদ দেয়।

Checkpoint: একটা ছোট (৫-১০ variable) unsatisfiable formula বানিয়ে verify করুন solver None ফেরত দেয়, আর একটা satisfiable formula-য় ফেরত দেওয়া assignment সত্যিই সব clause satisfy করে (একটা independent checker লিখে যাচাই করুন — solver-কেই বিশ্বাস করবেন না)।

৫. Stretch — এক-স্তরের CDCL sketch

আধুনিক solver (MiniSat, CaDiCaL) শুধু backtrack করে না — একটা conflict-এ পৌঁছালে সেই conflict-এর কারণ বিশ্লেষণ করে একটা নতুন clause “শেখে” (learned clause), যা ভবিষ্যতে একই ভুল এড়াতে সাহায্য করে।

def dpll_with_learning(clauses, assignment, learned=None):
    if learned is None:
        learned = []
    all_clauses = clauses + learned

    assignment = unit_propagate(all_clauses, dict(assignment))
    if assignment is None:
        # সরলীকৃত conflict analysis — বর্তমান decision-গুলোর negation-ই
        # একটা বৈধ (যদিও দুর্বল) learned clause
        decided = [(-v if val else v) for v, val in assignment.items()] \
            if assignment else []
        return None, learned  # সম্পূর্ণ CDCL-এ এখানে একটা নতুন clause যোগ হতো

    unassigned = {abs(l) for c in all_clauses for l in c} - set(assignment)
    if not unassigned:
        return assignment, learned

    var = next(iter(unassigned))
    for value in (True, False):
        new_assignment = dict(assignment)
        new_assignment[var] = value
        result, learned = dpll_with_learning(clauses, new_assignment, learned)
        if result is not None:
            return result, learned
    return None, learned

এটা একটা সরলীকৃত sketch — প্রকৃত CDCL implication graph ব্যবহার করে সঠিক learned clause বের করে (1-UIP scheme) আর non-chronological backtracking করে। এখানে লক্ষ্য শুধু ধারণাটা অনুভব করা: conflict থেকে তথ্য বাঁচিয়ে রাখা, প্রতিবার শূন্য থেকে শুরু না করা।

Checkpoint: একটা formula বানান যেখানে একই “ভুল” branching সিদ্ধান্ত বারবার বিভিন্ন সাব-ট্রিতে আসে — plain DPLL কতবার একই conflict-এ পড়ে গণনা করুন, আর ব্যাখ্যা করুন কেন learning এটা কমাতে পারত (সম্পূর্ণ implement করা optional, তাত্ত্বিক ব্যাখ্যাই যথেষ্ট এই checkpoint-এ)।

৬. Random 3-SAT generator ও benchmark

import random

def random_3sat(n_vars, n_clauses, seed=None):
    rng = random.Random(seed)
    clauses = []
    for _ in range(n_clauses):
        vars_chosen = rng.sample(range(1, n_vars + 1), 3)
        clause = [v if rng.random() < 0.5 else -v for v in vars_chosen]
        clauses.append(clause)
    return clauses


import time

def benchmark(n_vars=20, ratios=None, trials=20):
    if ratios is None:
        ratios = [3.0, 3.5, 4.0, 4.2, 4.26, 4.3, 4.5, 5.0, 5.5, 6.0]
    print(f"{'ratio':>6} {'sat%':>6} {'avg_time(ms)':>14} {'max_time(ms)':>14}")
    for ratio in ratios:
        n_clauses = int(n_vars * ratio)
        times, sat_count = [], 0
        for t in range(trials):
            clauses = random_3sat(n_vars, n_clauses, seed=t)
            t0 = time.perf_counter()
            result = solve(clauses)
            dt = (time.perf_counter() - t0) * 1000
            times.append(dt)
            if result is not None:
                sat_count += 1
        print(f"{ratio:>6.2f} {100*sat_count/trials:>5.0f}% "
              f"{sum(times)/len(times):>14.2f} {max(times):>14.2f}")

n_vars = 20-এর কাছাকাছি চালান (বড় হলে plain DPLL অনেক সময় নেবে)।

নিজেকে চ্যালেঞ্জ করুন

  1. Branching heuristic উন্নত করুন — random variable বাছার বদলে “সবচেয়ে বেশি clause-এ আছে এমন variable” (DLIS-এর মতো heuristic) বাছুন, আর runtime-এর উন্নতি measure করুন
  2. সম্পূর্ণ CDCL — implication graph বানিয়ে সঠিক 1-UIP learned clause বের করুন, আর non-chronological backtracking (backjumping) implement করুন
  3. Clause database management — learned clause সংখ্যা বাড়তে থাকলে memory বাড়ে; একটা simple “activity-based” clause deletion policy লিখুন (কম-ব্যবহৃত learned clause সরিয়ে ফেলা)
  4. Watched literals — unit propagation-এর O(n) প্রতি clause-চেক এড়িয়ে, MiniSat-এর “two watched literals” কৌশল দিয়ে propagation দ্রুত করুন
  5. DIMACS competition benchmark — SATLIB বা SAT Competition-এর বাস্তব benchmark ফাইল (public archive) দিয়ে আপনার solver যাচাই করুন, একটা established solver-এর সাথে সময় তুলনা করুন
  6. Reduction demo — আগের লেসনের 3-SAT → Vertex Cover reduction ব্যবহার করে একটা Vertex Cover instance-কে আপনার SAT solver দিয়ে সমাধান করুন (reduce করে solve, তারপর ফলাফল ফিরিয়ে আনুন)

যা এখানে শিখলেন, পরে যেখানে লাগবে

এখানেপরে
CNF, unit propagation, backtrackingFormal methods module — model checker-এর ভেতরে ঠিক এই একই SAT/SMT ইঞ্জিন চলে
Search tree pruning (DPLL-এর মূল কৌশল)যেকোনো constraint satisfaction/backtracking algorithm — N-Queens থেকে Sudoku সমাধান পর্যন্ত
Conflict learning (CDCL sketch)Modern ILP/SMT solver-এর ভেতরের একই ধারণা — Gurobi, Z3
Reduction-চালিত সমস্যা-সমাধানPackage manager dependency resolution (apt, cargo) — আক্ষরিকভাবে SAT সমাধান করছে ভেতরে
“Easy-hard-easy” threshold observationRandom structure-এর phase transition — statistical physics-এর সাথে জটিলতা তত্ত্বের একটা বিখ্যাত সংযোগ