সরাসরি পর্বে যান
পর্ব ১৩ · ৮৩-এর মধ্যে · মডিউল ২

Program-এর সঠিকতা — loop invariant আর Hoare logic-এর ধারণা

Program Correctness — Specifications, Hoare Triples, Loop Invariants, Termination and a Real Verifier

Taught in Bangla, this lesson defines correctness against a specification, separates partial from total correctness, and teaches the five Hoare rules with backward assignment. It proves loop invariants for division and fast exponentiation, shows termination with a variant for Euclid's gcd, and catches a buggy invariant in in-page Python. A real desktop Z3 run proves verification conditions for swap and division, returns a counterexample for a weak invariant, and stalls on the nonlinear gcd step.

এই পর্বে যা শিখবেন

  • Precondition আর postcondition দিয়ে একটি প্রোগ্রামের কাজ নিখুঁতভাবে লেখা; partial আর total correctness-এর পার্থক্য
  • Hoare triple আর তার পাঁচটি নিয়ম; weakest precondition বের করা
  • Loop invariant দিয়ে ভাগ, অ্যারের যোগফল, দ্রুত ঘাত আর Euclid-এর gcd-এর পূর্ণ প্রমাণ
  • Variant আর well-ordering দিয়ে লুপ থামার প্রমাণ
  • Testing বনাম প্রমাণ, কোডে assert, আর Z3 দিয়ে verification condition যাচাই

১ · “চলছে” থেকে “ঠিক” — specification, partial আর total correctness

বাজারঘরের গুদামে প্রতিদিন a-টি অর্ডারের প্যাকেট আসে। একটি ডেলিভারি ভ্যানে ঠিক b-টি প্যাকেট ধরে। ম্যানেজার জানতে চান কয়টি ভ্যান পুরো ভরবে (q) আর কয়টি প্যাকেট পড়ে থাকবে (r). একজন প্রোগ্রামার দুই লাইনের একটি লুপ লিখলেন: যতক্ষণ বাকি প্যাকেট অন্তত b, ততক্ষণ আরেকটি ভ্যান ভরা হবে। কুড়িটি সংখ্যায় চালিয়ে দেখলেন উত্তর মিলছে। প্রশ্ন হলো, এটি কি সব a আর b-এর জন্য ঠিক? আর “ঠিক” কথাটির মানেই বা কী?

আগের পাঁচটি পর্বে প্রমাণ লেখা শিখেছেন: direct, contrapositive, contradiction (পর্ব ০৮), case আর counterexample (পর্ব ০৯), induction (পর্ব ১০), strong induction আর well-ordering (পর্ব ১১), recursive সংজ্ঞা আর structural induction (পর্ব ১২)। এই পর্বে সেই সব হাতিয়ার একটি নতুন ধরনের বস্তুর ওপর প্রয়োগ করব: প্রোগ্রাম. DSA কোর্সের পর্ব ৩ অ্যালগরিদমের দিক থেকে (insertion sort, binary search) এই কাজ দেখায়; এখানে আমরা তার নিচের যুক্তির কাঠামো দেখব, যাতে যেকোনো ছোট প্রোগ্রামের প্রমাণ নিজে লিখতে পারেন।

সংজ্ঞা ১.১

একটি প্রোগ্রাম S-এর specification দুটি বাক্য দিয়ে লেখা হয়। Precondition (পূর্বশর্ত) P: শুরুতে ইনপুট সম্পর্কে যা সত্য বলে ধরে নেওয়া হয়। Postcondition (উত্তরশর্ত) Q: শেষে আউটপুট সম্পর্কে যা সত্য হওয়ার কথা।

S, P আর Q-এর সাপেক্ষে partially correct, যদি P সত্য এমন যেকোনো অবস্থায় শুরু করে S থামলে শেষ অবস্থায় Q সত্য হয়। S totally correct, যদি এর সঙ্গে P সত্য এমন প্রতিটি অবস্থায় S অবশ্যই থামে।

ভ্যানের সমস্যায়: P = “a ≥ 0 এবং b > 0”, Q = “a = qb + r এবং 0 ≤ r < b”. খেয়াল করুন, Q-তে দুটি অংশই লাগে। শুধু a = qb + r লিখলে q = 0, r = a-ও Q মানত, যা কোনো কাজের উত্তর নয়। আর P-তে b > 0 না থাকলে কী হয়, তা §৬-এ দেখবেন: b = 0 হলে লুপ কখনো থামে না। Specification লেখাই প্রায়ই সবচেয়ে কঠিন অংশ, আর বাস্তবের অনেক বাগ আসলে specification-এর ভুল।

দর্জির দোকানে অর্ডার দেওয়ার কথা ভাবুন। “আমার মাপ বুক ৩৮, লম্বা ২৮” হলো precondition; “পাঞ্জাবি গায়ে ঠিকমতো লাগবে” হলো postcondition. দর্জি যদি কোনোদিন ডেলিভারিই না দেন, তবু তিনি “ভুল মাপের পাঞ্জাবি দেননি”: এটি partial correctness-এর মতো, থামলে ঠিক। আপনি চান total correctness: ঈদের আগে ডেলিভারি হবে, এবং মাপ ঠিক হবে। আর আপনি যদি ভুল মাপ বলে আসেন (precondition ভাঙা), দর্জির কাছে ঠিক পাঞ্জাবি দাবি করা যায় না।

Partial correctness অদ্ভুত শোনাতে পারে: যে প্রোগ্রাম কখনো থামে না, সে যেকোনো Q-এর সাপেক্ষে partially correct, কারণ “থামলে Q” বাক্যটির শর্ত মিথ্যা; পর্ব ০২-এর vacuous truth. তবু দুটি আলাদা রাখার কারণ আছে: দুটি প্রমাণের কৌশল আলাদা। Q-এর প্রমাণ আসে invariant থেকে (§৪), থামার প্রমাণ আসে একটি কমতে থাকা সংখ্যা থেকে (§৬)। আলাদা আলাদা করলে প্রতিটি অংশ ছোট হয়।

একটি while লুপের প্রমাণের মানচিত্র

ভ্যানের লুপ: amber বাক্স invariant, violet বাক্স variant

Proof map of a while loop {P}: a ≥ 0, b > 0 q := 0; r := a I: a = qb + r, r ≥ 0 B: r ≥ b ? q+1, r−b I ∧ r < b {Q}: a = qb + r, 0 ≤ r < b variant r ≥ 0 প্রতি ঘোরায় কমে {I ∧ B} S {I} হ্যাঁ না
ছবি ১.১ — তিনটি তীর তিনটি প্রমাণের দায়: শুরুর কোড শেষে I সত্য (initialization), সবুজ body এক ঘোরায় I রক্ষা করে (preservation), আর “না” তীরে I ∧ ¬B থেকে Q আসে (exit). Variant নিশ্চিত করে “না” তীরে একদিন পৌঁছানো যাবেই।

২ · Hoare triple আর assignment-এর নিয়ম — পেছন থেকে ভাবা

১৯৬৯ সালে Tony Hoare একটি ছোট সংকেত প্রস্তাব করেন, যা আজ তাঁর নামেই পরিচিত। একটি Hoare triple লেখা হয় {P} S {Q}: “P সত্য অবস্থায় S শুরু করলে, আর S থামলে, শেষে Q সত্য।” অর্থাৎ Hoare triple মানেই partial correctness. বাক্যগুলো (P, Q) পর্ব ০৪-এর predicate: প্রোগ্রামের variable-গুলোর ওপর শর্ত। আর {P} S {Q} প্রমাণের নিয়মগুলোর সংকলনকে বলে Hoare logic. নিয়মগুলো পর্ব ০৬-এর inference rule-এর মতোই লেখা হয়: দাগের ওপরে যা জানা, নিচে যা পাওয়া যায়।

সবচেয়ে ছোট নিয়ম: assignment

x := e মানে “e-এর মান হিসাব করে x-এ রাখা” (Python-এ x = e). প্রশ্ন: এর পরে Q সত্য হতে হলে আগে কী সত্য থাকতে হবে? উত্তর আশ্চর্য রকম সরল: Q-তে যেখানে যেখানে x আছে, সেখানে e বসিয়ে যা পাওয়া যায়। এই বাক্যকে লেখা হয় Q[e/x] (“Q, যেখানে x-এর জায়গায় e”).

নিয়ম ২.১ (assignment axiom)

{Q[e/x]} x := e {Q}

এটি একটি axiom: কোনো পূর্বশর্ত লাগে না। উদাহরণ: {x + 1 > 5} x := x + 1 {x > 5}, অর্থাৎ {x > 4} x := x + 1 {x > 5}.

কেন পেছন থেকে? কারণ assignment-এর পরে x-এর নতুন মান ঠিক e-এর পুরোনো মান। তাই “পরে x সম্পর্কে Q” আর “আগে e সম্পর্কে Q” একই কথা। সামনে থেকে ভাবতে গেলে বিপদ: {x = 5} x := x + 1 {?}-এর উত্তর “x = 5 আর x = x + 1” লিখলে ভুল, কারণ দুই x আলাদা সময়ের। সামনে থেকে সঠিক নিয়মও আছে (পুরোনো মানের জন্য একটি নতুন নাম লাগে), কিন্তু পেছনের নিয়মটি অনেক পরিষ্কার।

জোড়া লাগানো আর শর্ত শিথিল করা

নিয়ম ২.২ (composition)

{P} S1 {R} আর {R} S2 {Q} থেকে পাওয়া যায় {P} S1; S2 {Q}. মাঝের R হলো দুই অংশের “চুক্তি”। এটি composition rule.

নিয়ম ২.৩ (consequence)

P ⇒ P′, {P′} S {Q′} আর Q′ ⇒ Q থেকে পাওয়া যায় {P} S {Q}. অর্থাৎ পূর্বশর্ত শক্ত করা আর উত্তরশর্ত দুর্বল করা সবসময় বৈধ। এটি consequence rule; এখানেই সাধারণ গণিত (বীজগণিত, অসমতা) প্রোগ্রামের প্রমাণে ঢোকে।

এই তিনটি নিয়ম দিয়ে যেকোনো assignment-এর ক্রমের প্রমাণ একটি যান্ত্রিক কাজ: শেষ থেকে শুরু করে প্রতিটি assignment পেরোনোর সময় substitution, আর শুরুতে পৌঁছে যে বাক্য পাওয়া যায় তাকে বলে weakest precondition, wp(S, Q): সবচেয়ে দুর্বল শর্ত, যা S-এর পরে Q নিশ্চিত করে। শেষে দেখাতে হয় P ⇒ wp(S, Q); এই গাণিতিক বাক্যটিকে বলে verification condition (VC). প্রোগ্রাম অদৃশ্য হয়ে গেল, রইল শুধু একটি সাধারণ গাণিতিক দাবি, যা আগের পর্বগুলোর পদ্ধতিতে প্রমাণ করা যায়।

উপপাদ্য ২.৪

{x = A ∧ y = B} t := x; x := y; y := t {x = B ∧ y = A}. অর্থাৎ অস্থায়ী variable t দিয়ে অদলবদল (swap) সঠিক। এখানে A আর B “ভূতুড়ে” variable: প্রোগ্রাম এদের ছোঁয় না, শুধু শুরুর মান মনে রাখে।

1{x = A ∧ y = B}2t := x3x := y4y := t5{x = B ∧ y = A}
নমুনা: A = 5, B = 8
এখানে যা সত্য হতে হবে
নিয়ম
নমুনায় এই জায়গার মান
  1. শেষ থেকে শুরু। লক্ষ্য Q = (x = B ∧ y = A). নমুনায় প্রোগ্রাম শেষে x = 8, y = 5, আর A = 5, B = 8, তাই Q সত্য। এবার প্রতিটি লাইন উল্টো দিকে পেরোব।
  2. লাইন ৪ পেরোনো। y := t-এর assignment axiom: Q-তে y-এর জায়গায় t বসাই, পাই x = B ∧ t = A. এটি লাইন ৪-এর ঠিক আগে সত্য হলেই Q নিশ্চিত। নমুনায় লাইন ৪-এর আগে x = 8, t = 5: মিলছে।
  3. লাইন ৩ পেরোনো। x := y: x-এর জায়গায় y বসিয়ে পাই y = B ∧ t = A. নমুনায় লাইন ৩-এর আগে y = 8, t = 5.
  4. লাইন ২ পেরোনো। t := x: t-এর জায়গায় x বসিয়ে পাই y = B ∧ x = A. এটিই wp(S, Q). Composition নিয়মে তিনটি ধাপ জোড়া লাগল।
  5. Verification condition. দেখাতে হবে P ⇒ wp, অর্থাৎ (x = A ∧ y = B) ⇒ (y = B ∧ x = A). এটি ∧-এর বিনিময় নিয়মে সত্য (পর্ব ০৩)। Consequence নিয়মে {P} S {Q} প্রমাণিত। ∎

এখন একটি ভুল ক্রম ভাবুন: x := y; t := x; y := t. পেছন থেকে গেলে wp দাঁড়ায় y = B ∧ y = A, যা শুধু A = B হলে সত্য; তাই VC মিথ্যা, আর প্রোগ্রামটি ভুল। নিচের ব্লকে একটি ছোট VC generator আছে: এটি assignment-এর তালিকা থেকে পেছন দিকে substitution করে wp ছাপে, তারপর ছোট একটি ক্ষেত্রে প্রতিটি অবস্থায় P ⇒ wp brute force-এ পরীক্ষা করে। প্রথমবার চালালে ব্রাউজারে Python (Pyodide) নামতে কয়েক সেকেন্ড লাগে।

import re
from itertools import product

def subst(cond, var, expr):            # Q[expr/var]: every whole-word var -> (expr)
    return re.sub(rf"\b{var}\b", f"({expr})", cond)

def wp(prog, post):                    # assignments, read from the last one back
    for var, expr in reversed(prog):
        post = subst(post, var, expr)
        print(f"   wp before {var} = {expr:<7}:  {post}")
    return post

def check(title, pre, prog, post, names, dom=range(-3, 4)):
    print(title)
    w = wp(prog, post)
    states = [dict(zip(names, v)) for v in product(dom, repeat=len(names))]
    bad = next((s for s in states if eval(pre, {}, s) and not eval(w, {}, s)), None)
    print(f"   pre => wp on {len(states)} states:", "valid" if bad is None else f"FAILS at {bad}")

check("1) swap with t", "x == A and y == B",
      [("t", "x"), ("x", "y"), ("y", "t")], "x == B and y == A", "xytAB")
check("2) wrong order", "x == A and y == B",
      [("x", "y"), ("t", "x"), ("y", "t")], "x == B and y == A", "xytAB")
check("3) pre x > 6", "x > 6", [("x", "2*x + 1"), ("y", "x - 3")], "y > 10", "xy",
      dom=range(-50, 51))
check("4) pre x >= 6", "x >= 6", [("x", "2*x + 1"), ("y", "x - 3")], "y > 10", "xy",
      dom=range(-50, 51))

আসল আউটপুট (ব্রাউজারের Python):

1) swap with t
   wp before y = t      :  x == B and (t) == A
   wp before x = y      :  (y) == B and (t) == A
   wp before t = x      :  (y) == B and ((x)) == A
   pre => wp on 16807 states: valid
2) wrong order
   wp before y = t      :  x == B and (t) == A
   wp before t = x      :  x == B and ((x)) == A
   wp before x = y      :  (y) == B and (((y))) == A
   pre => wp on 16807 states: FAILS at {'x': -3, 'y': -2, 't': -3, 'A': -3, 'B': -2}
3) pre x > 6
   wp before y = x - 3  :  (x - 3) > 10
   wp before x = 2*x + 1:  ((2*x + 1) - 3) > 10
   pre => wp on 10201 states: valid
4) pre x >= 6
   wp before y = x - 3  :  (x - 3) > 10
   wp before x = 2*x + 1:  ((2*x + 1) - 3) > 10
   pre => wp on 10201 states: FAILS at {'x': 6, 'y': -50}

প্রথম অংশ stepper-এর তিনটি substitution হুবহু ছাপল, আর 75 = 16807টি অবস্থায় VC সত্য। দ্বিতীয় অংশে wp দাঁড়াল “y == B and y == A”; A = −3, B = −2-এ এটি মিথ্যা, তাই ভুল ক্রমটি ধরা পড়ল। তৃতীয় আর চতুর্থ অংশে wp = (2x + 1) − 3 > 10, অর্থাৎ 2x > 12, অর্থাৎ x > 6. তাই x > 6 পূর্বশর্ত হিসেবে যথেষ্ট, কিন্তু x ≥ 6 নয়: x = 6-এ y = 10, যা 10-এর বড় নয়। একটি সতর্কতা: এই brute force শুধু ছোট একটি পরিসর দেখে, তাই এটি “সম্ভবত ঠিক” বলে, প্রমাণ নয়। প্রমাণ হলো বীজগণিতের ওই এক লাইন; আর §৭-এ একটি সত্যিকারের solver সব পূর্ণসংখ্যার জন্য একই কাজ করবে।

৩ · শর্ত আর লুপের নিয়ম — if আর while

প্রোগ্রামে শুধু assignment থাকে না; থাকে if আর while. দুটির জন্য দুটি নিয়ম, আর এই পাঁচটি নিয়মই একটি ছোট প্রোগ্রামিং ভাষার পুরো Hoare logic.

নিয়ম ৩.১ (conditional)

{P ∧ B} S1 {Q} আর {P ∧ ¬B} S2 {Q} থেকে পাওয়া যায় {P} if B then S1 else S2 {Q}. এটি conditional rule: পর্ব ০৯-এর proof by cases, শুধু ক্ষেত্র দুটি প্রোগ্রাম নিজেই ঠিক করে দেয়।

নিয়ম ৩.২ (while)

{I ∧ B} S {I} থেকে পাওয়া যায় {I} while B do S {I ∧ ¬B}. এটি while rule; I হলো loop invariant.

Conditional-এর একটি সম্পূর্ণ প্রমাণ

বাজারঘরে একজন ক্রেতার কাছে দুটি কুপন, ছাড় c1 আর c2 টাকা। নিয়ম: বড়টি দেওয়া হবে। প্রোগ্রাম: if c1 ≥ c2 then m := c1 else m := c2. Specification: P = true (কোনো শর্ত নেই), Q = (m ≥ c1 ∧ m ≥ c2 ∧ (m = c1 ∨ m = c2)). শেষ অংশটি না থাকলে m = ১০ লাখ টাকাও Q মানত; §৮-এ এই ধরনের “দুর্বল specification”-এর বিপদ দেখবেন।

প্রমাণ:

  1. প্রথম শাখা: assignment axiom অনুযায়ী wp(m := c1, Q) = (c1 ≥ c1 ∧ c1 ≥ c2 ∧ (c1 = c1 ∨ c1 = c2)), যা সরল করলে c1 ≥ c2. — assignment axiom, সরলীকরণ
  2. এই শাখায় P ∧ B = (c1 ≥ c2), যা ঠিক wp-এর সমান; তাই {P ∧ B} m := c1 {Q}. — consequence
  3. দ্বিতীয় শাখা: wp(m := c2, Q) সরল করলে c2 ≥ c1. — assignment axiom
  4. এই শাখায় P ∧ ¬B = (c1 < c2), যা থেকে c2 ≥ c1 আসে; তাই {P ∧ ¬B} m := c2 {Q}. — consequence
  5. দুই শাখা মিলিয়ে {P} if … {Q}. ∎ — conditional rule

While rule কেন সঠিক: এটি আসলে induction

While rule-এর মধ্যে কোনো জাদু নেই। ধরুন I শুরুতে সত্য, আর প্রতিটি ঘোরা (B সত্য থাকা অবস্থায় S একবার) I রক্ষা করে। দাবি: k-টি ঘোরার পরে I সত্য, প্রতিটি স্বাভাবিক সংখ্যা k-এর জন্য। এটি পর্ব ১০ “Mathematical induction — এক ধাপ থেকে অসীম পর্যন্ত”-এর হুবহু কাঠামো: basis k = 0 (শুরুতে I), inductive step k থেকে k + 1 ({I ∧ B} S {I})। লুপ থামে ঠিক তখন, যখন B মিথ্যা হয়; তখনও I সত্য, তাই শেষে I ∧ ¬B. invariant হলো সেই induction-এর hypothesis, আর ঘোরার সংখ্যা হলো k.

While rule শুধু partial correctness দেয়: “যদি থামে, তবে I ∧ ¬B”. while true do skip (কিছু না করে চিরকাল ঘোরা) লুপের জন্য I = true নিলে নিয়মটি বলে {true} while true do skip {true ∧ false}, অর্থাৎ শেষে “মিথ্যা” সত্য! কোনো বিরোধ নেই: লুপ কখনো থামে না, তাই “থামলে” বাক্যটি vacuously সত্য। থামার প্রমাণ আলাদা লাগে (§৬)।

৪ · Loop invariant — ভ্যানের ভাগ আর দিনের মোট বিক্রি

While rule একটি প্রশ্ন রেখে যায়: I কোথা থেকে আসবে? নিয়ম নিজে বলে না; invariant খুঁজে বের করা মানুষের কাজ, আর এটিই প্রোগ্রামের প্রমাণের সৃজনশীল অংশ। একবার I পেলে বাকিটা প্রায় যান্ত্রিক: তিনটি দায় (ছবি ১.১)।

loop invariant দিয়ে প্রমাণের তিন দায়

(১) Initialization: লুপের আগের কোড শেষে I সত্য। (২) Preservation: {I ∧ B} body {I}. (৩) Exit: I ∧ ¬B ⇒ Q. তিনটি মিলে partial correctness; সঙ্গে termination (§৬) মিলে total correctness.

প্রমাণ খুঁজে পাওয়া: আগে খসড়া

ভ্যানের লুপটি a = 17, b = 5-এ হাতে চালাই: (q, r) = (0, 17), (1, 12), (2, 7), (3, 2). প্রতিটি সারিতে 5q + r হিসাব করুন: 17, 17, 17, 17. বদলাচ্ছে q আর r, কিন্তু 5q + r বদলায় না। এটিই invariant-এর গন্ধ: যে রাশি লুপ জুড়ে স্থির থাকে। শেষ অবস্থায় r = 2 < 5, আর 17 = 5 · 3 + 2, ঠিক Q. তাই প্রথম অনুমান I = (a = qb + r). §৭-এ দেখবেন, এই অনুমান একটু দুর্বল; Q-এর 0 ≤ r অংশ পেতে r ≥ 0-ও I-তে রাখতে হয়।

উপপাদ্য ৪.১ (ভাগের লুপ)

প্রোগ্রাম DIV: q := 0; r := a; while r ≥ b do (q := q + 1; r := r − b). তাহলে DIV, P = (a ≥ 0 ∧ b > 0) আর Q = (a = qb + r ∧ 0 ≤ r < b)-এর সাপেক্ষে totally correct; এখানে a, b, q, r পূর্ণসংখ্যা।

প্রমাণ: invariant I = (a = qb + r ∧ r ≥ 0).

  1. Initialization: q := 0; r := a-এর পরে I-এর জন্য wp হলো a = 0 · b + a ∧ a ≥ 0, অর্থাৎ a ≥ 0, যা P থেকে আসে। — assignment axiom, composition, consequence
  2. Preservation: ধরি I আর B (r ≥ b) সত্য। Body-র পরে নতুন মান q′ = q + 1, r′ = r − b. তাহলে q′b + r′ = qb + b + r − b = qb + r = a. — I-এর প্রথম অংশ, বীজগণিত
  3. আর r′ = r − b ≥ 0, কারণ r ≥ b. তাই body-র পরে আবার I. — B
  4. While rule অনুযায়ী লুপ থামলে I ∧ ¬(r ≥ b), অর্থাৎ a = qb + r ∧ r ≥ 0 ∧ r < b. — while rule
  5. Exit: এটি হুবহু Q. — consequence
  6. Termination: r ≥ 0 সবসময় (I থেকে), আর প্রতি ঘোরায় r অন্তত 1 কমে, কারণ b ≥ 1. অঋণাত্মক পূর্ণসংখ্যা চিরকাল কমতে পারে না (well-ordering, §৬)। তাই লুপ থামে, সর্বোচ্চ a-টি ঘোরায়। ∎

লক্ষ করুন b > 0 কোথায় লেগেছে: শুধু termination-এ। a ≥ 0 লেগেছে initialization-এ। Precondition-এর প্রতিটি অংশ প্রমাণের কোনো না কোনো ধাপে ব্যবহার হওয়া উচিত; কোনো অংশ কোথাও না লাগলে হয় সেটি অপ্রয়োজনীয়, নয়তো প্রমাণে কিছু বাদ পড়েছে।

দ্বিতীয় উদাহরণ: দিনের মোট বিক্রি

বাজারঘরের দিনের n-টি অর্ডারের টাকা একটি অ্যারে A[0], …, A[n − 1]-এ আছে। প্রোগ্রাম: s := 0; i := 0; while i < n do (s := s + A[i]; i := i + 1). P = (n ≥ 0), Q = (s = A[0] + ⋯ + A[n − 1]). Invariant খোঁজার একটি কার্যকর কৌশল: Q-এর একটি ধ্রুবককে লুপের variable দিয়ে বদলান। Q-তে n-এর জায়গায় i বসালে পাই I = (s = A[0] + ⋯ + A[i − 1] ∧ 0 ≤ i ≤ n); i = 0 হলে যোগফল ফাঁকা, মান 0.

  1. Initialization: s = 0, i = 0: ফাঁকা যোগফল 0, আর 0 ≤ 0 ≤ n কারণ n ≥ 0. — P
  2. Preservation: I আর i < n ধরে s′ = s + A[i] = A[0] + ⋯ + A[i], i′ = i + 1, তাই s′ = A[0] + ⋯ + A[i′ − 1]; আর i < n থেকে i′ ≤ n. — বীজগণিত
  3. Exit: I ∧ ¬(i < n) থেকে i ≤ n আর i ≥ n, তাই i = n, আর s = A[0] + ⋯ + A[n − 1] = Q. — consequence
  4. Termination: variant n − i: I থেকে ≥ 0, প্রতি ঘোরায় ঠিক 1 কমে। ∎

ধাপ ৩-এ 0 ≤ i ≤ n অংশটি অপরিহার্য: শুধু s-এর অংশ থাকলে “i ≥ n” থেকে i = n বের করা যেত না। Invariant-এ প্রায়ই এমন “সীমার” অংশ লাগে, যা মূল দাবির মতো আকর্ষণীয় নয় কিন্তু exit ধাপটি খাটায়।

প্রমাণকে কোডের পাহারাদার বানানো

Invariant জানা থাকলে তাকে কোডের ভেতরে assertion হিসেবে বসানো যায়: Python-এর assert শর্ত শর্তটি মিথ্যা হলে AssertionError তোলে। প্রতিটি ঘোরায় invariant পরীক্ষা হলে একটি ভুল যেখানে ঘটে, ঠিক সেখানেই ধরা পড়ে, চূড়ান্ত উত্তর ভুল হওয়া পর্যন্ত অপেক্ষা করতে হয় না। নিচের ব্লক ভাগের লুপ আর §৫-এর দ্রুত ঘাতের লুপকে হাজার হাজার এলোমেলো ইনপুটে invariant-সহ চালায়, তারপর দ্রুত ঘাতের একটি ভুল সংস্করণ (b-কে এক লাইন আগে বর্গ করা) চালিয়ে দেখায় invariant কীভাবে বাগ ধরে।

import random
random.seed(13)

def divide(a, b):                      # pre: a >= 0, b > 0
    q, r = 0, a
    while r >= b:
        assert a == q * b + r and r >= 0          # invariant I
        q, r = q + 1, r - b
    assert a == q * b + r and 0 <= r < b          # post Q
    return q, r

def power(a, n, buggy=False):          # pre: n >= 0
    r, b, e = 1, a, n
    while e > 0:
        assert r * b ** e == a ** n, (a, n, r, b, e)   # invariant I
        if buggy:
            b = b * b                  # bug: b squared too early
        if e % 2 == 1:
            r = r * b
        if not buggy:
            b = b * b
        e = e // 2
    return r

cases = [(random.randint(0, 10**5), random.randint(1, 999)) for _ in range(3000)]
print("divide:", all(divide(a, b) == divmod(a, b) for a, b in cases), "on", len(cases), "inputs")
pairs = [(random.randint(-9, 9), random.randint(0, 60)) for _ in range(3000)]
print("power :", all(power(a, n) == a ** n for a, n in pairs), "on", len(pairs), "inputs")
for a, n in [(2, 0), (2, 1), (3, 13)]:
    try:
        print(f"buggy power({a}, {n}) =", power(a, n, buggy=True))
    except AssertionError as err:
        print(f"buggy power({a}, {n}): invariant broken at (a, n, r, b, e) =", err)

আসল আউটপুট (ব্রাউজারের Python):

divide: True on 3000 inputs
power : True on 3000 inputs
buggy power(2, 0) = 1
buggy power(2, 1) = 4
buggy power(3, 13): invariant broken at (a, n, r, b, e) = (3, 13, 9, 9, 6)

সঠিক দুটি ফাংশন প্রতিটি ঘোরায় invariant আর শেষে উত্তর মেলায়, তিন হাজার করে ইনপুটে একবারও assertion ভাঙেনি। ভুল সংস্করণের গল্প বেশি শিক্ষণীয়। n = 0-এ লুপ একবারও ঘোরে না, উত্তর 1, ঠিক। n = 1-এ এক ঘোরায় ভুল উত্তর 4 (21 = 2 হওয়ার কথা), অথচ কোনো assertion ভাঙেনি: invariant পরীক্ষা হয় ঘোরার শুরুতে, আর এক ঘোরার পরেই লুপ শেষ; শেষে উত্তর পরীক্ষার assert r == a ** n এই ফাংশনে নেই। n = 13-এ প্রথম ঘোরার পরেই (r, b, e) = (9, 9, 6) দাঁড়ায়, আর 9 · 96 = 97 ≠ 313, তাই দ্বিতীয় ঘোরার শুরুতে invariant বাগটি ঠিক জায়গায় ধরল। শিক্ষা দুটি: invariant আর postcondition দুটোই assert করুন; আর এলোমেলো পরীক্ষা যতই হোক, এটি প্রমাণ নয়, শুধু প্রমাণের দাবিকে বারবার যাচাই করার সস্তা উপায়।

৫ · দ্রুত ঘাত — 313 চার ধাপে

an হিসাব করার সরল পথ n − 1টি গুণ। কিন্তু 313-এর জন্য বারোটি গুণ লাগে না। 13-এর binary রূপ 1101, অর্থাৎ 13 = 8 + 4 + 1, তাই 313 = 38 · 34 · 31; আর 3, 32, 34, 38 পরপর বর্গ করে পাওয়া যায়। এই কৌশলের নাম binary exponentiation (দ্রুত ঘাত)। বড় সংখ্যার modular ঘাতে, যেমন RSA-তে, এটি অপরিহার্য; সেই প্রয়োগ পর্ব ২২ “Modular arithmetic — ঘড়ির গণিত আর modular exponentiation”-এ। এখানে প্রশ্ন শুধু: লুপটি কেন ঠিক?

উপপাদ্য ৫.১

প্রোগ্রাম POW: r := 1; b := a; e := n; while e > 0 do (if e বিজোড় then r := r · b; b := b · b; e := ⌊e/2⌋). P = (n ≥ 0) হলে POW totally correct, Q = (r = an).

খসড়া: invariant খুঁজতে জিজ্ঞেস করুন, কোন রাশি স্থির থাকে? r বাড়ে, b বাড়ে, e কমে। “যা হিসাব করা হয়ে গেছে” (r) আর “যা বাকি” (be) মিলে সবসময় পুরো উত্তর: I = (r · be = an ∧ e ≥ 0).

প্রমাণ:

  1. Initialization: r = 1, b = a, e = n: 1 · an = an, আর e = n ≥ 0. — P
  2. Preservation, e জোড়: e = 2k ধরি (k ≥ 1). নতুন মান r′ = r, b′ = b², e′ = k. তাহলে r′ · b′e′ = r · (b²)k = r · b2k = r · be = an. — ঘাতের নিয়ম, I
  3. Preservation, e বিজোড়: e = 2k + 1 (k ≥ 0). নতুন মান r′ = r · b, b′ = b², e′ = ⌊(2k + 1)/2⌋ = k. তাহলে r′ · b′e′ = r · b · b2k = r · b2k+1 = an. দুই ক্ষেত্রেই e′ ≥ 0. — proof by cases (পর্ব ০৯)
  4. Exit: I ∧ ¬(e > 0) থেকে e ≥ 0 আর e ≤ 0, তাই e = 0, আর r · b0 = r = an. — consequence
  5. Termination: variant e. e ≥ 1 হলে ⌊e/2⌋ < e, আর e ≥ 0 সবসময়। ∎

কতবার ঘোরে? প্রতি ঘোরায় e-এর binary রূপের শেষ অঙ্কটি মুছে যায়, তাই n ≥ 1 হলে ঠিক ⌊log2 n⌋ + 1 বার, অর্থাৎ n-এর binary অঙ্কের সংখ্যা। n = 13-এ 4 বার, n = 1018-এও মাত্র 60 বার।

1r, b, e = 1, 3, 132while e > 0:3  if e % 2 == 1: r = r*b4  b = b * b5  e = e // 26# e = 0: r = 3^13
invariant: r · b^e = 3^13
r
b
e (binary)
r · b^e
  1. শুরু। r = 1, b = 3, e = 13 (binary 1101). Invariant: 1 · 313 = 1594323 = 313, সত্য। এটি initialization-এর দায়।
  2. ঘোরা ১। e = 13 বিজোড়, তাই r = 1 · 3 = 3; তারপর b = 9, e = 6. Binary-র শেষ অঙ্ক 1 মুছে গেল। Invariant: 3 · 96 = 3 · 531441 = 1594323, টিকে আছে।
  3. ঘোরা ২। e = 6 জোড়, r বদলায় না; b = 81, e = 3. Invariant: 3 · 813 = 3 · 531441 = 1594323. জোড় ক্ষেত্রে শুধু “বাকি অংশ” be নতুন রূপ নিল, মান একই।
  4. ঘোরা ৩। e = 3 বিজোড়: r = 3 · 81 = 243 = 35; b = 6561 = 38, e = 1. Invariant: 243 · 6561 = 35 · 38 = 1594323.
  5. ঘোরা ৪। e = 1 বিজোড়: r = 243 · 6561 = 1594323; b = 43046721 (এটি আর লাগবে না), e = 0. Invariant: 1594323 · b0 = 1594323. Variant e: 13, 6, 3, 1, 0, প্রতিবার কমেছে।
  6. Exit. e > 0 মিথ্যা, তাই লুপ শেষ। I ∧ ¬B থেকে e = 0, আর r = r · b0 = 313 = 1594323. চারটি ঘোরা, 13-এর চারটি binary অঙ্কের সমান। ∎

৬ · লুপ থামবে তো? — variant আর Euclid-এর gcd

Partial correctness প্রমাণ হয়ে গেলেও একটি প্রশ্ন বাকি: termination, অর্থাৎ লুপ কি সত্যিই থামবে? এর সাধারণ হাতিয়ার একটি variant function: প্রোগ্রামের variable থেকে হিসাব করা একটি পূর্ণসংখ্যা V, যা

  • লুপ চলাকালীন কখনো ঋণাত্মক হয় না (I ∧ B ⇒ V ≥ 0), এবং
  • প্রতিটি ঘোরায় কঠোরভাবে কমে (body-র পরের V < আগের V).
উপপাদ্য ৬.১

উপরের দুই শর্ত মানে এমন একটি variant থাকলে লুপ সসীম সংখ্যক ঘোরায় থামে; শুরুর মান V0 হলে সর্বোচ্চ V0 + 1 বার ঘোরে।

প্রমাণ (contradiction):

  1. ধরি লুপ কখনো থামে না; তাহলে প্রতিটি k-এর জন্য k-তম ঘোরা আছে, আর V-এর মানের অসীম ক্রম V0 > V1 > V2 > ⋯ পাই। — দ্বিতীয় শর্ত
  2. প্রতিটি Vk ≥ 0, তাই সেট {V0, V1, …} স্বাভাবিক সংখ্যার একটি অশূন্য উপসেট। — প্রথম শর্ত
  3. Well-ordering principle অনুযায়ী এর একটি ক্ষুদ্রতম উপাদান Vm আছে। — well-ordering (পর্ব ১১)
  4. কিন্তু Vm+1 < Vm, আর Vm+1-ও সেটে আছে; ক্ষুদ্রতমের চেয়েও ছোট উপাদান, বিরোধ। ∎

এটি পর্ব ১১ “Strong induction আর well-ordering”-এর minimal counterexample কৌশলেরই আরেক রূপ। আর সীমাটি সহজ: পূর্ণসংখ্যা প্রতিবার অন্তত 1 কমলে V0-এর পরে বড়জোর V0 ধাপে 0-তে পৌঁছায়।

Euclid-এর gcd: invariant আর variant একসঙ্গে

দুটি অঋণাত্মক পূর্ণসংখ্যার gcd (গরিষ্ঠ সাধারণ গুণনীয়ক) বের করার Euclid-এর algorithm প্রায় ২,৩০০ বছর পুরোনো: x := a; y := b; while y ≠ 0 do (x, y := y, x mod y). বাজারঘরের উদাহরণ: ১০৭১টি সাবান আর ৪৬২টি টুথপেস্ট সমান সংখ্যক প্যাকেটে, প্রতিটি প্যাকেটে একই মিশ্রণে ভাগ করতে চাইলে সর্বোচ্চ কয়টি প্যাকেট? উত্তর gcd(1071, 462) = 21. সংখ্যাতত্ত্বের দিক (Bézout, extended Euclid) পর্ব ২১ “GCD আর Euclid-এর algorithm — Bézout আর extended Euclid”-এ; recursive রূপের induction-প্রমাণ DSA কোর্সের পর্ব ৩-এ। এখানে লুপ রূপের প্রমাণ।

লেমা ৬.২

y > 0 আর x ≥ 0 পূর্ণসংখ্যা হলে, প্রতিটি পূর্ণসংখ্যা d-এর জন্য: d ∣ x এবং d ∣ y ⇔ d ∣ y এবং d ∣ (x mod y). (d ∣ x মানে d, x-কে নিঃশেষে ভাগ করে।)

প্রমাণ:

  1. ভাগের উপপাদ্য (উপপাদ্য ৪.১-এর লুপ ঠিক এটিই হিসাব করে): x = qy + m, যেখানে m = x mod y. — division algorithm
  2. (⇒) d ∣ x আর d ∣ y হলে x = dk1, y = dk2; তাহলে m = x − qy = d(k1 − qk2), আর k1 − qk2 ∈ ℤ, তাই d ∣ m. — বীজগণিত, closure
  3. (⇐) d ∣ y আর d ∣ m হলে y = dk2, m = dk3; তাহলে x = qy + m = d(qk2 + k3), তাই d ∣ x. ∎ — বীজগণিত
উপপাদ্য ৬.৩ (Euclid-এর লুপ)

P = (a ≥ 0 ∧ b ≥ 0 ∧ (a, b) ≠ (0, 0)) হলে লুপটি থামে, আর শেষে x = gcd(a, b).

প্রমাণ: invariant I = “(x, y)-এর সাধারণ ভাজকের সেট = (a, b)-এর সাধারণ ভাজকের সেট, x ≥ 0, y ≥ 0, (x, y) ≠ (0, 0)”.

  1. Initialization: x = a, y = b, তাই সেট দুটি একই; বাকি অংশ P থেকে। — P
  2. Preservation: y ≠ 0, তাই y > 0; লেমা ৬.২ বলে (y, x mod y)-এর সাধারণ ভাজক = (x, y)-এর সাধারণ ভাজক = (a, b)-এর। নতুন জোড়া (y, x mod y)-এ y > 0, তাই (0, 0) নয়, আর 0 ≤ x mod y. — লেমা ৬.২
  3. Exit: y = 0 হলে (x, 0)-এর সাধারণ ভাজক মানে x-এর ভাজক (সবকিছু 0-কে ভাগ করে), আর x > 0. x-এর সবচেয়ে বড় ভাজক x নিজে, তাই gcd(a, b) = x. — I ∧ ¬B
  4. Termination: variant y. লুপে y ≥ 0, আর নতুন y = x mod y, যা 0 ≤ x mod y < y. উপপাদ্য ৬.১ প্রয়োগ। ∎
1x, y = 1071, 4622while y != 0:3  x, y = y, x % y4# y = 0: x = gcd
variant V = y, invariant: gcd অপরিবর্তিত
x
y = V
এই ঘোরার ভাগ
gcd(x, y)
  1. শুরু। x = 1071, y = 462. Variant V = y = 462 ≥ 0. Invariant: সাধারণ ভাজকের সেট (1071, 462)-এর সেটই; সবচেয়ে বড়টি 21.
  2. ঘোরা ১। 1071 = 2 · 462 + 147, তাই নতুন (x, y) = (462, 147). V কমল: 462 → 147. লেমা ৬.২-এ সাধারণ ভাজক অপরিবর্তিত, gcd এখনো 21.
  3. ঘোরা ২। 462 = 3 · 147 + 21, নতুন (x, y) = (147, 21). V: 147 → 21. ভাগশেষ সবসময় ভাজকের চেয়ে ছোট, তাই এই কমা নিশ্চিত, ভাগ্যের ব্যাপার নয়।
  4. ঘোরা ৩। 147 = 7 · 21 + 0, নতুন (x, y) = (21, 0). V = 0, শর্ত y ≠ 0 মিথ্যা, লুপ থামে। Exit ধাপ: (21, 0)-এর সাধারণ ভাজক = 21-এর ভাজক, সবচেয়ে বড় 21.
  5. Termination-এর যুক্তি। V-এর ক্রম 462 > 147 > 21 > 0: অঋণাত্মক পূর্ণসংখ্যার কঠোরভাবে কমতে থাকা ক্রম, যা well-ordering অনুযায়ী অসীম হতে পারে না। সাধারণ সীমা “বড়জোর b + 1 ঘোরা” খুব ঢিলা; এখানে লেগেছে মাত্র ৩টি। ∎

সীমা কতটা টাইট? নিচের ব্লক ৩০০ পর্যন্ত সব জোড়ায় ঘোরার সংখ্যা গোনে, আর দেখায় variant না থাকলে কী হয়: b = 0 বা b < 0 হলে ভাগের লুপ, আর একটি “2 করে কমা” লুপ, যার “variant” আসলে নিচে আটকায় না।

def euclid(x, y):                     # pre: x >= 0, y >= 0
    variant = [y]                     # values of the variant y
    while y != 0:
        x, y = y, x % y
        variant.append(y)
    return x, variant

print("gcd(1071, 462):", euclid(1071, 462))

# how many rounds at most? search all pairs 1 <= y < x <= 300
worst = max(((len(euclid(x, y)[1]) - 1, x, y)
             for x in range(2, 301) for y in range(1, x)))
print("most rounds for x <= 300:", worst)

def divide(a, b, cap=8):              # the division loop, with a safety cap
    q, r, rounds = 0, a, 0
    while r >= b:
        q, r, rounds = q + 1, r - b, rounds + 1
        if rounds == cap:
            return f"still running after {cap} rounds (r = {r})"
    return q, r

for a, b in [(17, 5), (17, 0), (17, -3)]:
    print(f"divide({a}, {b}):", divide(a, b))

def countdown(x, cap=6):              # pre: x >= 0 ... but is x a good variant?
    seen = [x]
    while x != 0 and len(seen) < cap:
        x = x - 2
        seen.append(x)
    return seen

print("countdown(6):", countdown(6), " countdown(5):", countdown(5))

আসল আউটপুট (ব্রাউজারের Python):

gcd(1071, 462): (21, [462, 147, 21, 0])
most rounds for x <= 300: (11, 233, 144)
divide(17, 5): (3, 2)
divide(17, 0): still running after 8 rounds (r = 17)
divide(17, -3): still running after 8 rounds (r = 41)
countdown(6): [6, 4, 2, 0]  countdown(5): [5, 3, 1, -1, -3, -5]

প্রথম লাইন stepper-এর variant ক্রমটিই দিল। দ্বিতীয় লাইন: ৩০০ পর্যন্ত সবচেয়ে বেশি ঘোরা লাগে (233, 144)-এ, ১১ বার; দুটিই পরপর Fibonacci সংখ্যা। এটি কাকতালীয় নয়: Gabriel Lamé ১৮৪৪ সালে দেখান, ছোট সংখ্যাটির দশমিক অঙ্ক যত, ঘোরা তার পাঁচগুণের বেশি লাগে না, আর সবচেয়ে খারাপ ক্ষেত্র পরপর Fibonacci সংখ্যা (Fibonacci-র recursive সংজ্ঞা পর্ব ১২-এ)। তাই সীমা “b + 1” নয়, লগারিদমের মতো ছোট। তৃতীয় অংশ: b = 0-এ r একটুও কমে না, b = −3-এ r বাড়ে; দুই ক্ষেত্রেই precondition b > 0 ভাঙায় variant-এর যুক্তি খাটে না, আর লুপ নিরাপত্তা-সীমা (৮ ঘোরা) পর্যন্ত চলেছে। শেষ অংশ: x = 5-এ x প্রতিবার কমছে, কিন্তু 0-কে টপকে ঋণাত্মকে চলে যায়, তাই শর্ত x ≠ 0 কখনো মিথ্যা হয় না। “কমে” যথেষ্ট নয়; “কমে এবং লুপ চলাকালীন ≥ 0 থাকে” দুটোই লাগে, আর x = 5-এ দ্বিতীয়টি ভাঙে (precondition-এ “x জোড়” যোগ করলে ঠিক হয়)।

৭ · যন্ত্রে যাচাই — Z3 দিয়ে verification condition

§২-এর VC generator ছোট পরিসরে brute force করেছিল। একটি SMT solver একই VC সব পূর্ণসংখ্যার জন্য পরীক্ষা করতে পারে: VC-এর অস্বীকার ¬VC-কে সত্য করে এমন কোনো মান নেই দেখালেই VC প্রমাণিত; থাকলে সেই মানগুলোই একটি counterexample. পর্ব ০৭-এ Z3 দিয়ে কুপন-নিয়ম যাচাই দেখেছেন; এবার একে প্রোগ্রামের প্রমাণে লাগাব। প্রোগ্রাম থেকে VC বানিয়ে solver-কে দেওয়া, এটিই আধুনিক formal verification টুলের (যেমন Dafny, Why3, Frama-C) মূল কাঠামো।

নিচের পরীক্ষাগুলো এই কোর্সের লেখার সময় একটি সাধারণ Windows ডেস্কটপে সত্যিকারভাবে চালানো হয়েছে: Python 3.12, z3-solver 5.3.3 (MIT লাইসেন্স), uv দিয়ে বানানো একটি ছোট প্রজেক্টে। নিজের কম্পিউটারে চালাতে চাইলে:

cd ~/bazarghar-dm
uv add z3-solver
uv run python hoare_swap.py
uv run python div_vc.py
uv run python gcd_vc.py

সত্যিকারের run ১: swap, Hoare logic-এ

Z3-এর substitute ঠিক assignment axiom-এর Q[e/x]. তাই wp লেখার ফাংশন তিন লাইনের:

from z3 import Ints, BitVecs, And, Implies, substitute, prove

def wp(prog, post):
    # weakest precondition of a list of assignments (var, expr):
    # go backwards, replacing var by expr in the postcondition
    for var, expr in reversed(prog):
        post = substitute(post, (var, expr))
    return post

def check(name, pre, prog, post):
    vc = Implies(pre, wp(prog, post))
    print(f"{name:22}", end=" ")
    prove(vc)

x, y, t, A, B = Ints("x y t A B")
pre, post = And(x == A, y == B), And(x == B, y == A)

check("swap with temp", pre, [(t, x), (x, y), (y, t)], post)
check("swap, wrong order", pre, [(x, y), (t, x), (y, t)], post)
check("swap by + and -", pre, [(x, x + y), (y, x - y), (x, x - y)], post)

# same arithmetic trick on 8-bit machine integers (wrap-around)
bx, by, bA, bB = BitVecs("bx by bA bB", 8)
check("8-bit swap by + and -", And(bx == bA, by == bB),
      [(bx, bx + by), (by, bx - by), (bx, bx - by)], And(bx == bB, by == bA))
$ uv run python hoare_swap.py
swap with temp         proved
swap, wrong order      counterexample
[B = 1, A = 0, x = 0, y = 1]
swap by + and -        proved
8-bit swap by + and -  proved

প্রথম দুটি §২-এর ফলই, তবে এবার সব পূর্ণসংখ্যার জন্য: সঠিক ক্রম “proved”, ভুল ক্রমে A = 0, B = 1 counterexample. তৃতীয়টি একটি পুরোনো কৌশল, t ছাড়াই swap: x := x + y; y := x − y; x := x − y. গণিতের পূর্ণসংখ্যায় এটি সঠিক। চতুর্থটি আরও মজার: ৮-bit যন্ত্র-পূর্ণসংখ্যায় (BitVec, যেখানে 255 + 1 = 0, অর্থাৎ mod 256 গণিত) যোগফল উপচে পড়লেও উত্তর ঠিক থাকে, কারণ mod 256-এ যোগ-বিয়োগ একে অপরকে বাতিল করে। Z3 দুটি জগতকে আলাদাভাবে যুক্তি করতে পারে; প্রোগ্রামের প্রমাণে কোন জগৎ (গণিতের ℤ, না যন্ত্রের সীমিত bit) তা স্পষ্ট বলা জরুরি।

সত্যিকারের run ২: ভাগের লুপের চারটি VC

§৪-এর তিন দায় আর termination, চারটি আলাদা VC. দুটি invariant দিয়ে চালাই: সঠিকটি, আর §৪-এর খসড়ার প্রথম অনুমান (r ≥ 0 ছাড়া)।

from z3 import Ints, IntVal, And, Not, Implies, Solver, substitute, unsat

# q = 0; r = a;  while r >= b: q = q + 1; r = r - b
a, b, q, r = Ints("a b q r")
pre   = And(a >= 0, b > 0)
guard = r >= b
post  = And(a == q * b + r, 0 <= r, r < b)

def before_loop(e):  return substitute(e, (q, IntVal(0)), (r, a))
def after_body(e):   return substitute(e, (q, q + 1), (r, r - b))

def vcs(inv):
    return {"1 init    ": Implies(pre, before_loop(inv)),
            "2 preserve": Implies(And(pre, inv, guard), after_body(inv)),
            "3 exit    ": Implies(And(pre, inv, Not(guard)), post),
            "4 variant ": Implies(And(pre, inv, guard),
                                  And(after_body(r) < r, after_body(r) >= 0))}

for name, inv in [("a == q*b + r and r >= 0", And(a == q * b + r, r >= 0)),
                  ("a == q*b + r (weaker)  ", a == q * b + r)]:
    print("invariant:", name)
    for vc_name, vc in vcs(inv).items():
        s = Solver(); s.add(Not(vc))          # valid  <=>  Not(vc) has no model
        print("  ", vc_name, "valid" if s.check() == unsat else f"FAILS {s.model()}")
$ uv run python div_vc.py
invariant: a == q*b + r and r >= 0
   1 init     valid
   2 preserve valid
   3 exit     valid
   4 variant  valid
invariant: a == q*b + r (weaker)
   1 init     valid
   2 preserve valid
   3 exit     FAILS [q = 7, a = 3, b = 13, r = -88]
   4 variant  valid

সঠিক invariant-এ চারটি VC-ই valid: উপপাদ্য ৪.১-এর হাতের প্রমাণ যন্ত্রও মেনে নিল। দুর্বল invariant-এ exit ভাঙে, আর counterexample-টি পড়ার মতো: a = 3, b = 13, q = 7, r = −88. পরীক্ষা করুন: 7 · 13 + (−88) = 91 − 88 = 3, তাই a = qb + r সত্য, আর r = −88 < 13, তাই লুপ থেমেছে; কিন্তু Q-এর 0 ≤ r মিথ্যা। আসল প্রোগ্রামে r কখনো −88 হয় না; সমস্যা হলো দুর্বল invariant সেটি বলে না, তাই প্রমাণ এগোতে পারে না। এটি invariant-এর একটি মৌলিক শিক্ষা: invariant সত্য হওয়াই যথেষ্ট নয়, প্রমাণ চালানোর মতো শক্ত হতে হয়।

সত্যিকারের run ৩: যেখানে solver আটকে যায়

এবার Euclid-এর লেমা ৬.২-এর (⇒) দিক। প্রথমে solver-কে সরাসরি জিজ্ঞেস করি (mod দিয়ে), তারপর মানুষের প্রমাণের ইঙ্গিত (m = d(k1 − qk2)) দিয়ে। ফাইলের মূল অংশ:

x, y, d, q, m, k1, k2, k3 = Ints("x y d q m k1 k2 k3")

def show(name, *facts_and_negated_goal):
    s = Solver(); s.set("timeout", 20000)          # 20 seconds at most
    s.add(*facts_and_negated_goal)
    t0 = time.perf_counter(); res = s.check(); dt = time.perf_counter() - t0
    print(f"{name:34}", "proved" if res == unsat else res, f"({dt:.2f} s)")

show("variant: 0 <= x % y < y", y > 0, Not(And(0 <= x % y, x % y < y)))
show("d|x, d|y => d|(x%y), raw %",
     x >= 0, y > 0, d > 0, x % d == 0, y % d == 0, Not((x % y) % d == 0))
show("same, hint m = d*(k1 - q*k2)",
     x == q * y + m, x == d * k1, y == d * k2, Not(m == d * (k1 - q * k2)))
show("back: x = d*(q*k2 + k3)",
     x == q * y + m, y == d * k2, m == d * k3, Not(x == d * (q * k2 + k3)))
$ uv run python gcd_vc.py
variant: 0 <= x % y < y            proved (0.00 s)
d|x, d|y => d|(x%y), raw %         unknown (20.01 s)
same, hint m = d*(k1 - q*k2)       proved (0.00 s)
back: x = d*(q*k2 + k3)            proved (0.00 s)

Variant-এর VC সঙ্গে সঙ্গে প্রমাণ। কিন্তু লেমার সরাসরি রূপে Z3 বিশ সেকেন্ড চেষ্টা করে বলল unknown: সে প্রমাণও পায়নি, counterexample-ও পায়নি। কারণ এখানে variable-এর সঙ্গে variable-এর গুণ আর mod আছে (অরৈখিক পূর্ণসংখ্যা গণিত), আর এমন সূত্রের সত্যতা সাধারণভাবে কোনো algorithm দিয়েই নির্ধারণ করা যায় না (undecidable; ভাবনার প্রশ্ন ২)। তারপর মানুষ লেমার প্রমাণ থেকে “সাক্ষী” রাশি k1 − qk2 লিখে দিল, আর একই দাবি শূন্য সেকেন্ডে প্রমাণ হলো; উল্টো দিকও। সময়ের সংখ্যা যন্ত্রভেদে বদলাবে, কিন্তু শিক্ষাটি বদলাবে না: verifier প্রমাণের যান্ত্রিক অংশ নেয়; invariant বাছাই আর মূল কৌশল (এখানে সাক্ষী) এখনো সাধারণত মানুষকে দিতে হয়। আর unknown মানে কখনোই “প্রমাণিত” নয়।

ইতিহাস সংক্ষেপে, যতটা নিশ্চিতভাবে বলা যায়: Alan Turing ১৯৪৯ সালে “Checking a Large Routine” নামের একটি ছোট লেখায় প্রোগ্রামে assertion বসিয়ে সঠিকতা যাচাইয়ের ধারণা দেন। Robert Floyd ১৯৬৭ সালে “Assigning Meanings to Programs”-এ flowchart-এর প্রতিটি তীরে শর্ত বসিয়ে প্রমাণের পদ্ধতি দেন, আর C. A. R. (Tony) Hoare ১৯৬৯ সালে “An Axiomatic Basis for Computer Programming”-এ তাকে আজকের triple আর নিয়মের আকার দেন; তাই একে কখনো Floyd–Hoare logic বলা হয়। Edsger Dijkstra ১৯৭৫ সালের দিকে weakest precondition-কে প্রোগ্রাম তৈরির হাতিয়ার হিসেবে ব্যবহার করেন। Floyd (১৯৭৮) আর Hoare (১৯৮০) দুজনেই Turing Award পান।

৮ · Testing বনাম প্রমাণ — আর একটি ভুল প্রমাণ ধরা

“Program testing can be used to show the presence of bugs, but never to show their absence!”
— Edsger W. Dijkstra

এই উক্তিটি সাধারণত ১৯৬৯-৭০ সালের দিকে Dijkstra-র লেখা ও বক্তৃতার (তাঁর “Notes on Structured Programming” সহ) বলে উল্লেখ করা হয়; হুবহু শব্দ উৎসভেদে একটু আলাদা পাওয়া যায়। অর্থ পরিষ্কার: টেস্ট কয়েকটি ইনপুট দেখে, প্রমাণ সবগুলো। পর্ব ০৮-এর ভাষায়, টেস্ট উদাহরণ, প্রমাণ উপপাদ্য। তবে উল্টো দিকটাও সত্য: প্রমাণ শুধু ততটুকু নিশ্চিত করে, যতটুকু specification বলে, আর specification নিজেই ভুল হতে পারে। তাই বাস্তবে দুটি একসঙ্গে চলে। টেস্ট লেখার বিস্তারিত সফটওয়্যার ইঞ্জিনিয়ারিং ও Git কোর্সের পর্ব ৪৫ আর Python কোর্সের পর্ব ৪২-এ।

একটি বাস্তবধর্মী উদাহরণ: পূর্ণসংখ্যার বর্গমূলের পূর্ণ অংশ ⌊√n⌋. দ্রুত উপায় মনে হয় int(math.sqrt(n)); কিন্তু math.sqrt float-এ কাজ করে, যার নির্ভুলতা সীমিত। বিকল্প হলো invariant-সহ binary search: lo² ≤ n < hi², variant hi − lo. এর প্রমাণ §৪-এর ধাঁচেই (অনুশীলনের মতো করে নিজে লিখে দেখতে পারেন)।

import math, random
random.seed(2026)

def isqrt_float(n):                    # quick idea: use the float square root
    return int(math.sqrt(n))

def isqrt_proved(n):                   # pre: n >= 0
    lo, hi = 0, n + 1                  # invariant: lo*lo <= n < hi*hi
    while hi - lo > 1:                 # variant: hi - lo
        mid = (lo + hi) // 2
        if mid * mid <= n:
            lo = mid
        else:
            hi = mid
    return lo                          # post: lo*lo <= n < (lo+1)**2

tests = [random.randrange(10**9) for _ in range(20000)]
agree = all(isqrt_float(n) == isqrt_proved(n) for n in tests)
print("20000 random tests below 10**9, both agree:", agree)

for n in [10**16 - 1, 4503599761588224]:
    f, p = isqrt_float(n), isqrt_proved(n)
    print(f"n = {n}: float gives {f}, proved gives {p}, "
          f"check {p}^2 <= n < {p + 1}^2: {p * p <= n < (p + 1) ** 2}")
print("math.isqrt agrees:", math.isqrt(10**16 - 1), math.isqrt(4503599761588224))

আসল আউটপুট (ব্রাউজারের Python):

20000 random tests below 10**9, both agree: True
n = 9999999999999999: float gives 100000000, proved gives 99999999, check 99999999^2 <= n < 100000000^2: True
n = 4503599761588224: float gives 67108865, proved gives 67108864, check 67108864^2 <= n < 67108865^2: True
math.isqrt agrees: 99999999 67108864

বিশ হাজার এলোমেলো টেস্টে দুই ফাংশন একমত; টেস্ট-নির্ভর একজন প্রোগ্রামার float সংস্করণটি নিশ্চিন্তে চালু করে দিতেন। কিন্তু n = 1016 − 1-এ float উত্তর 108, অথচ (108)² = 1016 > n; সঠিক উত্তর 99999999. দ্বিতীয় n = 67108865² − 1 (67108865 = 226 + 1)-এও একই ভুল। কারণ: এত বড় সংখ্যায় float পাশাপাশি পূর্ণসংখ্যা আলাদা করতে পারে না, তাই n আর n + 1 প্রায় একই float হয়ে যায়। Invariant-ভিত্তিক সংস্করণ শুধু পূর্ণসংখ্যার গুণ ব্যবহার করে, আর Python-এর int যত বড়ই হোক নির্ভুল; তাই প্রমাণটি সব n-এ খাটে। শেষ লাইনে Python-এর নিজের math.isqrt-ও একই উত্তর দিল।

প্রমাণের ভুল ধরুন: invariant শুরুতেই মিথ্যা

একজন §৪-এর মোট বিক্রির লুপের (s := 0; i := 0; while i < n …) এই “প্রমাণ” লিখলেন। পড়ে ভুলটি নিজে খুঁজুন।

  1. Invariant: I = (s = A[0] + ⋯ + A[i]).
  2. Preservation: I আর i < n ধরে, body-র পরে s′ = s + A[i]… (বীজগণিত, দেখতে ঠিকঠাক)।
  3. Exit: i = n, তাই s = A[0] + ⋯ + A[n]. এটিই মোট বিক্রি। ∎

ভুল কোথায়: initialization ধাপটিই নেই, আর থাকলে ভাঙত: শুরুতে s = 0, অথচ I বলে s = A[0], যা A[0] ≠ 0 হলে মিথ্যা। এটি induction-এ basis বাদ দেওয়ার মতো ভুল (পর্ব ১০-এর “সব ঘোড়ার রং এক” ধাঁচের)। লক্ষণও আছে: exit-এর উত্তরে A[n] এসেছে, যা অ্যারের বাইরে (index 0 থেকে n − 1). আসলে body-র পরে i বাড়ে, তাই preservation-ও এই I-এর জন্য খাটে না। সঠিক invariant §৪-এরটি: s = A[0] + ⋯ + A[i − 1]. Off-by-one ভুল প্রায়ই invariant-এর একটি সূচক ভুল থেকে আসে, আর তিন দায়ের প্রতিটি পরীক্ষা করলে ধরা পড়ে।

Python-এ assert প্রমাণ নয়, আর নিরাপত্তার পাহারাও নয়: python -O দিয়ে চালালে সব assert বাদ যায়। Invariant-assert ডিবাগ আর টেস্টের সময় বাগ ধরার জন্য; ব্যবহারকারীর ইনপুট যাচাই করতে সাধারণ if আর exception ব্যবহার করুন।

আরও গভীরে: Hoare logic-এর আধুনিক উত্তরসূরি separation logic (মোটামুটি ২০০০-এর দশকের শুরুতে John Reynolds, Peter O'Hearn ও সহকর্মীদের কাজ) pointer আর heap memory নিয়ে যুক্তি সহজ করে, আর শিল্প-পর্যায়ের কিছু static analyser-এর ভিত্তি। পূর্ণ যাচাইয়ের নামকরা উদাহরণ seL4 operating system kernel (২০০৯ সালের দিকে তার functional correctness-এর যন্ত্র-যাচাইকৃত প্রমাণ প্রকাশিত হয়) আর CompCert C compiler (Xavier Leroy-র নেতৃত্বে, Coq/Rocq-এ প্রমাণিত)। Dafny-র মতো ভাষায় প্রোগ্রামার কোডের পাশে precondition, postcondition আর invariant লেখেন, আর টুল নিজে VC বানিয়ে Z3-কে দেয়: এই পর্বের পুরো পদ্ধতি, স্বয়ংক্রিয়ভাবে।

পরীক্ষা ও ইন্টারভিউতে: বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন প্রায়ই আসে:

  • {P} S {Q}-এর অর্থ কী? Partial আর total correctness-এর পার্থক্য? P-তে শুরু করে S থামলে Q; total-এ থামাটাও নিশ্চিত।
  • wp(x := x + 1, x > 5) কত? x + 1 > 5, অর্থাৎ x > 4.
  • Loop invariant দিয়ে প্রমাণের ধাপগুলো কী? Initialization, preservation, exit (I ∧ ¬B ⇒ Q), আর আলাদা করে variant দিয়ে termination.
  • Euclid-এর algorithm থামে কেন? Variant y: অঋণাত্মক আর প্রতিবার x mod y < y; well-ordering.
  • দ্রুত ঘাতের invariant কী? r · be = an; ঘোরা ⌊log2 n⌋ + 1 বার।

৯ · AI-এর যুগে — AI-এর লেখা কোড আর তার “প্রমাণ” যাচাই

AI সহকারী এখন মুহূর্তে লুপ লেখে, আর চাইলে সঙ্গে একটি invariant আর “প্রমাণ”-ও দেয়। সেগুলো দেখতে নিখুঁত, কিন্তু এই পর্বের প্রতিটি ভুল সেখানে নিয়মিত পাওয়া যায়:

AI-এর ভুলকীভাবে ধরবেন
Invariant শুরুতে মিথ্যা, বা body-র পরে টেকে না (off-by-one সূচক)তিন দায় আলাদা করে লিখতে বলুন; কোডে assert বসিয়ে এলোমেলো ইনপুটে চালান
Invariant এত দুর্বল যে exit-এ Q আসে না (যেমন r ≥ 0 বাদ)I ∧ ¬B ⇒ Q আলাদা করে পরীক্ষা করুন; Z3-এ VC দিলে counterexample দেখায়
“লুপ থামে কারণ x কমে”, অথচ x নিচে আটকায় নাVariant ≥ 0 থাকে কি না দেখুন; বিজোড়, শূন্য, ঋণাত্মক ইনপুটে চালান
Precondition বাদ (b > 0, n ≥ 0), বা যন্ত্রের overflow/float উপেক্ষাপ্রমাণের কোন ধাপে কোন শর্ত লাগল জিজ্ঞেস করুন; প্রান্তিক আর বিশাল মানে টেস্ট করুন
কয়েকটি টেস্ট পাস করাকে “প্রমাণ” বলাটেস্ট আর প্রমাণ আলাদা দাবি করুন; টেস্টের ফল দেখান, প্রমাণ ধাপে ধাপে চান
Solver-এর unknown বা timeout-কে “verified” বলা; অস্তিত্বহীন টুল বা গবেষণাপত্রের নামআসল আউটপুট নিজে চালিয়ে দেখুন; নাম আর বছর বিশ্বস্ত উৎসে মেলান

AI-কে কোড লিখতে দিলে কোডের সঙ্গে চুক্তি আর প্রমাণও চেয়ে নিন। নিচের কাঠামো কাজে দেয়:

নিচের কাজের জন্য একটি Python ফাংশন লেখো, সঙ্গে সঠিকতার প্রমাণ দাও: কাজ: [এখানে কাজের বর্ণনা] ১. প্রথমে precondition আর postcondition গাণিতিক ভাষায় লেখো। ২. প্রতিটি লুপের invariant আর variant লেখো, আর কোডে comment হিসেবেও রাখো। ৩. Initialization, preservation আর exit (I ∧ ¬B ⇒ Q) আলাদা আলাদা ধাপে প্রমাণ করো; প্রতিটি ধাপে কোন শর্ত লাগল বলো। ৪. Variant কেন ≥ 0 থাকে আর কেন প্রতিবার কমে, দেখাও। ৫. কোডে invariant আর postcondition-এর assert বসাও, আর প্রান্তিক ইনপুটের (0, 1, ঋণাত্মক, খুব বড়) একটি টেস্ট তালিকা দাও। ৬. কোনো ধাপে নিশ্চিত না হলে তা স্পষ্ট বলো; অনুমান করে প্রমাণ সম্পূর্ণ দেখিও না।

AI-এর কোড আর প্রমাণ হাতে পেলে — যাচাইয়ের চেকলিস্ট

মনে রাখুন, AI-এর লেখা invariant-কে assert বানিয়ে চালানো সবচেয়ে সস্তা প্রথম পরীক্ষা: একবার ভাঙলেই “প্রমাণ” বাতিল। আর ভাঙেনি মানেই প্রমাণিত নয়; তখন তিন দায় নিজে পড়ুন।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১টেস্ট যদি বাগের অনুপস্থিতি দেখাতে না পারে, তাহলে সব সফটওয়্যারের প্রমাণ লেখা হয় না কেন?

প্রথম কারণ খরচ। এই পর্বের দশ লাইনের লুপগুলোর প্রমাণই কোডের চেয়ে কয়েকগুণ লম্বা। বড় প্রকল্পে হিসাব আরও কঠিন: seL4 kernel-এর যাচাইকৃত প্রমাণের কাজে কয়েক বছর আর বহু মানুষের শ্রম লেগেছিল বলে তার দলের প্রকাশিত লেখায় উল্লেখ আছে, অথচ kernel-টি কয়েক হাজার লাইনের। কোটি লাইনের সাধারণ সফটওয়্যারে একই মাত্রার প্রমাণ এখনো বাস্তবসম্মত নয়।

দ্বিতীয় কারণ specification. প্রমাণ বলে “কোড specification মানে”; specification ভুল হলে প্রমাণ নিখুঁতভাবে ভুল জিনিসটি নিশ্চিত করে। ১৯৯৬ সালের জুনে ইউরোপের Ariane 5 রকেটের প্রথম উড্ডয়ন ব্যর্থ হয়; তদন্ত প্রতিবেদন অনুযায়ী Ariane 4 থেকে নেওয়া একটি সফটওয়্যার অংশে 64-bit float থেকে 16-bit পূর্ণসংখ্যায় রূপান্তরের সময় মান উপচে পড়েছিল। সেই কোড Ariane 4-এর পরিবেশে ঠিক ছিল; নতুন রকেটের দ্রুততর গতিতে তার পূর্বশর্ত আর সত্য ছিল না। অর্থাৎ বিপদ ছিল precondition বদলে যাওয়ায়, যা ঠিক সেই জিনিস যা Hoare triple স্পষ্ট লিখতে বাধ্য করে।

তাহলে বাস্তবে কী হয়? একটি সিঁড়ি: সবার জন্য টেস্ট আর type system; গুরুত্বপূর্ণ জায়গায় assert আর property-based testing; আরও গুরুত্বপূর্ণ অংশে (cryptography library, বিমানের নিয়ন্ত্রণ, distributed protocol, compiler) আংশিক বা পূর্ণ formal verification. বড় কিছু প্রতিষ্ঠান distributed system-এর নকশা model checker দিয়ে যাচাই করে বলে প্রকাশিত অভিজ্ঞতা আছে; সেখানে পুরো কোড নয়, নকশার মূল invariant যাচাই হয়। আর যেখানে পূর্ণ প্রমাণ হয় না, সেখানেও invariant-এর ভাষায় ভাবা কোডের মান বাড়ায়: কোন শর্ত সবসময় সত্য থাকার কথা, তা জানা প্রোগ্রামার বাগ কম লেখেন।

মূল উপলব্ধি: প্রমাণ আর টেস্ট প্রতিদ্বন্দ্বী নয়, একটি সিঁড়ির ধাপ। প্রমাণের খরচ ঝুঁকির সঙ্গে মিলিয়ে বেছে নিতে হয়, আর যেকোনো প্রমাণ ঠিক ততটাই মূল্যবান, যতটা তার specification.

প্র ০২Z3 লেমা ৬.২-এ “unknown” বলল, অথচ লেমাটির প্রমাণ তিন লাইনের। এমন কেন হয়, আর কোনো দিন কি এমন verifier বানানো যাবে যে সবসময় উত্তর দেবে?

না, সাধারণভাবে যাবে না, আর এটি গণিতের প্রমাণিত সত্য। ১৯০০ সালে David Hilbert তাঁর বিখ্যাত প্রশ্নতালিকার দশম প্রশ্নে জানতে চান: পূর্ণসংখ্যা সহগের একটি বহুপদী সমীকরণের পূর্ণসংখ্যা সমাধান আছে কি না, তা নির্ধারণের সাধারণ পদ্ধতি আছে কি? Martin Davis, Hilary Putnam আর Julia Robinson-এর কয়েক দশকের কাজের ওপর দাঁড়িয়ে ১৯৭০ সালে Yuri Matiyasevich দেখান, এমন পদ্ধতি নেই। অর্থাৎ গুণসহ পূর্ণসংখ্যা গণিত (অরৈখিক) সাধারণভাবে undecidable. লেমা ৬.২-এর সরাসরি রূপ ঠিক সেই জগতে পড়ে, তাই Z3-এর কৌশলগুলো (যা অনেক ক্ষেত্রে দারুণ কাজ করে) এখানে সময়সীমার মধ্যে কিছু পায়নি।

আরও মৌলিক বাধা প্রোগ্রাম নিয়েই। Alan Turing ১৯৩৬ সালে দেখান, কোনো প্রোগ্রাম সব প্রোগ্রামের ক্ষেত্রে বলতে পারে না সেটি থামবে কি না (halting problem). ১৯৫০-এর দশকে H. G. Rice এটি সাধারণীকরণ করেন: প্রোগ্রামের আচরণ-সংক্রান্ত যেকোনো অ-তুচ্ছ প্রশ্ন (যেমন “এটি কি সবসময় gcd ফেরত দেয়?”) সাধারণভাবে undecidable. তাই “যেকোনো প্রোগ্রাম দাও, আমি বলে দেব ঠিক কি না” ধরনের verifier অসম্ভব।

তাহলে verifier কাজ করে কীভাবে? তিনটি উপায়ে অসম্ভবতাকে পাশ কাটিয়ে। এক, মানুষ invariant, variant আর সাহায্যকারী lemma দেয়, যাতে বাকি VC-গুলো solver-এর পক্ষে সহজ জগতে পড়ে (রৈখিক গণিত, যা decidable). আমাদের run-এ “সাক্ষী” লিখে দেওয়ামাত্র প্রমাণ শূন্য সেকেন্ডে হলো। দুই, উত্তর তিন রকম: proved, counterexample, unknown; আর সৎ টুল unknown-কে কখনো proved বলে না। তিন, সীমিত জগতে কাজ: ৮-bit বা ৬৪-bit সংখ্যার জগৎ সসীম, তাই সেখানে প্রশ্ন নীতিগতভাবে decidable, যদিও কঠিন হতে পারে।

মূল উপলব্ধি: undecidability verifier-কে অকেজো করে না, শুধু মানুষ আর যন্ত্রের কাজ ভাগ করে দেয়: মানুষ মূল ধারণা (invariant, সাক্ষী) দেয়, যন্ত্র ক্লান্তিহীনভাবে প্রতিটি খুঁটিনাটি পরীক্ষা করে।

প্র ০৩Loop invariant কোথা থেকে আসে? এটি কি শুধু প্রতিভার ব্যাপার, নাকি খোঁজার নিয়মিত কৌশল আছে?

পুরোপুরি যান্ত্রিক কৌশল নেই (প্র ০২-এর কারণে থাকতে পারে না), কিন্তু কয়েকটি নির্ভরযোগ্য কৌশল আছে, যা David Gries-এর ১৯৮১ সালের বই “The Science of Programming”-এর মতো পাঠ্যবইয়ে বিস্তারিত আলোচিত। প্রথম কৌশল: postcondition-এর ধ্রুবককে variable দিয়ে বদলানো। মোট বিক্রির Q “s = A[0] + ⋯ + A[n − 1]”-এ n-কে i বানালেই I. দ্বিতীয়: postcondition-এর একটি অংশ বাদ দেওয়া। ভাগের Q “a = qb + r ∧ 0 ≤ r ∧ r < b” থেকে r < b বাদ দিলে I; বাদ দেওয়া অংশটিই লুপের শর্তের অস্বীকার (r ≥ b মিথ্যা)। দারুণ মিল: I ∧ ¬B = Q স্বয়ংক্রিয়ভাবে।

তৃতীয় কৌশল: trace table থেকে স্থির রাশি খোঁজা। §৪-এ 17 আর 5 নিয়ে হাতে চালিয়ে দেখেছিলাম 5q + r সবসময় 17; দ্রুত ঘাতে r · be সবসময় 1594323. ছোট ইনপুটে কয়েকটি variable-এর মান পাশাপাশি লিখে কোন সমন্বয় বদলায় না খোঁজা প্রায়ই সবচেয়ে দ্রুত পথ, আর Python এখানে চমৎকার সঙ্গী। গবেষণায় এই ধারণা থেকেই স্বয়ংক্রিয় invariant অনুমানের টুল তৈরি হয়েছে, যা অনেক trace দেখে সম্ভাব্য invariant প্রস্তাব করে; প্রমাণ তারপরও আলাদা।

চতুর্থ: শক্তি ঠিক করা। Invariant দুই দিকে ভুল হতে পারে। খুব দুর্বল হলে exit-এ Q আসে না (Z3 r = −88 দেখিয়েছিল)। খুব শক্ত হলে preservation ভাঙে বা initialization-এ মিথ্যা হয় (§৮-এর ভুল প্রমাণ)। এটি induction-এর পরিচিত ঘটনা: কখনো বড় দাবি প্রমাণ করা সহজ, কারণ inductive hypothesis-এ বেশি তথ্য থাকে; পর্ব ১০-এর tromino tiling-এ “একটি ঘর বাদ দেওয়া বোর্ড” ঠিক এমন শক্ত করা দাবি।

আর Dijkstra-র পরামর্শ ছিল প্রোগ্রাম আর প্রমাণ একসঙ্গে গড়া: আগে invariant ঠিক করুন, তারপর এমন body লিখুন যা তাকে রক্ষা করে আর variant কমায়। তখন invariant খুঁজতে হয় না, কারণ সেটিই নকশা।

মূল উপলব্ধি: invariant খোঁজা একটি শেখা যায় এমন দক্ষতা: postcondition দুর্বল করুন, ছোট trace-এ স্থির রাশি দেখুন, আর তিন দায় দিয়ে পরীক্ষা করে শক্তি ঠিক করুন।

প্র ০৪Collatz-এর লুপ: n জোড় হলে n/2, বিজোড় হলে 3n + 1, n = 1 হলে থামা। এর partial correctness প্রমাণ এক লাইনে হয়, অথচ total correctness কেউ জানেন না। কেন?

প্রোগ্রাম: while n ≠ 1 do (if n জোড় then n := n/2 else n := 3n + 1), P = (n ≥ 1), Q = (n = 1). Partial correctness: invariant I = (n ≥ 1). শুরুতে সত্য; body-তে n জোড় আর ≥ 2 হলে n/2 ≥ 1, বিজোড় হলে 3n + 1 ≥ 4. আর exit-এ I ∧ ¬(n ≠ 1) মানে n = 1 = Q. প্রমাণ শেষ, যদিও এতে প্রোগ্রাম সম্পর্কে প্রায় কিছুই শিখলাম না: শর্ত n ≠ 1-এর অস্বীকারই Q.

Total correctness মানে প্রতিটি n ≥ 1-এ লুপ থামবে, অর্থাৎ Collatz conjecture, যা ১৯৩৭ সালের দিকে Lothar Collatz-এর নামে পরিচিত আর আজও খোলা প্রশ্ন (পর্ব ০৯ “প্রমাণের কৌশল — case, existence, uniqueness আর counterexample”-এ এর আলোচনা আছে)। কম্পিউটারে অনেক বিশাল সীমা পর্যন্ত প্রতিটি n-এ লুপ থামতে দেখা গেছে, কিন্তু পর্ব ০৮-এর শিক্ষা অনুযায়ী তা প্রমাণ নয়। সমস্যা হলো কোনো variant খুঁজে পাওয়া যায়নি: n নিজে variant নয় (3n + 1-এ বাড়ে), আর এমন কোনো রাশি জানা নেই যা প্রতিবার কমে ও নিচে আটকে থাকে। এমন variant পাওয়া মানেই conjecture-টির প্রমাণ।

এটি দেখায় কেন partial আর total correctness আলাদা রাখা হয়: দুটির কঠিনতা সম্পূর্ণ আলাদা হতে পারে। একদিকে এখানে partial correctness তুচ্ছ। অন্যদিকে উল্টো উদাহরণও আছে: সব ইনপুটে থামে এমন প্রোগ্রাম, যার উত্তর ঠিক কি না প্রমাণ কঠিন। আর halting problem (প্র ০২) বলে termination সাধারণভাবে undecidable; Collatz তার একটি ছোট, নিরীহ চেহারার, কিন্তু অজেয় নমুনা।

ব্যবহারিক শিক্ষা: কোডে এমন লুপ লিখলে যার থামার যুক্তি আপনি দিতে পারেন না, অন্তত একটি নিরাপত্তা-সীমা (সর্বোচ্চ ঘোরা) রাখুন, যেমন §৬-এর ব্লকে cap; তাতে আচরণ বদলায়, কিন্তু total correctness-এর একটি দুর্বল রূপ নিশ্চিত হয়।

মূল উপলব্ধি: termination-এর প্রমাণ মানে একটি variant খুঁজে পাওয়া; কোনো কোনো লুপে সেটি এক লাইনের, কোনোটিতে গণিতের খোলা প্রশ্ন।

প্র ০৫এই মডিউলের প্রমাণ-পদ্ধতিগুলো (contradiction, induction, well-ordering, structural induction) প্রোগ্রামের প্রমাণে ঠিক কোথায় কোথায় বসে?

প্রায় প্রতিটিরই একটি নির্দিষ্ট জায়গা আছে, আর সেটি দেখলে এই পর্বটি মডিউলের সারাংশ হয়ে দাঁড়ায়। Direct proof আর বীজগণিত (পর্ব ০৮) বসে প্রতিটি verification condition-এ: P ⇒ wp একটি সাধারণ গাণিতিক বাক্য, যা সংজ্ঞা খুলে সরাসরি প্রমাণ হয় (ভাগের preservation-এ q′b + r′ = a). Proof by cases (পর্ব ০৯) হলো conditional rule; দ্রুত ঘাতে e জোড়/বিজোড় দুই ক্ষেত্র।

Mathematical induction (পর্ব ১০) হলো while rule-এর সঠিকতার ভিত্তি: ঘোরার সংখ্যা k-এর ওপর induction, invariant হলো hypothesis. Well-ordering আর contradiction (পর্ব ১১, ০৮) মিলে termination: উপপাদ্য ৬.১-এর প্রমাণ “ক্ষুদ্রতম variant মান” নিয়ে বিরোধ। Strong induction লাগে recursive প্রোগ্রামে, যেখানে f(n) ডাকে f(⌊n/2⌋)-কে, শুধু f(n − 1)-কে নয়; recursive দ্রুত ঘাতের প্রমাণ এভাবেই হয় (DSA কোর্সের পর্ব ৩)। আর structural induction (পর্ব ১২) লাগে tree বা list-এর ওপর recursive প্রোগ্রামে: “প্রতিটি balanced bracket স্ট্রিং-এর parser সঠিক” ধরনের দাবিতে, যেখানে induction সংখ্যার ওপর নয়, ডেটার গঠনের ওপর।

এমনকি পর্ব ০৪-এর quantifier-ও লুকিয়ে আছে: {P} S {Q}-এর আসল অর্থ “P সত্য এমন প্রতিটি শুরুর অবস্থার জন্য”, আর লেমা ৬.২ “প্রতিটি d-এর জন্য”। Z3 যখন ¬VC-এর model খোঁজে, সে আসলে এই ∀-এর একটি counterexample খোঁজে, ঠিক পর্ব ০৪-এ যেমন ¬∀ ≡ ∃¬ শিখেছিলেন।

এই সংযোগের একটি গভীর রূপ আছে: কিছু proof assistant-এ (Lean, Rocq) প্রমাণ আর প্রোগ্রাম একই ভাষায় লেখা হয়, আর একটি induction-প্রমাণ নিজেই একটি recursive ফাংশন। সেই আলোচনা এই কোর্সের সীমার বাইরে, তবে মূল কথা সহজ: প্রোগ্রাম লেখা আর প্রমাণ লেখা একই যুক্তির দুই চেহারা।

মূল উপলব্ধি: প্রোগ্রামের প্রমাণ নতুন কোনো গণিত নয়; এটি এই মডিউলের প্রতিটি পদ্ধতির একসঙ্গে প্রয়োগ, শুধু বস্তুটি সংখ্যার বদলে প্রোগ্রামের অবস্থা।

অনুশীলন

আগে খাতায় করুন, তারপর উত্তর খুলে মেলান।

  1. প্রমাণ: প্রোগ্রাম ODD: s := 0; i := 0; while i < n do (s := s + 2i + 1; i := i + 1). P = (n ≥ 0), Q = (s = n²). উপযুক্ত invariant আর variant দিয়ে ODD-এর total correctness সংখ্যাযুক্ত ধাপে প্রমাণ করুন।

    খসড়া: n = 4-এ s = 1, 4, 9, 16; প্রতিবার s = i². তাই I = (s = i² ∧ 0 ≤ i ≤ n), variant n − i.

    1. Initialization: s = 0 = 0², i = 0, আর 0 ≤ 0 ≤ n কারণ n ≥ 0. — P
    2. Preservation: I আর i < n ধরে s′ = i² + 2i + 1 = (i + 1)² = i′², আর i′ = i + 1 ≤ n. — বীজগণিত
    3. Exit: I ∧ i ≥ n থেকে i = n, তাই s = n². — consequence
    4. Termination: n − i ≥ 0 (I থেকে), আর প্রতিবার ঠিক 1 কমে। ∎

    এটি পর্ব ১০-এর “প্রথম n বিজোড় সংখ্যার যোগফল n²”-এরই প্রোগ্রাম রূপ। যাচাই: n = 0 থেকে 29 পর্যন্ত প্রতিটি ঘোরায় s = i² assert করে scratch-এ Python চালানো হয়েছে, কোনো ব্যতিক্রম নেই।

  2. হিসাব: (ক) wp(y := x · x; x := y + 2, x ≤ 11) বের করুন, আর কোন পূর্ণসংখ্যা x এটি মানে তা লিখুন। (খ) দ্রুত ঘাতের লুপে (r, b, e) = (1, 2, 10) থেকে শুরু করে প্রতিটি ঘোরার পরের মান লিখুন; কয়টি ঘোরা লাগে, আর 10-এর binary রূপের সঙ্গে সম্পর্ক কী? (গ) Euclid-এর লুপ (233, 144)-এ কয়বার ঘোরে?

    (ক) পেছন থেকে: x := y + 2 পেরিয়ে y + 2 ≤ 11; তারপর y := x · x পেরিয়ে x · x + 2 ≤ 11, অর্থাৎ x² ≤ 9, অর্থাৎ −3 ≤ x ≤ 3. পূর্ণসংখ্যা: −3, −2, −1, 0, 1, 2, 3 (−50 থেকে 50 পর্যন্ত scratch-এ যাচাই করা)।

    (খ) (1, 4, 5) → (4, 16, 2) → (4, 256, 1) → (1024, 65536, 0). চারটি ঘোরা; 10 = 1010 (binary), চার অঙ্ক। r বদলেছে শুধু যে ঘোরায় e বিজোড় ছিল (5 আর 1), অর্থাৎ binary-র 1 অঙ্কগুলোয়: 210 = 22 · 28 = 4 · 256 = 1024.

    (গ) ১১ বার; §৬-এর ব্লক ৩০০ পর্যন্ত সর্বোচ্চ ঘোরা হিসেবে ঠিক এই জোড়া পেয়েছিল। 233 আর 144 পরপর Fibonacci সংখ্যা, Lamé-এর সবচেয়ে খারাপ ক্ষেত্র।

  3. আউটপুট অনুমান: কোড না চালিয়ে বলুন কী ছাপা হবে, আর তৃতীয় কলামটি কেন বদলায় না। তারপর যেকোনো Python-এ চালিয়ে মেলান।
    q, r = 0, 17
    while r >= 5:
        q, r = q + 1, r - 5
        print(q, r, 5 * q + r)
    assert 17 == 5 * q + r and 0 <= r < 5
    print("done", q, r)
    1 12 17
    2 7 17
    3 2 17
    done 3 2

    তৃতীয় কলাম 5q + r হলো invariant a = qb + r-এর বাম দিক, তাই প্রতিটি ঘোরার পরে 17. লুপ থামে যখন r = 2 < 5; assert ভাঙে না, কারণ I ∧ ¬B = Q (উপপাদ্য ৪.১)। কোনো AssertionError নেই, তাই শেষ লাইনও ছাপা হয়।

  4. প্রমাণের ভুল ধরুন: প্রোগ্রাম: while x ≠ 0 do x := x − 2. একজন লিখলেন: “দাবি: P = (x ≥ 0) হলে লুপ থামে আর শেষে x = 0. প্রমাণ: variant V = x. প্রতি ঘোরায় x ঠিক 2 কমে, তাই V কঠোরভাবে কমে। অতএব লুপ থামে। Exit-এ x ≠ 0 মিথ্যা, তাই x = 0. ∎” ভুল কোথায়? দাবিটি কি সত্য? কীভাবে ঠিক করবেন?
    • ভুল: variant-এর দ্বিতীয় শর্ত (কমে) দেখানো হয়েছে, প্রথমটি (লুপ চলাকালীন V ≥ 0) নয়। x বিজোড় হলে 5, 3, 1, −1, −3, … x 0-কে টপকে ঋণাত্মকে যায়, তাই V নিচে আটকায় না আর well-ordering খাটে না; §৬-এর ব্লকে countdown(5) ঠিক এটি দেখিয়েছে।
    • দাবি: termination অংশ মিথ্যা (x = 5 counterexample). Exit অংশ (থামলে x = 0) সত্য; সেটি partial correctness, I = true দিয়েই।
    • সংশোধন: P = (x ≥ 0 ∧ x জোড়) নিন, আর invariant I = (x ≥ 0 ∧ x জোড়). Preservation: x জোড়, x ≥ 0 আর x ≠ 0 হলে x ≥ 2, তাই x − 2 ≥ 0 আর জোড়। তখন V = x ≥ 0 থাকে আর কমে, প্রমাণ সম্পূর্ণ। ∎

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি while লুপের partial correctness প্রমাণের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Precondition P আর postcondition Q লেখা
  2. একটি loop invariant I প্রস্তাব করা (যেমন trace table দেখে)
  3. Initialization: লুপের আগের কোড শেষে I সত্য দেখানো
  4. Preservation: I ∧ B থেকে body শেষে আবার I দেখানো
  5. Exit: I ∧ ¬B থেকে Q দেখানো

Hoare logic-এর assignment axiom অনুযায়ী wp(x := x + 3, x > 10) কোনটি?

  • x > 13
  • x > 7
  • x > 10
  • x ≥ 7

একটি প্রোগ্রাম S, যা কোনো ইনপুটেই থামে না। Hoare triple {true} S {x = 42} সম্পর্কে কোনটি সঠিক?

  • Triple-টি মিথ্যা, কারণ x কখনো 42 হয় না
  • S totally correct
  • Triple-টি (partial correctness) সত্য, কিন্তু S totally correct নয়
  • না থামা প্রোগ্রামের জন্য Hoare triple সংজ্ঞায়িত নয়

লুপ while B do S-এর invariant I. লুপ শেষে postcondition Q প্রমাণ করতে কোন বাক্যটি দেখাতে হয়?

  • I ∧ ¬B ⇒ Q
  • I ∧ B ⇒ Q
  • I ⇒ Q, B ছাড়াই
  • Q ⇒ I

পূর্ণসংখ্যা x ≥ 0 দিয়ে শুরু হওয়া লুপ while x != 0: x = x - 2-এ “variant x” দিয়ে termination প্রমাণ কেন খাটে না?

  • x প্রতিবার কমে না
  • Variant-কে প্রতিবার ঠিক 1 কমতে হয়
  • শুরুর x অনেক বড় হতে পারে
  • লুপ চলাকালীন x ঋণাত্মক হতে পারে

দ্রুত ঘাতের লুপ (r := 1; b := a; e := n; প্রতি ঘোরায় e বিজোড় হলে r := r · b, তারপর b := b², e := ⌊e/2⌋)-এর উপযুক্ত invariant কোনটি?

  • r = an
  • r · be = an
  • b = a এবং e ≤ n
  • e ≥ n

একটি verification condition Z3-এ দিলে ফল আসে unknown. এর সঠিক অর্থ কী?

  • VC প্রমাণিত, কারণ কোনো counterexample পাওয়া যায়নি
  • VC মিথ্যা, প্রোগ্রামে বাগ আছে
  • সিদ্ধান্ত হয়নি; প্রমাণ বা counterexample কোনোটিই পাওয়া যায়নি
  • VC-এর লেখায় সিনট্যাক্স ভুল আছে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Specification = precondition P + postcondition Q. Partial correctness: থামলে Q; total: থামবেও। Q যথেষ্ট শক্ত লিখুন।
  2. Hoare logic: {Q[e/x]} x := e {Q} (পেছন থেকে), composition, consequence, conditional (cases), while ({I ∧ B} S {I} ⇒ {I} loop {I ∧ ¬B})।
  3. Loop invariant-এর তিন দায়: initialization, preservation, exit; এটি ঘোরার সংখ্যার ওপর induction. ভাগে a = qb + r ∧ r ≥ 0, দ্রুত ঘাতে r · be = an.
  4. Termination: variant ≥ 0 থাকে আর কঠোরভাবে কমে; well-ordering. Euclid-এ y, দ্রুত ঘাতে e. “কমে” একাই যথেষ্ট নয়।
  5. যাচাই: assert ভুল ধরে, টেস্ট প্রমাণ নয়, Z3 VC প্রমাণ করে বা counterexample দেয়; unknown মানে প্রমাণ নয়।

পরের পর্বে: মডিউল ৩ শুরু, পর্ব ১৪ “সেট — সংজ্ঞা, subset, power set আর Cartesian product”: discrete math-এর সবচেয়ে মৌলিক বস্তু সেট, আর এই মডিউলের প্রমাণ-পদ্ধতি দিয়ে সেট-সংক্রান্ত দাবির প্রমাণ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান