Lean에 내장된 증명 자동화만으로도 arrayMapHelper와 findHelper가 종료된다는 것을 충분히 확인할 수 있습니다. 필요한 것은 재귀 호출마다 값이 감소하는 표현식을 제공하는 것뿐이었습니다. 그러나 Lean에 내장된 자동화는 마법이 아니며, 도움이 필요한 경우가 많습니다.
종료 증명이 자명하지 않은 함수의 한 예로 List에 대한 병합 정렬이 있습니다. 병합 정렬은 두 단계로 이루어집니다. 먼저 리스트를 절반으로 나눕니다. 나뉜 각 절반을 병합 정렬로 정렬한 다음, 정렬된 두 리스트를 하나의 더 큰 정렬된 리스트로 합치는 함수를 사용하여 그 결과를 병합합니다. 기저 사례는 빈 리스트와 원소가 하나인 리스트이며, 둘 다 이미 정렬된 것으로 간주됩니다.
정렬된 두 리스트를 병합할 때 고려해야 할 기본적인 경우는 두 가지입니다.
입력 리스트 중 하나가 비어 있으면, 결과는 나머지 리스트입니다.
두 리스트가 모두 비어 있지 않다면 두 리스트의 머리를 비교해야 합니다. 함수의 결과는 두 머리 중 더 작은 값 뒤에 두 리스트의 나머지 원소들을 병합한 결과가 이어지는 리스트입니다.
이 함수는 두 리스트 중 어느 쪽에 대해서도 구조적 재귀가 아닙니다. 재귀 호출마다 두 리스트 중 하나에서 항목이 하나씩 제거되므로 재귀는 종료되지만, 어느 리스트에서 제거될지는 정해져 있지 않습니다. 내부적으로 Lean은 이 사실을 이용해 함수가 종료됨을 증명합니다:
병합 정렬은 기저 사례에 도달했는지 확인합니다. 도달했다면 입력 리스트를 반환합니다. 도달하지 않았다면 입력을 나누고, 각 절반을 정렬한 결과를 병합합니다:
deffail to show termination formergeSortwith errorsfailed to infer structural recursion:Not considering parameter α of mergeSort:it is unchanged in the recursive callsNot considering parameter #2 of mergeSort:it is unchanged in the recursive callsCannot use parameter xs:failed to eliminate recursive applicationmergeSorthalves.fstCould not find a decreasing measure.
The basic measures relate at each recursive call as follows:
(<, ≤, =: relation proved, ? all proofs failed, _: no proof attempted)
xs #1
1) 70:11-31 ? ?
2) 70:34-54 _ _
#1: xs.length
Please use `termination_by` to specify a decreasing measure.mergeSort[Ordα](xs:Listα):Listα:=ifh:xs.length<2thenmatchxswith|[]=>[]|[x]=>[x]elselethalves:=splitListxsmerge(mergeSorthalves.fst)(mergeSorthalves.snd)
Lean의 패턴 매칭 컴파일러는 xs.length<2인지 검사하는 if가 도입한 가정 h가 원소가 둘 이상인 리스트를 배제한다는 것을 알아낼 수 있으므로, “누락된 경우(missing cases)” 오류가 발생하지 않습니다. 그러나 이 프로그램은 항상 종료하지만 구조적 재귀가 아니며, Lean은 감소하는 척도를 자동으로 찾아내지 못합니다:
fail to show termination formergeSortwith errorsfailed to infer structural recursion:Not considering parameter α of mergeSort:it is unchanged in the recursive callsNot considering parameter #2 of mergeSort:it is unchanged in the recursive callsCannot use parameter xs:failed to eliminate recursive applicationmergeSorthalves.fstCould not find a decreasing measure.
The basic measures relate at each recursive call as follows:
(<, ≤, =: relation proved, ? all proofs failed, _: no proof attempted)
xs #1
1) 70:11-31 ? ?
2) 70:34-54 _ _
#1: xs.length
Please use `termination_by` to specify a decreasing measure.
이 함수가 종료되는 이유는 splitList가 적어도 원소가 두 개 이상인 리스트에 적용될 때는 항상 입력보다 짧은 리스트를 반환하기 때문입니다. 따라서 halves.fst와 halves.snd의 길이는 xs의 길이보다 짧습니다. 이는 termination_by 절을 사용하여 표현할 수 있습니다:
defmergeSort[Ordα](xs:Listα):Listα:=ifh:xs.length<2thenmatchxswith|[]=>[]|[x]=>[x]elselethalves:=splitListxsmerge(failed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goalα:Type u_1xs:Listαh:¬xs.length<2halves:Listα×Listα := splitListxs⊢ (splitListxs).fst.length<xs.lengthmergeSorthalves.fst)(mergeSorthalves.snd)termination_byxs.length
이 절을 추가하면 오류 메시지가 바뀝니다. Lean은 함수가 구조적으로 재귀적이지 않다고 불평하는 대신, (splitList xs).fst.length < xs.length임을 자동으로 증명할 수 없었다는 점을 지적합니다:
failed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goalα:Type u_1xs:Listαh:¬xs.length<2halves:Listα×Listα := splitListxs⊢ (splitListxs).fst.length<xs.length
또한 (splitList xs).snd.length < xs.length임도 증명해야 합니다. splitList는 두 리스트에 번갈아 가며 항목을 추가하므로 두 명제를 한꺼번에 증명하는 것이 가장 쉽습니다. 그러면 증명의 구조가 splitList를 구현하는 데 사용한 알고리즘을 따라갈 수 있습니다. 다시 말해, ∀(lst:Listα),(splitListlst).fst.length<lst.length∧(splitListlst).snd.length<lst.length임을 증명하는 것이 가장 쉽습니다.
안타깝게도 이 명제는 거짓입니다. 구체적으로 말하면, splitList[]는 ([],[])입니다. 두 출력 리스트의 길이는 모두 0이며, 이는 입력 리스트의 길이인 0보다 작지 않습니다. 마찬가지로 splitList["basalt"]는 (["basalt"],[])로 평가되며, ["basalt"]는 ["basalt"]보다 짧지 않습니다. 하지만 splitList["basalt","granite"]는 (["basalt"],["granite"])로 평가되며, 이 두 출력 리스트는 모두 입력 리스트보다 짧습니다.
출력 리스트의 길이는 항상 입력 리스트의 길이보다 작거나 같지만, 엄밀하게 더 짧아지는 것은 입력 리스트에 원소가 두 개 이상 있을 때뿐입니다. 앞의 명제를 먼저 증명한 다음 이를 뒤의 명제로 확장하는 것이 가장 쉽습니다. 먼저 정리 명제부터 작성합니다.
splitList는 리스트에 대해 구조적으로 재귀하므로, 증명에는 귀납법을 사용해야 합니다. splitList의 구조적 재귀는 귀납법에 의한 증명에 완벽하게 들어맞습니다. 귀납법의 기저 사례는 재귀의 기저 사례에 대응하고, 귀납 단계는 재귀 호출에 대응합니다. induction 택틱은 두 개의 목표를 제시합니다.
nil 경우의 목표는 단순화기를 호출하여 splitList의 정의를 펼치도록 지시하면 증명할 수 있습니다. 빈 리스트의 길이는 빈 리스트의 길이보다 작거나 같기 때문입니다. 마찬가지로, cons 경우에서 splitList로 단순화하면 목표에 있는 길이들을 Nat.succ가 감싸게 됩니다:
다시 말해, A∧B의 증명은 left 필드에 들어가는 A의 증명과 right 필드에 들어가는 B의 증명에 And.intro 생성자를 적용한 것으로 이루어집니다.
cases 택틱을 사용하면 증명에서 데이터 타입의 각 생성자나 명제의 가능한 각 증명을 차례로 고려할 수 있습니다. 이는 재귀가 없는 match 표현식에 해당합니다. 구조체에 cases를 사용하면 구조체가 분해되고 구조체의 각 필드마다 가정이 하나씩 추가됩니다. 이는 패턴 매칭 표현식이 프로그램에서 사용할 수 있도록 구조체의 필드를 추출하는 것과 같습니다. 구조체에는 생성자가 하나뿐이므로, 구조체에 cases를 사용해도 목표가 추가로 생기지 않습니다.
내부에서 어떤 일이 일어나는지 드러내기 위해 apply 택틱과 exact 택틱을 사용하여 어떤 생성자가 적용되는지 정확히 나타낼 수 있습니다. apply 택틱은 반환 타입이 일치하는 함수나 생성자를 적용하여 현재 목표를 해결하며, 제공되지 않은 인자마다 새로운 목표를 생성합니다. 반면 exact는 새로운 목표가 하나라도 필요하게 되면 실패합니다:
이 짧은 택틱 스크립트에서는 induction이 만들어 낸 두 목표를 모두 repeat(first|constructor|assumption)을 사용하여 처리합니다. 택틱 first | T1 | T2 | ... | Tn은 T1부터 Tn까지 순서대로 시도하여 처음으로 성공하는 택틱을 사용한다는 뜻입니다. 다시 말해, repeat(first|constructor|assumption)은 가능한 한 계속 생성자를 적용한 다음, 가정을 사용하여 목표를 해결하려고 시도합니다.
증명 방식마다 적합한 상황이 서로 다릅니다. 상세한 증명 스크립트는 초보자가 코드를 읽을 수 있는 경우나 증명의 단계가 어떤 통찰을 주는 경우에 유용합니다. 짧고 고도로 자동화된 증명 스크립트는 일반적으로 유지 관리하기가 더 쉽습니다. 자동화는 정의와 데이터 타입이 조금 바뀌어도 유연하고 견고하게 대응하는 경우가 많기 때문입니다. 재귀 함수는 일반적으로 수학적 증명의 관점에서 이해하기도 더 어렵고 유지 관리하기도 더 어렵지만, 대화형 정리 증명을 막 시작하는 프로그래머에게는 유용한 다리 역할을 할 수 있습니다.
병합 정렬에는 splitList가 반환하는 하위 리스트마다 하나씩, 모두 두 개의 재귀 호출이 있습니다. 각 재귀 호출에는 그 호출에 전달되는 리스트의 길이가 입력 리스트의 길이보다 짧다는 증명이 필요합니다. 종료 증명은 보통 두 단계로 작성하는 것이 편리합니다. 먼저 Lean이 종료를 검증할 수 있게 해 줄 명제들을 적은 다음, 그 명제들을 증명합니다. 그렇게 하지 않으면 명제들을 증명하는 데 많은 노력을 들인 뒤에야 그 명제들이 재귀 호출의 입력이 더 작다는 것을 입증하는 데 꼭 필요한 것이 아니었음을 알게 될 수 있습니다.
sorry 택틱은 거짓인 목표까지 포함하여 어떤 목표든 증명할 수 있습니다. 프로덕션 코드나 최종 증명에 사용하기 위한 것은 아니지만, 증명이나 프로그램을 미리 “스케치”해 두는 데 편리한 방법입니다. sorry를 사용하는 정의나 정리에는 모두 경고가 표시됩니다.
sorry를 사용하는 mergeSort 종료 인자의 초기 스케치는 Lean이 증명하지 못한 목표를 have 표현식으로 옮겨 적어서 작성할 수 있습니다. Lean에서 have는 let과 비슷합니다. have를 사용할 때는 이름을 생략할 수 있습니다. 일반적으로 let은 흥미로운 값을 가리키는 이름을 정의하는 데 사용하고, have는 Lean이 배열 조회가 범위 안에 있다는 근거나 함수가 종료한다는 근거를 찾을 때 발견될 수 있는 명제를 지역적으로 증명하는 데 사용합니다.
곱셈이 덧셈의 반복이고 거듭제곱이 곱셈의 반복이듯이, 나눗셈은 뺄셈의 반복으로 이해할 수 있습니다. 이 책에서 재귀 함수를 맨 처음 설명한 부분에서는 제수가 0이 아니면 종료되는 나눗셈을 제시하지만, Lean은 이 정의를 받아들이지 않습니다. 나눗셈이 종료됨을 증명하려면 부등식에 관한 사실을 사용해야 합니다.
Lean은 이 나눗셈 정의가 종료된다는 것을 증명할 수 없습니다:
deffail to show termination fordivwith errorsfailed to infer structural recursion:Not considering parameter k of div:it is unchanged in the recursive callsCannot use parameter n:failed to eliminate recursive applicationdiv(n-k)kfailed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goalkn:Nath✝:¬n<k⊢ n-k<ndiv(nk:Nat):Nat:=ifn<kthen0else1+div(n-k)k
fail to show termination fordivwith errorsfailed to infer structural recursion:Not considering parameter k of div:it is unchanged in the recursive callsCannot use parameter n:failed to eliminate recursive applicationdiv(n-k)kfailed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goalkn:Nath✝:¬n<k⊢ n-k<n
다행스러운 일입니다. 실제로 종료되지 않기 때문입니다! k가 0이면 n의 값이 줄어들지 않으므로 이 프로그램은 무한 루프에 빠집니다.
k가 0이 아니라는 증거를 받도록 함수를 다시 작성하면 Lean이 종료성을 자동으로 증명할 수 있습니다: