Chronic Under the Hood: Is Regex Pattern Matching Enough for Natural Language Time Parsing?

By James Aspinwall, co-written by Alfred Pennyworth (my trusted AI) — March 3, 2026, 07:14


The Orchestrator uses Chronic to parse natural language time expressions — “tomorrow at 9am”, “aug 3 at 5:26pm”, “half past 2”. It works for those. But try “in 2 minutes” or “3 days ago” and you get {:error, :unknown_format}.

This article tears the library apart, evaluates whether patching it is worth the effort, and considers what a better approach looks like.

What Chronic Actually Is

Chronic is a 500-line Elixir library. Two files. No dependencies. The entire architecture is:

  1. Try ISO8601 first (fast path via DateTime.from_iso8601)
  2. Preprocess — replace dashes, strip spaces before am/pm, split on whitespace
  3. Tokenize — classify each word into one of 6 types via regex
  4. Pattern match — 27 process/2 clauses dispatch on token sequences

The tokenizer recognizes:

Token Type Examples
{:month, int} “aug”, “january”, “sept.”
{:day_of_the_week, int} “tuesday”, “fri”
{:number, int} “15”, “2024”
{:time, keyword} “9:30am”, “14:00”, “5pm”
{:word, string} everything else

The parser then matches on the token list. Here’s what a clause looks like:

defp process([{:word, "tomorrow"}, {:word, "at"}, {:time, time}], opts) do
  currently = opts[:currently]
  tomorrow = :calendar.gregorian_seconds_to_datetime(
    :calendar.datetime_to_gregorian_seconds(currently) + 86400
  )
  combine(tomorrow, time)
end

One clause per expression shape. 27 shapes total. If your input doesn’t match any of them, you get :unknown_format.

What Works

Chronic handles a decent set of absolute and semi-relative expressions:

The day-of-week logic is clever — if today is Tuesday and you say “Tuesday 4pm” at 9am, it returns today. Say it at 5pm, it returns next Tuesday.

What Fails

Every relative duration and directional reference:

Expression Result Why
"in 2 minutes" :unknown_format No token type for “in”, no duration concept
"in 30 seconds" :unknown_format Same
"in 1 hour" :unknown_format Same
"in 3 weeks" :unknown_format Same
"2 days ago" :unknown_format No “ago” handler, no past-direction concept
"last monday" :unknown_format No “last” keyword
"next friday" :unknown_format No “next” keyword
"this morning at 8am" :unknown_format No “this” keyword
"tomorrow 9am" :unknown_format Requires “at” — only matches [word:"tomorrow", word:"at", time]

The gap isn’t random. Chronic has no concept of duration units (minutes, hours, days, weeks) or directional modifiers (in, ago, next, last). The tokenizer classifies “minutes” as {:word, "minutes"} and the parser has no clause that knows what to do with it.

The Regex Ceiling

Chronic isn’t really regex-based in the way people usually mean. The regexes are only in the tokenizer (two patterns: one for ordinals like “3rd”, one for times like “9:30am”). The actual parsing is Elixir pattern matching on token lists. This is closer to a hand-rolled recursive descent parser than a regex cascade.

But it hits the same ceiling. Each expression shape needs an explicit clause. There’s no composition — you can’t say “a duration is a number followed by a unit” and then reuse that concept in “in {duration}” and “{duration} ago” and “{duration} from now”. Every combination is hand-coded.

Compare with how Ruby’s original Chronic gem handles this. Ruby Chronic has a 4-stage pipeline:

  1. Pre-normalize — “tomorrow” becomes “next day”, “noon” becomes “12:00pm”
  2. Multi-tag tokenize — each token gets tagged by ~15 tagger classes (Repeater, Grabber, Pointer, Scalar, etc.). A single token can carry multiple tags.
  3. Handler dispatch — ~60 handler patterns match on tag sequences, not raw token types
  4. Span construction — handlers produce time ranges, then guess() collapses to a point

The key difference: Ruby Chronic has semantic tags like Grabber (“next”, “last”, “this”) and Pointer (future/past direction) and Repeater (minute, hour, day, week, month, year). These compose. The handler for “in 3 hours” and “in 2 weeks” is the same handler — it just sees [Pointer:future, Scalar:N, Repeater:unit].

Elixir Chronic has no semantic layer. Its tokens are syntactic (month, number, time, word). Adding “in 2 minutes” means adding a clause that matches [{:word, "in"}, {:number, n}, {:word, "minutes"}] — and then another for “hours”, and another for “days”, and another for “weeks”, and another for “seconds”. Five clauses for one concept.

What It Would Take to Fix Chronic

Adding relative duration support to the existing Chronic architecture:

Tokenizer changes — add a :unit token type for duration words:

@units %{
  "second" => :second, "seconds" => :second, "sec" => :second,
  "minute" => :minute, "minutes" => :minute, "min" => :minute,
  "hour" => :hour, "hours" => :hour, "hr" => :hour,
  "day" => :day, "days" => :day,
  "week" => :week, "weeks" => :week
}

defp classify(word) do
  case Map.get(@units, String.downcase(word)) do
    nil -> # ... existing logic ...
    unit -> {:unit, unit}
  end
end

New process clauses — ~10 clauses for relative expressions:

# "in 2 minutes", "in 3 hours"
defp process([{:word, "in"}, {:number, n}, {:unit, unit}], opts) do
  shift(opts[:currently], n, unit)
end

# "2 days ago", "3 hours ago"
defp process([{:number, n}, {:unit, unit}, {:word, "ago"}], opts) do
  shift(opts[:currently], -n, unit)
end

# "next monday", "next friday"
defp process([{:word, "next"}, {:day_of_the_week, dow}], opts) do
  next_day_of_week(opts[:currently], dow)
end

# "last tuesday"
defp process([{:word, "last"}, {:day_of_the_week, dow}], opts) do
  previous_day_of_week(opts[:currently], dow)
end

# "tomorrow 9am" (without "at")
defp process([{:word, "tomorrow"}, {:time, time}], opts) do
  # ... same as "tomorrow at 9am" clause ...
end

Plus a shift/3 helper:

defp shift(currently, n, :second) do
  :calendar.gregorian_seconds_to_datetime(
    :calendar.datetime_to_gregorian_seconds(currently) + n
  )
end
defp shift(currently, n, :minute), do: shift(currently, n * 60, :second)
defp shift(currently, n, :hour), do: shift(currently, n * 3600, :second)
defp shift(currently, n, :day), do: shift(currently, n * 86400, :second)
defp shift(currently, n, :week), do: shift(currently, n * 7 * 86400, :second)

Estimated effort: ~80-100 lines of new code. No architectural changes. Fork the library, add the clauses, done.

The Alternative: A Proper Semantic Parser

A cleaner approach would separate tokenization from semantic interpretation:

"next friday at 3pm"
  → tokenize  → [{:grabber, :next}, {:dow, 5}, {:sep, "at"}, {:time, 15:00}]
  → interpret → %{anchor: :friday, direction: :future, time: ~T[15:00:00]}
  → resolve   → ~N[2026-03-06 15:00:00]

This is what Ruby Chronic does. You could build it with NimbleParsec for the tokenizer (compile-time optimized, blazing fast) and Elixir pattern matching for the interpreter. The result would be composable — new expression types emerge from combining existing semantic concepts rather than adding clause after clause.

But for what? The Orchestrator parses maybe 5-10 time strings per day, all from a single user who types predictable patterns. A NimbleParsec parser would execute in 0.1 microseconds instead of 2 microseconds. Nobody would notice.

The Alternatives in the Elixir Ecosystem

Library What It Does NL Support
Chronic Token + pattern match Absolute dates, some relative
DateTimeParser Tokenizer for structured dates No — “Aug 2, 2024” yes, “tomorrow” no
Timex strftime-style format parsing No NL at all

There is no comprehensive NL time parser in the Elixir ecosystem. Python has dateparser (200+ locales, relative times, ago expressions). Ruby has the full Chronic gem (~60 handlers). Elixir has a 500-line subset.

Verdict: Fork and Extend

The regex/pattern-matching technique isn’t the limitation. Elixir’s pattern matching on token lists is a perfectly sound architecture for this problem. It’s idiomatic, fast, debuggable, and trivially extensible. The limitation is that nobody wrote the missing clauses.

The practical path:

  1. Fork Chronic (or vendor it into the project)
  2. Add :unit token type to the tokenizer (~15 lines)
  3. Add ~10 process/2 clauses for relative expressions (~60 lines)
  4. Add shift/3 helper (~10 lines)
  5. Fix the “tomorrow 9am” gap (missing “at” — 1 clause)

Total: ~100 lines of code. One afternoon. No architectural revolution needed.

What would NOT be worth it:

The right amount of engineering is the minimum that covers the actual use cases. Chronic’s architecture is fine. It just needs a few more clauses and a duration concept. Ship it.