Lean에 내장된 증명 자동화만으로도 arrayMapHelper와 findHelper가 종료함을 확인하기에 충분합니다. 필요했던 것은 재귀 호출마다 그 값이 감소하는 표현식을 제공하는 것뿐이었습니다. 하지만 Lean에 내장된 자동화는 마법이 아니며, 종종 도움이 필요합니다.
종료 증명이 자명하지 않은 함수의 한 예로 List에 대한 병합 정렬이 있습니다. 병합 정렬은 두 단계로 구성됩니다. 먼저 리스트를 절반으로 나눕니다. 각 절반은 병합 정렬(merge sort)을 사용하여 정렬된 후, 두 개의 정렬된 리스트를 더 큰 정렬된 리스트로 합치는 함수를 사용하여 결과가 병합됩니다. 기저 사례는 빈 리스트와 단일 원소 리스트이며, 둘 다 이미 정렬된 것으로 간주됩니다.
정렬된 두 리스트를 병합하려면 고려해야 할 두 가지 기본 경우가 있습니다:
입력 목록 중 하나가 비어 있으면, 결과는 다른 목록이 됩니다.
두 리스트가 모두 비어 있지 않다면, 두 리스트의 머리를 비교해야 합니다. 함수의 결과는 두 머리 중 더 작은 것 다음에 두 리스트의 나머지 항목들을 병합한 결과가 이어진 것입니다.
이것은 두 리스트 중 어느 쪽에 대해서도 구조적으로 재귀적이지 않습니다. 이 재귀는 각 재귀 호출마다 두 리스트 중 하나에서 항목이 제거되기 때문에 종료되지만, 어느 리스트에서 제거될지는 정해져 있지 않습니다. 내부적으로 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가 항목이 하나보다 긴 리스트를 배제한다는 것을 알아낼 수 있으므로, "누락된 경우" 오류가 발생하지 않습니다. 하지만 이 프로그램이 항상 종료하더라도, 이는 구조적 재귀가 아니며 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를 사용해도 추가 목표가 생기지 않습니다.
splitList_shorter_le를 증명하는 데 필요한 부등식은 ∀(nm:Nat),n≤m→n≤m+1입니다. n≤m라는 들어오는 가정은 본질적으로 n과(와) m 사이의 차이를 Nat.le.step 생성자의 개수로 추적합니다. 따라서 증명은 기본 사례(base case)에서 추가로 Nat.le.step을 덧붙여야 합니다.
배후에서 일어나는 일을 드러내려면, apply와 exact 택틱을 사용하여 정확히 어떤 생성자가 적용되고 있는지 나타낼 수 있습니다. apply 택틱은 반환 타입이 일치하는 함수나 생성자를 적용하여 현재 목표를 해결하며, 제공되지 않은 인자마다 새로운 목표를 생성하는 반면, exact는 새로운 목표가 필요한 경우 실패합니다:
이 짧은 택틱 스크립트에서 induction에 의해 도입된 두 목표 모두 repeat(first|constructor|assumption)을 사용하여 처리됩니다. first | T1 | T2 | ... | Tn 택틱은 T1부터 Tn까지를 순서대로 시도하여, 성공하는 첫 번째 택틱을 사용하는 것을 의미합니다. 다시 말해, repeat(first|constructor|assumption)는 가능한 한 생성자를 적용한 다음, 가정을 사용하여 목표를 해결하려고 시도합니다.
grind를 사용하면 증명을 훨씬 더 짧게 만들 수 있는데, 이 택틱에는 선형 산술을 위한 솔버가 포함되어 있습니다:
각 증명 스타일은 상황에 따라 적절할 수 있습니다. 상세한 증명 스크립트는 초보자가 코드를 읽게 될 수 있는 경우, 또는 증명의 단계들이 어떤 식으로든 통찰을 제공하는 경우에 유용합니다. 짧고 고도로 자동화된 증명 스크립트는 유지 관리가 더 쉬운 경우가 많은데, 이는 자동화가 정의와 데이터 타입에 대한 작은 변경에도 유연하고 견고한 경우가 많기 때문입니다. 재귀 함수는 일반적으로 수학적 증명의 관점에서 이해하기가 더 어렵고 유지 보수하기도 더 어렵지만, 대화형 정리 증명기 작업을 막 시작한 프로그래머에게는 유용한 다리 역할을 할 수 있습니다.
병합 정렬(merge sort)은 두 개의 재귀 호출을 가지며, 각각 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이 종료성을 자동으로 증명할 수 있습니다: