I was really dying when you sent me when you sent me the recording of the duck that you encountered on your run. And you the duck's quacking and Kano he's doing very bad duck sound effects.
ConorI thought that was a decent duck quack.
BryceQuack. Quack.
ConorWelcome to ADSP the podcast episode 47, recorded on October 3rd, 2021. My name is Connor, and today with my co-host Bryce, we talk about my favorite topic at the moment, combinatory logic. And we talk about some combinator birds and real birds as well.
BryceWe should um you should teach me some stuff about combinators or something.
ConorWhile we wait for Dave, is Dave coming? Have we heard from him?
BryceI have not heard from Dave. So Oh man.
ConorDo we want to do like a mini combinators episode until Dave shows up? If Dave shows up.
BryceI think so. I think it's a screen master.
ConorOh boy, oh boy. Oh boy, oh boy. So some of you may have already seen the talk that I uploaded and premiered on YouTube on October 2nd. There was a digression in the midst of that talk that covered the history of combinatory logic. But where do I want to start? So, one, you can go watch that talk if you are interested afterwards. I've also on the most recent episode of Arraycast, my other podcast, I go on like a 10 or 15 minute ramble about how combinatory logic and combinators have led to the most beautiful code that I've ever seen in my life. But yeah, let's start with, let's start with the history, a mini history of combinatory logic. The year is 1924. My main guy, my guy, Moses Schaumfinkel. Real sad story. He had like uh health problems, ended up dying pretty young. But before he did that, he managed to publish one paper on his own, another with a co-author, and that one paper was called On the Building Blocks of Mathematical Logic. And my guy, Moses Schaumfinkel, introduces five combinators, I, C, and S. Two others that don't really matter, T and Z, but C would later be renamed the K combinator. And I, K, and S, for those of you that are in the know, are the three combinators that make up the SKI combinator calculus. We'll get to that in a sec. This is 1924. Five years later, a guy named Haskell Curry, some of you may or may not have heard of him, the Haskell is from Haskell and the Curry is from Curry. No big deal. He rediscovers combinatory logic and also discovers that Sean Finkel had this paper in 24 that introduces these combinators. So his paper, I believe, is called An Analysis of Substitution Logic or something like that. And that is one of his first papers that will lead to two or three decades of research on what ultimately is going to be called combinatory logic. And he publishes, Haskell Curry, that is, publishes a seminal text, which is volume one of a two-volume book, textbook with uh, I believe the first volume is um authored with uh Robert Faye, who I actually don't know who that is, but co-author should get mentioned. And this is basically it's an untyped, simple lambda calculus. So everyone's heard of lambda calculus, but like it's at least to me, a lot a lot less people have heard of the this um ski combinator calculus or combinatory logic. And all you actually need to have a turn complete language is S and K. I is expressible in terms of S and K.
BryceBut okay, but hang on, hang on, hang on. What is a combinator?
ConorA combinator, in my in my opinion, is a composition pattern. Um they're all just functions, higher order functions. Some of them don't really express quote unquote composition patterns, such as the i combinator, because the i combinator is just the identity function. For those of you that have not heard of the identity function, it's a function that takes a single argument and returns you back that argument. When I first heard of the identity function, I thought, that sounds stupid. Why would you ever need that? And admittedly, I think that's a very reasonable reaction to have to a function that literally just returns you what it you pass into it. But if it's so useless, why did it show up in C20?
BryceWell, you you you need it for composition, right? You know, if you're gonna plug it into some other higher order function.
ConorYes, there are many higher order functions, and uh just uh in case there are some non-functional programmers that have are not super familiar with the term, a higher order function is just a regular function, but it can take a function as an argument or return a function. Um so it's just a function that takes other functions or returns functions. Great examples of these, sort of if you use sort of little hand wavy, is like all of the std algorithms. Um, you know, std accumulate. Uh what any algorithm that can be customized with a function object or a lambda, you can consider as kind of a higher order function. It's not, you know, by the book definition because you know things decay to pointers and blah blah blah, but the equivalents of these in other languages like Haskell and functional languages, those are all higher order functions. Um, versus a function like, you know, um uh plus that just adds two numbers together. That that is not uh a higher order function. It takes sort of data as arguments. So all of these combinators, so s, k, and i I can describe s is a little complicated, but i and k are super easy to describe. So i takes a single argument, it's a unary function, just returns you what you passed in. K takes it's a binary function, takes two arguments, and just returns you the first one. So it throws away uh the second uh argument. Um also seems a tiny bit useless, but these things become very important. And S is what is known as the most powerful combinator of all. Um in fact, uh Steven Wolfram, for those of you that are following that guy, Mathematica, he has the combinator challenge out right now. He literally just launched it two weeks ago. And it's um $20,000 to anybody that can show that the S combinator on its own is universally is computationally universal, whatever that means. I don't actually think initially I thought that meant that like you, you know, that means turing complete. So s and k are shown to be turning complete just on their own. I thought he was asking to just so that s is turing complete, but I don't think computationally universal uh is the same as turing complete. Uh but S, what S is, is it is a function that takes three arguments. Basically a binary function, a unary function, and an argument. And the composition pattern there is you take your arg your argument, pass it to the uh unary function, and then you take the result of that evaluation along with the original argument again, and pass those as two arguments to the binary function. Um so that is a little bit of uh something to digest like mentally, but a really easy example is which I think we've talked about this on the podcast before, is um is palindrome. So if you want to check is this string or is this vector of numbers uh palindromic? If you reverse it, is it the same thing? So your unary function is reverse, and C literally you std reverse. Um your binary function is std equal, checking to whether a string or a list are the same thing. And the composition pattern then is once you reverse the string or the vector, and then you take that reverse string with your initial initial string, pass those to std equal, and if they're the same, you know that you have a palindrome. Um so that's a very, very simple example of where you can use the s um combinator. Uh and very interestingly, in the J language, which is the second array programming language that Ken Iverson worked on after APL, two juxtaposed functions form the S combinator. And they call that a hook because of sort of the pattern, which I'm not going to get into. And that is different from APL, where two juxtaposed functions is the B combinator.
BryceOkay, explain what you mean by juxtaposed function.
ConorJust like side by side. So literally like in in J, equal, std equal, the equivalent of std equal is a verb or a function called match, which is hyphen colon. So in in j they have digraphs, so two ASCII symbols. Um so the equivalent of std equal is hyphen colon and reverse is pipe period. So when you put those next to each other with the binary function first and the unary function second, literally, without doing anything else, that automatically forms an S combinator, which they call a hook. Whereas an APL, if you put two, if you put those same functions next to each other, it's not going to evaluate correctly because it forms the B combinator, which if you're a Haskell programmer, is just the dot composition operator, which is you take two unary functions, you first evaluate the first one, and then you take the result of that and pass it to the second function. Um so like very if you want to add one to a number and then multiply it by two, you can do, you know, one plus x, and then your second thing you do two times x, and you just evaluate those in order. So it's very interesting that APL uses the B combinator for what they call two trains, which is just two juxtaposed functions, whereas in J they use the S combinator. And super interestingly, so J was developed in the early 90s. There's a paper by Roger Huey, who was the main implementer uh that worked with Ken Iverson on J, that he published in 2006, and he called it hook conjunctions question mark, and basically asserts that after 17 years of experience using the S combinator uh for two juxtaposed functions, he thinks that that was a mistake. Because in APL, they have uh the three train, and J also has this, so that's when you have three juxtaposed functions, that forms uh what's called a fork or an S prime combinator. Um and it's very, very similar to the S combinator, but the S combinator takes a binary function, a unary function, and your argument. An S prime combinator takes one binary function and two unary functions. And it has the exact same pattern, but where we passed the original argument as the second argument for the uh S combinator, you you basically have the initial argument, you apply the unary function, each unary function to that initial argument, and then take the results of both of those and pass them to the binary function. So in order to define reverse, or sorry, is palindrome using an S prime combinator, you just need to, for that second unary operation, use the I combinator, use the identity function. So just like pass along the original one. And so basically he was pointing out that this S prime combinator, it's the more general version of the S combinator. So as soon as you have the S prime combinator, you can already spell the S combinator just with an extra one or two characters. And so it's sort of a waste. And using two juxtaposed functions as the S combinator leads to a bunch of other things that are less ideal. Um and I had actually always thought that, but then I was like, oh, this is APL 2.0. Ken obviously, you know, he had his best ideas, most likely in J, but the thing is they only started experimenting with combinators in J. They didn't actually have these in APL. And APL, dialogue APL, didn't get combinators or trains until 2014. So eight years after Roger Huey had decided that the S combinator in J was a mistake. So yeah, S, K, I, and then, and those, the birds that correspond to those are Starling, Kestrel, and Identity, or idiot, but I don't really like the word idiot. And yeah, and then there's they're just there's so it's it's I'm probably at this point I've lost like all of the listeners by trying to explain that the S prime combinator is a more general form of the S combinator, and if you have that, yada yada, but like it is it is so beautiful. So for instance, let me let me take I I talked about this one briefly in in the Arraycast episode. So for listeners of both podcasts, I apologize.
BryceBut Woo! I did not approve this. You can't be like I've already made my peace with the fact that you're cheating on me with another podcast, but you can't come in here and advertise it.
ConorOh no, I just uh I mean I'll I can cut it out, but I won't because hey, I'm the I'm the editor of uh so if you want to there was a was it a leak code or a Pearl Weekly Challenge, one of the two. Given two lists of numbers, uh return true if they're disjoint, if they have no overlapping elements. Very simple problem in terms of like a problem statement. So if you're given, you know, as your first list one, two, three, and your second list uh four, five, six, that returns true because those don't have any overlapping. But if you're given one, two, three as your first list and three, four, five as your second list, you return false. So in a functional language, when you have a bunch of algorithms at your disposal, uh, the way that I would try and solve this is look for a function called intersect or intersection that gives you the list of numbers or the elements that show up in both, and then just check is that empty. And Haskell has both of these functions. The intersect function is called intersection, and the is an empty list is called null. So ideally, like the way you can solve this without using combinators is you just, you know, in parentheses you go, you know, if your first list is a and your second second list is B, you go in parentheses, intersection AB, and then the result of that you just pass to null. But like I like point free, I don't like having to state, you know, my arguments, and I also don't really like parentheses. I like reading things linearly. So how do you compose a function that takes two arguments with a function that takes one argument? In Haskell, the composition operator, the dot, it composes unary functions. A function that you know takes an A and returns a B and then takes a B and returns a C. You can compose those together with the dot and then get a function that takes an A as an argument and returns a C. But this one, how do you how do you do that? So you basically can't, unless if you know about the Blackbird. The Blackbird, otherwise known as the B1 combinator. Woo! I love the B1 combinator. It is just so awesome. It comes up all the time. And um you can if you download the uh data dot composition um module or library in Haskell, it provides you with a um an operator or a function uh that is dot colon. And that is the b1 combinator. You throw that in between null and intersection, and you're good to go. Um Well wait, what does it do? What does it do? It composes a function that takes two arguments with a function that takes one argument. So when you when you it's just it's just the mechanics. You just define you define it um such that that it performs that order of operations.
BryceAnd so what it does is if if you put on the left side of the Well it it feeds the binary into the urinary.
ConorUh so it's gonna if given uh a function a that's a unary function, and a function b that's a binary function, if you if you spell a dot colon b, aka insert the binary uh the the blackbird or the b1 combinator in between it, it will then construct a function that uh takes that's a binary function that takes two arguments and first applies b, feeds those two arguments to b, evaluates it, and then feeds the result of that to A.
BryceOh, okay, I see.
ConorSo you could very simply do this in Python or C by just writing a function that takes uh you know two function arguments uh that would either have to be templated or you can just use auto and then you know, so it would be auto a, auto b, auto c auto d where C and D are your arguments and A and B are your functions, and then you would just return a lambda that you know captures all that stuff and it basically is spelt return inside the body of the lambda, return a parentheses, b parentheses, c comma d end parentheses, and parentheses, semicolon. I think I got that right. I might have less missed one in parentheses. Yep. Whether that made sense to anybody.
BryceBut anyways, these are it sort of reminds me of that extra parameter that we have on some of the range-based overloads of algorithms. Oh, yeah, yeah, yeah, yeah. Yep, yep, yep, yep, yep. I can't remember what's that project parameter. Yeah, projection. There we go. Yeah.
ConorSo what is what's a great example? Like actually, what combinator is that? It's applying a unary function to a sequence of elements and then doing your algorithm on it.
BryceSo that is I don't actually think that specifically maps to Well, isn't it similar to that B combinator that you just described?
ConorIt is, yes. I think actually that in all cases that might be the case. I'm just trying to think that so like let's the the the classic example of this is sort. Um so say you have a s you have a list of strings and you want to sort them by length. Currently, pre-c plus plus 20, the way you're gonna do that is by writing a custom comparator, a binary uh lambda, and then you're gonna you know const you know, write the comparator that is doing a dot size, you know, less than b dot size or whatever. Or you know, more accurately, you know, lhs dot size for left hand side less than rhs.size. But in c plus plus 20, you can now use the projection overload to pass a member function. So I believe the spelling of that would be uh like ampersand. Uh you know, if it's if it's string, it's gonna be you know std stron colon string colon colon size. The spell I might have misspelled that. But then once you have that, it's gonna basically apply that unary function. So it's very similar to a transform iterator if you're familiar with the boost iterators or the thrust iterators, and then it's gonna perform the sort on that uh basically modified element. Um so instead of comparing it on strings, you're pairing it, comparing it on size T's or whatever, whatever that returns. So I I think this is an example of like an embedded B combinator, which is although it's it's not really though, right? Because actually what's being performed is a a custom comp it's uh it's a comparator. So it's applying a binary function in the midst of this sort algorithm after applying a unary function to modify what you're um doing that. So it's actually an embedded psi combinator. Wow. That's awesome. And that's what I mean is that I think for a function like uh std transform, it wouldn't really make sense to use a projection, I guess. Or maybe it would. Right. But like you you could you could really just embed whatever that projection is inside your lambda that you're performing. Um but potentially you're you're doing some already custom function object that you don't want to have to like create a lambda. So if you're doing that, then I don't know. There might be some use cases.
BryceYou can also there's a range adapter, there's a views transform. So you could just pipe two of those together if you want to do that. Yeah.
ConorUm but so that's the thing, is in the case where you're performing a unary operation on the projected elements, it's an example of like an embedded B combinator where you're doing one unary function followed by another unary function. Where it's a binary function, whether that's in the form of a custom comparator or uh you know a binary function that returns some sort of element. Um that's an embedded psi combinator. So a psi combinator, PSI, uh in case I'm pronouncing that wrong, it's so it's so awesome. It's known as on in Haskell, where basically you apply a unary function to your two arguments to your binary function, and then apply your binary function. So it's actually a specialization, another specialization of the S prime combinator. So the S prime combinator had a specialization in the S combinator where one of the unary functions was fixed to identity.
BryceRight.
ConorThe psi combinator is a specialization of the S prime combinator where each of the unary functions is the same. Um so in the case of doing our, you know, uh sorting strings by length, that unary function is our size method. And then the the binary operation is just the the less than or whatever kind of sort we're doing. Um, that's awesome. That's awesome. That's awesome. I mean, and there's there's this there's this fantastic parallel, although I'm sure this exists in like everything in life, where the same way that there are the most general versions of algorithms, like std mismatch is at the top of sort of the mismatch algorithms, and std accumulate, roughly speaking, is at the top or the bottom, you know, the root of the tree of reduction algorithms. I say sort of because most of our reduction algorithms return iterators and std accumulate returns a value, so you know, yada yada. Um but so the point is you have these like hierarchical relationships where one algorithm is a specialization of another algorithm is a specialization of another algorithm, but it can all be traced back to std transform. The same thing exists in combinator land, like S prime combinator, I'm not sure if that's a root one, but there's these sort of latent, or not latent, but there's like direct relationships between them where it's not I haven't read enough on the literature if they talk about how one is like the more general version of the other one. But yeah, I just it's it's and that's the thing is it this sounds like what did um Richard Park, who was one of the panelists on the most recent Array Cast episode, when he heard me and sort of others talking about tacit programming or point view programming and using all these combinators um under whatever name you want to give them, he used the word highfalutin. Uh that it sounded like, oh, like you know, and I think there was a lot of Truth. Like there is something I think that it's a valid criticism to hear someone going on and on about, like, oh, this is so awesome, it's so beautiful, it's so elegant. But it's it's all of this extra literature and sort of structure that you need to learn and know. But I I think it is worth learning. And I also think that well, you know this already, Bryce, but I think that if you were to express your programs in a point-free kind of form that is heavily relies on these combinators, the structure, the con the patterns within these composition patterns that are combinators, enables a compiler to be written that can just take, they can do ridiculous like AST transformations. Um, like this is I'm not sure if I've talked about this on the podcast or it's just been in private conversations with Bryce. But like the fact that, you know, and I guess this is fixed a little bit in um with ranges, but like the fact that you call, you know, three std transforms in a row in C11, that's three times slower than calling a single one. And like a sophisticated enough compiler that maybe removed some of the legacy of C should be able to see that, oh, hey, I'm calling three algorithms and those could be fused together.
BryceWe should we shouldn't say three times slower, because that's not actually correct. What you mean is three times more efficient.
ConorI mean it's actually worse than three times slower.
BryceIt's typically typically certain assumptions there. It may actually be the case that um uh like I'll give you an example. Let's say that combining those three transforms meant that uh uh the um the that you blew up cache, right? That like like when you when you run all three of those transform operators like consecutively on on the elements, um uh that causes you to to to blow out your like L1 cache. Um uh and and so uh it's less cache efficient, and so it's actually slower to uh combine all of those operators into a single pass than to do three separate passes.
ConorRight. Okay, yes. So there are going to be certain use cases where I'm sort of hand-waving and making general broad statements that it won't be true for.
BryceRight, but but if well my point is you you were talking about speed, but you didn't actually mean speed. You meant efficiency. You meant, you know, this is a uh uh a three you know, a three-pass uh algorithm versus a one-pass algorithm. You meant like a you meant like a speed in like a theoretical sense. You didn't mean like speed in practice. Like it may it be. I mean, but like that might train the same thing.
ConorIn practice, I feel like in actually I shouldn't speak about, but I just there are me, there are many cases where in practice it will be.
BryceRight, right, right. Th there are, but there are also many cases where like like it's not something that you can just blindly do. Um there will be plenty of cases where it um uh you know that sort of loop fusion will will be less efficient. And like that's something that you have to like tune for and balance.
ConorRight. Um in general though, for like the simple cases, you know, there's been code reviews where there's been two algorithms, you know, uh a reduction and a transform, and I've spelled it, you know, w using the correct algorithms, and then the code review says this'll be slower, use a std for each and combine these. And then I profile it, and then you know, sure enough, that's the case. And that makes me sad because I'm choosing the less expressive way to Well, why aren't you using a transform reduce? Uh because that's all the correct answer. That no, that does not that does not work. So it's it's not a transform and a reduction. It's like I'm I'm doing a reduction and a transform each separately.
BryceSo you're doing a reduction, then you're doing the transform that depends on the reduction?
ConorNo, they're they're orth they're orthogonal to each other.
BryceOh, I understand.
ConorBut it's like you're iterating over the same data.
BryceYeah.
ConorAnd so the the the most expressive way to code that is just to use the correct algorithms. But if you find yourself coding two, you know, algorithms one after another, and they're operating on the same data, it is going to be faster to use what is, in my opinion, one of the worst algorithms, the stood for each, and sort of losing the explicity expressivity of what you're doing, but gaining the performance. And my dream is to have a programming language that you can you can write it the most expressive way and it's still be as efficient as it being sort of folded into um, and that's the thing is so using these combinators, like the classic example is if you're using uh S prime combinator, aka what's known as a fork, an APL, to um perform min-max element, the equivalent of min max element. So minimum and maximum are unary operations. They take a single range and they do a reduction, and then the binary operation in this case is just like make pair. Um but there are many different versions of the algorithm. Like say you want to know the difference between the maximum and the minimum. Well, you just you change your binary operation from make pair to uh minus, and you make sure that your maximum is on the left side of uh that binary operation. If you perform uh like you know, min element and then max element, you know, that will be half as efficient as the min max element, because that's a single pass algorithm. Well, with this expressed in the S prime combinator, it enables, it doesn't exist because in APL I've profiled this, and it is you know roughly 260 times or no, 2.6 times as slow or whatever efficient based on the profiling that I did. Um, but you could hypothetically write an interpreter or compiler that recognizes that idiom and says, oh look, we're doing two reductions, and we know that because it's the S prime combinator, it it's working on the same sequence of data. Um so you can just bundle those binary operations into a reduction that performs both of them, and the accumulator is a pair of uh, you know, your two results, and poof. You know, you now have you now are able to express this as you know in the most expressive way possible, but you're not giving up any performance. And this is just a single example. Like on top of that, there's a bunch of other examples. And I've actually heard someone, I need to look into this, but apparently um John Backis had a language in the 60s, I don't know, I might be off by a decade, uh, called FP, that was largely based on APL. From what, from the light reading that I've done on that, that is more focused on the algebraic properties that we've discussed in the past, where like if you know the associativity or the commutativity, can you um can you know compile down to different parallel implementations of algorithms? That can be used in this kind of compiler that I'm talking about. That that's sort of a different thing, though. That's just keeping track of, you know, if you're using a point-free you know operation and some reduction, knowing at the end of the day that, oh, it's this composition of things is still both commutative and associative. Let's let's call std reduce.
BryceWoo! Hey Dave. So uh what is point-free programming?
ConorPoint-free programming is programming without points.
BryceThat was the worst definition I've ever heard.
ConorReally? The worst? You haven't heard worse than that?
BryceYou you used all of the term, like one of the key rules of good definition is don't define it in terms of itself.
ConorI was I was gonna elaborate. I just wanted. I I saw the opportunity for a joke and I did my best. I it's very counter or it's very confusing because in Haskell, as I've mentioned before, the B combinator, the composition operator, is the period, which is a dot or a point. And when you're doing point-free programming, you're using a lot of combinators, and the main combinator being the B combinator, aka the point. So point-free programming in Haskell ends up using a lot of points. And I was like, what the what the heck? Like this doesn't seem right. But what they mean by point free is argument-free. Point refers to the function arguments, so not having to mention the arguments explicitly. So if I wanted if you want to go back to that is this set disjoint um question, one way, the non-point-free way to define that is to go uh is disjoint AB equals null parentheses intersection a b n parentheses, and you're done. Um but note I had to name the two arguments, the two lists a and b, and those get mentioned in the solution. Uh the point-free version is is disjoint equals null b1 combinator, aka dot intersection. And a and b are not mentioned anywhere. There's no arguments mentioned. And some people don't really they don't like it, they think it's confusing. Um I absolutely love point-free solutions. Um it's it's the epitome of elegance in programming, in my opinion.
BryceI just realized we didn't tell Dave that he needed to record on his end.
ConorWe didn't, we didn't Oh yeah, well, I assume, so yeah, I alright. Uh, you know. The listener has has heard some amount of uh uh well uh we just heard from Dave. He popped in and uh didn't have his mic recording. Um so yeah, that's uh Dave popped his head in for a sec and is he's sorting some stuff out. But yeah. Anyways, we should wrap it up.
BryceI'm just making this super hard for you to edit.
ConorNo, no, it's the best, man. I I I have a blast. Uh this honestly, it's uh every whatever two weeks or whatever when we record, this is like it's one of the highlights of the week. Uh, you know, what is so I was talking to someone, um, this is just random tangent, but my sister was in town for the last week from Calgary.
BryceYeah, Shannon, right? Yeah.
ConorYeah, yeah. Uh it was awesome to have her around and her partner Evan. Um, and we went to this sort of outdoor patio at one point, and uh I ended up actually, he was thinking about starting a podcast, and yeah, he was like asking me, like, you know, oh, like how do you do you like always have topics planned ahead? And uh et cetera, et cetera. And I was like, oh, sometimes we do, sometimes we don't. Like, I mean, you want it to be really structured at the beginning, and now we just have to all went out the window. Yeah, we had this whole document of like, he's like, we got to brainstorm, we gotta get the first, you know, 40 ideas uh laid out. And then I was like, honestly, when your co-host is Bryce, uh, and is as entertaining as he is, um I was like, that's the key. Just find a really entertaining co-host.
BryceUh that's no no, the key, the key, Connor, the key is the chemistry. It's that rich, lucky, Bryce and Connor chemistry. That's what makes the podcast. It was just me with some random person, it wouldn't be a podcast.
ConorThat's true, that's true. Yeah, there has to be chemistry. I I will admit, though, I think between the two of us, uh, you definitely bring a lot more, uh, I don't know, personality or what it is, but like, you know, the whole the whole bit last time about you being like, well, I do, I am quite fond of waterfowl. I mean that's a rare, that's a rare kind of kind of you know uh thing to find in someone.
BryceUh I was really dying when you sent me when you sent me the recording of the duck that you and Canon Man here on. And you, the duck's quacking, and Connor is doing very bad duck sound effects.
ConorI thought that was a decent duck quack.
BryceUm It was pretty good. It was pretty good.
ConorYeah, we gotta we got tons of birds. You gotta CPP North, man. You're definitely gonna have to come up and uh Oh yeah.
BryceShould should we? I mean, at some point, maybe we should do a live, you know, a live recording of this, like in front of a live audience.
ConorUm, I mean we we definitely need to record in the same room at some point. Um whether we would invite I don't know how I feel about uh did like because that's I don't know, it just seems very like who do people care enough? Do they actually want to be?
BryceOh yeah, kind of we have fans. We have fans, trust me. I don't know.
ConorI don't fans. I don't know. Do we I think we have people that listen? Would they call themselves fans?
BryceUm there's there's people who listen and tolerate, but yeah.
ConorThanks for listening. We hope you enjoyed and have a great day.