insanity.md - Preview

building a forensic pipeline for my own digital humiliation

you can never really know how bad a conversation is going until you write a script to calculate your own double-text ratio

the backup gatekeep

i started by trying to pull my sms database using iMazing. the app does a full device backup to get access to the filesystem, so i sat there for an hour and a half watching a progress bar crawl across my screen. when it finally finished, iMazing hit me with a 30 dollar license paywall to actually export anything. classic

at that point i figured i'd do it the official way through apple devices. i configured a local unencrypted backup and waited an hour for it to finish. halfway through, i realized something deeply embarrassing

the appdata method

iMazing had already dumped the backup on my hard drive. the full sqlite database containing every text i've ever sent was just sitting in my appdata directory the entire time i was trying to find another way to get it:

C:\Users\sawyer\AppData\Roaming\iMazing\Caches\00008150-000824211A20401C\PartialBackup\00008150-000824211A20401C\3d\3d0d7e5fb2ce288813306e4d4636395e047a3d28

that hex string at the end is the SHA-1 hash of HomeDomain-Library/SMS/sms.db. i found the exact path from this blog post that is somehow still accurate 14 years later

i cancelled the apple devices backup and opened the database

we're looking at about 2 hours of waiting around and not once did i ever consider this was a bad idea. i was actually in a call with my friends the entire time and i fully blame erin and jake for not stopping me

metadata laundering

the sql is trivial. join the message table to the handle table so you can actually see who you're talking to, and pull the timestamps:

query = """
SELECT m.is_from_me, m.date, m.text 
FROM message m 
JOIN handle h ON m.handle_id = h.ROWID 
WHERE h.id = ?
ORDER BY m.date ASC
"""

since i did not want to accidentally expose my desperate ass texts to the entire internet, the extraction script replaces every message body with a character count before it ever makes it to the csv. it's metadata laundering, the analytics work the same but nobody gets to see what i actually said at 2am on a saturday

is_from_me,date,char_count
0,2026-06-11 21:56:47,44
0,2026-06-11 21:57:12,88
1,2026-06-11 23:09:02,58

the one genuinely annoying part was apple's timestamp format. ios doesn't store normal timestamps. it stores seconds (or sometimes nanoseconds??) since january 1, 2001, because apparently the unix epoch wasn't special enough. you have to detect which format you're dealing with and add 978307200 seconds to convert back to something usable:

if date_val > 1000000000000000:  # nanoseconds
    seconds_since_2001 = date_val / 1000000000
else:  # seconds
    seconds_since_2001 = date_val

unix_timestamp = seconds_since_2001 + 978307200

the full extraction script is here

the analytics

the second script is where the actual damage happens. it reads the sanitized csv and computes every metric you see in the profiler below

the first thing it does is group individual messages into "turns." if the same person sends multiple messages within 4 minutes, those get collapsed into a single turn. this also tracks burst speed: how fast someone fires off back-to-back texts. four minutes felt like the right cutoff. i did not test other values. i did not need to:

for msg in messages:
    if not curr_turn:
        curr_turn.append(msg)
    else:
        gap = (msg['date'] - curr_turn[-1]['date']).total_seconds()
        same_sender = msg['is_from_me'] == curr_turn[-1]['is_from_me']
        if same_sender and gap <= 240:
            if msg['is_from_me'] == 1:
                stats['burst_gaps_me'].append(gap)
            else:
                stats['burst_gaps_her'].append(gap)
            curr_turn.append(msg)
        else:
            turns.append(curr_turn)
            curr_turn = [msg]

once you have turns, everything works off the gaps between them. a gap over 20 minutes means the conversation is still going but the response was "cold" (not rapid-fire). a gap over 3 hours means the conversation died and whoever texts next is the conversation starter. if the same person sent the last turn and the next one across a 3-hour gap, that's a double text:

if gap_seconds >= 10800:
    if prev_is_me:
        stats['ghosted_by_her_3h'] += 1
    else:
        stats['ghosted_by_me_3h'] += 1
        
    if curr_is_me == prev_is_me:
        if curr_is_me:
            stats['double_texts_me'] += 1
            stats['double_text_attempts_me'] += 1

double text success survival is whether the other person responded within 12 hours. the script scans forward through the remaining turns looking for a reply. there's a technical term in the DSM-5 for scanning your own message history to calculate whether double-texting worked. i don't know it. i just wrote the loop:

for j in range(i+1, len(turns)):
    if turns[j][0]['is_from_me'] == 0:
        if (turns[j][0]['date'] - curr[0]['date']).total_seconds() <= 43200:
            stats['double_text_success_me'] += 1
        break
    if (turns[j][0]['date'] - curr[0]['date']).total_seconds() > 43200:
        break

i also had to write custom logic for response times. the script pauses the timer during sleeping hours (1am–8am) so a message sent at midnight and answered at 9am logs as a 2-hour delay, not a 9-hour delay. yes, i though about this enough to build a sleep schedule into the analysis, but i didn't stop to think how insane it was:

def get_active_seconds(start_dt, end_dt):
    current = start_dt
    active_seconds = 0
    while current < end_dt:
        next_day = (current + timedelta(days=1)).replace(hour=0, minute=0, second=0)
        sleep_start = current.replace(hour=1, minute=0, second=0)
        sleep_end = current.replace(hour=8, minute=0, second=0)
        
        step_end = min(end_dt, next_day)
        
        # count hours before sleep (midnight–1am)
        p1_start = max(current, current.replace(hour=0, minute=0, second=0))
        p1_end = min(step_end, sleep_start)
        if p1_start < p1_end:
            active_seconds += (p1_end - p1_start).total_seconds()
            
        # count hours after sleep (8am–midnight)
        p2_start = max(current, sleep_end)
        p2_end = step_end
        if p2_start < p2_end:
            active_seconds += (p2_end - p2_start).total_seconds()
            
        current = step_end
    return active_seconds

the full analytics script is here

for the record: none of this includes about a week of texting before our first date, since that happened over a different platform and never touched iMessage. also, if you're in a group chat with someone, you'll need to filter those messages out or they'll pollute your 1-on-1 stats

the system profiler

i dumped all of this in to a frontend to render these stats. i spent a couple hours styling the ui like the OS X 10 system profiler. tables, split panes, and a live activity monitor tracking the slow decline of my talking stage

System Profiler: Texting Stats
▶ Overview
▶ General
▶ Response Times
▶ Ghosting
▶ Conversation Ends
FieldValue
Data SourceSQLite Database Dump (Parsed JSON)
Total Conversations125
Average Conversation Length4.2 messages
Conversations Past 5 Messages9%
Most Active Hour11:00 PM
MetricParty A (Sawyer)Party B
Total Messages Sent280248
Average Characters per Message3926.5
Time Between Back-to-Back Texts 25s
(Avg gap between texts in a burst)
14s
(Avg gap between texts in a burst)
MetricParty A (Sawyer)Party B
Average Cold Response Time1h 19m3h 23m
Response Time Consistency ± 2h 6m
(Standard Deviation)
± 6h 52m
(Standard Deviation)
Fast Reply Rate (<30m) 42%
(Chance you reply quickly)
36%
(Chance she replies quickly)
Longest Ghosting Duration 18h 26m
(Longest time Sawyer ghosted)
2d 11h 56m
(Longest time Party B ghosted)
Reply Time After Being Ghosted (>1h) 38m (after 7h 11m wait)
(Your reply time after she delays >1h)
2h 30m (after 3h 33m wait)
(Her reply time after you delay >1h)
MetricParty A (Sawyer)Party B
Double Texts (>3h gap)132
Double Text Success Rate62%100%
Message Length After Ghosting54 chars41 chars
MetricParty A (Sawyer)Party B
Sent Last Message Before Convo Died3012
Conversation Starters (>3h gap)2319

the raw numbers are one thing. watching them move week to week is another. here's where the volume actually drops off

Activity Monitor: Message Frequency
Activity Monitor: Messages Over Time Peak: 11:00 PM

post-mortem

the activity monitor tells the whole story in one chart. the week of june 15th was peak volume: 86 messages from me, 73 from her. by june 29th it was 29 and 17. only 9% of our conversations made it past 5 messages

the data doesn't lie, and that's the problem. i reply faster. i double text more. i send longer messages after being ghosted. i'm the last one to text in 71% of dead conversations. if this were any other system i'd call it a resource leak. one node dumping packets into a connection that stopped sending ACKs weeks ago

the worst part isn't even the numbers. it's that i had to build an entire local environment just to confirm what i already knew. sometimes you don't need a statistical analysis to know you're the one trying harder. but apparently i did1

1

update: the stats were enough. i brought it up and broke things off.

Back to Archives