내 바에 있는 재료와 레시피북을 바탕으로, Datalog로 어떤 칵테일을 만들 수 있고 무엇을 사야 선택지를 최대화할 수 있는지 계산하는 작은 프로그램과 그 구현·시행착오를 소개한다.
내 바에 있는 재료:
$ cat facts/bar
brandy
dry vermouth
lemon
light rum
lime
orange liqueur
reposado tequila
sugar
그리고 나의 칵테일 레시피 북:
$ head facts/recipes
martini <- london dry gin
martini <- dry vermouth
daiquiri <- light rum
daiquiri <- lime juice
daiquiri <- simple syrup
margarita <- blanco tequila or reposado tequila
margarita <- lime juice
margarita <- orange liqueur
margarita <- lime wedge
...
나는 어떤 칵테일을 섞을 수 있을까?
$ cat results/mixable
daiquiri
margarita
sidecar
세상에, 별로 많지 않네. 선택지를 넓히려면 바에 무엇을 더 사야 할까?
$ cat results/shopping-list
london dry gin -> gimlet
london dry gin -> martini
champagne -> airmail
cognac -> between-the-sheets
sherry -> sherry-cobbler
rhum agricole -> ti-punch
아니요, 똑똑합니다. 다이키리(Daiquiri)를 자세히 보세요: 레시피에는 라임 주스가 필요하다고 되어 있는데, 우리 바에는 라임 주스가 없습니다. 라임만 있죠. 그런데 왜 만들 수 있는 칵테일 목록에 나오죠?
질문해줘서 참 기쁘네요. 이 결과는 내가 만든 작은 Datalog 프로그램인 Mixologician이 생성합니다.
그리고 Mixologician은 몇 가지 규칙을 알고 있어요. 라임으로 라임 주스를 만들 수 있다는 것 – 그리고 라임 웨지, 라임 제스트 등도 만들 수 있다는 걸요. 심지어 라임 제스트와 설탕으로 라임 코디얼을 만들 수 있다는 것도 알아요 – 그래서 우리는 김릿(Gimlet)을 만들기까지 단 한 가지 재료만 더 필요하다는 걸 압니다. 비록 기술적으로는 그 칵테일이 요구하는 재료를 하나도 갖고 있지 않더라도요.
그럼요!
나는 칵테일을 좋아합니다. 예전에는 식당에 가서 칵테일을 주문하곤 했죠. 그런데 어떤 이유 때문에 한동안 식당에 가지 않게 되면서, 대신 집에서 칵테일을 만들어 마시게 됐습니다.
하지만 매번 같은 칵테일만 만들고 싶지는 않죠. 식당에서 칵테일을 주문하는 것의 묘미 중 큰 부분은 다양성이었어요. 예전에 자주 가던 멋진 힙스터 식당들은 각자 고유한 칵테일 메뉴 – 내가 들어본 적도 없는 재료들로 가득한 독특한 음료 목록 – 를 갖고 있었거든요.
이건 집에서 술을 만드는 사람에게는 약간 문제입니다.
보세요, 내 술장은 아주 방대하지 않습니다. 기본은 갖추고 있고, 격리 기간 동안 조금 더 근사한 재료들도 몇 가지 사들였죠. 그런데 근사한 재료는 보통 활용도가 높지 않아요: 페이퍼 플레인(Paper Plane)을 만들려고 한 번 아마로 노니노(Amaro Nonino)를 샀습니다. 근데 알고 보니 페이퍼 플레인을 그다지 좋아하지 않더라고요. 그래서 지금은 아마로 노니노 병의 약 97.1%가 남아 있고, 쓸 데가 없습니다.1
그건 아주 비효율적인 구매였죠. 더 잘할 수 있어요.
그래서 나는 작은 프로그램을 썼습니다. 지금 내 바에 있는 걸 기준으로, 무엇을 추가로 사면 가장 많은 새로운 칵테일을 만들 수 있게 되는지 알려주는 프로그램이요. 다시 말해, 가장 효율적인 구매 – 내가 가장 “막혀 있는” 재료 – 가 무엇인지 알려주도록 했습니다.
이건 _Datalog_를 써 볼 좋은 기회라고 생각했습니다. 예전에 어디선가 들어만 본 단어이기도 했고요.
아마 이게 사소한 문제처럼 들릴지도 모르겠어요. 파이썬 몇 줄로 현재 갖고 있는 재료와 필요한 재료 간의 차집합을 구하고, 한 가지 재료만 빠진 칵테일로 필터링하면 되겠다고 말이죠.
그리고… 맞습니다. 그리 어려운 문제가 아니에요.
내가 논리 프로그래밍이 적합하다고 느낀 이유는 “생산 규칙” 같은 개념 때문이었습니다. 어떤 재료는 다른 재료가 될 수 있고(혹은 결합되어 다른 재료가 될 수 있고), 어떤 재료는 다른 재료처럼 _행동_할 수 있다는 개념 말이죠.
물론, 좋아하는 스크립트 언어로 그걸 모델링하는 게 _어렵다_고 말하려는 건 아니에요. 다만 이제는 더 이상 추측과 검증을 섞은 무차별 대입식 탐색 없이 해결하는 방법이 딱히 자명하진 않다는 겁니다. 그게 나쁘다는 건 아니고요; 그저 논리 프로그래밍이 더 자연스럽게 맞아 떨어지는 느낌이었습니다.
아니면 아닐 수도! 누가 알겠어요. 사실 나도 논리 프로그래밍에 대해 아는 게 별로 없거든요. 이 모든 건 그냥 한 번 써 보려는 핑계일 뿐입니다.
코드는, 모든 코드가 그렇듯, GitHub에 있습니다. 프로젝트를 훑어볼 수 있어요 – 테스트부터 시작하는 걸 추천합니다. 어떻게 동작하는지 문학적으로 안내해 주는 역할을 합니다.
하지만 감히 말하건대, 코드 자체는 그렇게 흥미롭지 않을 수 있어요: 이미 Datalog를 안다면 사소하고; 모른다면 난해하니까요.
그래서 대신, 최종 결과를 한 조각씩 살펴보며, 그 과정에서 내가 저지른 시행착오와 실수를 이야기하려 합니다. 이게 내 첫 Datalog 경험이었고, Datalog가 요구하는 방식으로 “생각”할 수 있게 되기까지 시간이 조금 걸렸거든요 – 그리고 과정에서 생긴 오류들이 최종 결과보다 훨씬 더 흥미롭다고 생각합니다.
원한다면 집에서 따라와도 좋아요.
타입 정의 몇 개로 시작합니다:
.type Ingredient <: symbol .type Recipe <: symbol
커스텀 타입은 이후 코드의 가독성을 높여주고, 관계의 인자 순서를 뒤바꾸는 같은 어이없는 실수를 컴파일러가 잡아줄 수 있게 해줍니다.
symbol은 Datalog에서 “string”을 의미합니다 – LISP나 Ruby처럼 이 용어를 다르게 쓰는 언어에 익숙하다면 약간 헷갈릴 수 있어서 짚고 넘어갑니다.
.decl Has(x : Ingredient) .input Has(filename="bar")
Has는 우리의 첫 번째 “관계(relation)”입니다.
관계에 대해 생각하는 게 많이 어려웠는데, 나중에야 깨달은 게 있어요. 돌이켜보면 너무 당연한데: 사실 나는 관계형 데이터베이스에서 관계를 매일 씁니다. 그래서 Has를 SQL의 단일 컬럼 테이블처럼 생각하는 게 도움이 될 수 있어요. 완전히 같은 개념은 아니지만, 데이터를 어떻게 “형상화”할지 생각하는 출발점으로는 괜찮았습니다.
.input 줄은 텍스트 파일에서 재료 목록을 – 한 줄에 하나씩 – 불러옵니다. 우리 테이블에 행(row)을 삽입한다고 할 수도 있겠죠 – 다만 Datalog에서는 “행”을 “사실(fact)”이라고 부릅니다.
.decl Needs(drink : Recipe, ingredient : Ingredient) .input Needs(filename="recipes", delimiter=" <- ")
이 관계는 좀 더 흥미롭습니다: 이게 우리의 레시피입니다. 전통적인 언어에서는 레시피를 { name : string, ingredients : List<string> } 같은 형태로 생각하겠지만, 관계를 “테이블 같은” 표현에 끼워 맞춰야 합니다.
여기서도 플레인 텍스트 파일에서 데이터를 읽어옵니다. 기본적으로 Soufflé는 탭으로 구분된 값을 원하지만, 보기 좋고 탭을 다루지 않아도 되도록 커스텀 구분자를 썼습니다. 탭을 상대하고 싶은 사람은 없잖아요.
.decl Begets(in : Ingredient, out : Ingredient) .input Begets(filename="begets", delimiter=" -> ") .input Begets(filename="auto-begets", delimiter=" -> ")
Begets는 나중에 많이 이야기하게 될 관계입니다. 지금은 “라임은 라임 주스를 낳는다” 또는 “코냑은 브랜디를 낳는다” 같은 문장 모음이라고 생각하세요 – 어떤 재료가 다른 재료가 될 수 있거나, 어떤 재료가 다른 재료처럼 행동할 수 있다는 뜻입니다. 이름 Begets가 어색하고 좀 투박한데, 왜 Makes나 ActsAs 같은 이름 대신 이걸 골랐는지는, 실제로 쓰는 모습을 본 뒤에 설명할게요.
Begets는 두 파일에서 로드합니다. 하나는 수작업으로 관리하고, 다른 하나는 자동 생성됩니다 – 왜 그런지는 레시피북을 이야기할 때 설명하겠습니다.
.decl Composite(result : Ingredient, first : Ingredient, second : Ingredient) .input Composite(filename="combinations", delimiter=", ")
이건 “다중 재료로 이루어진 재료”입니다. 기본적으로 향 시럽 같은 것들이죠 – 파일을 보면 몇 가지 예시가 있습니다:
$ cat facts/combinations
cinnamon syrup, sugar, cinnamon sticks
ginger syrup, sugar, ginger
lime cordial, sugar, lime zest
...
나는 두 가지 재료 조합만 지원하도록 했습니다. 대부분의 경우를 다 커버하거든요. 하지만 원한다면 Composite3() 같은 관계를 추가해서 더 많은 재료를 지원할 수도 있습니다.
.decl IsRecipe(x : Recipe) IsRecipe(x) :- Needs(x, _).
이제 조금 흥미로워집니다: 첫 번째 규칙(rule)입니다.
이건 파일에서 로드하지 않습니다. 대신 “Needs 관계의 첫 번째 ‘열’에 등장하면 x는 레시피다”라고 말하는 셈이죠. SQL로 비유하자면, 대충 이런 뷰에 해당합니다:
create view IsRecipe as
select name from Needs;
나는 단지 도우미로 IsRecipe를 만들었습니다. “모든 레시피”를 의미할 때 Needs(x, _)라고 쓰는 것보다 훨씬 명시적이라고 생각해서요.
.decl IsIngredient(x : Ingredient) IsIngredient(x) :- Needs(_, x). IsIngredient(x) :- Begets(x, _). IsIngredient(x) :- Begets(_, x). IsIngredient(x) :- Composite(x, _, _). IsIngredient(x) :- Composite(_, x, _). IsIngredient(x) :- Composite(_, _, x).
이것도 또 다른 도우미인데, 여러 규칙을 가집니다. 이 모든 규칙의 결과가 일종의 “합집합”으로 묶입니다. 요컨대: “레시피에 쓰였거나, Begets 규칙에 등장했거나, Composite 설명에 등장하면 x는 재료다”라는 뜻입니다.
물론 모든 재료를 어딘가 파일로 나열할 수도 있지만, 내포적(intensional)으로 관계를 선언하는 편이 훨씬 낫다고 생각합니다. 그리고 생애 처음으로 이 용어를 제대로 쓸 수 있었음을 자랑하고 싶기도 했고요.2
.decl Unbuyable(x : Ingredient) .input Unbuyable(filename="unbuyable") .input Unbuyable(filename="auto-unbuyable")
다음 관계는 꽤 단순합니다: Unbuyable은 egg white처럼 재료로는 등장하지만 최종 출력에는 나오지 않게 하고 싶은 것들의 목록입니다. 가게에서 살 수 없기 때문이죠. Begets와 마찬가지로 자동 생성된 항목과 수작업으로 고른 항목이 섞여 있습니다.
Begets(x, x) :- IsIngredient(x). Begets(x, z) :- Begets(x, y), Begets(y, z).
좋아요, 이제 핵심으로 들어갑니다.
첫 번째 규칙은 “모든 재료는 자기 자신을 낳는다”는 뜻입니다. 그냥 Begets(x, x).라고 쓰고 싶었지만, Datalog는 그런 “무한” 규칙을 허용하지 않습니다 – x의 도메인을 제공해야 하고, 여기서 IsIngredient 도우미가 등장합니다.
관계를 Makes나 Produces 대신 “begets(낳는다)”라고 부르는 이유가 바로 이것입니다. 원래는 Makes였고, 그래서 “만약 x가 있거나 Makes(y, x)인 y가 있으면 …” 같은 어색한 표현이 생겼습니다. 모든 것이 자기 자신을 낳게 만들자 “쇼핑 목록” 계산을 크게 단순화할 수 있었습니다.
두 번째 규칙은 Begets가 추이적(transitive)이라는 뜻입니다: 라임이 라임 껍질을 만들고, 라임 껍질이 라임 제스트를 만든다면, 라임은 라임 제스트를 만듭니다. 실제로 그렇게 세밀한 수준으로 규칙을 쓰지는 않았지만, 원한다면 그렇게 해도 됩니다.
이제 Begets를 실제로 어떻게 쓰는지 보죠.
Has(out) :- Has(in), Begets(in, out).
기본적으로, 입력(in, 예: 레몬)을 가지고 있고, 그 입력이 다른 것(out, 예: 레몬 주스)을 낳는다면, 그 “출력”도 갖고 있는 셈입니다. 다만 output이라는 단어는 예약어라서 쓰면 헷갈리는 에러가 납니다:
Error: syntax error, unexpected relation qualifier output, expecting )
in file mixologician.dl at line 32
Has(output) :- Has(in), Begets(in, output).
----^---------------------------------------
1 errors generated, evaluation aborted
처음에는 Has를 파일에서 로드했고, Datalog가 말하는 “사실(fact)”의 평평한 리스트였죠. 그런데 지금은 거기에 동적으로 사실을 추가하고 있습니다 – 처음에는 테이블로 생각했는데, 이제는 테이블/뷰 하이브리드 같은 이상한 게 되었네요. 그래서 SQL 비유가 약간 무너집니다.
그리고, Begets(x, x) 때문에 “만약 x가 있으면 x를 갖고 있다 – 왜냐하면 x가 x를 낳기 때문” 같은 자기지시적 무한 문장처럼 느껴질 수 있지만, Datalog는 신경 쓰지 않습니다.
Begets(x, result) :- Composite(result, first, second), Has(first), Begets(x, second). Begets(x, result) :- Composite(result, first, second), Has(second), Begets(x, first).
여기서는 “합성 재료”의 한 구성 요소가, 다른 구성 요소를 이미 가지고 있을 때 그 합성 재료를 낳는다고 말합니다.
첫 번째 복잡한 규칙이라, 더 단순한 예시에서부터 이 결론에 도달하는 과정을 보여줄게요. 처음에 나는 이렇게 썼습니다:
Begets("lime zest", "lime cordial") :- Has("sugar").
Begets("sugar", "lime cordial") :- Has("lime zest").
“설탕이 있으면 라임 제스트가 라임 코디얼을 낳고, 라임 제스트가 있으면 설탕이 라임 코디얼을 낳는다”는 뜻입니다.
이건 꽤 잘 동작합니다 – 하지만, 그걸 코드로 쓰고 싶지는 않죠. 파일에서 사실로 로드하고 싶으니, Composite 관계를 들여옵니다:
Begets(first, result) :- Composite(result, first, second), Has(second).
Begets(second, result) :- Composite(result, first, second), Has(first).
앞의 내용을 다시 쓴 것뿐이지만, 이제 Composite 관계의 어떤 항목에도 적용됩니다.
그런데 여기엔 _미묘한 문제_가 있습니다. 즉: 라임 _제스트_는 라임 코디얼을 낳지만, _라임_은 그렇지 않다는 점이죠. 보통 라임 제스트만 따로 구비해 두지는 않으니, 이 논리에 따르면 나는 라임이 있어도 라임 코디얼을 만들 수 없습니다.
그래서 간접화를 한 단계 더 둡니다: 사실 라임 제스트를 낳는 어떤 것이든, 설탕만 같이 있으면 라임 코디얼을 낳습니다. 그러니 Composite를 쓰지 않고 “구체적인” 형태로 된 동작 규칙을 보죠:
Begets(x, "lime cordial") :- Has("sugar"), Begets(x, "lime zest").
Begets(x, "lime cordial") :- Has("lime zest"), Begets(x, "sugar").
이게 훨씬 읽기 쉽고, 여기에 Composite 대체를 적용하면 위의 “완전한” 규칙이 됩니다.
더 간접화할 필요는 없다는 점에 주의하세요:
Begets(x, "lime cordial") :- Has(y), Begets(y, "sugar"), Begets(x, "lime zest").
Begets(x, "lime cordial") :- Has(y), Begets(y, "lime zest"), Begets(x, "sugar").
이걸 먼저 썼다가, 불필요하다는 걸 깨달았습니다. Has(out) :- Has(in), Begets(in, out). 규칙 덕분에 Has("lime zest") 부분으로 이미 이 경우가 커버되거든요.3
좋아요. 코드로 돌아갑시다.
.decl Missing(drink : Recipe, ingredient : Ingredient) Missing(drink, ingredient) :- Needs(drink, ingredient), !Has(ingredient).
꽤 사소한 도우미 관계입니다 – 어떤 음료가 특정 재료를 필요로 하고, 우리가 그걸 갖고 있지 않으면 그 음료는 그 재료가 빠져 있는 겁니다. 관계 부정(negation)의 첫 예시이기도 합니다. 재밌는 말이죠.
.decl Mixable(drink : Recipe) Mixable(drink) :- IsRecipe(drink), !Missing(drink, _).
그리고 이를 이용해 우리가 만들 수 있는 음료 – 즉, 빠진 재료가 하나도 없는 모든 음료 – 를 선언합니다.
처음에는 그냥 이렇게 쓰고 싶었습니다:
Mixable(drink) :- !Missing(drink, _).
하지만 Soufflé는 이걸 거부합니다: 관계의 도메인을 제한해야 하거든요. 다른 SQL 테이블에 존재하지 않는 모든 값을 모아 새 테이블을 만들려는 상황을 떠올릴 수 있습니다 – 디스크 공간이 금방 바닥나겠죠. Soufflé라면, 아마 메모리가 바닥나겠죠?
Error: Ungrounded variable drink in file mixologician.dl at line 41
Mixable(drink) :- !Missing(drink, _).
이건 Datalog의 본질을 엿보게 해준다고 생각합니다: 함수를 선언하듯 규칙을 쓰지만, 결국 모든 관계는 거대한 튜플 더미로 실현(realize)될 수 있어야 합니다. 엔진이 실제 실현을 최적화로 생략할 수는 있겠지만, _가능_해야 합니다. 아마요. 다시 말하지만, 나는 Datalog에 대해 아는 게 별로 없습니다.
.decl MixableRecipe(drink : Recipe, ingredient : Ingredient)
MixableRecipe(drink, recipe) :- Mixable(drink), Needs(drink, recipe).
이 관계는 매우 단순한데, 내가 실제로 Mixologician을 써서 만들 음료를 찾으려 했을 때 추가했습니다. 이름만 나열하는 대신, 만들 수 있는 레시피만 남긴 내 레시피북 그대로라서, 특정 재료가 들어간 음료를 기분 따라 찾을 수 있습니다.
하지만 마지막 규칙이 핵심입니다. 자, 서론은 이쯤 하고, 우리가 모두 여기 온 이유를 보시죠:
.decl Enables(missing : Ingredient, drink : Recipe) Enables(ingredient, drink) :- !Unbuyable(ingredient), Missing(drink, out), Begets(ingredient, out), count : { Missing(drink, _) } = count : { Begets(ingredient, product), Missing(drink, product) } . .output Mixable(filename="mixable") .output MixableRecipe(filename="mixable-recipes", delimiter=" <- ") .output Enables(filename="shopping-list", delimiter=" -> ")
이게 전체 프로그램입니다. 해냈어요.
나는 Enables 규칙에 대해 한참 이야기할 예정이라, 먼저 .output 줄을 비켜두고 싶었습니다. 프로그램을 쓰는 동안, 다른 관계들도 자주 덤프 떠 보며 디버깅했어요. 꽤 유용했습니다.
좋아요, 이제 이 괴물을 분해해 봅시다:
Enables(ingredient, drink) :-
!Unbuyable(ingredient),
Missing(drink, out),
Begets(ingredient, out),
count : { Missing(drink, _) } =
count : { Begets(ingredient, product), Missing(drink, product) }
.
!Unbuyable(ingredient) 절은 사소합니다 – lime zest처럼 출력에 보고하고 싶지 않은 것들을 필터링할 뿐입니다. 일단은 무시하죠:
Enables(ingredient, drink) :-
Missing(drink, out),
Begets(ingredient, out),
count : { Missing(drink, _) } =
count : { Begets(ingredient, product), Missing(drink, product) }
.
한국어로 풀면: “ingredient가 drink를 가능하게 한다는 건, drink에 ingredient가 낳을 수 있는 무언가가 빠져 있고, 또한 그 _빠진 모든 재료_가 ingredient가 낳을 수 있는 것들일 때다.”
뭐라고요? 그렇게 써 있지 않잖아요!
음, 아니죠, 하지만 _의미_는 그렇습니다. 실제로는 이렇게 말합니다. “ingredient가 drink를 가능하게 하려면, drink에 ingredient가 낳을 수 있는 무언가가 빠져 있어야 하고, drink에서 빠진 재료의 _개수_가 ingredient가 낳을 수 있는, drink에서 빠진 재료의 _개수_와 같아야 한다.” 우리는 집합의 동등성 대신 기수(원소 수)를 비교하고 있지만, 한쪽 집합이 다른 쪽의 부분집합이므로 기수가 같으려면 두 집합은 동일해야 합니다.4 가능하다면 집합 동등성을 비교했겠지만, 내가 아는 한 Soufflé로는 불가능합니다.
그러니 이제 이해되길 바랍니다. 결국 꽤 단순한 식입니다. 하지만 이걸 쓰는 데 _몇 시간_이 걸렸습니다. 정말로요! 저 한 규칙을 만들면서 _몇 시간_을 쓴 것 같아요. 엄청 재미있고 배울 것도 많았지만, 최종 해답으로 바로 점프하면 여정에서 얻은 대부분의 가치를 잃는 느낌이 듭니다.
그럴 수 있죠. 하지만 몇 걸음 뒤로 물러나, 여기까지 어떻게 왔는지 이야기하고 싶습니다. 이 식에서 끝낼 여정을 함께 해 주세요. 하지만 처음은 아주 달랐습니다.
처음 시작했을 때, 나는 Datalog 방식으로 생각하지 않았습니다. “논리”로 생각했죠. 이런 걸 쓰고 싶었습니다:
Enables(ingredient, drink) :-
!Mixable(drink),
!Has(ingredient),
Has(ingredient) -> Mixable(drink)
.
(이건 유효한 Datalog가 아닙니다, 분명히 해둘게요.)
“지금 없는 재료 중에서, 만약 그걸 갖추면 그때 새 음료를 만들 수 있게 되는 재료를 보여줘”라고 말하고 싶었죠. 이 논리식을 Datalog로 번역하려 오래 애썼지만, Datalog에서는 – “가정(hypothetical)” 연산자가 없기 때문에 – 표현할 수 없다는 걸 곧 깨달았습니다. 아마 Prolog라면 비슷한 걸 쓸 수 있을까요? 앞서 말했듯, 논리 프로그래밍에 대해서는 잘 모릅니다.
그래서 그건 불가능하다는 걸 빠르게 깨닫고, 더 단순한 걸 해 보기로 했습니다: 한 가지만 빠진 레시피를 찾는 겁니다. Begets나 Composite 같은 건 잊어버리고요; 그건 나중에 다시 넣죠. 지금은 “거의” 만들 수 있는 레시피를 어떻게 찾을까요?
처음에 쓰고 싶었던 건 이런 것이었습니다:
AlmostMixable(drink) :-
Missing(drink, ingredient), other != ingredient, !Missing(drink, other).
이는 전형적인 1차 논리의 “유일성” 표현을 매우 어설프게 번역한 겁니다:
Missing(drink, ingredient) ∧ ∀x(Missing(drink, x) → x = ingredient)
“drink는 ingredient가 빠져 있고, 그 외에는 아무것도 빠져 있지 않다”라고 말하고 싶었죠. 하지만 작동하지 않습니다. 여전히 나는 “논리” 방식으로 생각했고, Datalog 방식이 아니었으니까요.
결국 문서를 읽다가 count 집계를 보고, 이렇게 쓸 수 있었습니다 – 처음으로 흥미로운 걸 알려준 식이었죠:
AlmostMixable(drink) :-
Missing(drink, _), count : { Missing(drink, _) } = 1.
좋습니다.
사실, 먼저 이렇게 쓰려고 했습니다:
AlmostMixable(drink) :- count : { Missing(drink, _) } = 1.
하지만 Soufflé가 이렇게 말하더군요:
Error: Witness problem: argument grounded by an aggregator's inner scope is
used ungrounded in outer scope in file mixologician.dl at line 53
AlmostMixable(drink) :- count : { Missing(drink, _) } = 1.
--------------^--------------------------------------------
1 errors generated, evaluation aborted
그래서 집계 외부에서 변수를 “그라운딩(grounding)”하려면 첫 번째 Missing(drink, _)이 필요하다는 걸 알았습니다 – 이해하시죠. SQL 집계식에서도 비슷한 오류를 만납니다 – 같은 수준에 “존재하지 않는” 변수를 참조하는 문제요. 이걸 제대로 설명할 단어를 내가 잘 모르겠습니다.
어쨌든, 여기에 도달하자 빠진 재료가 무엇인지 보고 싶었습니다. 그건 쉬운 변경이었어요 – 무시하던 변수를 드러내면 됩니다:
Enables(ingredient, drink) :-
Missing(drink, ingredient), count : { Missing(drink, _) } = 1.
훌륭합니다! 한 가지 재료만 빠진 음료를 알려주죠.
하지만 이제 “다른 재료를 만드는” 재료를 신경 써야 합니다. 당시 내게 있던 관계는 이런 모습이었습니다:
.decl Makes(in : Ingredient, out : Ingredient)
Has(out) :- Has(in), Makes(in, out).
즉, 아직 Begets(x, x) 규칙이 없었습니다 – 곧 도달하겠지만요.
그래서 이렇게 썼습니다:
Enables(ingredient, drink) :-
Missing(drink, ingredient), count : { Missing(drink, _) } = 1.
Enables(in, drink) :- Makes(in, missing), Enables(missing, drink).
많은 경우에 이건 잘 작동합니다.
하지만 두 번째 Enables 규칙만으로는 충분하지 않습니다: “한 가지 재료만 빠진 음료가 있다면, 그 재료를 만들어 주는 무엇이든 구하면 그 음료를 만들 수 있다”는 뜻이니까요.
라임을 사서 라임 주스를 얻고, 이제 마가리타를 만들 수 있는 것처럼 단순한 경우에는 잘 작동합니다. 하지만 여러 재료가 빠졌는데, 하나의 새 재료를 사면 그 여러 재료 모두를 만들 수 있는 경우는 어떨까요? 이건 김릿에서 실제로 나타나는 경우입니다: 진과 설탕은 있는데, 라임 주스와 라임 코디얼이 빠졌다고 합시다. 라임만 사면 되지만, 김릿에는 두 가지 재료가 빠진 것으로 나오니 목록에 보이지 않죠.
그래서 더 복잡한 걸 해야 했습니다.
내가 시도한 건 이렇습니다:
Enables(ingredient, drink) :-
Missing(drink, ingredient), count : { Missing(drink, _) } = 1.
Enables(ingredient, drink) :-
Missing(drink, out),
Makes(ingredient, out),
count : { Missing(drink, _) } =
count : { Makes(ingredient, product), Missing(drink, product) }
.
한동안 이게 작동한다고 생각했습니다. 작동하는 것처럼 보였거든요. 그래서 테스트를 썼습니다. 그러던 중 이 로직이 커버하지 못하는 경우를 떠올렸습니다:
만약 어떤 재료가 레시피의 성분으로도 등장하고, 다른 성분을 만들 수 있는 것으로도 등장한다면 – 이를테면 어떤 레시피가 이상하게도 “reposado tequila”와 “tequila”를 동시에 요구한다고 하죠 – 이 로직은 reposado tequila 한 병으로 두 역할을 모두 대체할 수 있다는 사실을 잡아내지 못합니다.
첫 번째 수정 시도는 이랬습니다(세미콜론은 “또는”):
Enables(ingredient, drink) :-
Missing(drink, ingredient), count : { Missing(drink, _) } = 1.
Enables(ingredient, drink) :-
Missing(drink, out),
Makes(ingredient, out),
count : { Missing(drink, _) } =
count : { (ingredient = product; Makes(ingredient, product)),
Missing(drink, product)
}
.
하지만 Soufflé는 이걸 좋아하지 않습니다:
Error: ERROR: disjunctions in aggregation clauses are currently not supported
1 errors generated, evaluation aborted
놀랍지만, 좋아요. 집계 안의 논리합은 그냥 덧셈이죠:
Enables(ingredient, drink) :-
Missing(drink, ingredient), count : { Missing(drink, _) } = 1.
Enables(ingredient, drink) :-
Missing(drink, out),
Makes(ingredient, out),
count : { Missing(drink, _) } =
count : { Makes(ingredient, product), Missing(drink, product) }
+
count : { Missing(drink, ingredient) }
.
두 번째 count는 항상 0이나 1일 뿐이지만요.
아무튼, 이걸로 문제는 해결됩니다 – 그러나 여전히 “한 가지 재료만 빠진 음료”라는 기본 규칙이 필요합니다. 왜냐고요? 여전히 Makes(ingredient, out)라는 낡은 규칙을 쓰고 있기 때문입니다. 원래는 집계 바깥에서 out 변수를 “그라운딩”하려고 추가했는데, 지금은 과도하게 제한적이 되었습니다. 두 개의 집계 절을 갖고 있으므로 이제는 이렇게 말하고 싶습니다:
Enables(ingredient, drink) :-
Missing(drink, out),
(ingredient = out; Makes(ingredient, out)),
count : { Missing(drink, _) } =
count : { Makes(ingredient, product), Missing(drink, product) }
+
count : { Missing(drink, ingredient) }
.
그러면 두 경우를 모두 포괄하는 하나의 규칙으로 단순화됩니다.
하지만… 꽤 거친 규칙이죠. 두 가지 경우 – “그 재료 그 자체”인지, “그 재료가 다른 재료를 만드는지” – 가 서로 다른 두 곳에 있는 것도 지저분합니다.
이 시점에서, 내 사고 과정을 보여주기 위해 일기장을 그대로 인용하는 게 좋겠습니다. 네, 물론 이걸 하는 동안 일기를 썼습니다.
그럼 그냥 모든 값에 대해
Makes("lime", "lime").라고 하면 어떨까?Error: Argument in fact is not constant in file mixologician.dl at line 10 Makes(x, x). ------^------음, 이렇게 말하고 싶긴 한데, 이런 종류의 문장이 허용되지 않는 것도 이해는 됩니다.
매뉴얼에서 다음과 같이 반사(reflexive)를 선언하는 예시를 찾았습니다:
Makes(x, x) :- Makes(x, _).약간 머리가 꼬이지만, 잠깐 생각해 보면 이해가 됩니다.
x가 무언가를 만들면x는x를 만든다 – 그러므로x는x를 만들기 때문에x는x를 만든다. 그러니까, 음, 네.하지만 그 해석은 분명 틀렸습니다: 실행해 보니, 자기 자신을 “만드는” 건 이미 다른 “만들기” 규칙이 있는 것뿐이더군요 – 그래서
Makes("reposado tequila", "reposado tequila").는 생기지만Makes("tequila", "tequila").는 생기지 않습니다. 좋아요, 그럼 간단하게 씁시다:Makes(x, x) :- Needs(_, x).기본적으로: 음료에 들어가는 어떤 것이든 자기 자신을 “만든다” – 모든 재료를 열거하는 간단한 방법입니다. 이건 조금 헷갈리니, 별칭을 만듭니다:
.decl IsIngredient(x : Ingredient) IsIngredient(x) :- Needs(_, x). Makes(x, x) :- IsIngredient(x).조금 낫네요.
그리고
Makes를Provides로 바꿉니다. “tequila provides tequila”가 “tequila makes tequila”보다 자연스럽게 들리거든요. 글쎄요; 완벽하진 않습니다. 특히 완전히 생소한 패러다임에서 이름 짓기는 어렵죠. 아마Begets? 그게 더 마음에 듭니다: 다들 알다시피, tequila는 더 많은 tequila를 낳으니까요.어쨌든, 이제 이건 작동하고,
Enables규칙을 상당히 단순화할 수 있습니다:Enables(ingredient, drink) :- Missing(drink, out), Begets(ingredient, out), count : { Missing(drink, _) } = count : { Begets(ingredient, product), Missing(drink, product) } .
이게 전부입니다.
아니요! 물론 아닙니다. 나는 정말 많은 바보짓을 더 했습니다. 문제를 모델링하려는 첫 시도는 이랬습니다:
.decl Has(ingredient : symbol)
Has("lime juice") :- Has("lime")
Has("tequila") :- Has("reposado tequila")
Has("tequila") :- Has("blanco tequila")
Has("margarita") :- Has("tequila"), Has("lime juice"), Has("triple sec")
관계 하나, 타입도 없고, “사실”(레시피)을 “규칙”으로 표현했죠 – 당연히 그렇게는 멀리 가지 못했습니다. 하지만, 새로운 언어, 새로운 개념이었으니까요. 뭘 하는지 몰랐습니다. 시행착오를 거쳐, 데이터를 관계형 데이터베이스처럼 모델링해야 한다는 중요한 통찰을 얻었고, 결국 목적지에 도달했습니다. 심지어 각 “생산 규칙”을 규칙으로 쓰는 대신 Makes 관계를 만든 발상도 나에게는 큰 도약이었습니다.
하지만 머릿속에 지나간 모든 생각을 다 이야기할 수는 없습니다. 이 글도 이미 충분히 깁니다. 그리고 다음 이야기를 해야 합니다: 레시피입니다.
아. 네. 좋았어요! 이런 식으로 하는 건 처음이었는데, 내가 바꾸는 코드가 실제로 원하는 대로 동작하는지 확신하게 해 주는 좋은 방법이었습니다. 물론 새로운 도구를 만지작거리기 시작하자마자 테스트를 쓰지는 않았어요 – 괴물은 아니니까요 – 하지만 리팩터링하고 단순화하고 까다로운 경우를 점검할 때, 주석을 달았다 지웠다 하는 것보다 훨씬 낫습니다.
그리고 Soufflé 테스트 프레임워크는 놀랄 만큼 유쾌합니다! 도커 노드를 띄우기만 하면— 아니 아니 농담이에요. Soufflé 테스트는 없습니다. 그냥 Cram을 썼습니다. 내가 쓴 테스트의 일부를 보여드리죠:
Now let's test multi-ingredient syrups.
$ empty_bar
$ add_recipe gimlet gin "lime juice" "lime cordial"
$ buy gin
$ buy "lime juice"
In order to make lime cordial, I need both limes and sugar. My lime *juice*
won't do, because I need the zest. That's two new ingredients, so neither will
show up on my shopping list.
$ runtest
shopping list:
lime cordial -> gimlet
But it does tell me that I can just buy lime cordial directly (if, you know, I
could find it in a store).
But once I buy sugar...
$ buy sugar
$ runtest
shopping list:
lime cordial -> gimlet
lime -> gimlet
It lets me know that I could either buy lime cordial directly, or I could just
buy limes.
In fact, now that I have sugar, I can get rid of my lime juice, because all I'll
need to make a gimlet is lime.
$ sell "lime juice"
$ runtest
shopping list:
lime -> gimlet
Nice. Notice that I can no longer just buy lime cordial, as that won't be
sufficient -- can't make lime juice out of lime cordial.
나쁘지 않죠?
Cram은 인터랙티브 모드에서 UX 이슈가 좀 있습니다 – 덩어리를 쪼갤 수 없고; 커스텀 diff 도구도 쓸 수 없죠 – 하지만 Cram의 아이디어 자체는 _아주 훌륭_하고, 이런 단점들은 이런 잡다한 것을 테스트할 때 느끼는 유용성에 비하면 사소합니다.
그리고 솔직히, Nix의 도움이 없었다면 아마 이렇게까지 안 했을 거예요. shell.nix에 python39Packages.cram을 추가하기만 하면 되는 건 정말 마법 같았습니다. pyenv(혹은 asdf?)와 pip, virtualenv 같은 파이썬 생태계의 것들을 전부 거치지 않았다면 Cram을 쓰자고 쉽게 마음먹지 못했을 겁니다. Nix가 Cram을 쥐여 주고, 나머지 골칫거리는 알아서 처리해 줬습니다.
좋아요! 그게 전부입니다. 이제, 괜찮다면, 레시피 얘기를 해 봅시다.
맞습니다. 흥미로운 부분은 끝났습니다. 논리 프로그래밍을 했죠. 원한다면 바로 실행하는 부분으로 넘어가도 됩니다.
나머지는 그냥… 스크레이핑입니다. 그래도 비슷한 일을 하고 싶을 수 있으니, 과정을 적어둘게요.
먼저 레시피 소스가 필요합니다. 그런데 이미 있어요! 몇 년 전부터 내가 애용하던 칵테일 레시피북은 Tuxedo No. 2입니다. 멋진 사진과 재미있는 이야기로 가득 찬 맛있는 칵테일이 많은 멋진 사이트죠.
그러니 내가 할 일은 간단합니다. 그 사이트의 모든 레시피를 내 작은 텍스트 파일에 손으로 타이핑하면 됩니다. 그렇죠?
나는 서버에 무리를 주지 않도록 적당히 속도를 제한한 wget --recursive로 시작했습니다. 그다음 pup으로 파싱했죠. 물론 Beautiful Soup이나 Nokogiri 같은 익숙한 도구를 써도 좋지만, pup은 커맨드라인의 따뜻한 포옹을 벗어나지 않고도 빠르게 뭔가를 끝낼 수 있는 훌륭한 방법입니다.
안타깝게도, pup은 각 텍스트 노드를 개별 줄로 주려고 합니다. 내가 정말 원하는 건, 태그는 무시하고 평탄화된 텍스트를 보여주는 건데 말이죠.
참고로, 예시 레시피의 마크업은 이렇습니다:
$ pup -f bijou-cocktail-recipe '.recipe__recipe ul .ingredient'
<span class="ingredient">
<a href="/ingredients/gin-cocktail-recipes">
london dry gin
</a>
or
<a href="/ingredients/gin-cocktail-recipes">
new american gin
</a>
</span>
<span class="ingredient">
<a href="/ingredients/chartreuse-cocktail-recipes">
green chartreuse
</a>
</span>
<span class="ingredient">
<a href="/ingredients/vermouth-cocktail-recipes">
sweet vermouth
</a>
<br>
</span>
<span class="ingredient">
<a href="/ingredients/orange-bitters-cocktail-recipes">
orange bitters
</a>
<br>
</span>
<span class="ingredient">
<a href="/ingredients/cherry-cocktail-recipes">
cherry
</a>
or
<a href="/ingredients/lemon-cocktail-recipes">
lemon peel
</a>
for garnish
</span>
(구조만 보이도록 불필요한 속성은 일부 뺐습니다). 텍스트 노드만 보면:
$ pup -f bijou-cocktail-recipe '.recipe__recipe ul .ingredient text{}'
london dry gin
or
new american gin
green chartreuse
sweet vermouth
orange bitters
cherry
or
lemon peel
for garnish
이렇게 줄바꿈만 있는 텍스트 노드 같은 것들도 전부 보입니다. 뭐, 정확하긴 한데 귀찮죠.
그래서 이런 “인접” 줄들을 합치려고 끔찍한 sed를 좀 썼습니다:
$ pup -f bijou-cocktail-recipe '.recipe__recipe ul .ingredient text{}' \
| sed -En -e '/^$/d' -e ':start /^$/!{ H; n; b start }' -e 'x; s/\n//g; s/^\s+//; p'
london dry gin or new american gin
green chartreuse
sweet vermouth
orange bitters
cherry or lemon peel for garnish
이런 sed를 처음 보셨다면, 음, 운이 좋았다고 생각하세요. 하지만 설명은 해 보겠습니다. 이런 건 절대 프로덕션 코드베이스에 커밋되지 않을(해야 할) “한 번 쓰고 잊을” 작업에 꽤 요긴하거든요. 지금 당장 sed에 대해 생각하고 싶지 않다면, 이 섹션의 끝으로 건너뛰어도 됩니다. 중요한 걸 놓치지 않을 거예요.
대략적으로, -E는 정규표현식을 당신이 원하는 방식으로 동작하게 합니다.5 -n은 “내가 말할 때만 출력해”라는 뜻이고, -e는 다음 인자가 sed 표현식 문자열이라는 뜻입니다. 내 세 가지 “표현식”은 이렇습니다:
/^$/d
:start /^$/!{ H; n; b start }
x; s/\n//g; s/^\s+//; p
첫 번째는 “만약 줄이 비어 있으면 delete(삭제)하고 다음 줄로 가서 프로그램을 다시 시작하라”는 뜻입니다. s/foo/bar/ 다음으로 가장 익숙한 sed 명령일 테니 여기서 길게 설명하지는 않겠습니다.
두 번째는 비교적 덜 알려진 sed 개념인 분기(branching)와 홀드 스페이스(hold space)를 이용합니다. 기본적으로 루프를 만드는 겁니다: 이렇게 읽을 수 있죠:
while(current line is not empty) {
append line to the hold space
load the next line
}
하지만 sed에는 구조적 제어 흐름이 없어서, 무조건 분기하는 b(GOTO)를 사용합니다.
sed의 “홀드 스페이스”는 우연히 접할 일이 거의 없어서 모를 수 있습니다. 기본적으로 sed에는 홀드 스페이스와 패턴 스페이스라는 두 개의 버퍼가 있습니다. 줄을 읽으면 패턴 스페이스에 읽어들입니다. s/foo/bar/ 같은 명령은 패턴 스페이스를 대상으로 합니다. 하지만 줄을 “나중을 위해” 잠시 저장하는 명령들도 있습니다. 예를 들어, 입력의 첫 줄을 맨 아래로 옮기는 sed 프로그램은 다음과 같습니다:
$ seq 1 5 | sed -ne '1h; 1!p; ${ g; p }'
2
3
4
5
1
한국어로 풀면: 첫 줄이라면(줄 번호는 1부터 시작) 패턴 스페이스를 홀드 스페이스로 복사합니다. 첫 줄이 아니라면, 패턴 스페이스를 출력합니다. 마지막 줄에 도달하면, 패턴 스페이스를 홀드 스페이스의 내용으로 바꾸고 다시 출력합니다.
아무튼. 우리 프로그램으로 돌아갑시다:
/^$/d
:start /^$/!{ H; n; b start }
x; s/\n//g; s/^\s+//; p
x는 홀드 스페이스와 패턴 스페이스를 교환합니다. 우리는 지금 빈 줄 – 즉 패턴 스페이스가 비었다 – 에 있다는 걸 압니다. 루프를 빠져나왔기 때문이죠. 그러니 이 명령은 홀드 스페이스를 패턴 스페이스로 가져오고, 홀드 스페이스를 비웁니다.
이 시점에서 패턴 스페이스는 대략 이런 모양입니다:
london dry gin
or
new american gin
즉, 여러 줄 문자열입니다. 그래서 s/\n//g로 모든 줄바꿈을 제거하고, 앞의 공백을 지운 뒤, 결과를 출력합니다. 그리고 sed는 암묵적으로 다음 줄을 가져와 프로그램을 다시 시작합니다.
재밌죠? 재밌습니다.
입력 마지막에 빈 줄이 없으면 마지막 줄이 조용히 누락되는 버그가 있을지도 모릅니다. 하지만 내게는 잘 작동합니다. 또한 처음의 d가 동작하지 않았으니 비지 않은 줄에서 시작한다는 걸 아니까, 분기문으로 비어 있지 않은 줄을 검사하도록 옮길 수도 있습니다. 이제 sed 얘기는 그만하죠.6
sed 섹션의 끝휴, 좋아요. 이제 재료를 한 줄씩 얻었으니, 우리의 레시피 형식으로 바꿔야 합니다. 재포맷은 꽤 쉽지만, 먼저 이걸 어떻게 할지 생각해야 합니다:
london dry gin or new american gin
green chartreuse
sweet vermouth
orange bitters
cherry or lemon peel for garnish
Bijou의 재료 목록입니다. “or” 재료와 가니시가 있어서 골랐어요. 맛은 못 봤지만 꽤 맛있을 것 같습니다.
혼자 마실 때는 보통 가니시를 생략합니다. 그러니 체리가 없다고 해서 비쥬를 못 만든다고 하지는 않을 겁니다. 그래서 “garnish”가 들어간 재료 줄은 생략하겠습니다. 어떤 칵테일에서는 가니시가 아주 중요할 수도 있지만, 뭐, 내 작은 프로그램이 그걸 알 필요는 없죠.
하지만 진(gin)은 여전히 처리해야 합니다. 시작할 때는 이런 “or”가 있는 레시피를 아예 여러 레시피로 “폭발(explode)”시키려 했습니다. 그래서 이런 식으로 만들려 했죠:7
bijou1 -> london dry gin
bijou1 -> green chartreuse
bijou1 -> sweet vermouth
bijou1 -> orange bitters
bijou2 -> new american gin
bijou2 -> green chartreuse
bijou2 -> sweet vermouth
bijou2 -> orange bitters
하지만 그러면 “무엇을 사야 가장 이득인가” 로직이 망가집니다. “비쥬 1과 비쥬 2를 만들 수 있으니 그린 샤르트뢰즈를 사라”고 나오길 원하지 않거든요. 여전히 본질적으로 같은 음료니까요.
하지만! 이미 “Begets” 관계에 많은 시간을 썼으니, “london dry gin or new american gin”을 하나의 재료로 취급하고, 이렇게 말하면 되겠죠:
Begets("london dry gin", "london dry gin or new american gin").
Begets("new american gin", "london dry gin or new american gin").
Begets("london dry gin", "gin")과 정신적으로는 같습니다. 모양이 조금 우스울 뿐이죠. 그러니 그렇게 합시다.
하지만 “london dry gin or new american gin”을 가게에서 살 수는 없습니다. 이게 사야 할 재료 목록에 뜨면 신경 쓰일 겁니다. 그래서 이를 “살 수 없는(unbuyable)” 재료로 표시하고, Enables 관계가 실제로 살 수 없는 재료는 보여주지 않게 했습니다. 이미 봤던 Unbuyable 관계가 이렇게 탄생했습니다. 약간 이상한 핵(hack)이라는 건 인정합니다. 하지만 작동합니다!
어쨌든, for 루프로 감싸고 올바른 형식으로 출력합니다:
$ for file in *-cocktail-recipe; do
> name=${file%-cocktail-recipe}
> pup -p -f "$file" '.recipe__recipe ul .ingredient text{}' \
> | sed -En -e '/^$/d' -e ':start /^$/!{ H; n; b start }' -e 'x; s/\n//g; s/^\s+//; p' \
> | xargs -n1 -d'\n' printf '%s <- %s\n' "$name"
> done
18th-century <- batavia arrack
18th-century <- creme de cacao
18th-century <- sweet vermouth
18th-century <- lime juice
20th-century <- london dry gin
...
꽤 그럴듯한 원라이너죠.
이렇게 해서 모든 레시피 목록을 얻긴 했는데, 약간 지저분합니다. 좀 손봐야 하죠. 다음은 도저히 그대로 둘 수 없는 몇 줄의 예시입니다:
212 <- soda water (optional)
arrack-strap <- dashes mole bitters
champagne-cocktail <- brandy , optional
gilchrist <- amaro averna or similar sweet amaro
negus-punch <- zest of 2 lemon
old-fashioned <- simple syrup, rich or 1 sugar cube
pearl-of-puebla <- sprigs of fresh oregano
pineapple-fizz <- soda water to fill
three-dots-a-dash <- dark rum (see paragraph four)
verte-chaud <- wet heavy cream to top
하지만 이건 꽤 지루하고 고치기 쉽습니다. 줄 수가 많지 않아서, 그냥 손으로 고쳐도 돼요. 하지만 나는 sed를 조금 더 쓰기로 했습니다. 고치는 동안 재료만 뽑아 sort -u로 보며 이상한 것과 불일치를 찾아냈죠.
아마 프로젝트 전체에서 가장 지루한 부분이었습니다. 자세히 설명할 가치는 별로 없습니다; 코드는 공개되어 있습니다.
마침내 제대로 된 레시피북을 얻었습니다 – 최소한 출발점은요. 나중에 언제든지 추가할 수 있고; 다른 소스를 끌어올 수도 있습니다.
다음 단계는 모든 “or” 지시문에 대한 규칙을 생성하는 것입니다. 이건 꽤 쉬웠습니다. 내가 뭘 썼게요? 네. sed입니다. sed로 못할 건 없습니다.
$ sed recipes -En -e 's/^.+<- //' -e '/ or /{ s/(.+) or (.+)/\1 -> \0\n\2 -> \0/; p }'
lillet -> lillet or cocchi americano
cocchi americano -> lillet or cocchi americano
london dry gin -> london dry gin or new american gin
new american gin -> london dry gin or new american gin
lemon juice -> lemon juice or lime juice
lime juice -> lemon juice or lime juice
whole egg -> whole egg or egg white
egg white -> whole egg or egg white
madeira -> madeira or sherry
sherry -> madeira or sherry
...
이야, 아름답지 않나요? 나는 sed를 정말 싫어합니다. 출력은 파일로 저장하고, “이름에 ‘or’가 들어간 재료”의 단순 목록을 생성해 살 수 없는 것들로 표시합니다.
그리고… 끝!
하아, 아니요, 이제 내 바의 재고를 기록해야 합니다. 금방 다녀올게요…
좋습니다. 끝났어요. 생각보다 나쁘지 않았네요. 이제, 진실의 순간…
$ head results/shopping-list
pineapple -> sutter-s-mill
ginger -> penicillin
ginger -> presbyterian
ginger syrup -> presbyterian
sage -> nicholas-sage
creme de cacao -> 20th-century
creme de cacao -> brandy-alexander
creme de cacao -> green-glacier
grapefruit juice -> 212
grapefruit juice -> brown-derby
나는 자몽 주스가 없는 걸 자주 한탄합니다. 브라운 더비(Brown Derby)는 내가 가장 좋아하는 칵테일 중 하나입니다. 하지만 출력이 88줄이나 있네요! 전부 읽고 싶지는 않습니다. 투자 대비 최고의 수익은 무엇일까요?
$ sed results/shopping-list -E -e 's/ -> .+$//' | uniq -c | sort -nr | head
7 peychaud's bitters
6 dry vermouth
5 champagne
4 egg
4 curacao
3 orgeat
3 orange
3 green chartreuse
3 grapefruit juice
3 creme de cacao
페이쇼 비터스(Peychaud’s bitters)! 이럴 수가.
$ grep '^peychaud' results/shopping-list
peychaud's bitters -> cocktail-a-la-louisianne
peychaud's bitters -> improved-whiskey-cocktail
peychaud's bitters -> monte-carlo
peychaud's bitters -> queens-park-swizzle
peychaud's bitters -> sawyer
peychaud's bitters -> sazerac
peychaud's bitters -> vieux-carre
하지만 오늘 밤에는 새 재료를 살 수 없습니다. 밤은 깊었고, 가게는 모두 문을 닫았죠. 오늘 밤에는 이미 손에 있는 것으로 만족해야겠습니다. 늘 재고를 챙겨 두는, 오래된 단골로요…
$ grep -q negroni results/mixable
$ echo $?
0
건배.
사실, 그냥 마셔도 꽤 맛있습니다.↩︎
연상법: “extensional”과 “enumerate”는 e로 시작; “intensional”과 “inference”는 i로 시작.↩︎
그런데 왜 다른 재료 쪽에는 그걸 해 줘야 하냐고요? 다른 재료는 우리가 “갖고” 있지 않아서 규칙이 발화하지 않기 때문입니다.
이건 다음과 같은 문장을 쓸 수 없는 것과 같은 이유입니다:
Has(result) :- Composite(result, first, second), Has(first), Has(second).
완전히 _참_이지만 – 충분하지는 않습니다. Begets 관계를 이용해 일종의… 가정(hypothetical)을 “소환”해야 합니다.
네, 이건 나도 생각하기 꽤 혼란스러웠고, 같은 과정을 거치지 않았다면 이해가 될지 모르겠습니다. 최선을 다해 설명해 봤어요.↩︎
이 주장에 대한 증명은 Ian’s Shaky Memory of Basic Logical Concepts 431쪽의 보조정리 14.3을 참조.↩︎
이건 복잡한 주제라, BSD sed(macOS가 쓰는)에서는 “확장(extended)” 정규표현식 vs. “향상(enhanced)” 정규표현식처럼 실제로 신경 써야 하는 것에 대해 여기서까지 들어가고 싶진 않아요.↩︎
아니면 계속 sed 얘기를 해도 됩니다. 나중에 /./{H;b};x;s/^\s+|\n//gp로 골프를 좀 쳤습니다. 더 줄일 수 있나요?↩︎
음, 이 특정 경우에는 “london dry gin or new american gin”을 그냥 “gin”으로 바꿔도 될 것 같습니다. 나는 칵테일에 그 정도로 까다롭지 않으니까요. 하지만 요지는 전달됐을 거예요.↩︎