Skip to main content

Command Palette

Search for a command to run...

Dog Medication Tracking

An unexpected bit manipulation

Updated
8 min readView as Markdown
Dog Medication Tracking
J
I am a developer in Seattle with interests in Security (cyber and IRL), machine learning, and distributed systems.

Our Alaskan Malamute Tsuki has hypothyroidism (hypo, meaning under or below, thanks ChubbyEmu). This is something my partner discovered. Alaskan Malamutes are highly predisposed to hypothyroidism. Upon giving her the prescribed medication for this issue, she no longer snores and her face is no longer droopy. Her energy levels are up.

We were doing a terrible job giving her the pill and logging it. Sometimes we'd lose the log. We started to note pill feeding in Discord DMs, but it's still a hassle to look at history.

With some free time, I decided to think of a way to encode/decode the daily log details without permanent storage. I've seen some open-source pastebin implementations that do this. It would make sense to use 2-bit encoding since we we want to track 4 states (None, Morning, Evening, Both). With 2-bits we can have 00, 01, 01, and 11. Googling and looking for ways to store this yielded bit-packing. It's overkill, but cool to understand and implement. This was the original implementation in Python:


def pack_statuses(statuses: list) -> bytes:
    """
    Packs an array of daily 2-bit statuses into a 92-byte array (365 days max).
    Status mapping: 0=None, 1=Morning, 2=Evening, 3=Both.
    """
    # 365 days at 4 days per byte requires exactly 92 bytes. I'm not handling leap years.
    packed = bytearray(92)
    for i, status in enumerate(statuses):
        if i >= 365:
            break
        byte_idx = i // 4
        # Bit offsets per byte: Day1=bits 6-7, Day2=bits 4-5, Day3=bits 2-3, Day4=bits 0-1
        bit_shift = 6 - 2 * (i % 4)
        packed[byte_idx] |= (status & 0x03) << bit_shift
    return bytes(packed)

This unpack_statuses groups a 365-day array into blocks of 4 days:

 Bit:    [ 7 | 6 ]   [ 5 | 4 ]   [ 3 | 2 ]   [ 1 | 0 ]
 Day:      Day 0       Day 1       Day 2       Day 3

To place a day's value into the correct slot, the code calculates a bit_shift using the formula: 6 - 2 * (i % 4). Note that we're using Big-Endian which is left-to-right for the byte-ordering or endianness.

Imagine we logs pills for the first four days:

  10000000  (Day 0)
  00110000  (Day 1)
  00000000  (Day 2)
+ 00000001  (Day 3)
───────────────────
  10110001  = 0xB1 (Decimal 177)

Instead of using 4 bytes of memory to store these days, the app now uses just 1 byte. The bitwise OR operator (|=) merges these together into a single byte.

When we unpack:

def unpack_statuses(packed: bytes, num_days: int) -> list:
    """
    Extracts individual 2-bit statuses from a packed byte array.
    """
    statuses = []
    for i in range(num_days):
        byte_idx = i // 4
        bit_shift = 6 - 2 * (i % 4) 
        status = (packed[byte_idx] >> bit_shift) & 0x03
        statuses.append(status)
    return statuses

When decoding, the process runs in reverse. The function reads the packed byte stream and separates the 4 days within each byte using a two-step process: Right Shift and Bitmasking.

If we unpack that same byte 10110001 to recover the first day we know it is located at bits 4 and 5. To read them, we shift the entire byte to the right by 4 positions using (packed[byte_idx] >> bit_shift):

Before shift:  1 0 1 1 0 0 0 1
After >> 4:    0 0 0 0 1 0 1 1   (The bits we want are now at the very end)

The bitmask part. The shifted byte still has leftover data from Day 0 (10) at the front. We isolate only the last two bits by applying a mask of 0x03 (00000011 in binary):

  00001011  (Shifted value)
& 00000011  (Mask)
───────────
  00000011  = Decimal 3

The encoding and decoding methods:

import base64
import struct


def encode_state(current: dict) -> str:
    """
    Encodes the application state into a clean Base64url string.
    """
    packed_body = pack_statuses(current["statuses"])
    
    # Pack Header: Version (1 byte) - just in case, StartEpochDay (4 bytes), Days (2 bytes)
    # '>' forces Big-Endian byte ordering
    header = struct.pack(">B I H", current["version"], current["startEpochDay"], current["days"])
    payload_without_checksum = header + packed_body
    
    # Calculate and append trailing 16-bit checksum
    check_sum = checksum16(payload_without_checksum)
    final_bytes = payload_without_checksum + struct.pack(">H", check_sum)
    
    # Encode using URL-safe Base64 without '=' padding characters.  We could URL encode also, but takes up more space.
    b64_bytes = base64.urlsafe_b64encode(final_bytes)
    return b64_bytes.decode("utf-8").rstrip("=")


def decode_state(b64_url_str: str) -> dict:
    """
    Decodes a Base64url string back into structured Python data.
    Raises ValueError if checksum verification fails.
    """
    # Restore any stripped trailing base64 padding charcters
    padding_needed = len(b64_url_str) % 4
    if padding_needed:
        b64_url_str += "=" * (4 - padding_needed)
        
    raw_bytes = base64.urlsafe_b64decode(b64_url_str.encode("utf-8"))
    
    # Check that we have enough data
    if len(raw_bytes) < 9:
        raise ValueError("Payload too short to contain minimum headers and checksum")
        
    total_no_checksum = len(raw_bytes) - 2
    data_payload = raw_bytes[:total_no_checksum]
    
    # Extract trailing checksum
    received_checksum = struct.unpack(">H", raw_bytes[total_no_checksum:])[0]
    computed_checksum = checksum16(data_payload)
    
    if received_checksum != computed_checksum:
        raise ValueError(f"Checksum validation failed! Data is corrupted. (Expected {computed_checksum}, Got {received_checksum})")
        
    # Unpack the 7-byte header
    version, start_epoch_day, days = struct.unpack(">B I H", data_payload[:7])
    packed_body = data_payload[7:]
    
    # Decode 2-bit state structures
    statuses = unpack_statuses(packed_body, days)
    
    return {
        "version": version,
        "startEpochDay": start_epoch_day,
        "days": days,
        "statuses": statuses
    }

For fun, I re-visited CRC-16 and added that checksum:

def checksum16(data: bytes) -> int:
    """
    Computes a basic 16-bit summation checksum over the data block.
    """
    return sum(data) & 0xFFFF

At this point, the base64 encoded 2-bit encoded string with checksum looks like:

AQAATy4BbQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOw

It's a 135 character string with a lot of duplicated As. This looks compressible and I've done this LeetCode problem before. We can leverage run length encoding here.

def compress_rle(data: bytes) -> bytes:
    """
    Use run-length encoding to compress passed in string.
    """    
    if not data:
        return b""
    
    compressed = bytearray()
    current_byte = data[0]
    count = 1
    
    # Loop through the data starting from the second byte
    for byte in data[1:]:
        # If the byte matches and count hasn't hit the 255 byte limit, increment
        if byte == current_byte and count < 255:
            count += 1
        else:
            # Save the pair to our compressed bytearray
            compressed.append(current_byte)
            compressed.append(count)
            # Reset for the new byte sequence
            current_byte = byte
            count = 1
            
    # Don't forget to append the final sequence
    compressed.append(current_byte)
    compressed.append(count)
    
    return bytes(compressed)

We can improve upon this by forgoing the count if there is only one character like in this string: AAABCC which would have been A31BC2 to A3BC2 which is what the LeetCode problem describes. This is done with:

compressed.append(current_byte)
if count > 1:
    compressed.append(count)

On decompression, we have to handle this case which makes it trickier when a single letter is encountered. We can do it, but whether we should is another matter. If the string has a lot of single character differences like: ABA then compression is worse since the encoding doesn't compress but extends the string to A1B1A1. In the LeetCode problem, the output would be the same.

This is what it would look like keeping with the consistent A1 [LETTER, NUMBER_OF_OCCURANCES].

def decompress_rle(compressed_data: bytes) -> bytes:
    decompressed = bytearray()
    
    # Step through the array 2 bytes at a time (value, count)
    for i in range(0, len(compressed_data), 2):
        byte_value = compressed_data[i]
        count = compressed_data[i+1]
        
        # Extend the array by repeating the value
        decompressed.extend([byte_value] * count)
        
    return bytes(decompressed)

If we want to decompress the LeetCode encoding then, key is to iterate backwards:

def decompress_rle_leetcode(compressed_data: bytes) -> bytes:
    if not compressed_data:
        return b""
        
    text = compressed_data.decode('ascii')
    decompressed = []
    current_count = []
    
    # Iterate backwards to easily pair digits with their preceding character
    for char in reversed(text):
        if char.isdigit():
            current_count.append(char)
        else:
            # Reconstruct the multi-digit number (reversed back to normal)
            count = int("".join(reversed(current_count))) if current_count else 1
            decompressed.append(char * count)
            current_count.clear()
            
    return "".join(reversed(decompressed)).encode('ascii')

The optimized version probably would use regex since it runs fast in Python due to optimized C code, but not sure if it would be faster than Python loops.

So now encode follows this :

[365-Day Status Array]
          │
          ▼
   1. pack_statuses()  ──► Condenses 365 integers into a raw 92-byte array
          │
          ▼
   2. compress_rle()   ──► Shrinks the 92 bytes by collapsing sequences of zeros
          │
          ▼
   3. Combine Header   ──► Prepends 7 bytes of metadata (Version, Epoch Day, Total Days)
          │
          ▼
   4. checksum16()     ──► Computes a 16-bit hash over the Header + Compressed Body
          │
          ▼
   5. Append Checksum  ──► Appends the 2-bit (16-bit integer) checksum to the end
          │
          ▼
   6. Base64url Encode ──► Converts the final byte array into an ASCII text string for the URL

Decode does the opposite.

The full gist with tests:

https://gist.github.com/Wind010/ac4b3ce6b6fc65555da26ca0f7bb4b07

After all this, I still need to deploy it. I am not that strong with JavaScript and I didn't want to translate, so in steps an LLM for the tedium and vibe coded some more. The static site is then published on Github Pages. This project can be tailored for your needs. Now we just copy and paste the link to each other after logging whether or not we've given her the pill.

The encoded string is only 19 characters long (an 85.95% improvement): r.AQA2Ty~4BbQA123Ow. The r. is just a prefix indicating the run-length encoding. The ~4 is escaped 4 so the decoder knows it's a literal 4.

https://github.com/Wind010/Tsuki_Pill_Log

Tsuki Pill Log

I spent way too long on this for my own edification. Also relevant.

References