A conference deadline looks like the simplest datum in the field: a name, a date, a link. It is not. It is quoted in a timezone that is a full day behind the one you read it in, it lives in two volunteer-maintained files that do not always agree, it moves without warning, and the things you actually want next to it — the workshops, what got in last year, what a plane ticket and a badge will cost — live in four other systems that were never designed to be joined. This post is about building the join, and about the discipline of leaving a field blank when the honest answer is that nobody published it.
AoE, or why a deadline is not a date
Almost every call for papers in machine learning quotes its deadline in AoE — anywhere on
earth, UTC−12. It is a humane convention: your paper is on time as long as it is still the deadline
day somewhere. It is also the single most common way a deadline board misleads people, in
both directions. A "May 15 AoE" deadline is still open at 7am on May 16 in Berlin, and a board that
renders the string 2026-05-15 in your local calendar has quietly moved it a day earlier.
Do the same thing to a deadline quoted in UTC+8 and you have moved it later.
So the first thing the pipeline does with a deadline is destroy the string:
def to_utc(stamp, tz):
naive = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
hours, known = tz_offset(tz) # "AoE" -> -12.0, "UTC+8" -> 8.0
utc = naive.replace(tzinfo=timezone.utc) - timedelta(hours=hours)
return utc.strftime("%Y-%m-%dT%H:%M:%SZ"), known
Everything downstream — the countdown, the sort, the calendar file, the filters — works on that instant. The original local string is kept and shown underneath, because a submitter checking against the call for papers wants to see the same characters the CFP used, but nothing computes on it.
Note the second return value. An unrecognised timezone is treated as UTC and flagged, rather than silently assumed. That distinction — between a value and a value you are sure of — is the whole design of this thing, and it comes up again in every section below.
Two feeds, and the value of the disagreement
The field's deadline data is maintained in a handful of open repositories, and this board merges the two best: huggingface/ai-deadlines, which carries a full timeline per conference (abstract, supplementary, rebuttal window, notification, camera-ready, each with its own timezone), and ccfddl/ccf-deadlines, which carries CCF and CORE ranks, DBLP keys, and a second, independent copy of the dates.
The obvious design is to pick a winner: prefer one feed, fall back to the other. It is also the wrong one. Both are volunteer-maintained and both are occasionally wrong, and a tie-break rule produces a board that is confidently incorrect some fraction of the time with no way for a reader to know which fraction. What a third party can add here is not arbitration — it is the disagreement:
gap = abs(_parse(theirs) - _parse(mine["utc"]))
if gap > timedelta(hours=6):
return f"ccf-deadlines quotes {theirs} for the paper deadline"
Six hours, not zero, because the two feeds frequently record the same deadline against slightly different times of day and flagging that would make every row shout. Past six hours they are not rounding differently; one of them is wrong, and the row carries a feeds differ chip that says so and sends you to the CFP.
Parsing YAML without PyYAML
Both feeds are YAML, and every pipeline in this repository is standard-library-only — not as
asceticism but because pip install is one more thing that can be down on the morning the
board needs to refresh. So there is a parser in the module, and the interesting part is not writing it.
It is proving it.
The dialect these files use is small: block mappings, block sequences, quoted and plain scalars,
plain scalars folded across several indented lines, and one-line flow collections. Anchors, aliases,
tags and multi-document streams do not appear — and miniyaml.py raises on all of them
rather than guessing, so if a feed ever adopts one, the run fails loudly instead of dropping a
conference off the board.
How do you know the dialect is what you think it is? By checking, over the whole corpus, against a real implementation:
$ python3 modules/deadlines/tools/check_yaml.py --refresh
cached 223 files
223/223 files parse identically
The first run of that check was 215/223. The eight failures were all the same construct — a
long note: field wrapped across indented continuation lines, which is a plain scalar
folded, not a nested structure. Twelve lines of _absorb() later, the corpus was clean.
That is the entire argument for writing the check first: a hand-rolled parser that has never been
diffed against a real one is a liability, and one that has been diffed over 223 files is a
dependency you happen to own.
What actually got in: OpenReview, and what it will not tell you
The board wants more than dates. If you are choosing between two venues, you want to know how big each one is, how selective its oral track is, and what its last edition thought was good enough to highlight. OpenReview hosts the review process for a large share of the field and publishes it, so that is answerable — with three caveats that shape the whole feature.
First: a paper's decision is not a field, it is a sentence. Accepted notes carry a human
string like NeurIPS 2025 spotlight, so the oral/spotlight/poster split comes from
bucketing that string, and the code says as much where it does it.
Second: the note API sits behind a bot challenge that fires on some networks and not others.
Every call degrades to None instead of raising, the page reports which source failed on
its own status panel, and — the part that matters — every finished edition is cached permanently:
if vid in cache or ed["year"] > NOW.year:
continue # a finished conference never changes its mind
Because of that line, the statistics on the page deepen every day the job runs, and a day when OpenReview refuses costs only what had not been collected yet rather than the whole table.
Third, and most important: there is no acceptance-rate column, because a rate needs a submission count and most venues never publish one. There is also no "top papers by citation" board, because neither feed carries citations. What the page shows instead is the programme committee's own ordering — the orals and spotlights — labelled as exactly that. A plausible-looking percentage that nobody can check is worse than a dash, because a dash tells you to go and look it up.
The cost question, and the honest answer to it
"What will it cost to go?" is the question the deadline lists never answer, and the reason is not
laziness. It is that the answer barely exists in public form when you need it. The pipeline probes
each conference's registration page daily and extracts fee/label pairs conservatively — a number
counts only when a role word (student, industry, early, late) sits within a few text nodes of it, so
a $5,000 sponsorship never becomes a badge price, and every hit keeps its surrounding
text and the date it was read so you can check it.
There is one more guard, added after the scraper found something that looked perfect and was
wrong. Societies redirect a guessed URL to whatever edition they are currently selling: asking for
aaai-27/registration serves AAAI-26's fee table, which the extractor happily read and
would have published under the wrong year — precise, sourced, and about a different conference.
if year is not None and str(year) not in html:
return []
Most of the time this whole subsystem finds nothing, because fees go up a few months out and several conferences render them behind a login. So the cost panel is an estimator: registration is pre-filled from the observed table where one exists, and nights, hotel, flights and per-diem are yours, saved in your browser, with the arithmetic shown. Nobody can quote your flight, and a page that pretends otherwise is inventing the number you were least able to verify.
A calendar you can subscribe to
The board writes its deadlines twice: once in the browser, for whatever you have filtered down to, and once at build time to a fixed URL. Both use the same UID scheme, so importing the download and then subscribing does not duplicate every event.
The fixed file is the one that matters. A Blob the page builds cannot be subscribed to; a file at a stable address that gets rewritten every morning can, and any calendar that subscribes by URL will then track upstream changes on its own. Each event is the deadline instant in UTC — the client does the timezone conversion, which is the one job it is guaranteed to do correctly — with a one-day and a seven-day alarm. Lines are folded at 74 octets because a strict RFC 5545 reader will reject a whole calendar over one long line, which is a fun afternoon to lose.
Rendering it twice
The page renders the board server-side into the HTML and then again in the browser from the same
JSON. That is not an accident of the build. A page whose content is drawn entirely by script is a
page a search engine has to choose to render before it can index it, and this one exists to
be found by someone typing "NeurIPS 2026 deadline" into a search box. So the HTML ships with the open
deadlines as a plain list with real <time> elements and real links, plus
schema.org Event and FAQPage blocks; the script removes that copy on boot
and takes over with live countdowns and filters. Only editions with a real start date become
structured-data events — an Event with an invented date would be a lie a machine reads.
What it does not do
It does not know about a conference the upstream feeds have not added, and the fix for that belongs in the feed where everyone benefits. It does not catch an extension in the hour it is announced — a CFP page changes, then a feed, then this. It has no acceptance rates for venues that publish no submission counts, and no workshop that does not use OpenReview. Every one of those limits is stated on the page itself, next to the thing it limits, because the alternative is a board that looks complete and quietly is not.