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:
-
Try ISO8601 first (fast path via
DateTime.from_iso8601) - Preprocess — replace dashes, strip spaces before am/pm, split on whitespace
- Tokenize — classify each word into one of 6 types via regex
-
Pattern match — 27
process/2clauses 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:
-
Plain times:
"11am","9:30:15.123456am","12pm" -
Month + day:
"aug 2","2 aug","aug 3rd","2nd of aug" -
Month + day + time:
"aug 3 5:26pm","aug 3 at 9:26:15am" -
Month + day + year:
"2016-aug-20","20-aug-16"(smart 2-digit year guessing) -
British time:
"10 to 8","half past 2","half past 2pm" -
Relative days:
"tomorrow at 10am","yesterday at 10am","today at 10am" -
Day of week:
"tuesday","Tuesday 9am","Tuesday at 9am" -
Time of day phrases:
"6 in the morning","6 in the evening"
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:
- Pre-normalize — “tomorrow” becomes “next day”, “noon” becomes “12:00pm”
- Multi-tag tokenize — each token gets tagged by ~15 tagger classes (Repeater, Grabber, Pointer, Scalar, etc.). A single token can carry multiple tags.
- Handler dispatch — ~60 handler patterns match on tag sequences, not raw token types
-
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:
- Fork Chronic (or vendor it into the project)
-
Add
:unittoken type to the tokenizer (~15 lines) -
Add ~10
process/2clauses for relative expressions (~60 lines) -
Add
shift/3helper (~10 lines) - 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:
- Building a NimbleParsec-based parser from scratch — over-engineered for the volume
- Adopting a PEG grammar — wrong tool for fuzzy NL input
- Porting Ruby Chronic’s full handler system — 60 handlers for a problem that needs 10 new clauses
- Writing a dateparser-style multilingual solution — we parse English from one user
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.