1UbnP|OlNkdi3$UHETW+#AtSO*e#;qTH1%-m
z!YxWz_Gce^_Sqh1ht>h!g!0C7_jFzScgtcaIlGS42Nv}@@(f$t2%(VHS|(oiZkv-z
z=GpLmi=LOKb~*KZdo7in#I75=$e!p74B?p0%T9cUB*X`lMJFoc~PeZQ@3)29%pX
zf3^5rZuI2AD^*V)#q4iy$27z9M3E_KQ^&quf6<>mCsm|&ek7t$evpLymf;A0tCNWb
zHoPt`<;lE9K7mVbE)<+wcUK75{s(|ecn;lZ$mlgBD;nP{&
z6FhlE%I!C}5&Lrv!@A=3XHJbHC$CA?R$WT2ZyEej72FunyVh~s%WEu;YN*#+C|7AY
zr?zWttcdx7P;cpF=2%0L6$)KEv-UcdJ+X@~W^-EE?9&aK+cMTFMU3*OULD?{T;3Y9
zt3ROV)}FXS*_MMA=#me^hkPSSQR(I~>gesK7dK4bx3^>*UsePZ{f`^ge?Z{-HNc=~
z5msrSl>aio(BLXp>{u^9{vKfd)g6?!mHqD{%=b)JMp|9HIsG%j>MwUt+P3tn5r{6H
zJ?4KG&mMtZiM3CG#SR7rdSelDE#loJ#&B7!gAe6u@FI%&;cy|7VN}!C^wT#m*ll91&
z8&69LOWz$TX*{l{rpI;Q9k$pOpKc^U-aK4b`gT`qI$wsBD=jD>>EFN{kJP_j=-TS)
zX?7=|%zHRWd1>?+fN#(IYPByybkn;{6&AiNa8Sk^#
zhl*$?os!)ArO+n(eL|8xq2OiOyk-GlXRZZ%-rPTBTJQD2rS-WYKbOLXvz9!<94B{Y
z?qhjGP1-Mi(J|+9x72n)(i$_tjLaz24L*6F$JW=Zd0Hr?KrtHJR^GVY;p&B6|JOsD
zT$^Jf#|N*6XR>zkN9LeSg9I`#50gdi&$Ccip=>vH*9^YxmpIM&T5?dmNc#n=O3}$D
z199h)J<{EZvh_TABxk1$)93kb^fyZ5c_W@6$E5{BpTu-Ya7U^(CG@dqCQIm?DZhKc
zA|fb1g}ZoD)4<4Ebzw>D*39lCnVgx8Gpgs$f8di6POEvVO4xsCP#EEHd};nebyfc~
zyt=p7cQ*3b?O>sdI0Y{YLF-qNmOKI7L3<*h0~>R&Wpk(~UbwMD%n-$RI%}lKahLS|
zeZd5uT~R#L)APpJZDPrIp}H8c*^{>w-_A@un5s*&xdz876{HM_~O??-BiIVpTq@~>v*uE;f4|r)3C4c13o&S
zc+EtQmDZ`0nD#vN0MUiYch03fo+qE!d6^hZw4P?oARYK!F5~`YPmNUD$)P6s1mK}cJKK|X^DgF!~LR{FwbG$JKwEyt)KPW7o$HSZ&~B*
zRDS6=CeU#L8_92VY53k4G~vPnPq%li@z*+USSc6}Bn~$m3Om4*yD3*4A9xA3kvGa>
zi@%;!?6YR0-jVt;$GdEwyWH`!efGXW8|U9mbC|%!wp+!OV1UPd1=~tr-wGHQNz%^q
zsbAcF$&txIp@6MhPDN^VLrRmCICqxy0m%wj+1(yQDL0HQdQcA4^d|IT<>BnJo%8U=
zX~yxf+2OvWcV^|LqKD+BOlFXdc9)G?r^gYG6SL3mIXA)6tL-Tja?#^ln(AoV66DjR
zixLO*yYuvx{aRY
zJ(W11OBFnUe=X9-{PhU)oe~cr9lm#bA2A`V%#Q1uhTYY2`i?~p%g#L(JDIe7$31kpht==QpX3Y>E5ljb>U2A6KzjXI41e6m
zJq>rqr%hu+j)T8VW_J)Tc4&;*?uwL&r|OQDQ?C~^dDEL7q;)(zD_x-Xim(@Gaw{WU
zy`m~RB^S8?vs?MhvzF@jo6hx;b}fh&ow-jE#5teHDKvdk^^b{PbOXn0VM`G=AHmr~
z@qa#CSjUWQwCp%BkZehrM51Jh229Ik79MIv-n#1h&@0xAFH8ScYLkkTp{wMrLzi`<
z5+m-0R(GF@&u)Ab>@zm4YTHe<5xnSx9%&@7frOd
zN?YYVP?>rFC3|AqA?Kll0C9cq08&vD>ZQnqXF-Y=m38=L(XW`&dPsj|=gBt$P+0DO2g@O@vDk{2M^G})U({NBk&6m)nlf2CofdHH
zQ+~jZ%{OyuMtNzJW&+>KO=@}eA+OW%BXCoHN(7W{-M&3+U0CN3!V6
z^bK{jRSC!NyY}n9lM4uZuX5CoWF-GmSRqvL-SMTT82rbLutQ7<4#mxl24dYzk>oHb
z8wnpOY73=o)-0rkm7FSA%8yzQc$DI#g
z=?TwttM*5rt0H<_;`GNnk0ZOU6}*W4P#?tlnl0zIt*LIj}V+pEHTB3f8EP*e*pVMHP-`+Q!_r6!R;S&w+Z+){GX*ay*2Dy@hY>ktv
zHkVu5?)El&eRQ80R()YZRD!GtcGH9hGTltPqm)wNv|c7xlc{TAPx6P@t|B0Iyx-5Q(vAvd-jEs682>x?By`Z
zle-SN55axPAj~kV&cKlS?Zl)G_dF-XkOz|A9CSc2L^ykENCtCA##vTYR$Yx6)^Mxv
zPS*PHaOuc|K%U&hZd%6y?-Y4SnW*5T2itBneUekuc}zb=;J
z>nd*gS3K-^*VX!_wX?OeqrHipvut*b#YNLk+$`%(-pzFqUl%@&=HvzpbIFTS*I5&l
z%ti}Gqtq)#*Hm>Kt*tY?-JQ9ha{IYu!oB{xX7vM;B=-+J?f$2OV^6MK6SgT=@((Jz
zBHGJ;e9NJSV*aJD01YYrH@X}h=-2-OVGi*}EZSc>OYpIEwRT&nva)n>dY7O7pU==B
z0pyA!G`~XLfe3R&>sKSpDZF2SFt_jYgW%1996d`rB`U#~TOh(*_x~S&IZH->IX>{>
z;pe*o{_&gRL3Iu2dcAP9r{R>4@g%>{afhSKrH3`CRwIe;-@m=VdQb0oim$WZA$#h+
zhQX1z7G#WVGjUzhrN?0u%E^g)VPU5EmiK+q1&@4uDAYiCFrb%k_x!N;OX^o+OVgkF
z1&yn%rg&mFVC&YcsrGaiV^lCKy@KfxsMdDYRO{bJ(`I_QKVhZ;1p0RvA-s|g$=cS1
zK!w;49Gu)0#7E02#34?03gV_{J-8l8mEh>4#0vja@VbA2}a4k{ehJh>x4AJ4Mb%L3}xh9C%M7!^9!W
zEL0Z-aWlOG5Gs-A>f%I&AfPxXAOj$!A@*bt@Y|b6c7Xs;j5wGj+0I_hKuzOEhTxNe
zxFeNHl7qp#y}hB{NGOr)00Y3|N-#JAhCs-G95R$+?o?|Z8Fz{ZSl)6)oXmbDbEiO;
z>jVXZJ;I#KU;q>j`&kfe)F(TUXfEmyTEW&fL{F;1u3bNJA+0EVRW{%cU_MB&dElMg
z0W#6f)0RM15Z64Q3(<5WQmEgv1y9&P;Q$m6hy7^Se<=?X=AWw#_P~#N<3S!dJ))hH
z{V_FbDnSteM<8Y3DDdA1K*^y2ITQ}OmxIIcFgi}Kx4@*TWCED%Z<4OyT#=MWHUj%l
z(ay_>M3HcjgwV=|0uTu!f*C{?gs)yENJ1d0MAETk48Uaq#K79l$=2ExqJE43F(lek
zy+O3V?=@U5=0~o07_Adl2XNu39{iE&M0F)7E}Md~EA{)0s}oHr`;ZCtEVS?=EHoAVenG&61bF1v9`c!vR@PT(s*K4S*w&^n7rfEP{as2LSLy@~X6OBmhQ_TulSZl?8)ytl|SOC^)zy
zx{8KE;gH}=w~7X!u?%U!aJL9XJ_HsF-?Az#5`m=!Y+XeIzy{N|0||orGUSJnr9l;}
zN{hguXh_1VXaEX{0FNK9q9MR4gWsE0(Et=&mcG3REF27UNXG}ps-%}60*%Jerv)c@
zhJFL}mQnTy1S20D4Mtq1D;EI=u`cLnI2@K<1^^t*C{s8ZMK2dP4$F`h)D0Yc+u&&0
z*F(B;0W^+5Ut}5c0{|rLZIO2U)?HZy7*U#z2FGCO>qk2QPcH+oE+~3ifXm_-+6HPk
zie4@#ECRGNx^gjC+RBY~{i+{m2`G9RfT5kqr{hCn0R}!emQnxU0PV}dsxrVj)0Xe6
zX*djyQ3ikLUx3j*05F~rLmz>rMK5~<9AJ<$0**t_*AIb2(!M*dsviQ3c1&+G2vDXB
zWnghw`nn)+7&N`Ckl+|buLnrbRO#i6#Gx4U83m61^yQ+^2(TAd*BK=%i)P@%qEPhh
z07prHk%nd%{{S$YIlcY?2mni81_1iS(8~Zo!qN0?1CX*bSBO<|22cpbGEf+Xu@0R2
z0DAcWXjwFUzX2EwV|ziT5&HU}KvP)JM^v)4lPiJDg2zK(hEBc&aNdHz42VQ31WX7q
zfWWlf?THZDJZ=CU5Kx0y;MK9}8XC%~NDUPE%1BvNBt}I=7K_0s{&x;5
b7BG-7g=$TvE>D(da5}`Wh>EK3*I@Y{T*n`&
literal 0
HcmV?d00001
diff --git a/specifications/LeastCircularSubstring/LeastCircularSubstring.tla b/specifications/LeastCircularSubstring/LeastCircularSubstring.tla
new file mode 100644
index 00000000..f7e63397
--- /dev/null
+++ b/specifications/LeastCircularSubstring/LeastCircularSubstring.tla
@@ -0,0 +1,165 @@
+---------------------- MODULE LeastCircularSubstring ------------------------
+(***************************************************************************)
+(* An implementation of the lexicographically-least circular substring *)
+(* algorithm from the 1980 paper by Kellogg S. Booth. See: *)
+(* https://doi.org/10.1016/0020-0190(80)90149-0 *)
+(***************************************************************************)
+
+EXTENDS Integers, ZSequences
+
+CONSTANTS CharacterSet
+
+ASSUME CharacterSet \subseteq Nat
+
+(****************************************************************************
+--algorithm LeastCircularSubstring
+ variables
+ b \in Corpus;
+ n = ZLen(b);
+ f = [index \in 0..2*n |-> nil];
+ i = nil;
+ j = 1;
+ k = 0;
+ define
+ Corpus == ZSeq(CharacterSet)
+ nil == -1
+ end define;
+ begin
+L3: while j < 2 * n do
+L5: i := f[j - k - 1];
+L6: while b[j % n] /= b[(k + i + 1) % n] /\ i /= nil do
+L7: if b[j % n] < b[(k + i + 1) % n] then
+L8: k := j - i - 1;
+ end if;
+L9: i := f[i];
+ end while;
+L10: if b[j % n] /= b[(k + i + 1) % n] /\ i = nil then
+L11: if b[j % n] < b[(k + i + 1) % n] then
+L12: k := j;
+ end if;
+L13: f[j - k] := nil;
+ else
+L14: f[j - k] := i + 1;
+ end if;
+LVR: j := j + 1;
+ end while;
+end algorithm;
+
+****************************************************************************)
+\* BEGIN TRANSLATION (chksum(pcal) = "c2e05615" /\ chksum(tla) = "81694c33")
+VARIABLES b, n, f, i, j, k, pc
+
+(* define statement *)
+Corpus == ZSeq(CharacterSet)
+nil == -1
+
+
+vars == << b, n, f, i, j, k, pc >>
+
+Init == (* Global variables *)
+ /\ b \in Corpus
+ /\ n = ZLen(b)
+ /\ f = [index \in 0..2*n |-> nil]
+ /\ i = nil
+ /\ j = 1
+ /\ k = 0
+ /\ pc = "L3"
+
+L3 == /\ pc = "L3"
+ /\ IF j < 2 * n
+ THEN /\ pc' = "L5"
+ ELSE /\ pc' = "Done"
+ /\ UNCHANGED << b, n, f, i, j, k >>
+
+L5 == /\ pc = "L5"
+ /\ i' = f[j - k - 1]
+ /\ pc' = "L6"
+ /\ UNCHANGED << b, n, f, j, k >>
+
+L6 == /\ pc = "L6"
+ /\ IF b[j % n] /= b[(k + i + 1) % n] /\ i /= nil
+ THEN /\ pc' = "L7"
+ ELSE /\ pc' = "L10"
+ /\ UNCHANGED << b, n, f, i, j, k >>
+
+L7 == /\ pc = "L7"
+ /\ IF b[j % n] < b[(k + i + 1) % n]
+ THEN /\ pc' = "L8"
+ ELSE /\ pc' = "L9"
+ /\ UNCHANGED << b, n, f, i, j, k >>
+
+L8 == /\ pc = "L8"
+ /\ k' = j - i - 1
+ /\ pc' = "L9"
+ /\ UNCHANGED << b, n, f, i, j >>
+
+L9 == /\ pc = "L9"
+ /\ i' = f[i]
+ /\ pc' = "L6"
+ /\ UNCHANGED << b, n, f, j, k >>
+
+L10 == /\ pc = "L10"
+ /\ IF b[j % n] /= b[(k + i + 1) % n] /\ i = nil
+ THEN /\ pc' = "L11"
+ ELSE /\ pc' = "L14"
+ /\ UNCHANGED << b, n, f, i, j, k >>
+
+L11 == /\ pc = "L11"
+ /\ IF b[j % n] < b[(k + i + 1) % n]
+ THEN /\ pc' = "L12"
+ ELSE /\ pc' = "L13"
+ /\ UNCHANGED << b, n, f, i, j, k >>
+
+L12 == /\ pc = "L12"
+ /\ k' = j
+ /\ pc' = "L13"
+ /\ UNCHANGED << b, n, f, i, j >>
+
+L13 == /\ pc = "L13"
+ /\ f' = [f EXCEPT ![j - k] = nil]
+ /\ pc' = "LVR"
+ /\ UNCHANGED << b, n, i, j, k >>
+
+L14 == /\ pc = "L14"
+ /\ f' = [f EXCEPT ![j - k] = i + 1]
+ /\ pc' = "LVR"
+ /\ UNCHANGED << b, n, i, j, k >>
+
+LVR == /\ pc = "LVR"
+ /\ j' = j + 1
+ /\ pc' = "L3"
+ /\ UNCHANGED << b, n, f, i, k >>
+
+(* Allow infinite stuttering to prevent deadlock on termination. *)
+Terminating == pc = "Done" /\ UNCHANGED vars
+
+Next == L3 \/ L5 \/ L6 \/ L7 \/ L8 \/ L9 \/ L10 \/ L11 \/ L12 \/ L13 \/ L14
+ \/ LVR
+ \/ Terminating
+
+Spec == Init /\ [][Next]_vars
+
+Termination == <>(pc = "Done")
+
+\* END TRANSLATION
+
+TypeInvariant ==
+ /\ b \in Corpus
+ /\ n = ZLen(b)
+ /\ f \in [0..2*n -> 0..2*n \cup {nil}]
+ /\ i \in 0..2*n \cup {nil}
+ /\ j \in 0..2*n \cup {1}
+ /\ k \in ZIndices(b) \cup {0}
+
+\* Is this shift the lexicographically-minimal rotation?
+IsLeastMinimalRotation(s, r) ==
+ LET rotation == Rotation(s, r) IN
+ /\ \A other \in Rotations(s) :
+ /\ rotation \preceq other.seq
+ /\ rotation = other.seq => (r <= other.shift)
+
+Correctness ==
+ pc = "Done" => IsLeastMinimalRotation(b, k)
+
+=============================================================================
+
diff --git a/specifications/LeastCircularSubstring/MCLeastCircularSubstring.tla b/specifications/LeastCircularSubstring/MCLeastCircularSubstring.tla
new file mode 100644
index 00000000..a9b1ffee
--- /dev/null
+++ b/specifications/LeastCircularSubstring/MCLeastCircularSubstring.tla
@@ -0,0 +1,11 @@
+--------------------- MODULE MCLeastCircularSubstring -----------------------
+EXTENDS Naturals, LeastCircularSubstring
+
+CONSTANTS CharSetSize, MaxStringLength
+
+ZSeqNat == 0 .. MaxStringLength
+
+MCCharacterSet == 0 .. (CharSetSize - 1)
+
+=============================================================================
+
diff --git a/specifications/LeastCircularSubstring/MCLeastCircularSubstringMedium.cfg b/specifications/LeastCircularSubstring/MCLeastCircularSubstringMedium.cfg
new file mode 100644
index 00000000..f1d2d915
--- /dev/null
+++ b/specifications/LeastCircularSubstring/MCLeastCircularSubstringMedium.cfg
@@ -0,0 +1,12 @@
+SPECIFICATION Spec
+
+INVARIANT
+ TypeInvariant
+ Correctness
+
+CONSTANTS
+ CharSetSize = 3
+ MaxStringLength = 8
+ CharacterSet <- MCCharacterSet
+ Nat <- [ZSequences]ZSeqNat
+
diff --git a/specifications/LeastCircularSubstring/MCLeastCircularSubstringSmall.cfg b/specifications/LeastCircularSubstring/MCLeastCircularSubstringSmall.cfg
new file mode 100644
index 00000000..e7b268dc
--- /dev/null
+++ b/specifications/LeastCircularSubstring/MCLeastCircularSubstringSmall.cfg
@@ -0,0 +1,12 @@
+SPECIFICATION Spec
+
+INVARIANT
+ TypeInvariant
+ Correctness
+
+CONSTANTS
+ CharSetSize = 2
+ MaxStringLength = 6
+ CharacterSet <- MCCharacterSet
+ Nat <- [ZSequences]ZSeqNat
+
diff --git a/specifications/LeastCircularSubstring/README.md b/specifications/LeastCircularSubstring/README.md
new file mode 100644
index 00000000..01f4123b
--- /dev/null
+++ b/specifications/LeastCircularSubstring/README.md
@@ -0,0 +1,10 @@
+# Least Circular Substring
+
+This is a spec the least circular substring algorithm described in [*Lexicographically Least Circular Substrings*](https://doi.org/10.1016/0020-0190(80)90149-0) by Kellogg S. Booth.
+The spec is notable for being a PlusCal implementation of a complicated sequential string algorithm.
+It was written to explore the feasibility of using PlusCal as a replacement for pseudocode in published papers and textbooks.
+You can read about the findings [here](https://ahelwer.ca/post/2023-03-30-pseudocode/).
+PlusCal was compared against a Python implementation of the same algorithm; the Python implementation is also included in this directory.
+
+Since the algorithm as given in the paper uses 0-indexed strings and TLA⁺ uses 1-indexed sequences, a `ZSequences.tla` module was written that behaves similar to `Sequences.tla` but indexed from 0.
+
diff --git a/specifications/LeastCircularSubstring/ZSequences.tla b/specifications/LeastCircularSubstring/ZSequences.tla
new file mode 100644
index 00000000..2ef96297
--- /dev/null
+++ b/specifications/LeastCircularSubstring/ZSequences.tla
@@ -0,0 +1,78 @@
+------------------------------ MODULE ZSequences ----------------------------
+(***************************************************************************)
+(* Defines operators on finite zero-indexed sequences, where a sequence of *)
+(* length n is represented as a function whose domain is the set 0..(n-1) *)
+(* (the set {0, 1, ... , n-1}). *)
+(***************************************************************************)
+
+LOCAL INSTANCE FiniteSets
+LOCAL INSTANCE Naturals
+LOCAL INSTANCE Sequences
+
+\* The empty zero-indexed sequence
+EmptyZSeq == <<>>
+
+\* The set of valid indices for zero-indexed sequence s
+ZIndices(s) ==
+ IF s = EmptyZSeq
+ THEN {}
+ ELSE DOMAIN s
+
+\* The set of all zero-indexed sequences of elements in S with length n
+LOCAL ZSeqOfLength(S, n) ==
+ IF n = 0
+ THEN {EmptyZSeq}
+ ELSE [0 .. (n - 1) -> S]
+
+\* The set of all zero-indexed sequences of elements in S
+ZSeq(S) == UNION {ZSeqOfLength(S, n) : n \in Nat}
+
+\* The length of zero-indexed sequence s
+ZLen(s) ==
+ IF s = EmptyZSeq
+ THEN 0
+ ELSE Cardinality(DOMAIN s)
+
+\* Converts from a one-indexed sequence to a zero-indexed sequence
+ZSeqFromSeq(seq) ==
+ IF seq = <<>>
+ THEN EmptyZSeq
+ ELSE [i \in 0..(Len(seq)-1) |-> seq[i+1]]
+
+\* Converts from a zero-indexed sequence to a one-indexed sequence
+SeqFromZSeq(zseq) ==
+ IF zseq = EmptyZSeq
+ THEN <<>>
+ ELSE [i \in 1..ZLen(zseq) |-> zseq[i-1]]
+
+\* Lexicographic order on zero-indexed sequences a and b
+a \preceq b ==
+ LET
+ s1len == ZLen(a)
+ s2len == ZLen(b)
+ RECURSIVE IsLexLeq(_, _, _)
+ IsLexLeq(s1, s2, i) ==
+ CASE i = s1len \/ i = s2len -> s1len <= s2len
+ [] s1[i] < s2[i] -> TRUE
+ [] s1[i] > s2[i] -> FALSE
+ [] OTHER -> IsLexLeq(s1, s2, i + 1)
+ IN IsLexLeq(a, b, 0)
+
+\* Rotate the string s to the left by r indices
+Rotation(s, r) ==
+ IF s = EmptyZSeq
+ THEN EmptyZSeq
+ ELSE [i \in ZIndices(s) |-> s[(i + r) % ZLen(s)]]
+
+\* The set of all rotations of zero-indexed sequence s
+Rotations(s) ==
+ IF s = EmptyZSeq
+ THEN {}
+ ELSE {[
+ shift |-> r,
+ seq |-> Rotation(s, r)
+ ] : r \in ZIndices(s)
+ }
+
+=============================================================================
+
diff --git a/specifications/LeastCircularSubstring/lcs.py b/specifications/LeastCircularSubstring/lcs.py
new file mode 100644
index 00000000..a1f87344
--- /dev/null
+++ b/specifications/LeastCircularSubstring/lcs.py
@@ -0,0 +1,41 @@
+'''
+This is an implementation of the Booth Least Circular Substrings algorithm.
+To use the algorithm, import this module then call the lcs function with your
+string. To run the property-based test, install Hypothesis and PyTest using
+pip -r requirements.txt then run pytest lcs.py.
+'''
+
+def lcs(b):
+ n = len(b)
+ f = [-1] * (2 * n)
+ k = 0
+ for j in range(1, 2 * n):
+ i = f[j - k - 1]
+ while b[j % n] != b[(k + i + 1) % n] and i != -1:
+ if b[j % n] < b[(k + i + 1) % n]:
+ k = j - i - 1
+ i = f[i]
+ if b[j % n] != b[(k + i + 1) % n] and i == -1:
+ if b[j % n] < b[(k + i + 1) % n]:
+ k = j
+ f[j - k] = -1
+ else:
+ f[j - k] = i + 1
+ return k
+
+def naive_lcs(s):
+ n = len(s)
+ if n == 0:
+ return 0
+ rotations = [s[i:] + s[:i] for i in range(n)]
+ least_rotation = min(rotations)
+ return rotations.index(least_rotation)
+
+from hypothesis import given, settings
+from hypothesis.strategies import text, sampled_from
+
+@settings(max_examples=10000)
+@given(text(sampled_from("abc")))
+def test_lcs(b):
+ assert lcs(b) == naive_lcs(b)
+
diff --git a/specifications/LeastCircularSubstring/requirements.txt b/specifications/LeastCircularSubstring/requirements.txt
new file mode 100644
index 00000000..847933d4
--- /dev/null
+++ b/specifications/LeastCircularSubstring/requirements.txt
@@ -0,0 +1,3 @@
+hypothesis == 6.70.0
+pytest == 7.2.2
+