The I-Will System: Functional Specification

Changelog

2023-02-17: edited all references to "promises.to" since we dropped that domain
2023-02-17: added this changelog and reconstructed some past entries
2021-04-03: removed all date parsing from the spec
2020-12-16: notes on killing date parsing
2020-12-15: moved it back here
2020-03-12: moved the spec to a GitHub wiki
2018-08-29: (using the prototype pretty extensively around this time)
2018-01-06: prototype officially functional
2017-11-07: lots of work on this spec
2017-10-27: building a prototype
2017-08-09: original blog post with the seed of the idea: blog.bmndr.co/will

Preamble

Can you hear yourself casually saying to a coworker, “I'll see if I can reproduce that bug”? Or to a friend, “I'll let you know if I can make it to the show” or “I'll send you the photos”? And can you see yourself flaking out and failing to follow through on those things?

It was really bugging me that my future-tense statements could sometimes be falsehoods. So I started building this system so that instead of making a potentially false statement about what I'll do, I can instead -- impeccably truthfully and more informatively -- always give an exact probability that I'll do the thing. My current reliability for doing what I say is ~94%.

Here's how it works in practice, assuming your name is Alice and your coworker or friend is Bob. Any time you make any “I will” statement, let's say “I'll send you my edits tomorrow”, you type a URL like so:

alice.commits.to/send_bob_edits_by_tomorrow_5pm

As in, you literally type that, on the fly, directly to Bob, manually, when you’re making the commitment to him. When you or Bob click that URL a promise is created in the commits.to app and an entry is added to your calendar and ideally a datapoint is sent to Beeminder. The system lets you mark the promise completed and keeps track of your reliability — the fraction of promises you keep! — and shows it off to anyone who follows an alice.commits.to link.

My goal with this project is to have a way to say I’ll do something in a way that friends and colleagues can have, hopefully, ninety-nine point something percent faith in. I started doing this manually on 2017 July 27, tracking my promises in a spreadsheet and on Beeminder. And I’ve gotten the public accountability aspect by blogging about this.

The system is ridiculously powerful and satisfying. It’s even weirdly relaxing. When you get a commitment logged and on your calendar you yourself have faith that it will happen so you can put it out of your head in the meantime. I’m excited for this to be something anyone can use!

Overview

You create a promise by constructing a URL — URL as UI! — and you mark a promise complete by surfing to that URL and checking a box or clicking a button. By counting up how many promises were made and how many were marked completed (and applying a fancy late penalty function) we show a real-time reliability percentage for each user.

We’ve first deployed something that works for ourselves as the simplest possible CRUD app. No logins, no user accounts, no security, nothing. Anyone can surf to the URL for any promise and have carte blanche on changing it in any way. We just store all the promises and show the reliability statistics based on them.

Here’s a walk-through of what needs to happen for a generic example of Jo promising to do a thing by noon:

  1. Jo surfs to jo.commits.to/do_a_thing_by_noon
  2. The system checks if a promise with that URL exists yet (see “Parsing Dates and Promise Uniqueness”)
  3. If not, create it (see “Promise Data Structure”)
  4. The page served up for jo.commits.to/do_a_thing_by_noon shows a form with some of the promise fields (see “Marking Promises Fulfilled”)
  5. It also shows a big countdown to the deadline and any late penalty if the deadline has passed (see “Late Penalties”)
  6. In the header or corner of the page should be Jo’s overall reliability across all her promises (see “Computing Statistics”)
  7. Also on the page: a link to create a calendar entry (see “Calendar Integration”)
  8. (We’re eager to add Beeminder Integration but will wait on that till we have user logins)
  9. Nothing else special happens when a promise is marked fulfilled other than the reliability percentage updates and the color changes
  10. If you go to just jo.commits.to you see Jo’s overall reliability score and a list of all her promises, sorted by urgency/recency

Creation on GET

Creating an object in a database on the server in response to a GET request is not considered kosher. (Webdevs, please suppress your derisive snorts!) And, yes, it has practical disadvantages like crawlers creating rogue promises. The obvious way to solve that would be to have the GET request generate a page with a button which makes a POST request to confirm creation of the promise.

But we’re treating it as a core design principle to make all tradeoffs in favor of lower friction, and removing a confirmation click removes friction. In some chat clients, URLs are prefetched to show inline previews and in that case create-on-GET means no clicks at all. Also we’ve found that a typical promisee who clicks on a URL won’t click a confirmation button. It feels presumptuous or something. Or the page looked too intimidating in our early prototypes.

In any case, we’re running with create-on-GET. We really like how every yourname.commits.to URL you type gets almost automatically logged as a promise. And by restricting the allowed URL format we are finding that rogue promises from crawlers can be a non-issue. As for possible abuse as we scale up, that’s a bridge we’ll cross when we get to it.

Allowed URL Format

First, if you want to skip to the bottom line, here are the characters you can and can’t use in a promise URL:

WILL ALWAYS BE ALLOWED: a-z A-Z 0-9 - _
TENTATIVE YES BUT AVOID: : + ! $ ~ * @
NO: . / # ? % (all other characters)

And now the full story, with rationale for each character. The tricky part about this is that some special characters will cause confusion and even break things. So we need to educate users about exactly what characters are allowed. Our hope is that most special characters that a user may accidentally try to use will be rejected and they will be trained to avoid any special characters and not happen upon any that break things. Previously we intended to minimize that chance by rejecting all special characters except underscores and dashes. Currently we intend to allow a specific set of special characters that we know to not cause problems.

Without further ado, here are the exact rules for the URL format.

First, usernames are easy. They must be all lowercase, start with a letter, and contain only ASCII letters and numbers. No hyphens, even though those are common in subdomains. And no dots, i.e., no sub-subdomains. And no other characters that might technically be allowed in domain names.

For the part of the URL after the domain name (ok, after the slash after the domain name — turns out that slash isn’t necessarily obvious to non-technical users), the following is the full list of possible characters, whether we allow them, and what the considerations are.

Definitely Allowed

  1. Lower case ASCII letters (a-z) obviously.

  2. Upper case ASCII letters (A-Z) — commits.to is fully case-sensitive so alice.commits.to/aBc and alice.commits.to/abc and alice.commits.to/ABC are all distinct promises.

  3. Digits (0-9). And unlike for usernames, there’s no restriction that the path start with a letter. It’s even allowed to use nothing but numbers (please don’t actually do that).

  4. Underscores (_) are the most common way to separate words.

  5. Dashes (-) are the other most common way to separate words.

Tentatively Allowed (avoid these till we’re sure!)

  1. Colons (:). There are currently a handful of existing promises with colons — they’re useful for specifying times of day. (Note: In practice it has turned out to never be necessary to specify an off-the-hour deadline, like 5:30pm as opposed to 5pm or 6pm. Especially since you can fine-tune the deadline after the promise is created. So we’re avoiding colons but on the lookout for use cases.)

  2. Plus signs (+). The argument against them is that they have special syntactic meaning in URLs: namely, they’re a shortcut for %20 — the percent-encoding of the space character. This is confusing since we think of underscores as ersatz-spaces.

  3. Exclamation marks (!). This is out of scope of the current spec but you could imagine them having special meaning for indicating high priority or strict deadlines, like an all-or-nothing late penalty function.

  4. Dollar signs ($). Maybe in the future they could have special meaning to indicate variables, like in Perl? (Again, unclear in the current spec what that even would mean.)

  5. Tildes (~). Traditionally tildes have been used in URLs to prefix usernames, kind of like @-signs are now in other contexts. There’s also a Unix convention for tildes being shorthand for your home directory, whatever that might mean in a commits.to context.

  6. Asterisks (*). I don’t know what special syntactic meaning they could have but they seem to be innocuous.

  7. At signs (@). But we’re avoiding all these special characters until we decide for sure.

Rejected

  1. Dots (.). It’s especially handy to disallow these because everything like robots.txt and apple-touch-icon-blahblah.png and various things that bots check for, sometimes maliciously, that we don’t want to create promises for, have dots. (Counterpoint: Dots and dashes go together like peas and carrots, and dashes are allowed.)

  2. Slashes (/). There are currently hundreds of existing promises with slashes because our original implementation assumed due dates in the URL would be prefixed with /by/. But slashes cause problems because people, and maybe bots, when they see something like bob.commits.to/foo/by/soon will sometimes try hitting bob.commits.to/foo/by and bob.commits.to/foo. I’ve seen that happen often when giving out commits.to links publicly. A related problem is that it’s not obvious that URLs like alice.commits.to/reply_to_bob/by/5pm and alice.commits.to/reply_to_bob/by/4pm are treated as entirely distinct. Change those to alice.commits.to/reply_to_bob_by_5pm and alice.commits.to/reply_to_bob_by_4pm and it’s more clear that the system won’t try to do any magic to treat those as referring to the same underlying promise.

  3. Hashes (#) ought to be disallowed but this is technically hard! Normally URLs can contain hashes and the browser shows them fine in the address bar, but they don’t get passed to the server. So we don’t have an easy way to reject them. There’s a way to solve this — capturing anchor links — with client-side javascript and passing it to the server, which will be nice to implement later. Currently if you use a hash in a promise URL, we silently fail: The first occurrence of # and everything after it will be dropped from the URL the server sees.

  4. Question marks (?). No promise URLs so far have question marks but it’s slightly tricky for a route to reject them since they’re not considered part of the URL path. Anything after the first question mark is technically the query string. (Another important problem with question marks: since our route matching doesn’t see anything after the first question mark, all other characters that would normally be rejected will sneak through if they come anywhere after a question mark.)

  5. Percents (%). If you use unicode characters or some special characters like ^ then browsers will percent-encode them, like replacing ^ with %5E. We could allow URLs with percent-encodings fine but if a user types a % in a URL that doesn’t correspond to a valid percent-encoding then, well, the server freaks out and we need careful error-handling code to recover. But we’re making it moot and rejecting anything with a percent in it. Whatever other characters we decide to allow, we should reject percents just because of the disparity between what the user typed and what the browser turns it into. In other words, if the URL contains a percent then we don’t know if the user typed that or if they typed a special character that got percent-encoded. So that’s a bad choice for a promise URL.

  6. Everything else. Ampersands, parentheses, braces, equal signs, carets, quotes, etc etc, are all rejected. If you try to hit a URL with any of these characters you get a 404 page that also explains these rules.

Legacy Migration For URLs with Slashes

Slashes are the only controversial case above, due to the 232 non-voided legacy URLs that currently use them. We currently have a few candidates for what to do about them.

Option 0: Throw Slashes Under the Bus

The longer we go without using slashes in new promises the more reasonable it may be to just let the ancient slashy URLs break. We’d just convert the existing URLs in the database using the following deslashing algorithm:

Find the first dash or underscore in the urtext and call it sep. I.e., sep==='-' or sep==='_' (default: underscore). (And one dumb special case: if the urtext starts with schedule-k_tax_thing then set sep to be an underscore.) Now simply globally replace slashes in the urtext with sep.

This option would be more palatable if we could add manual custom redirects for certain legacy slashy URLs on a case-by-case basis.

Option 1: “Did You Mean…?”

If a URL contains a slash, it will 404, but also in that case it will display a huge “Did You Mean” with a link to the deslashed version of the URL. See Option 0 for the deslashing algorithm.

The migration plan is then as follows:

  1. Perform an update query to run the deslashing algorithm on all promises with slashes. If the deslashed version already exists though, do nothing. We’ll manually clean those up.
  2. At the same time or on the heels of #1, make any route containing a slash serve up a 404 page.
  3. Include logic in the 404 rendering to add the “Did You Mean” if the urtext contains a slash but no other special character such as dots or question marks. With the deslashing algorithm as a function, we can just run it on the fly on the URL the user requested to dynamically generate the link to the deslashed version. (Open question: If actual hyperlinks yield too many rogue promises then we could show the did-you-mean URL without linkifying it. I predict this won’t be necessary.)
  4. After the “Did You Mean” link, add: “(Our URL format changed! Slashes are no longer valid. Please update your bookmarks and get with the program!)”

Option 2: Slash Blindness

Only match up to the first slash. (This is how we originally spec’d the app.) We’d still parse the urtext after the first slash for setting the default due date upon promise creation, it would just be optional advisory-only info, kind of like how query strings are often used to track referrers and such.

For example, bob.commits.to/file_report/by_9pm and bob.commits.to/file_report/by_tuesday and in fact bob.commits.to/file_report/just_kidding_dont would all get canonicalized/redirected to bob.commits.to/file_report.

That way when bots or humans URL-hack a URL with slashes — going up a directory, so to speak — it doesn’t matter. Everything after the first slash is ignored (or used strictly to parse the default due date).

The advantage of this option is having unambiguous syntax for due dates. This would normally be very in line with my design sense. But we’re not willing to bite the bullet on fully unambiguous syntax, like bob.commits.to/foo/by/2018-05-01_17:00 or something. We want to be able to say crazy human things like “in 2 hours” or “by tomorrow”. And that’s fine as long as it’s clear to the user that the system is making a best guess that the user can then adjust.

There are also the following disadvantages of the slash blindness option:

  1. The work to implement it and to add back all the complications of having the urtext not be the unique identifier for a promise.
  2. It makes it harder to avoid accidental URL collisions.
  3. It’s less transparent to users what’s going on.

But another advantage of slash blindness is easy migration: It would consist of manually dealing with the handful of collisions. I believe all such collisions currently can be resolved by deleting a rogue/voided promise.


 

Note on URL format: We’re waiting for the implementation and this section of the spec to match each other before accepting more beta users.


 

Promise Data Structure

The fundamental object in the commits.to app is of course the promise, aka the commitment. The following fields comprise a Promise object:

(The ones in the parentheses we can ignore for the MVP.)

For example:

Currently we have two domains — commits.to and promises.to — and the latter simply redirects to the former. (UPDATE: Never mind the promises.to thing — we dropped that one.) It’s possible that in the future we’ll want “bob.commits.to/foo” and “bob.promises.to/foo” to be distinct promises. If not, we may want to canonicalize a promise’s key to strip out the domain. We could also choose to treat URLs as case-insensitive and store a canonicalized lowercase version. We could even choose to treat “foo-bar” and “foo_bar” as aliases to the same underlying promise. If we support username aliases, we could want aliases to canonicalize, e.g., “bobby.commits.to/foo” would redirect to “bob.commits.to/foo”.

But currently we’re assuming there will be no canonicalization of anything and all those questions are moot. In this spec we treat the original urtext as the primary key and refer to it interchangeably with the promise’s URL as the unique identifier for a promise.

Here are some other ideas for fields, that we can worry about as the project evolves:

Marking Promises Fulfilled

Anyone hitting a promise’s URL can edit anything any time. There are no logins or restrictions at all in the MVP. When you visit a promise’s URL you see an HTML form based on the Promise data structure”):

The user and URL fields aren’t editable because they uniquely identify the promise and we don’t want to have to validate uniqueness when submitting the form. See “For Later: Changing URLs”. The number of clicks, clix, is automatically incremented.

Initially the due date is determined by parsing the URL (see “Parsing Dates and Promise Uniqueness”) but the user has free rein to change it. Yes, it defeats the point if you can keep changing the deadline but for the MVP, honor system! We have ideas for later for how to further discourage cheating (see “For Later: Public Changelog”).

As a shortcut, instead of the user filling in tfin, they can instead click a button to mark the promise complete, which simply sets tfin equal to the current timestamp. If it were a checkbox instead of a button, then unchecking it would set tfin back to null.

For later: Whenever anything about the promise changes it should be automatically mirrored in Beeminder (see “For Later: Beeminder Integration”).

Parsing Dates and Promise Uniqueness

UPDATE: I now believe that all date-parsing should be dropped from the spec/MVP entirely. Create commitments with URLs that don’t mention a date and the deadline defaults to Right Now. That way it’s immediately overdue which prompts you to set an actual deadline. You’re always allowed to do that when there’s no deadline set yet. (And of course you’re perfectly free to let the URL mention a date, like if that helps disambiguate, but that’s just part of the slug; Commits.to doesn’t try to understand it.)

First, what should happen if alice says alice.commits.to/send_the_report_by_thu one week and then says alice.commits.to/send_the_report_by_fri the next week?

Answer: Promises are keyed only on the URL so those are entirely distinct promises. (This is pretty obvious now but was less so when we were using URLs like alice.commits.to/send_the_report/by/thu.) Which also means that if she uses exactly the same URL both weeks, the second time it will still resolve to the original promise, even if it’s marked completed. Also pretty obvious in retrospect despite a ton of early hand-wringing.

In practice it seems to be easy to make an unlimited number of unique names for promises and if there is a collision it’s perfectly clear to the user why and what to do about it. Namely, make up a new URL! Later we can consider letting the user change the existing URL if they’re ok with any links to the old promise pointing at the new one instead. But for the MVP, promise URLs are just necessarily unique.

What about the _by_ part of the URL? If the promise is first being created then we run it through a date parser and initialize the due date to whatever it says. If there’s no _by_... part or we couldn’t parse it as a date/time, tdue defaults to a week from now. If the promise already exists then the _by_ part doesn’t matter. It will never override a tdue that’s already set.

In short, the _by_... part of the URL is strictly advisory and can be changed by the user any time (see “Marking Promises Fulfilled”).

Late Penalties

A big part of commits.to is tracking how reliable you are. Namely, what fraction of the promises you logged did you actually fulfill? And there’s a fun twist: if you fulfill a promise late you get partial credit. That way we can always compute a single metric for your reliability at any moment in time.

The function we’re using for late penalties is below. The idea is to have your reliability decrease strictly monotonically the moment the deadline hits, with sudden drops when you’re a minute, an hour, a day, etc, late. Here’s a plot of that function — technically the fraction of credit remaining as a function of lateness — first zoomed in to the first 60some seconds, and then zoomed out further and further:

For example, credit(0) is 1 (no penalty) and credit(3600) is 0.999 (most of the credit for being just an hour late).

See “Computing Statistics” for how to actually use this in the app or read on for more on why we like this weirdo function.

Rationale for the Crazy Late Penalty Function

There are a few key constraints on the shape of this function:

1. Strict monotonicity

Being strictly monotone means that you always see your reliability score visibly ticking down second by second whenever you have an overdue promise.

2. Asymptotically approaches zero

Approaching but never reaching zero just means you’ll always get some epsilon of credit for fulfilling a promise no matter how late you are.

3. Sudden drops at Schelling fences

The third constraint is for beehavioral-economic reasons. We don’t want you to feel like, once you’ve missed the deadline, that another hour or day or week won’t matter. That’s a slippery slope to never finishing ever. So the second-order discontinuities work like this: If you miss the nominal deadline your credit drops to 99.999% within seconds. The next sudden drop is at the 1-minute mark. Being within 60 seconds of the deadline is noticeably better than being 61 seconds late. After that you can still get 99.9% credit if you’re less than an hour late. And if you miss that, you can still get 99% credit if you’re less than a day late. At 24 hours the credit drops again to 90%, etc. A minute, an hour, a day, a week, a month, all the way up to the one-year anniversary of the deadline. If you hit that then you still get 10% credit. After that it drops pretty quickly to 1% and asymptotically approaches 0%, without ever reaching it.

In short, we’re taking advantage of the following Schelling Fences:

  1. The deadline itself ⇒ 100% credit
  2. The deadline + 1 minute ⇒ 99.999% credit
  3. The deadline + 1 hour ⇒ 99.9% credit
  4. The deadline + 1 day (24 hours) ⇒ 99% credit
  5. The deadline + 1 week ⇒ 90% credit
  6. The deadline + 1 month (30 days) ⇒ 50% credit
  7. The deadline + 1 year (365 days) ⇒ 10% credit

(What about the fact that “1 month late” is commonly understood to be the same day of the month the next month and “1 year later” typically means the same calendar date the next year, regardless of leap years? Too bad, the late penalty function is complicated enough without having it depend on things like the calendar month of the deadline! And no one cares anyway — just go by what the late penalty function tells you. We do use 30 days rather than 30.4 and 365 instead of 365.25 though, so the Schelling deadlines are the same time of day as the original deadline.)

Implementation

The following fully implements the late penalty function and is fully tested.

/* The main function is credit(t) which computes the fraction of full credit
you get for being t seconds late. It's roughly a continuous version of this:
  credit(t) = 1      if t<=0s  # not late so no penalty
  credit(t) = .99999 if t<60s  # seconds late (essentially no penalty)
  credit(t) = .999   if t<1h   # minutes late (baaaasically counts)
  credit(t) = .99    if t<1d   # hours late   (no big deal, almost fully counts)
  credit(t) = .9     if t<1w   # days late    (main thing is it's done)
  credit(t) = .5     if t<30d  # weeks late   (half counts if this late)
  credit(t) = .1     if t<1y   # months late  (mostly doesn't count)
  credit(t) = .01    if t<10y  # years late   (better late than never, barely)
  credit(t) = 0      otherwise # decades late (essentially zero credit)
*/

const cSecs = 0.99999  // how much credit you get if you're  seconds  late
const cMins = 0.999    //                                    minutes
const cHrs  = 0.99     //                                    hours
const cDays = 0.9      //                                    days
const cWks  = 0.5      //                                    weeks
const cMos  = 0.1      //                                    months
const cYrs  = 0.01     // how much credit you get if you're  years    late

// Hand-picked magic h-values to give the lateness function sudden drops at all
// the focal lateness thresholds (a minute late, an hour late, etc) while still
// being continuous and strictly monotonically decreasing.
const hSecs = 300000   // h param for how steep the curve is if  seconds  late
const hMins = 3000     //                                        minutes
const hHrs  = 548      //                                        hours
const hDays = 32       //                                        days
const hWks  = 5.4      //                                        weeks
const hMos  = 4.2      //                                        months
const hYrs  = 4        // h param for how steep the curve is if  years    late

const SID = 86400      // seconds in a day   ( NB: treat 30d as 1mo & 365d as )
const SIW = 7   * SID  // seconds in a week  ( 1yr so that Schelling fence    )
const SIM = 30  * SID  // seconds in a month ( deadlines are the same time of )
const SIY = 365 * SID  // seconds in a year  ( day as the original deadline   )

const exp = Math.exp   // let's not ugly up all our pretty math
const log = Math.log   //  by littering it with "Math." prefixes
const pow = Math.pow   // (actually new javascript can do x**y for exponents)

// Linearly interpolate to return u when x=a and v when x=b
// This is equivalent to hscale with h=-1 and isn't used for commits.to but it's
// nice for comparison:
// function lscale(x, a, b, u, v) { return (b*u - a*v + (v-u)*x)/(b-a) }

// Exponentially interpolate to return u when x=a and v when x=b
// This is standard exponential growth and is the limiting case of h=0 in hscale
// below. That function isn't defined at h=0 so we need this as a special case.
// We could also just never set h to exactly 0 and use .000001 or something
// which would be plenty close enough but let's do it right cuz math is fun!
function escale(x, a, b, u, v) {
  return u*pow(u/v, a/(b-a))*exp(log(v/u)/(b-a)*x)
}

// Do an h-interpolation to return u when x=a and v when x=b.
// Special cases: h=-1 is linear, h=0 is exponential, h=1 is hyperbolic.
// As h approaches infinity this becomes a step function where hscale(a) = u
// but for x>a, hscale(x) = v (assuming u>v).
// For the derivation of this, see bonus.glitch.me
function hscale(h, x, a, b, u, v) {
  if (h === 0) { return escale(x, a, b, u, v) }
  return u*pow(1-(pow(v/u, -h)-1)/(a-b)*(x-a), -1/h)
  // i.e.: u*pow(1-h*r*(x-a), -1/h) where r = (pow(v/u, -h)-1)/h/(a-b)
}

// Compute the credit you get for being t seconds late
function credit(t) {
  return t <= 0   ? 1 : // not late at all or early => full credit
         t < 60   ? hscale(hSecs, t, 0,    60,     1,     cSecs) :
         t < 3600 ? hscale(hMins, t, 60,   3600,   cSecs, cMins) :
         t < SID  ? hscale(hHrs,  t, 3600, SID,    cMins, cHrs)  :
         t < SIW  ? hscale(hDays, t, SID,  SIW,    cHrs,  cDays) :
         t < SIM  ? hscale(hWks,  t, SIW,  SIM,    cDays, cWks)  :
         t < SIY  ? hscale(hMos,  t, SIM,  SIY,    cWks,  cMos)  :
                    hscale(hYrs,  t, SIY,  10*SIY, cMos,  cYrs)
}

Computing Statistics

We’ll care about the following statistics initially:

  1. Each promise’s late penalty (0% if not yet late)
  2. Each promise’s max credit (100% if not yet late)
  3. Each user’s total number of promises made
  4. Each user’s total number of promises pending
  5. Each user’s overall reliability score

Individual Promises

The relevant fields (see “Promise Data Structure”) are:

And we’ll assume we can get the current unixtime in seconds with a now() function. See “Late Penalties” where we define the credit() function for how much credit you get for a promise as a function of how late you fulfill it. Here we optimistically assume that any promise you’re late on you’re going to fulfill in the next instant.

For a specific promise, displayed prominently at the promise’s URL, we compute the optimistic late penalty (fraction of credit lost so far by being late) and max credit (1 minus the late penalty so far) as follows. First a handy function to compute the most possible credit (least late penalty) a promise will get, expressed as a fraction in (0,1]:

// The most possible credit (least late penalty) a promise p can have
function rosycredit(p) {
  if (p.tdue === null) { return 1 }
  const ot = (p.tfin === null ? now() : p.tfin)  // optimistic tfin is now
  return credit(ot - p.tdue)
}

And then the key numbers to show in the UI:

maxcred = rosycredit(p)     // show as "#{maxcred*100}%" in the UI
latepen = 1 - rosycredit(p) // show as "#{latepen*100}%" in the UI

The late penalty and max credit will change in real time for a pending promise that’s past its deadline, and will update instantly when tfin changes.

User’s Overall Reliability

For the overall reliability score for a user, we assume unfulfilled promises that are still pre-deadline don’t count for or against you. We call those pending promises, where there’s still time to get full credit:

// A promise p is pending if it's pre-deadline and not marked done
function isPending(p) {
  return (p.tdue === null || now() < p.tdue) && p.tfin === null
}

And we optimistically assume that any promise you’re late on you’re going to fulfill in the next instant. So a brute force implementation would iterate through a user’s promises like so:

let pending = 0
let numerator = 0
let denominator = 0
user.promises.forEach(p => {
  if (isPending(p)) { pending++ }
  else {
    numerator += rosycredit(p)
    denominator += 1
  }
})

That’s it! Now you can report that the user has made {denominator+pending} promises (of which {pending} are still in the future) and has a reliability of {denominator === 0 ? 0 : numerator/denominator*100}%.

The user’s overall realtime reliability score should be shown prominently next to the username wherever it appears or huge in the header or something. It’s the most important number in the whole app. Especially cool is how it will tick down in real time when one of your deadlines passes. (We recommend React for having numbers like that always updated in real time.)

Color-coding

Taking inspiration from Beeminder:

Sorting of Promises in the User’s Gallery

We refer to the page shown at alice.commits.to as Alice’s gallery because it shows the list of all her promises.

We sort them as follows. All incomplete promises (no tfin set) sort to the top. Among incomplete promises we sort by urgency, to be defined momentarily. Among complete promises we sort by decreasing completion date, tfin. So you see the most urgent things first, and then completed promises, starting with the most recently completed.

The obvious way to sort by urgency is simply amount of time till the deadline. For overdue promises that’s a negative number so the most overdue promise would sort to the very top.

But just for fun, and maybe because it’s useful, we’ll use a more sophisticated definition of urgency. Namely, we’ll sort by least absolute distance to the nearest Schelling fence. If nothing is overdue then it’s the same as the obvious definition of urgency, since the due date will in fact be the nearest Schelling fence. But when some things are overdue, the most urgent is the one where you’re losing reliability the fastest or are about to start doing so. For example, if you’re already a few days late on something then you’re probably treating 1 week overdue as the new deadline. If you have something else that just passed the 24-hour overdue mark, that’s more urgent.

Putting all that together, we can define a single metric using a function that takes a time t and a promise and returns:

Then we just sort everything by that single metric, smallest to biggest!

Calendar Integration

This is important enough and easy enough to be part of even the initial MVP. Namely, for each promise, create a link the user can click on to add it to their Google calendar. Like this:

Just view the html for that button here to see how that’s constructed. (Source: StackOverflow answer.) For the event text, use the part of the URL after “commits.to/”. For the event details: the whole URL. And for both the start and end date of the calendar event: the promise’s deadline. No Calendar API is needed that way — just construct the link and if the user is logged in to Google it will create the calendar entry when they click it and confirm.


Credits

Daniel Reeves wrote a blog post about the idea. Sergii Kalinchuk got the “promises.to” domain and had it redirecting to commits.to for a while. Marcin Borkowski had the idea for URLs-as-UI for creating promises. Chris Butler implemented most of the MVP.




For Later: Beeminder Integration

This is the first thing we’d like to add after the MVP spec’d above! I think it would even make sense to say that the only way to log in to commits.to is via oauth with your Beeminder account.

The idea for the integration is to send a datapoint to Beeminder for each promise you make. A Beeminder datapoint consists of a date, a value, and a comment. Beeminder plots those cumulatively on a graph for you and lets you hard-commit to a certain rate of progress.

There are two ways we could do the integration. We’ll first implement a simple way and then consider a more advanced way.

Simple Beeminder Integration

  1. Create a standard Do More goal for the user on Beeminder or ask the user for the goalname of an existing goal. The rate that the user is (meta) committing to should be 3 promises per week.
  2. Simply send a +1 to that Beeminder goal every time a new promise is created.
  3. The datapoint’s comment should just have the promise’s URL since that’s a link to all the data about a promise.

Advanced Beeminder Integration

The simple version of the integration just has the user committing to making some number of commits.to URLs per week, regardless of how many are fulfilled.

The advanced version has the user beemind their total number of successes, where fractional successes count fractionally.

Specifically, the date on the Beeminder datapoint is the promise’s completion date, if non-null, otherwise the deadline, tdue (even though it’s in the future). And the value of the Beeminder datapoint is initially zero, and, when fulfilled, is 1 minus the late penalty. As in the simple version, the datapoint’s comment should just contain the promise’s URL. Or something like “Auto-added by commits.to at 12:34pm — ” and then the URL. (It’s nice to use the timezone the user has set in Beeminder — available in the User resource in the Beeminder API — when showing a time of day.)

The Beeminder goal should be a do-more goal to fulfill, say, 8 promises per week. The way I (dreev) do this currently: I create a datapoint for each promise (via IFTTT from Google Calendar) when I promise it, and then change the datapoint to a 1 when I fulfill it (or something less than 1 if I fulfill it late).

So Beeminder is not enforcing a success rate, just an absolute number of successes.

Pro tip: Promise a friend some things from your to-do list that you could do any time. That way you’re always ready for an I-will beemergency. (But if your Personal Rule for commits.to is that only natural utterances of “I will” count as loggable commitments then making contrived promises like that may be cheating.)

The commits.to app’s interactions with Beeminder (via Beeminder API calls) are as follows:

  1. When a promise is created, create a datapoint
  2. When a promise is marked (partially) fulfilled, update the datapoint’s value
  3. When a promise’s due date changes, update the datapoint’s date
  4. [LATER] When a promise is deleted, delete the datapoint
  5. [LATER] When a promise is voided maybe also delete the datapoint in Beeminder
  6. [LATER] Create the initial Beeminder goal when a user signs up for commits.to

For Later: Public Changelog

I think this is the most elegant and flexible solution to prevent cheating. You can change anything at any time but you have to publicly justify each change and it’s all permanently displayed on the promise’s page as an audit log.

For example:

Some people will do things like “giving myself an extra day because my cat got sick” which completely defeats the point of the whole system (even for entirely unimpeachable excuses it defeats the point, unless you explicitly make the deadline conditional in the first place) but by having to make those justifications publicly you can see when someone is doing that and discount their supposed reliability percentage accordingly. I mean, people can cheat and game this in a million ways anyway so no restrictions we try to impose will ever really solve this kind of problem.

(An alternative we were hashing out before was allowing you to edit the due date exactly once in case the system initially parsed it wrong, or you just didn’t specify a deadline in the URL. I’m all for being super opinionated about things like not letting you edit deadlines but the public append-only changelog idea seems most general and flexible. In the meantime we can voluntarily log changes in the note field.)

For Later: Future Discounting

If you were late in the past but are always on time now, your past sins should fade over time. In other words, we should apply a discount rate to reliability scores. Let’s declare 6% per year to be reasonable. So set a constant R = 0.06 as well as SIY = 31557600 for seconds in a year because we’ll need the discount rate per second.

So instead of summing up the scores for the promises and dividing by how many there are, we take a weighted average of the scores. A score’s weight is exp(-R*a/SIY) where a is the age of that promise. (Note that if a promise’s age is zero then its weight is 1, and it takes about 12 years for a promise to lose half its weight.) We can compute the age of a promise like so:

// Return seconds elapsed since a promise's most recent milestone, where
// milestones include the promise being made, being due, & being fulfilled.
// If any of those are in the future, then the age is zero.
function age(p) {
  const t = Math.max(p.tini, p.tdue, p.tfin)
  return Math.max(0, now() - t)
}

So we just multiply the scores by their weights, sum them up, and divide by the sum of the weights. Easy peasy.

(Note that 6% per year may take a long time to be perceptible. We could also try 36% per year — the basis of Beeminder’s Exquisitely Fair Pre-Pay Discounts.)

For Later: Partial Fulfillment

This was part of the original spec but it seems to never be needed in practice so we’ve demoted it to this “for later” section to be revisited if there’s demand for it.

In the above spec, we assume a promise is fully fulfilled if the tfin date is non-null. Partial fulfillment means generalizing that so that tfin gives the date that the promise was fractionally fulfilled, even if that fraction is 0%, and xfin gives the actual fraction. If xfin is always 1 whenever tfin is non-null and 0 otherwise, then we have the special case that is what’s spec’d above. In other words, the tfin field in the Promise object is replaced with two fields:

For example, if the user deemed a promise half fulfilled then they’d set xfin = 0.5.

To handle this, we need the following generalizations:

1. Generalize “fulfilled” checkbox

In “Marking Promises Fulfilled”, the shortcut where you click a button to mark a promise fulfilled, in addition to setting tfin to now, sets xfin to 1. If it’s a box you can check and uncheck then unchecking it sets xfin back to null.

2. Editable xfin

Also xfin would be an editable field in the HTML form for a promise, settable to anything from 0% to 100%, or null. Some combinations of tfin and xfin don’t make sense so we’ll consider each possibility:

Case 1: tfin null, xfin null

The promise is unfulfilled. This is the default state.

Case 2: tfin specified, xfin null

This combination doesn’t make sense. We won’t prohibit it but will show this on the page:

Error: Promise fulfilled at [tfin] but needs fraction fulfilled!

We also won’t worry about tfin possibly being in the future, although that’s also weird.

Case 3: tfin null, 0 <= xfin < 1

This is just the user treating xfin like a progress bar. “I haven’t marked it done but I’m 75% of the way there!” If it’s before the deadline then the isPending() function in “Computing Statistics” will count the promise as pending, meaning it won’t count for or against your reliability score. If it’s after the deadline then we optimistically assume you’ll complete it in the next instant and show your remaining credit accordingly. (Again, see “Computing Statistics”.)

Case 4: tfin null, xfin 1

Another combination that doesn’t make sense. If you’re 100% done then there must be a date that that happened. So show this on the page:

Warning: Promise marked done but needs completion date!

In “Computing Statistics” this is treated optimistically as if the promise will be completed in the next instant.

3. Generalize max credit

In “Computing Statistics”, the max credit is xfin minus the late penalty so far instead of just 1 minus the late penalty so far. Specifically:

latepen = 1 - rosycredit(p) // show as "#{latepen*100}%" in the UI
maxcred = (xfin === null ? 1 : xfin) * (1 - latepen) // also show as percent

The above code, including the rosycredit() function, is robust to all the crazy combinations of tfin and xfin discussed in #2 and just always shows the most optimistic numbers.

(To be clear, if, say, xfin is 50% and tfin is 2017-10-31 that isn’t meant like a progress meter — “promise is 50% complete as of the 31st” — though the user could manually treat it that way. The idea is to treat the promise as being as done as it’s going to get on Oct 31 and the credit you’re getting is 50% of what you’d normally get. No optimism about an xfin of 50%, only an xfin that’s null. So you multiply that 50% by whatever the credit function says based on how much after the due date Oct 31 is and that’s your max credit.)

4. “___ changing”

For every mention of tfin changing, this generalizes to “tfin or xfin changing”.

5. The isPending function

The isPending() function changes to:

// A promise p is pending if it's pre-deadline and not marked totally done
function isPending(p) {
  return (p.tdue === null || now() < p.tdue) && p.xfin !== 1
}

6. Score computation

The brute force algorithm for iterating through a user’s promises to compute their overall score changes to:

let pending = 0
let numerator = 0
let denominator = 0
user.promises.forEach(p => {
  if (isPending(p)) { pending++ }
  else {
    numerator += (p.xfin === null ? 1 : p.xfin) * rosycredit(p)
    denominator += 1
  }
})

7. Beeminder integration

In “Beeminder Integration”, the datapoint value is xfin minus the late penalty instead of 1 minus the late penalty.

For Later: Account Settings

  1. Username, used as a subdomain for the URL
  2. Beeminder access token
  3. Timezone (needed to parse the deadlines; but less important since you can change the deadline if it’s misparsed)

Even Later:

  1. Pronoun (default “they/them/their/theirs”)
  2. Display name, e.g., “Alice” as opposed to username “alice”

For Later: Humanized Promise Names from URLs

For the MVP we just want to use the descriptions in the URL as given. At most we can apply a humanize() function to them when displaying the promise on the page that could, for example, replace underscores with spaces. Or try to be smart and turn “do-the-thing” into “do the thing” but also display “do_things_1-3” as “do things 1-3” and not “do things 1 3”. It’s a can of worms so for the MVP we should pick something very simple and only do it in the display logic.

For Later: Calendar as UI

This is totally at odds with the current spec but before we had the URLs-as-UI idea we thought you’d create promises by creating calendar entries and use the calendar API to automatically capture those.

There are various ways to add calendar entries with very low friction already. Then that would need to automatically trigger commits.to to capture each calendar entry. (I’m doing that now with IFTTT to send promises to Beeminder.)

And maybe it’d be fine for every calendar entry to get automatically added. Some of them wouldn’t be promises but that’s fine — you could just mark them as non-promises or delete them and they wouldn’t count. If they were promises then you’d need to manually mark them as fulfilled or not. Beeminder (plus the embarrassment of having your reliability percentage drop when a deadline passes) would suffice to make sure you remember to do that.

Again, this is moot while we work on the URL-as-UI version.

For Later: Security and Privacy

Alice’s friends can troll her by making up URLs like alice.commits.to/kick_a_puppy but that’s not a huge concern. Alice, when logged in, could have to approve promises to be public. So the prankster would see a page that says Alice promises to kick a puppy but no one else would.

In the MVP we can skip the approval UI and worry about abuse like that the first time it’s a problem, which I predict will be after commits.to is a million dollar company.

For Later: Active vs Inactive Promises

Define a promise to be inactive if its tfin and tdue dates are both non-null and in the past. So even if a promise is done early it’s still active till the due date, and even if it’s overdue it’s still active till it’s done. (Or “done” — it could be marked 0% fulfilled.)

We might want to display active and inactive promises differently.

For Later: Changing URLs

I sometimes dash out a promise URL on my phone but later would prefer a better URL. Maybe I’m sure no one is going to click on the original one (I continue to be surprised how infrequently people click on these URLs, especially once the novelty wears off) and would like to just change it and let the original link break. Ok, you shouldn’t ever just assume your promisee won’t click the link but maybe you explicitly gave the new, better one. Or maybe you only made the promise verbally and logged it directly via the address bar of your browser.

Or maybe alice.commits.to/send_the_report was completed and everyone knows it and now you want to promise to send_the_report again. The most un-can-of-worms-y way to do that is to rename the old promise via a convention like alice.commits.to/send_the_report-old or alice.commits.to/archived:send_the_report and then just start over with alice.commits.to/send_the_report like usual.

(That could even be an Official Convention so that any promise page for “foo_by_soon” would look up and display links to any “archived:1:foo_by_soon”, “archived:2:foo_by_soon”, etc promises at the bottom, saying “looking for one of these previous incarnations of this promise?”. The UI could help too: maybe promises have an archive button which replaces the path part of the URL “foo_by_soon” with “archived:1:foo_by_soon”, or “archived:2:foo_by_soon” if there’s already a “:1:”, etc. So you hit archive and then any old links to the promise will create a new promise but with a pointer to the archived version.)

Whatever the reason, you sometimes want to change a promise’s URL. We could just let you do that, showing in real time as you edit it whether the new URL is taken. If it’s not taken then let the user hit submit.

I think that will be worth implementing soon after the MVP. At least the renaming, if not the whole archiving/reusing convention.

Finally, some pie in the sky for later still: What if the user could somehow add 301-redirects willy-nilly? Then you could change URLs without breaking links.

For Later: Similar/Related Promises

On every promise page we can link to the 3 promises with the most similar URLs. PostgreSQL has built in functions for this.

For Posterity: Domain Name Ideas

A possibly silly idea: “promises.to” and “commits.to” are pretty synonomous but if we had other domains, that could maybe affect the reliability score. Like “promising” is one thing but if it’s alice.intends.to (not that we have that domain) then maybe it doesn’t fully count against you if you don’t actually do it. Also if we made this work for people’s personal domain names, like dreev.es/will, then we could have arbitrary verbs — like dreev.es/might, etc. So maybe verb would make sense as one of the promise data structure fields in the future?