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
))
)Parsing time expressions
Resolve dates only when the reference date is explicit
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.
Riverton dates and notices in this lesson are invented teaching examples.
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().
| 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)))
)| 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)
)
)| Parser category | Examples |
|---|---|
| fully resolved | 1 |
| reference-date needed | 5 |
| not resolvable | 5 |
| 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()| Pipeline | Version | Language | License | spaCy |
|---|---|---|---|---|
| core_web_sm | 3.8.0 | en | MIT | 3.8.7 |
| 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 Fridayortomorrowrequire an explicit reference date. The document creation time can provide that reference. Callers must supply the date, for example throughRELATIVE_BASEindateparser. - Granularity and representative ambiguity: Granularity is the size of the time unit.
last monthcould 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 Fridayspecial 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.