There is no symmetry, because Base64 is not a one-to-one mapping for padded strings. Let's start with the actual decoded content. If you look at your decoded string in hexadecimal format (using, for example, s.unpack('H*') , it will be as follows:
6B C7 67 | B2 38 3C | 6A 2C 3C | 8E
I added borders for each input block to the Base64 algorithm: it takes 3 octets of input and returns 4 characters of output. Thus, our last block contains only one input octet, so the result will be 4 characters, which ends in "==" in accordance with the standard.
Let's see what the canonical encoding of this last block will be. In the binary representation, 8E is 10001110 . The RFC tells us to fill in the missing bits with zeros until we reach the required 24 bits:
100011 100000 000000 000000
I made groups of 6 bits, because this is what we need to get the corresponding characters from the Base64 alphabet. The first group (100011) is converted to 35 decimal places, and thus is j in the Base64 alphabet. The second (100000) is 32 decimal and, therefore, g. The two remaining characters must be padded as "==" in accordance with the rules. So canonical coding
jg==
If you look at jq ==, now in binary it will be
100011 101010 000000 000000
So the difference is in the second group. But since we already know that we are only interested in the first 8 bits ("==" tells us so → we will extract only one decoded octet from these four characters), we actually only care about the first two bits of the second group, because 6 bits groups 1 and 2 of the first bits of group 2 form our decoded octet. 100011 10 together again form our initial value of byte 8E . The remaining 16 bits are irrelevant to us and can be dropped.
This also implies why the concept of “strong” Base64 encoding makes sense: loose decoding discards any garbage at the end, while line decoding will check that the remaining bits are zero in the final group of 6. That's why your non-canonical encoding will be rejected by strict rules decoding.