My address book holds 1,883 contacts. A scoring pass calls 186 of them active, 9 stale, and 1,688 archived. That’s 89.6% of the book. No message, no email, no meeting with any of them in years.

That number is why I built Sodalis. It’s also why I spent almost no time on the scorer. Anything that can archive 1,688 records can archive the wrong 1,688.

The scoring rule fits on one line

Every interaction Sodalis can see contributes a weight that decays with age. Sent mail, received mail, calendar events, iMessages in both directions.

score += weight * 0.5 ** (days_since / self.decay_half_life)

decay_half_life is 360 days. The weights are asymmetric on purpose, because who started the contact tells you something:

InteractionWeight
calendar_event1.2
email_sent1.0
email_received0.8
imessage_sent0.8
imessage_received0.6

A shared calendar event outranks an email because it took two people agreeing. Mail I sent outranks mail I got, since inbound is mostly other people’s automation.

Classification reads two thresholds. Score of 1.0 or more is active. 0.3 or more is stale. Below that is archived. One email I sent last month keeps somebody active. One newsletter from three years ago doesn’t.

Base is 0.5, not e. The comment in src/activity/scorer.py:70-72 says why:

At decay_half_life days an interaction is worth exactly half its weight, which is what the config knob claims; exp(-days/half_life) would leave 36.8%.

Two characters, and no bug attached. exp() decay would have worked fine. It would just have meant the knob labelled “half-life” didn’t give me a half. Config keys that lie about their own units get me debugging arithmetic at midnight.

Four guards

In order of how much damage they prevent.

Mass-archive refusal. If a classification pass would newly archive more than max_archive_fraction (0.25) of the book, it refuses. No statuses change at all. I didn’t build it for a bad threshold. I built it for a scoring outage. If an import source quietly returns nothing, every score is zero. Every contact falls under 0.3. A perfectly correct classifier then archives my entire address book. The guard turns that into a log line. It skips books under 10 contacts. A two-contact test fixture trips a percentage guard constantly.

Sync never runs unattended. The nightly timer fires at 03:15 local. Persistent=true, with a 10-minute randomized delay. It runs import, score, and classify. It does not run sync. Writing to iCloud needs --confirm, typed by me, looking at the diff. Reads can be automated. Deletes get a human.

Backup before delete. Every card goes to a git-versioned vCard dump before the writer touches iCloud. 3,368 raw vCards on disk right now. Recovery is git checkout, not a support ticket.

Label overrides beat the scorer. always_active_labels covers Family, VIP, and Starred, and short-circuits scoring entirely. The person I text twice a year isn’t a data-quality problem.

Deduplication follows the same idea. A shared email address scores 0.95 confidence and a shared phone number 0.90. A fuzzy name match on its own gets multiplied by 0.7. A name is a weak signal. Two different people really are called the same thing.

Merges happen automatically at auto_merge_threshold 0.90 and above. Anything from review_threshold 0.60 up goes to a review queue instead. Without an exact email or phone match, last names have to clear 0.90 similarity. Otherwise I throw the pair out. I’d rather do the 0.60-to-0.90 band by hand than be wrong quietly.

What I get out of it

A tidier contacts list is cosmetic. What I wanted was for my phone’s Focus modes to work.

“Allow calls from this group” only helps if the group means something. 89.6% of the entries in that book are inert. A group built from it is noise with a filter on it.

One warning, and it cost me a guard to learn. Archiving deletes the contact’s iCloud card. iCloud then drops that person from every group and every Focus that named them. No error anywhere. The card comes back from the vCard backup. The Focus membership doesn’t.

So any label used by a focus-group rule has to be in always_active_labels too. The config file says that directly above the list. Not in a wiki I’d never open again.