SIGN IN SIGN UP

fix(query-analyzer): reject lone four-digit integers as years (#3250) (#3665)

* fix(query-analyzer): reject lone four-digit integers as years (#3250)

`_date_match_score` awarded +100 — its largest signal — for "any token
contains a digit", and dateparser resolves any isolated four-digit integer
to a year with month/day taken from the reference date. So `port 9077`
scored higher than every other candidate, won `max()`, and became a
`9077-08-07` single-day constraint. Non-null, so nothing downstream could
tell extraction had failed: the temporal arm returned zero candidates while
still paying its latency.

Reject the span during scoring when its only date content is one four-digit
integer. A real date always carries more — a second number ("2026-06-10",
"15:30"), a calendar word ("March 1890", "year 2019"), or an ordinal/unit
suffix ("the 21st", "10am"). Rejecting during scoring rather than filtering
afterwards is what lets "port 9077 and also last Tuesday" still resolve to
last Tuesday.

A reference-relative year window was the other candidate, but it misses
in-range identifiers — PR, issue and ticket numbers between 1900 and 2046
are as common in developer queries as ports — and needs an English padding
word list to cope with dateparser widening the span.

The cost is that a bare year introduced only by a preposition ("in 2019")
is no longer temporal. It was already broken: it produced the reference
date's month/day in that year, a single wrong day, never the year asked
for. `extract_period` has no bare-year rule, so no correct-year path is
given up.

Golden corpus regenerated: 36 diffs over 4 queries, three of which were
recording this bug ("chunk size of 2000 tokens" -> year 2000, "2024年" ->
year 0002, "15.01.2024" -> 2024 with the reference month/day).

* fix(query-analyzer): resolve a disambiguated bare year to the whole year

Rejecting lone integers in the dateparser fallback also dropped "in 2019",
which is genuinely temporal. dateparser cannot tell it from "port 2019" —
it returns the identical span "2019" for both — so the rule goes in
extract_period, where the whole query is still in hand and periods already
resolve to a full range ("last year", "june 2019").

A four-digit number introduced by a year word ("in", "during", "throughout",
"year", and the es/it/fr/de/ru equivalents) becomes Jan 1 - Dec 31 of that
year. That is the window the query asks for, and one the old path never
produced: dateparser filled month/day from the reference date, so "in 2019"
used to mean the single day 2019-<today's month/day>.

"since"/"from" are deliberately excluded — they open an interval ending now
rather than selecting the named year, so collapsing them to Jan-Dec would be
narrower than asked.

The year must be plausible relative to the reference date, or "listening in
8080" recreates the empty-arm failure with a preposition in front of it.
Implausible years fall through rather than returning NO_TEMPORAL_CONSTRAINT:
the sentinel would also drop a real date elsewhere in the query, and there is
nothing left to guard against downstream now that the fallback rejects bare
integers itself.

* refactor(query-analyzer): drop a lookbehind that can never fire

The `(?<![-/.])` in _YEAR_ONLY_RE sat directly after `\s+`, so the character
it inspects is whitespace by construction and the assertion was always true.
The trailing `(?![-/.]\d)` is the one doing the work — it keeps "in 2026-06-10"
from collapsing to the year.

Also assert exact windows in the overreach test instead of "not the whole of
2019", which passed vacuously for the two cases that return no constraint.
N
Nicolò Boschi committed
e20bb290f795dd83e158a18ad90a2a45ced8a5d9
Parent: ea9efa8
Committed by GitHub <noreply@github.com> on 8/20/2026, 10:12:17 AM