[Program Synthesis #3] CEGIS: 반례로 프로그램을 찾아가는 방법
Program Synthesis 시리즈 3편 – CounterExample-Guided Inductive Synthesis(CEGIS)를 통해 프로그램을 점진적으로 찾아가는 핵심 알고리즘 이해하기
들어가며
앞선 글에서 우리는 Program Synthesis 문제를
논리식(SAT/SMT), 그리고 grammar 기반 문제(SyGuS)로 바꾸는 방법을 살펴봤다.
이제 진짜 중요한 질문이 남는다.
“그래서 프로그램은 어떻게 찾는가?”
이 질문에 대한 가장 핵심적인 답이 바로 CEGIS다.
CEGIS는 CounterExample-Guided Inductive Synthesis의 약자로,
이름 그대로 “반례(counterexample)를 이용해서 프로그램을 점점 개선해 나가는 방법”이다.
이 방식은 단순한 brute-force가 아니라,
틀린 프로그램을 빠르게 제거하면서 정답으로 수렴하는 구조를 가진다.
이 글에서는 다음을 다룬다:
- Program Synthesis를 푸는 기본 루프
- CEGIS 알고리즘의 구조
- 왜 이 방식이 효과적인지
- 실제로 어떻게 동작하는지
이 글을 이해하면, 이후 나오는 대부분의 synthesis 기법들은
전부 “CEGIS의 변형”이라는 걸 보게 될 것이다.
CEGIS의 핵심 아이디어
Program Synthesis 문제를 다시 생각해보자.
우리는 다음과 같은 문제를 풀고 있다:
\[\exists f.\ \forall x.\ P(f, x)\]즉,
- 어떤 프로그램 \(f\)를 찾아서
- 모든 입력 \(x\)에 대해
- 원하는 조건 \(P(f, x)\)를 만족하게 해야 한다
이 문제는 직접 풀기 어렵다.
왜냐하면:
- \(f\)는 프로그램 전체 (거대한 공간)
- \(x\)는 모든 가능한 입력 (무한 공간)
그래서 CEGIS는 이걸 이렇게 바꾼다:
“모든 입력을 한 번에 보지 말고,
일부 입력만 보고 프로그램을 만들고 → 틀리면 고친다”
이걸 일상적인 비유로 보면:
- 대충 프로그램 하나 만든다 (guess)
- 테스트를 돌린다
- 틀린 입력을 발견한다 (counterexample)
- 그 입력을 반영해서 다시 만든다
이 과정을 반복한다.
즉, CEGIS는 이렇게 요약된다:
“프로그램을 만들고 → 반례로 깨고 → 다시 만든다”
CEGIS 알고리즘 구조
앞에서 본 직관을 조금 더 정식화해보자.
CEGIS는 Program Synthesis 문제
\[\exists f.\ \forall x.\ P(f, x)\]를 다음과 같은 반복 구조로 푼다:
1. 두 개의 문제로 나누기
CEGIS의 핵심은 문제를 두 개로 분리하는 것이다.
- Synthesis 문제 (Generator)
- Verification 문제 (Checker)
Generator (프로그램 생성)
현재까지 알려진 입력 집합 \(E\)에 대해:
\[\exists f.\ \forall x \in E.\ P(f, x)\]즉,
“지금까지 발견된 입력들에서는 맞는 프로그램을 하나 만들어라”
Checker (검증)
이제 그 프로그램이 진짜 맞는지 확인한다:
\[\exists x.\ \neg P(f, x)\]즉,
“이 프로그램을 틀리게 만드는 입력이 존재하는가?”
- 존재하면 → counterexample 발견
- 없으면 → 정답 프로그램
2. 전체 루프
이걸 합치면 CEGIS는 다음과 같은 반복이 된다:
1
2
3
4
5
6
7
8
9
10
11
E = {} # counterexample set
while True:
f = synthesize(E) # Generator
x = verify(f) # Checker
if x is None:
return f # 정답 발견
E = E ∪ {x} # 반례 추가
3. 핵심 포인트
이 구조에서 중요한 점은 딱 세 가지다:
(1) 전체 입력을 보지 않는다
처음에는 \(E = \emptyset\) 이다.
즉, 프로그램은 아무 제약 없이 생성된다.
→ 그래서 매우 빠르게 후보가 나온다
(2) 틀린 부분만 학습한다
Checker는 전체를 검사하는 대신,
“딱 하나 틀리는 입력”만 찾아준다
→ search space를 효율적으로 줄인다
(3) 점점 정확해진다
\[E = \{x_1, x_2, x_3, ...\}\]이렇게 반례가 쌓일수록,
- Generator는 더 많은 조건을 만족해야 하고
- 프로그램은 점점 정답에 가까워진다
4. 구조적으로 보면
CEGIS는 사실 이렇게 볼 수 있다:
Induction + Counterexample
- Induction: 일부 데이터로 일반화된 프로그램 생성
- Counterexample: 잘못된 일반화를 깨는 증거
즉,
“틀릴 수 있는 자유를 주고 → 틀리면 바로 고친다”
이게 CEGIS의 본질이다.
간단한 예제로 보는 CEGIS
이제 CEGIS가 실제로 어떻게 동작하는지 간단한 예제로 살펴보자.
문제 정의
다음과 같은 프로그램을 찾고 싶다고 하자:
입력 \(x\)에 대해 \(x + 1\)을 반환하는 함수 \(f(x)\)
즉, 우리가 원하는 조건은:
\[P(f, x) := f(x) = x + 1\]그리고 우리는 다음과 같은 매우 제한된 grammar를 사용한다고 가정하자:
1
2
3
f(x) ::= x
| x + 1
| x + 2
Step 1 — 초기 상태
처음에는 counterexample 집합이 비어 있다:
\[E = \emptyset\]Generator는 다음 문제를 푼다:
\[\exists f.\ \forall x \in \emptyset.\ P(f, x)\]이건 항상 참이다.
→ 즉, 아무 프로그램이나 선택해도 된다.
그래서 Generator는 임의로 다음을 선택할 수 있다:
1
f(x) = x
Step 2 — Verification
이제 Checker가 등장한다.
우리는 다음을 검사한다:
\[\exists x.\ f(x) \neq x + 1\]간단히 보면:
- \(x = 0\)일 때
- \[f(0) = 0\]
- 하지만 \(0 + 1 = 1\)
→ 틀렸다
그래서 counterexample:
\[x = 0\]이제:
\[E = \{0\}\]Step 3 — 다시 Synthesis
이제 Generator는 다음을 만족해야 한다:
\[\exists f.\ P(f, 0)\]즉,
\[f(0) = 1\]가능한 후보들을 보면:
- \(f(x) = x\) → \(f(0)=0\) ❌
- \(f(x) = x + 1\) → \(f(0)=1\) ✅
- \(f(x) = x + 2\) → \(f(0)=2\) ❌
그래서 Generator는:
1
f(x) = x + 1
을 선택한다.
Step 4 — 다시 Verification
이제 다시 검사한다:
\[\exists x.\ f(x) \neq x + 1\]이번에는?
- 어떤 \(x\)를 넣어도
- \[f(x) = x + 1\]
→ 반례 없음
즉,
검증 성공 → synthesis 종료
전체 흐름 요약
- 틀린 프로그램 \(f(x)=x\) 생성
- 반례 \(x=0\) 발견
- 조건 강화
- 올바른 프로그램 \(f(x)=x+1\) 도출
중요한 관찰
이 예제에서 핵심은 이것이다:
처음부터 완벽한 프로그램을 찾지 않는다
대신:
- “대충 맞는 프로그램”을 만들고
- “틀린 부분만 수정”한다
이 방식 덕분에:
- 전체 입력 공간을 다 보지 않아도 되고
- 빠르게 정답으로 수렴할 수 있다
왜 CEGIS는 잘 동작하는가
앞의 예제는 단순했지만, 중요한 질문이 하나 남는다.
왜 이런 방식이 실제로도 잘 동작할까?
CEGIS는 겉보기에는 단순한 반복처럼 보이지만,
그 안에는 매우 중요한 구조적 이유가 있다.
1. 문제를 “어려운 것 → 쉬운 것”으로 바꾼다
원래 우리가 풀고 싶은 문제는 이것이다:
\[\exists f.\ \forall x.\ P(f, x)\]이건 매우 어렵다.
- \(f\)는 프로그램 전체
- \(x\)는 모든 입력
CEGIS는 이걸 이렇게 바꾼다:
\[\exists f.\ \forall x \in E.\ P(f, x)\]즉,
“일부 입력만 맞추는 문제”로 축소한다
이건 훨씬 쉽다.
- 입력이 finite set \(E\)로 제한됨
- constraint solving이 가능해짐 (SMT로 풀 수 있음)
2. Verification이 “틀린 부분만 알려준다”
Checker는 전체 correctness를 증명하지 않는다.
대신:
\[\exists x.\ \neg P(f, x)\]이걸 푼다.
즉,
“이 프로그램을 깨는 입력 하나만 찾아라”
이게 중요한 이유는:
- 전체 검증보다 훨씬 쉽고
- 실패 원인을 정확히 pinpoint 한다
3. Search space를 빠르게 줄인다
각 iteration에서 우리는 하나의 constraint를 추가한다:
\[P(f, x_1),\ P(f, x_2),\ \dots\]즉 프로그램은 점점 더 많은 조건을 만족해야 한다.
이건 곧:
가능한 프로그램 후보가 계속 줄어든다는 뜻이다
직관적으로 보면:
- 처음: 거의 모든 프로그램 가능
- 중간: 일부만 가능
- 마지막: 정답만 남음
4. Overfitting을 방지한다
Inductive synthesis에서 가장 큰 문제는 이것이다:
주어진 예제에는 맞지만, 실제로는 틀린 프로그램
CEGIS는 이 문제를 이렇게 해결한다:
- 잘못된 일반화 → counterexample로 깨짐
- 그 입력이 다음 학습에 반영됨
즉,
“틀린 일반화는 반드시 반례로 제거된다”
5. Lazy solving과의 유사성
이 구조는 앞에서 본 SMT의 Lazy 방식과 매우 닮아 있다.
Recall:
\[\text{Guess} \rightarrow \text{Check} \rightarrow \text{Refine}\]CEGIS도 동일하다:
\[\text{Program} \rightarrow \text{Counterexample} \rightarrow \text{Refine}\]즉,
CEGIS는 synthesis 버전의 Lazy solving이다
핵심 정리
CEGIS가 잘 동작하는 이유는 다음 세 가지로 요약된다:
- 전체 문제를 작은 문제로 쪼갠다
- 틀린 부분만 정확히 수정한다
- 반복하면서 탐색 공간을 빠르게 줄인다
그래서 CEGIS는 단순한 알고리즘이 아니라,
Program Synthesis의 “기본 구조”
라고 볼 수 있다.
SyGuS와 SMT에서의 CEGIS
지금까지 CEGIS를 하나의 알고리즘으로 이해했다.
이제 중요한 질문이 남는다.
이 구조가 실제 SyGuS solver에서는 어떻게 사용될까?
1. 역할 분담: Generator vs SMT Solver
CEGIS를 다시 보면 두 개의 역할이 있었다:
- Generator: 프로그램을 만든다
- Checker: 프로그램이 맞는지 검사한다
SyGuS에서는 이 역할이 다음처럼 나뉜다:
- Generator → synthesis engine
- Checker → SMT solver
즉,
SMT solver는 “프로그램이 틀렸는지 찾는 역할”을 한다
2. Verification은 SMT 문제다
Checker 단계는 다음 문제를 푸는 것이다:
\[\exists x.\ \neg P(f, x)\]이건 정확히 SMT 문제다.
예를 들어:
\[f(x) = x + 1\]이고,
\[P(f, x) := f(x) = x + 1\]라면 SMT solver는 이런 질문을 받는다:
“이 식을 깨는 \(x\)가 존재하는가?”
- 존재하면 → 모델 (counterexample) 반환
- 없으면 → UNSAT (정답)
즉,
counterexample은 SMT solver가 생성하는 모델이다
3. 전체 구조를 다시 보면
SyGuS solver는 내부적으로 다음 구조를 가진다:
\[\text{Generate} \rightarrow \text{SMT Check} \rightarrow \text{Refine}\]조금 더 구체적으로 쓰면:
1
2
3
4
5
6
7
8
9
10
11
E = {}
while True:
f = generate_from_grammar(E)
x = SMT_solve(∃x. ¬P(f, x))
if UNSAT:
return f
E.add(x)
4. Grammar와의 연결
여기서 중요한 점 하나:
Generator는 아무 프로그램이나 만드는 것이 아니다
항상 다음 조건을 만족해야 한다:
\[f \in L(G)\]즉,
- Grammar가 허용하는 프로그램만 생성 가능
- search space는 이미 제한되어 있음
그래서 전체 구조는 이렇게 된다:
\[\text{Grammar} + \text{CEGIS} + \text{SMT}\]- Grammar → 어디까지 탐색할지
- CEGIS → 어떻게 탐색할지
- SMT → 맞는지 검사
5. 핵심 통합 구조
이제 SyGuS 전체를 한 줄로 정리하면 이렇게 된다:
\[\text{Synthesis} = \text{Search (Grammar)} + \text{Verification (SMT)} + \text{Loop (CEGIS)}\]이게 바로 실제 synthesis system의 기본 구조다.
중요한 관점
이제 관점이 완전히 바뀐다.
우리는 더 이상:
“프로그램을 만든다”
가 아니라,
“프로그램 후보를 만들고, SMT로 틀린 걸 제거한다”
를 하고 있다.
즉 Program Synthesis는 본질적으로:
탐색 + 반례 기반 필터링 과정
이다.
다음 글
이제 전체 구조는 완성됐다.
다음 글에서는 이 구조의 첫 번째 핵심 구성 요소인
Enumerative Synthesis
를 다룬다.
- Grammar에서 프로그램을 실제로 어떻게 생성하는지
- bottom-up vs top-down
- 왜 naive enumeration이 터지는지
를 구체적으로 살펴보게 된다.
