Parsing time expressions

Resolve dates only when the reference date is explicit

entity enrichment
temporal parsing
workforce research
Learn how simple R rules handle dates, weekdays, times of day, and durations in workforce text.

The phrase Apply by October 15 gives the coordinator a month and day but no year. The deadline needs a year before the team can add it to a calendar.

A date parser converts a time expression into a sortable date. Some expressions give complete dates, while others need a reference date. Other expressions do not describe calendar dates.

Note

Riverton dates and notices in this lesson are invented teaching examples.

TipWhat you will learn

By the end of this lesson, you will be able to:

  • separate full dates from relative dates.
  • use an explicit reference date rather than Sys.Date().
  • recognize durations and times of day as different kinds of time language.
  • compare a small temporal parser with this page’s expected answers.
  • compare the rule output with spaCy entity labels.

State the reference date

The examples use the fixed reference date 2026-08-28, a Friday. A date that changes daily would prevent comparison with fixed expected results. The lesson therefore passes the date as a variable and never calls Sys.Date().

library(readr)
library(dplyr)
library(tibble)
library(purrr)
library(stringr)
library(spacyr)

sentences <- read_csv(
  "data/workforce/workforce_sentences.csv",
  na = character(),
  col_types = cols(
    sentence_id = col_character(),
    document_id = col_character(),
    source_line = col_character(),
    text = col_character(),
    reference_label = col_character(),
    uncertainty = col_character(),
    annotator_id = col_character(),
    rationale = col_character(),
    codebook_version = col_character(),
    codebook_hash = col_character(),
    derived = col_character(),
    transformation = col_character()
  )
)

reference_date <- as.Date("2026-08-28")

temporal_examples <- tibble(
  expr_id = sprintf("time-%02d", 1:11),
  text = c(
    sentences |>
      filter(sentence_id == "s028") |>
      pull(text),
    "Evening",
    "12-week",
    "Monday",
    "next Friday",
    "next Monday",
    "2026-10-15",
    "starts in the fall",
    "Classes begin on September 14",
    sentences |>
      filter(sentence_id == "s020") |>
      pull(text),
    sentences |>
      filter(sentence_id == "s011") |>
      pull(text)
  ),
  expected_category = c(
    "reference-date needed",
    "not resolvable",
    "not resolvable",
    "reference-date needed",
    "reference-date needed",
    "reference-date needed",
    "fully resolved",
    "not resolvable",
    "reference-date needed",
    "not resolvable",
    "not resolvable"
  ),
  expected_date = as.Date(c(
    "2026-10-15",
    NA,
    NA,
    "2026-08-31",
    "2026-09-04",
    "2026-09-07",
    "2026-10-15",
    NA,
    "2026-09-14",
    NA,
    NA
  ))
)
Temporal examples with expected answers
Expression ID Text Expected category Expected date
time-01 Apply by October 15 reference-date needed 2026-10-15
time-02 Evening not resolvable NA
time-03 12-week not resolvable NA
time-04 Monday reference-date needed 2026-08-31
time-05 next Friday reference-date needed 2026-09-04
time-06 next Monday reference-date needed 2026-09-07
time-07 2026-10-15 fully resolved 2026-10-15
time-08 starts in the fall not resolvable NA
time-09 Classes begin on September 14 reference-date needed 2026-09-14
time-10 Weekend shifts are required. not resolvable NA
time-11 Remote work is available two days each week. not resolvable NA

October 15 needs the reference year. Evening is a time of day without a date. 12-week is a duration, not a point on the calendar.

Write a small resolver

The resolver first searches for a full ISO date, such as 2026-10-15, in year-month-day order. It then searches for a month and day, followed by weekdays. The shorthand \(text) means β€œfor each text value, execute this small function.”

resolve_temporal <- function(text, reference_date) {
  lower_text <- str_to_lower(text)

  if (str_detect(text, "\\b\\d{4}-\\d{2}-\\d{2}\\b")) {
    return(list(
      category = "fully resolved",
      date = as.Date(str_extract(text, "\\b\\d{4}-\\d{2}-\\d{2}\\b"))
    ))
  }

  month_match <- str_match(
    text,
    "\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+(\\d{1,2})\\b"
  )

  if (!is.na(month_match[1, 1])) {
    resolved <- as.Date(paste(
      format(reference_date, "%Y"),
      match(month_match[1, 2], month.name),
      month_match[1, 3],
      sep = "-"
    ))
    return(list(category = "reference-date needed", date = resolved))
  }

  weekday_names <- c("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")

  if (str_detect(lower_text, "next friday")) {
    target_day <- 6L
    current_day <- as.POSIXlt(reference_date)$wday + 1L
    days_forward <- ((target_day - current_day) %% 7L) + 7L
    return(list(category = "reference-date needed", date = reference_date + days_forward))
  }

  found_weekday <- weekday_names[str_detect(lower_text, str_to_lower(weekday_names))]

  if (length(found_weekday) > 0L) {
    target_day <- match(found_weekday[1], weekday_names)
    current_day <- as.POSIXlt(reference_date)$wday + 1L
    days_forward <- (target_day - current_day) %% 7L
    if (days_forward == 0L) {
      days_forward <- 7L
    }
    return(list(category = "reference-date needed", date = reference_date + days_forward))
  }

  list(category = "not resolvable", date = as.Date(NA))
}

parsed_temporal <- map(
  temporal_examples$text,
  \(text) resolve_temporal(text, reference_date)
)

temporal_results <- temporal_examples |>
  mutate(
    category = map_chr(parsed_temporal, "category"),
    resolved_date = as.Date(map_chr(parsed_temporal, \(item) as.character(item$date)))
  )
Rule-based temporal parsing results
Expression ID Text Parser category Resolved date
time-01 Apply by October 15 reference-date needed 2026-10-15
time-02 Evening not resolvable NA
time-03 12-week not resolvable NA
time-04 Monday reference-date needed 2026-08-31
time-05 next Friday reference-date needed 2026-09-04
time-06 next Monday reference-date needed 2026-08-31
time-07 2026-10-15 fully resolved 2026-10-15
time-08 starts in the fall not resolvable NA
time-09 Classes begin on September 14 reference-date needed 2026-09-14
time-10 Weekend shifts are required. not resolvable NA
time-11 Remote work is available two days each week. not resolvable NA

The resolver assigns Monday to the next Monday after the reference date. Month-day phrases use the reference year. next Friday has a separate branch, while other weekday names mean their next occurrence. These are policy choices.

A rollover rule, which advances the year, would be necessary after October 15. The next Monday result shows that the general weekday branch misses a distinction in the expected answers.

Score the examples

The resolver and expected answers share an author, so the score below does not give an estimate of parser accuracy. It compares the page’s actual behavior with its stated behavior.

date_matches <- (is.na(temporal_results$resolved_date) & is.na(temporal_results$expected_date)) |
  (!is.na(temporal_results$resolved_date) &
    !is.na(temporal_results$expected_date) &
    temporal_results$resolved_date == temporal_results$expected_date)

category_counts <- tibble(
  category = c("fully resolved", "reference-date needed", "not resolvable")
) |>
  left_join(
    temporal_results |>
      count(category, name = "examples"),
    by = join_by(category)
  ) |>
  mutate(examples = coalesce(examples, 0L))

score_summary <- tibble(
  measure = c("category matches", "date matches"),
  examples = c(
    sum(temporal_results$category == temporal_results$expected_category),
    sum(date_matches)
  )
)
Temporal categories in the examples
Parser category Examples
fully resolved 1
reference-date needed 5
not resolvable 5
Agreement with this page’s expected answers
Measure Matching examples
category matches 11
date matches 10

One example is fully resolved from the text alone. Five need the reference date. Five are not resolvable as a single date. The next Monday row is the date mismatch: the rule treats it like Monday, while this lesson’s expected answer uses the following week.

Compare spaCy entity labels

spaCy gives spans of text entity labels such as DATE and TIME. The labels help locate time expressions but do not determine their dates.

source("R/use-spacy.R")

pipeline <- use_project_spacy()
pipeline_version <- spacy_pipeline_version()

spacy_parsed <- spacy_parse(
  setNames(temporal_examples$text, temporal_examples$expr_id),
  pos = TRUE,
  lemma = TRUE,
  entity = TRUE,
  dependency = TRUE,
  nounphrase = TRUE
)
spacy_entities <- entity_extract(spacy_parsed, type = "all")

spacy_checks <- spacy_entities |>
  filter(doc_id %in% c("time-01", "time-02", "time-03")) |>
  select(doc_id, entity, entity_type)

spacy_finalize()
spaCy pipeline used for the entity comparison
Pipeline Version Language License spaCy
core_web_sm 3.8.0 en MIT 3.8.7
spaCy DATE and TIME labels on selected examples
Expression ID Entity text spaCy label
time-01 October_15 DATE
time-02 Evening TIME
time-03 12_-_week DATE

spaCy gives October 15 the label DATE, Evening the label TIME, and 12-week the label DATE. The first expression still needs a year. spaCy’s training scheme puts periods in the DATE class, so it assigns that label to 12-week. That label is correct under the scheme but does not specify a calendar date.

Temporal parsing in production

Temporal-span detection is only the first step, as spaCy’s DATE label for October 15 shows. Full normalization requires a dedicated tool, such as Meta’s Duckling or Python’s dateparser, rather than custom R branches.

These tools distinguish concepts that custom rules can combine incorrectly:

  • Document Creation Time (DCT) / Reference Time: Relative expressions such as next Friday or tomorrow require an explicit reference date. The document creation time can provide that reference. Callers must supply the date, for example through RELATIVE_BASE in dateparser.
  • Granularity and representative ambiguity: Granularity is the size of the time unit. last month could mean the previous calendar month or the last 30 days. A parser must separate span detection from normalization and record those assumptions.

What to remember

  • A temporal parser needs a stated reference date for relative expressions.
  • Month-day dates need a same-year or rollover policy.
  • Weekday phrases need more than one next Friday special case.
  • Entity labels can find time language without resolving a date.

The lesson supplies 2026 as the reference year, so Apply by October 15 resolves to a date in 2026. Evening and starts in the fall still give this parser no specific day.

Sources