Daniel Reeves edited this page Apr 25, 2018 · 98 revisions

The I-Will System: Functional Specification

Discuss this spec on Slack (slack.commits.to) or the commits.to category of the Beeminder forum (forum.beeminder.com/c/commitsto).

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 99.74%.

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.

(We have both the "commits.to" and "promises.to" domain names with the latter redirecting to the former currently.)

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 (see "Creation on GET")
  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 or jo.promises.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. 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.

  2. 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.

  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.

Technical Quibble About Percent-Encodings
The above about percent-encoding is not quite true. There are some special characters that modern browsers consistently don't percent-encode. For example, en.wikipedia.org/wiki/Möbius_strip. So in theory it could be nice to allow those characters in promise URLs. As long as what the user typed and what the browser displays in the address bar are the same, then that's what we'd want as the urtext. If the browser changes what was typed — like with bob.commits.to/foo^bar — then we'd want to reject it. But this is a can of worms so we're going to disallow all non-ASCII characters. Which we do by just rejecting percent symbols because all non-ASCII characters get percent-encoded before being requested from the server.
Metaquibble
If you use a percent symbol in such a way that it happens to encode a normal alphanumeric character then browsers will helpfully undo the pointless percent-encoding. For example, if you type alice.commits.to/%41 in the address bar, what the server will get is alice.commits.to/A (because 41 is the code for "A"). So a promise will be created despite the user having typed a URL with a percent in it! There's really no solution to that other than to make sure users don't try to use percents at all.

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:

  • urtext: full original text (URL) the user typed to create the promise, also the primary key
  • user: who's making the promise, parsed as the subdomain in the URL
  • note: optional additional notes or context for the promise
  • tini: unixtime that the promise was made
  • tdue: unixtime that the promise is due, aka the deadline
  • tfin: unixtime that the promise was fulfilled
  • (firm: true when the due date is confirmed and can't be edited again )
  • void: true if the promise became unfulfillable or moot
  • clix: number of clicks a promise has gotten
  • (bmid: the id of the Beeminder datapoint for this promise )

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

For example:

  • urtext = "bob.commits.to/Foo_the_bar_by_noon_tomorrow"
  • user = "bob"
  • note = "promised in slack discussion about such-and-such"
  • tini = [unixtime of first GET request of the promise's URL]
  • tdue = [what "noon tomorrow" parsed to at time tini]
  • tfin = [unixtime that the user marked the promise as fulfilled]
  • firm = false
  • void = false
  • clix = 0
  • bmid = 4f9dd9fd86f22478d3007007

Currently we have two domains — commits.to and promises.to — and the latter simply redirects to the former. 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:

  • Public changelog for justifying things like changes to the due date
  • Whether the promise was created by the actual user (if they were logged in and were the first to click on it) or by another logged-in user or by someone not logged in
  • Information about the client (browser, geoIP, etc) that originally created the promise
  • The original created-at date of the Promise object, regardless of what the user may set tini to for the actual date the promise was made
  • An updated-at date
  • Cached values like the last computed credit remaining for a promise

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"):

  • urtext, i.e., the URL is not editable
  • user is shown at the top of the page with a reliability scorenot editable
  • note is editable
  • tini defaults to the time of the first GET request and is editable
  • tdue defaults to what was parsed from the URL and is editable
  • tfin defaults to blank and is editable
  • void defaults to false and is editable
  • clix is shown at the bottom of the page — not editable

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

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").

Implementation Note
The Sherlock date parser is working well for this. It can be passed the whole urtext (with dashes and underscores replaced with spaces) and pick out the date fine. So the word "by" doesn't have to appear. For example, "reply in 2 weeks" or even "file the report march 30" are fine.

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 (365.25/12 = 30.4 days) ⇒ 50% credit
  7. The deadline + 1 year ⇒ 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.)

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<1mo  # 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 cHours  = 0.99     //                                    hours
const cDays   = 0.9      //                                    days
const cWeeks  = 0.5      //                                    weeks
const cMonths = 0.1      //                                    months
const cYears  = 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 hHours  = 548     //                                        hours
const hDays   = 32      //                                        days
const hWeeks  = 5.4     //                                        weeks
const hMonths = 4.2     //                                        months
const hYears  = 4       // h param for how steep the curve is if  years    late

const SIH = 3600        // seconds in an hour
const SID = 86400       // seconds in a day
const SIW = SID*7       // seconds in a week
const DIY = 365.25      // days in a year
const SIM = SID*DIY/12  // seconds in a month
const SIY = SID*DIY     // seconds in a year

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) }
  const r = (pow(v/u, -h)-1)/h/(a-b)
  return u*pow(1-h*r*(x-a), -1/h)
}

// Compute the credit you get for being t seconds late
function credit(t) {
  if      (t <= 0)  { return 1 } // not late at all, or early, means full credit
  else if (t < 60)  { return hscale(hSecs,   t,   0,     60, 1,       cSecs)   }
  else if (t < SIH) { return hscale(hMins,   t,  60,    SIH, cSecs,   cMins)   }
  else if (t < SID) { return hscale(hHours,  t, SIH,    SID, cMins,   cHours)  }
  else if (t < SIW) { return hscale(hDays,   t, SID,    SIW, cHours,  cDays)   }
  else if (t < SIM) { return hscale(hWeeks,  t, SIW,    SIM, cDays,   cWeeks)  }
  else if (t < SIY) { return hscale(hMonths, t, SIM,    SIY, cWeeks,  cMonths) }
  else              { return hscale(hYears,  t, SIY, 10*SIY, cMonths, cYears)  }
}

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:

  • tfin — when the promise was fulfilled
  • tdue — promise's deadline

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.

Implementation Note
In Javascript you get current unixtime in seconds with Date.now()/1000 (just Date.now() returns it in milliseconds).

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:

  • Gray: completed promises
  • Hot pink or showing flames or something cute: overdue promises
  • Red: deadline in less than 24 hours
  • Orange: deadline in less than 48 hours
  • Blue: deadline in less than 72 hours
  • Green: deadline in more than 72 hours

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.

Here's the code to implement it, again assuming a now() function that gives the current unixtime in seconds:

const SIH = 3600        // seconds in an hour
const SID = 86400       // seconds in a day
const SIW = SID*7       // seconds in a week
const DIY = 365.25      // days in a year
const SIM = SID*DIY/12  // seconds in a month
const SIY = SID*DIY     // seconds in a year

// Given time t and a promise p, return the absolute distance in seconds 
// between t and the promise's Schelling fence nearest to t.
// If the deadline is null then return tini minus t, so lack of deadline sorts
// to the top and, among those, ties are broken so that promises created 
// earlier appear first. (And if promises with no deadlines have creation dates
// in the future for some reason, those may in fact sort later. That's probably
// (a) reasonable (b) almost always moot.)
function scheldist(t, p) {
  if (p.tdue === null) { return p.tini - t }
  return Math.min(
    Math.abs(t - p.tdue),         // abs difference betw now  &  deadline
    Math.abs(t - p.tdue - 60),    //                             1 minute late
    Math.abs(t - p.tdue - SIH),   //                             1 hour late
    Math.abs(t - p.tdue - SID),   //                             1 day late
    Math.abs(t - p.tdue - SIW),   //                             1 week late
    Math.abs(t - p.tdue - SIM),   //                             1 month late
    Math.abs(t - p.tdue - SIY),   // abs difference betw now  &  1 year late
  )
}

function promisort(a, b) {
  if (a.tfin === null && b.tfin === null) {  // sort by decreasing urgency
    const t = now()
    return sheldist(t, a.tdue) - sheldist(t, b.tdue)
    // Or the nice simple thing to do would be to always sort in plain old
    // due date order like so:
    // return a.tdue - b.tdue
  }
  if (a.tfin === null) {  // only promise a is incomplete so sort it first
    return -1 
  }
  if (b.tfin === null) {  // only promise b is incomplete so sort it first
    return 1 
  }
  return b.tfin - a.tfin  // sort by decreasing completion date
}

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 has it redirecting to commits.to. 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:

  • 2017-10-31: due date changed from 11/14 to 11/30 with comment "the original promise URL didn't specify a date and defaulted to a week out but the end of the month is what I had in mind"
  • 2017-11-01: creation date changed from 10/31 to 10/30 with comment "the promise was made verbally the previous day"
  • 2017-11-28: completion date changed from null to 11/28 (promise marked complete)
  • 2017-11-28: completion date changed from 11/28 to null with comment "accidentally marked this complete too soon!"
  • 2017-11-29: the void field changed from false to true with comment "just kidding, the lawyers say we can't do this"

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:

  • tfin: unixtime that the promise was (fractionally) fulfilled (even if 0%)
  • xfin: fraction fulfilled, default null to indicate still pending

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. 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. 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.

  1. 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.)

  1. For every mention of tfin changing, this generalizes to "tfin or xfin changing".

  2. 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
}
  1. 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
  }
})
  1. 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 promises.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

  • dreev.es/will/ (for anyone who has a domain for their own name)
  • alice.promises.to/ (sergii grabbed this one)
  • alice.commits.to/ (dreev grabbed this one)
  • alice.willdefinite.ly/ (kinda awkward)
  • alice.willveri.ly/ (too cutesy?)
  • alice.willprobab.ly/ (emphasizes the reliability percentage)
  • alice.willresolute.ly (maybe it would grow on me?)

A possibly silly idea: we currently have "promises.to" and "commits.to" which 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 — dreev.es/might, etc. So maybe verb would make sense as one of the promise data structure fields in the future?

UPDATE: The app is now hosted on Heroku at the domain commits.to. Previously we were both hosting it and editing it on Glitch.

Clone this wiki locally
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.