Last time^{[1]} we took a look at Borel sets of real numbers, which are subsets of the real numbers that can be represented as unions of countable sets of intervals \(I_i\). We got as far as implementing the
With these in place we're ready to implement a type to represent Borel sets and we shall do exactly that in this post.
Now these are a little terse and so its worth explaining exactly what they're doing.
Firstly, if the values of the
Secondly, if their values are equal an open lower bound should compare greater than a closed lower bound whereas a closed upper bound should compare greater than an open one, both of which we achieve by coercing a
Note that we don't have to worry about the corner cases of subtracting NaNs or equal infinities since we don't allow the former as values of
When comparing
The reason for this somewhat unintuitive ordering, which makes little sense mathematically and is implemented by
Note that when testing for a nonempty interval we only need to check that its bounds are different or that one of them is closed since we represent all empty intervals with open lower and upper bounds of zero. Furthermore, we are again exploiting the
Program 1 demonstrates the result of using this comparator to sort an array of intervals.
Once we have sorted the intervals and removed the empty ones, we know that the lower bound of an interval must be less than or equal to that of the next in the array. The criterion for merging them into a single interval is consequently that there aren't any numbers between the lower bound of the first and the upper bound of the second that are not included in either of them. This will be the case if the value of the first interval's upper bound is greater than that of the second's lower bound or if their values are equal and either of them is closed.
If this criterion has been met then the first interval will wholly include the second if its upper bound is greater than or equal to the latter's.
These criteria are implemented by the
With these in place we are ready to transform an array of
So the first thing that we do after sorting the intervals is to find the index of the first empty one, if there are any, by iterating backward from the end of the array. If there's more than one nonempty interval we enter a loop to merge any that are overlapping, otherwise we set the length of the array to that index, removing any empty intervals.
The loop works by keeping track of the index of the interval into which we might merge another in
Program 2 demonstrates the effect of this function on an array of
Here we do so directly after initialising it with our usual technique of dispatching to a
Next we add property access methods,
Finally we have the conversion method
The constructors are reasonably simple, allowing initialisation from an array of objects from which
Program 3 initialises a pair of
Returning to the
These both use our private, by convention rather than enforcement,
Since the value of the lower bound of the interval at that position might be greater than or equal to the value we must check for inclusion in both the interval at the previous position, if there is one, and at the position itself, if it isn't after the last in the array.
Program 4 demonstrates the results of the
The helper functions for
Note that in the latter function we only need to search the elements of the state from the previously located interval since the intervals in
Program 5 shows the results of the
We shall also again use the relational operators to test for the inclusion of one Borel set within another, reflecting the fact that we can define a partial ordering of Borel sets with
Listing 10 gives the definitions of these operators, together with those of equality and inequality.
We have previously implemented the set operations of union, intersection, difference and symmetric difference for sorted arrays representing multisets^{[3]} with
Since we have a type to represent Borel sets it makes sense to implement these operations as overloads such as the arithmetic
To create the complement we must construct intervals of the numbers lying before the lower bound of the first interval, between the upper bounds of each interval and the lower bounds of the next and after the upper bound of the last interval, as done by the
This captures all of the numbers less than the lower bound of the first interval with an initial closed lower bound at minus infinity. It then iteratively creates new intervals between the current lower bound and an upper bound with a value equal to that of the lower bound of the current interval but of the opposite type, so that a closed lower bound yield an open upper bound and an open lower bound yields a closed upper bound. The current lower bound is the replaced with the equivalent opposite of the interval's upper bound. Finally, we create an interval from the current lower bound to a closed upper bound at infinity to capture the numbers greater than the last upper bound. Note that since half open intervals with the same value for their lower and upper bounds are empty, the first and last intervals in the result will be empty if the set includes minus and plus infinity respectively.
Listing 12 overloads the
Program 6 demonstrates some of these operations for a pair of Borel sets.
Finally, you will find the implementation of
[2] I Still Haven't Found What I'm Looking For, www.thusspakeak.com, 2017.
[3] Let's Talk About Sets, www.thusspakeak.com, 2018.
ak.borelInterval
type to represent an interval as a pair of ak.borelBound
objects holding its lower and upper bounds.With these in place we're ready to implement a type to represent Borel sets and we shall do exactly that in this post.
A Brief Interval
Recall that the bounds of an interval can be open, in which case they do not include its value, or closed, in which case they do, and that we represent them with round and square brackets respectively. Specifically, we have the four cases
\[
\begin{align*}
x \in (l, u) &\rightarrow l < x < u\\
x \in [l, u) &\rightarrow l \leqslant x < u\\
x \in (l, u] &\rightarrow l < x \leqslant u\\
x \in [l, u] &\rightarrow l \leqslant x \leqslant u
\end{align*}
\]
where \(\in\) means within and \(\rightarrow\) means implies. If the lower and upper bounds of an interval have an equal value and either of them is open then that interval is the empty set \(\varnothing\)
\[
(x) = [x) = (x] = \varnothing
\]
Furthermore, for values \(x_i\) satisfying
\[
x < j \rightarrow x_i < x_j
\]
we have unions, intersections and complements, represented for sets \(A\) and \(B\) as \(A \cup B\), \(A \cap B\) and \(A^\prime\) respectively, of
\[
\begin{align*}
\left(x_0, x_2\right) \cup \left(x_1, x_3\right) &= \left(x_0, x_3\right)\\
\left(x_0, x_2\right) \cup \left[x_2, x_3\right) &= \left(x_0, x_3\right)\\
\left(x_0, x_2\right] \cup \left(x_2, x_3\right) &= \left(x_0, x_3\right)\\
\\
\left(x_0, x_2\right) \cap \left(x_1, x_3\right) &= \left(x_1, x_2\right)\\
\left(x_0, x_2\right) \cap \left[x_2, x_3\right) &= \varnothing\\
\left(x_0, x_2\right] \cap \left(x_2, x_3\right) &= \varnothing\\
\\
\left(x_0, x_1\right)^\prime &= \left[\infty, x_0\right] \cup \left[x_1, \infty\right]
\end{align*}
\]
The State Of The Union
A natural way to represent Borel sets in JavaScript is with arrays of intervals. It also makes sense to merge any overlapping intervals so that two equal Borel sets, containing exactly the same subset of the real numbers, will be represented by the same set of intervals. To do this efficiently we need to sort the intervals by their bounds and so the first thing that we need are comparison functions for them, as given in listing 1.Listing 1: The Bounds Comparators
function lbCompare(l, r) { var lv = l.value(); var rv = r.value(); if(lv===rv) { lv = Number(l.open()); rv = Number(r.open()); } return lvrv; } function ubCompare(l, r) { var lv = l.value(); var rv = r.value(); if(lv===rv) { lv = Number(l.closed()); rv = Number(r.closed()); } return lvrv; }
Now these are a little terse and so its worth explaining exactly what they're doing.
Firstly, if the values of the
ak.borelBound
arguments are different then both lbCompare
and ubCompare
simply compare those values numerically by subtraction, conforming to the Array.sort
requirement of returning a negative result if the first argument comes before the second, a positive result if the former comes after the latter and zero if they compare equal.Secondly, if their values are equal an open lower bound should compare greater than a closed lower bound whereas a closed upper bound should compare greater than an open one, both of which we achieve by coercing a
Boolean
indicating whether a bound is of the greater type to a Number
, which maps true
to one and false
to zero, so that the final subtractions yield correct results for the comparisons.Note that we don't have to worry about the corner cases of subtracting NaNs or equal infinities since we don't allow the former as values of
ak.borelBound
objects and the latter will result in comparing the types of the bounds, not their values.When comparing
ak.borelInterval
objects we shall firstly rank empty intervals as greater than nonempty intervals. We shall then rank one nonempty interval before another if its lower bound comes before the latter's, or if they are equal and its upper bound comes after the latter's. Conversely, we shall rank it after if its lower bound is greater than the latter's or if they are equal and its upper bound comes first. Finally, we shall rank them equal if both of their bounds compare equal.The reason for this somewhat unintuitive ordering, which makes little sense mathematically and is implemented by
intervalCompare
in listing 2, is that it will make it ever so slightly easier to correctly merge overlapping intervals.Listing 2: The Interval Comparator
function nonEmptyInterval(i) { var lb = i.lb(); var ub = i.ub(); return lb.value()!==ub.value()  lb.closed(); } function intervalCompare(l, r) { var lne = nonEmptyInterval(l); var rne = nonEmptyInterval(r); var c; if(lne && rne) { c = lbCompare(l.lb(), r.lb()); return c!==0 ? c : ubCompare(r.ub(), l.ub()); } return Number(rne)Number(lne); }
Note that when testing for a nonempty interval we only need to check that its bounds are different or that one of them is closed since we represent all empty intervals with open lower and upper bounds of zero. Furthermore, we are again exploiting the
Boolean
to Number
conversion rules to ensure that empty intervals compare greater than nonempty intervals and equal to each other.Program 1 demonstrates the result of using this comparator to sort an array of intervals.
Program 1: Sorting Intervals



Once we have sorted the intervals and removed the empty ones, we know that the lower bound of an interval must be less than or equal to that of the next in the array. The criterion for merging them into a single interval is consequently that there aren't any numbers between the lower bound of the first and the upper bound of the second that are not included in either of them. This will be the case if the value of the first interval's upper bound is greater than that of the second's lower bound or if their values are equal and either of them is closed.
If this criterion has been met then the first interval will wholly include the second if its upper bound is greater than or equal to the latter's.
These criteria are implemented by the
touchesNextLB
and includesNextUB
functions respectively, both of which are defined in listing 3.Listing 3: Testing For Overlap And Inclusion
function touchesNextLB(ub0, lb1) { var v0 = ub0.value(); var v1 = lb1.value(); return v0>v1  (v0===v1 && (ub0.closed()  lb1.closed())); } function includesNextUB(ub0, ub1) { var v0 = ub0.value(); var v1 = ub1.value(); return v0>v1  (v0===v1 && (ub0.closed()  ub1.open())); }
With these in place we are ready to transform an array of
ak.borelInterval
objects into the smallest sorted, and hence unique, array of ak.borelInterval
objects that represents the same Borel set, as done by the compress
function given in listing 4.Listing 4: Compressing The State
function compress(state) { var n, i0, i1; state.sort(intervalCompare); n = state.length; while(n>0 && !nonEmptyInterval(state[n1])) n; if(n>1) { i0 = i1 = 0; while(i1<n) { while(++i1<n && touchesNextLB(state[i0].ub(), state[i1].lb())) { if(!includesNextUB(state[i0].ub(), state[i1].ub())) { state[i0] = ak.borelInterval(state[i0].lb(), state[i1].ub()); } } if(i1<n) state[++i0] = state[i1]; } state.length = i0+1; } else { state.length = n; } }
So the first thing that we do after sorting the intervals is to find the index of the first empty one, if there are any, by iterating backward from the end of the array. If there's more than one nonempty interval we enter a loop to merge any that are overlapping, otherwise we set the length of the array to that index, removing any empty intervals.
The loop works by keeping track of the index of the interval into which we might merge another in
i0
and the index of a candidate to be merged in i1
. While, after incrementing i1
, the interval it indexes is touching the one indexed by i0
, we merge them. If the former is entirely included in the latter this is done by simply skipping past it, which we can safely do because our unusual ordering always puts an interval that is a subset of another after it. Otherwise we replace the latter with a new ak.borelInterval
having its lower bound and the former's upper bound. If we find an interval that isn't touching the one at i0
we increment it and copy that interval into its place, which will contain the same interval if we haven't merged any or an interval that has been merged if we have. We continue looping until there are no more candidates for merging and finally set the length of the array to i0+1
, removing any intervals that have been merged.Program 2 demonstrates the effect of this function on an array of
ak.borelInterval
objects.
Program 2: Compressing The State



ak.borelSet
As you may have guessed by the name of its argument, we shall use thecompress
function to ensure that the state
array of our type to represent Borel sets, ak.borelSet
defined in listing 5, has a canonical form so that two equivalent Borel sets will represented by objects with equal state
arrays.Listing 5: ak.boralSet
ak.BOREL_SET_T = 'ak.borelSet'; function BorelSet(){} BorelSet.prototype = { TYPE: ak.BOREL_SET_T, valueOf: function(){return ak.NaN;} }; ak.borelSet = function(set) { var b = new BorelSet(); var state = []; constructors[ak.nativeType(set)](state, set); compress(state); b.intervals = function() {return state.length;}; b.at = function(i) {return state[Number(i)];}; b.contains = function(x) { switch(ak.type(x)) { case ak.NUMBER_T: return containsNumber(state, x); case ak.BOREL_BOUND_T: return containsBound(state, x); } throw new Error('invalid argument in ak.borelSet.contains'); }; b.includes = function(x) { switch(ak.type(x)) { case ak.BOREL_INTERVAL_T: return includesInterval(state, x); case ak.BOREL_SET_T: return includesSet(state, x); } throw new Error('invalid argument in ak.borelSet.includes'); }; b.toArray = function() {return state.slice(0);}; b.toString = function() {return '{' + state.join('+') + '}';}; b.toExponential = function(d) { return '{'+state.map(function(x){return x.toExponential(d);}).join('+')+'}'; }; b.toFixed = function(d) { return '{'+state.map(function(x){return x.toFixed(d);}).join('+')+'}'; }; b.toPrecision = function(d) { return '{'+state.map(function(x){return x.toPrecision(d);}).join('+')+'}'; }; return Object.freeze(b); };
Here we do so directly after initialising it with our usual technique of dispatching to a
constructors
object according to the type of the set
argument.Next we add property access methods,
intervals
and at
, to return the number of intervals in the set and the interval at a given index. The contains
method tests whether a number or a bound is within the set by forwarding to helper functions that we shall describe later. Similarly, the includes
method tests whether an interval or a Borel set are subsets of it.Finally we have the conversion method
toArray
to extract an array containing the intervals in the set and the usual string conversion methods that join their string representations with plus signs to represent the union operation and enclose them in the curly braces typically used to represent sets.The constructors are reasonably simple, allowing initialisation from an array of objects from which
ak.borelInterval
objects can be constructed and from an object having an intervals
property or method indicating the number of intervals and an at
method to return an object from which an ak.borelInterval
can be constructed at a given index, as shown by listing 6.Listing 6: ak.boralSet Constructors
var constructors = {}; constructors[ak.ARRAY_T] = function(state, set) { var n = set.length; var i; state.length = n; for(i=0;i<n;++i) state[i] = ak.borelInterval(set[i]); }; constructors[ak.OBJECT_T] = function(state, set) { var n = (ak.nativeType(set.intervals)===ak.FUNCTION_T) ? Number(set.intervals()) : Number(set.intervals); var i; state.length = n; for(i=0;i<n;++i) state[i] = ak.borelInterval(set.at(i)); };
Program 3 initialises a pair of
ak.borelSet
objects from arrays of different intervals representing equivalent Borel sets and then prints them to show that they are equal.
Program 3: Constructing ak.borelSet Objects



Returning to the
contains
method, listing 7 shows the containsNumber
and containsBound
helper functions that we used to implement it.Listing 7: The containsNumber And containsBound Helper Functions
function numberCompare(li, rv) { return li.lb().value()rv; } function containsNumber(state, x) { var n = state.length; var i = ak._unsafeLowerBound(state, x, numberCompare, 0, n); return (i>0 && state[i1].contains(x))  (i<n && state[i].contains(x)); } function containsBound(state, x) { var n = state.length; var i = ak._unsafeLowerBound(state, x.value(), numberCompare, 0, n); return (i>0 && state[i1].contains(x))  (i<n && state[i].contains(x)); }
These both use our private, by convention rather than enforcement,
ak._unsafeLowerBound
function, which you will recall returns the first position in a sorted array before which a value can be inserted whilst keeping the array sorted^{[2]}, to find the first interval in the state
array that has a lower bound with a value greater than or equal to the number or the value of the bound. Note that the numberCompare
function relies upon our knowing the implementation details of ak._unsafeLowerBound
; specifically that the first argument is always an element of the array and the second is always the value whose insertion point we're looking for.Since the value of the lower bound of the interval at that position might be greater than or equal to the value we must check for inclusion in both the interval at the previous position, if there is one, and at the position itself, if it isn't after the last in the array.
Program 4 demonstrates the results of the
contains
method of an ak.borelSet
for a range of numbers and open and closed, lower and upper bounds.
Program 4: The contains Method



The helper functions for
includes
, includesInterval
and includesSet
defined in listing 8, use the same approach to find the intervals that might include the ak.borelInterval
argument or each of them in the ak.borelSet
argument.Listing 8: The includesInterval And includesSet Helper Functions
function includesInterval(state, x) { var n = state.length; var i = ak._unsafeLowerBound(state, x.lb().value(), numberCompare, 0, n); return (i>0 && state[i1].includes(x))  (i<n && state[i].includes(x)); } function includesSet(state, x) { var n = state.length; var m = x.intervals(); var i = 0; var j, xj; for(j=0;j<m;++j) { xj = x.at(j); i = ak._unsafeLowerBound(state, xj.lb().value(), numberCompare, i, n); if(!(i>0 && state[i1].includes(xj)) && !(i<n && state[i].includes(xj))) { return false; } } return true; }
Note that in the latter function we only need to search the elements of the state from the previously located interval since the intervals in
x
are also sorted.Program 5 shows the results of the
includes
method of two ak.borelSet
objects for a few different ak.borelInterval
and ak.borelSet
arguments.
Program 5: The includes Method



ak.borelSet Operators
Once again we shall useis
and nis
to represent the membership operator \(\in\) and its negation \(\notin\), as shown by listing 9.Listing 9: The is And nis Operators
function is (l, r) {return r.contains(l);} function nis(l, r) {return !r.contains(l);} if(!ak.is) { ak.is = function(x0, x1) {return ak.is [ak.type(x0)][ak.type(x1)](x0, x1)}; } if(!ak.nis) { ak.nis = function(x0, x1) {return ak.nis[ak.type(x0)][ak.type(x1)](x0, x1)}; } ak.overload(ak.is, [ak.BOREL_BOUND_T, ak.BOREL_SET_T], is); ak.overload(ak.is, [ak.NUMBER_T, ak.BOREL_SET_T], is); ak.overload(ak.nis, [ak.BOREL_BOUND_T, ak.BOREL_SET_T], nis); ak.overload(ak.nis, [ak.NUMBER_T, ak.BOREL_SET_T], nis);
We shall also again use the relational operators to test for the inclusion of one Borel set within another, reflecting the fact that we can define a partial ordering of Borel sets with
\[
\begin{align*}
A \subset B &\rightarrow A < B \wedge B > A\\
A \subseteq B &\rightarrow A \leqslant B \wedge B \geqslant A
\end{align*}
\]
where \(\subseteq\) means is a subset of, \(\subset\) means is a proper subset of or, in other words, is a subset of, but is not equal to and \(\wedge\) means and. Note that, unlike the total ordering of numbers, it is possible for one set to be neither less than, equal to nor greater than another; specifically, if they both contain at least one element that is not within the other.Listing 10 gives the definitions of these operators, together with those of equality and inequality.
Listing 10: The Equality, Inequality And Relational Operators
var eqInterval = ak.eq[ak.BOREL_INTERVAL_T][ak.BOREL_INTERVAL_T]; function eq(l, r) { var n = l.intervals(); var i = 0; if(r.intervals()!==n) return false; while(i<n && eqInterval(l.at(i), r.at(i))) ++i; return i===n; } function ne(l, r) { var n = l.intervals(); var i = 0; if(r.intervals()!==n) return true; while(i<n && eqInterval(l.at(i), r.at(i))) ++i; return i<n; } function le(l, r) {return r.includes(l);} function lt(l, r) {return r.includes(l) && ne(l, r);} function ge(l, r) {return l.includes(r);} function gt(l, r) {return l.includes(r) && ne(l, r);} ak.overload(ak.eq, [ak.BOREL_SET_T, ak.BOREL_SET_T], eq); ak.overload(ak.ge, [ak.BOREL_SET_T, ak.BOREL_SET_T], ge); ak.overload(ak.gt, [ak.BOREL_SET_T, ak.BOREL_SET_T], gt); ak.overload(ak.le, [ak.BOREL_SET_T, ak.BOREL_SET_T], le); ak.overload(ak.lt, [ak.BOREL_SET_T, ak.BOREL_SET_T], lt); ak.overload(ak.ne, [ak.BOREL_SET_T, ak.BOREL_SET_T], ne);
We have previously implemented the set operations of union, intersection, difference and symmetric difference for sorted arrays representing multisets^{[3]} with
ak.setUnion
, ak.setIntersection
, ak.setDifference
and ak.setSymmetricDifference
.Since we have a type to represent Borel sets it makes sense to implement these operations as overloads such as the arithmetic
ak.add
operation and the ak.or
logical one for the union and the logical ak.not
for the complement. Once we have these we can implement the intersection, difference and symmetric difference using the identities
\[
\begin{align*}
A \cap B &= \left(A^\prime \cup B^\prime\right)^\prime\\
A  B &= A \cap B^\prime = \left(A^\prime \cup B\right)^\prime\\
A \ominus B &= \left(A \cup B\right)  \left(A \cap B\right)
= \left(\left(A \cup B\right)^\prime \cup \left(A^\prime \cup B^\prime\right)^\prime\right)^\prime
\end{align*}
\]
To implement the union of two ak.borelSet
object we can simply convert them to arrays, concatenate them and construct a new ak.borelSet
with the result, trusting its constructor to transform its intervals into the smallest set of intervals that contain every number in them which is necessarily their union.To create the complement we must construct intervals of the numbers lying before the lower bound of the first interval, between the upper bounds of each interval and the lower bounds of the next and after the upper bound of the last interval, as done by the
not
function in listing 11.Listing 11: The Complement Operator
function flipLB(lb) { return ak.borelBound(lb.value(), lb.open() ? ']' : ')'); } function flipUB(ub) { return ak.borelBound(ub.open() ? '[' : '(', ub.value()); } function not(x) { var n = x.intervals(); var a = new Array(n+1); var i = 0; var l = ak.borelBound('[', ak.INFINITY); var b; while(i<n) { b = x.at(i); a[i++] = ak.borelInterval(l, flipLB(b.lb())); l = flipUB(b.ub()); } a[i] = ak.borelInterval(l, ak.borelBound(ak.INFINITY, ']')); return ak.borelSet(a); }
This captures all of the numbers less than the lower bound of the first interval with an initial closed lower bound at minus infinity. It then iteratively creates new intervals between the current lower bound and an upper bound with a value equal to that of the lower bound of the current interval but of the opposite type, so that a closed lower bound yield an open upper bound and an open lower bound yields a closed upper bound. The current lower bound is the replaced with the equivalent opposite of the interval's upper bound. Finally, we create an interval from the current lower bound to a closed upper bound at infinity to capture the numbers greater than the last upper bound. Note that since half open intervals with the same value for their lower and upper bounds are empty, the first and last intervals in the result will be empty if the set includes minus and plus infinity respectively.
Listing 12 overloads the
ak.not
logical operation with this ak.borelSet
complement, ak.add
and ak.or
for its union operation, ak.and
for the intersection, ak.sub
for the difference and ak.xor
for the symmetric difference. Furthermore, it overloads ak.nand
with the complement of the intersection, ak.nand
with that of the union and ak.xnor
with that of the symmetric difference.Listing 12: The Set Operators
function and (l, r) {return not(or(not(l), not(r)));} function nand(l, r) {return or(not(l), not(r));} function nor (l, r) {return not(or(l, r));} function or (l, r) {return ak.borelSet(l.toArray().concat(r.toArray()));} function sub (l, r) {return not(or(not(l), r));} function xnor(l, r) {return or(not(or(l, r)), not(or(not(l), not(r))));} function xor (l, r) {return not(or(not(or(l, r)), not(or(not(l), not(r)))));} ak.overload(ak.not, ak.BOREL_SET_T, not); ak.overload(ak.add, [ak.BOREL_SET_T, ak.BOREL_SET_T], or); ak.overload(ak.and, [ak.BOREL_SET_T, ak.BOREL_SET_T], and); ak.overload(ak.nand, [ak.BOREL_SET_T, ak.BOREL_SET_T], nand); ak.overload(ak.nor, [ak.BOREL_SET_T, ak.BOREL_SET_T], nor); ak.overload(ak.or, [ak.BOREL_SET_T, ak.BOREL_SET_T], or); ak.overload(ak.sub, [ak.BOREL_SET_T, ak.BOREL_SET_T], sub); ak.overload(ak.xnor, [ak.BOREL_SET_T, ak.BOREL_SET_T], xnor); ak.overload(ak.xor, [ak.BOREL_SET_T, ak.BOREL_SET_T], xor);
Program 6 demonstrates some of these operations for a pair of Borel sets.
Program 6: Some Set Operations



Finally, you will find the implementation of
ak.borelSet
and its operators in BorelSet.js.
References
[1] A Decent Borel Code, www.thusspakeak.com, 2018.[2] I Still Haven't Found What I'm Looking For, www.thusspakeak.com, 2017.
[3] Let's Talk About Sets, www.thusspakeak.com, 2018.
Leave a comment