Skip to content

fix(core): honour or refuse a stated timezone in normalize_date_to_iso (#252) - #259

Merged
cdeust merged 7 commits into
mainfrom
fix-normalize-date-tz-252
Jul 29, 2026
Merged

fix(core): honour or refuse a stated timezone in normalize_date_to_iso (#252)#259
cdeust merged 7 commits into
mainfrom
fix-normalize-date-tz-252

Conversation

@cdeust

@cdeust cdeust commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #252.

What was wrong

normalize_date_to_iso had no timezone policy on any path, and three
defects stacked on top of that to make a stated zone vanish:

# Input main this PR
1 8 May 2023 13:56 EST '8 May 2023 13:56 EST' — returned unparsed, because the "already ISO" guard was the substring test "T" in raw and EST/PST/CST/MST all contain a T 2023-05-08T13:56:00-05:00
2 8 May 2023 13:56 +02:00 2023-05-08T00:00:00 — the fast path matches the date at the start and discards the rest, so time and offset were dropped 2023-05-08T13:56:00+02:00
3 8 May 2023 13:56 XYZ 2023-05-08T00:00:00, silently None + a warning naming the abbreviation

Measured on origin/main @ c1f449f with python -W error::Warning.

And the same substring test again, one layer up. Both write paths guarded
the call with "T" not in raw_created as a cheap "is it already ISO?" test:

# pg_store._build_insert_params / sqlite_store._insert_memory_rows, on main
if raw_created and isinstance(raw_created, str) and "T" not in raw_created:
    raw_created = normalize_date_to_iso(raw_created) or raw_created

So 8 May 2023 13:56 EST reached memories.created_at verbatim no matter what
normalize_date_to_iso did with it. The store-level regression test failed on
its first run for exactly this reason — which is why the issue's guarantee is now
asserted on the stored row and not only on the parser. The guard is deleted from
both sites rather than corrected twice: deciding what is already ISO belongs to
the one function that owns it.

And the parser was not installed. The first CI run on this PR failed in all
four Test jobs with ModuleNotFoundError: No module named 'dateutil':
python-dateutil was never a declared dependency and is absent from every
requirements/ci-*.txt, so the dateutil branch — the one the docstring's own
example (1:56 pm on 8 May, 2023, LoCoMo) depends on — had been dead code in
CI since it was written
, and any install resolving without it kept dates it
could not read. That is fixed here rather than logged (§15): the dependency is
declared, locked, and re-exported into the 9 hash-pinned requirement sets.

The fix (root cause, §6)

mcp_server/core/temporal_timezones.py — a tzinfos resolver dateutil
parses under:

  • an already-resolved numeric offset passes through (+02:00, UTC, GMT, Z);
  • an RFC 5322 §4.3 obs-zone abbreviation resolves through the cited table —
    the normative answer to "which EST?" (EST ≡ −0500), cross-checked row for row
    against CPython's Lib/email/_parseaddr.py::_timezones;
  • anything else is recorded and the value refused, with a warning naming the
    input, the abbreviation, the resolvable set, the consequence avoided and the
    fix. RFC 5322's own "read an unknown zone as -0000" default is deliberately
    not applied — persisting that as an instant is the defect.

Supplying a resolver also (a) disables dateutil's host-dependent fallback to the
machine's local zone name, so parsing is identical on every machine, and (b)
means UnknownTimezoneWarning is never raised, so no warnings.catch_warnings()
on a store write path
(criterion 3) — verified by an AST assertion, not a grep.

One rule, applied everywhere: a stated zone is honoured or the value is
refused.
That includes the degraded path — if the parse produces nothing and
the string states a zone, the date is not salvaged, because salvaging returns a
naive date and a naive date is exactly what gets read back as UTC. A string that
states no zone still salvages its date, and says so.

mcp_server/core/temporal_normalize.pynormalize_date_to_iso moves out
of temporal.py. Storage normalization ("which instant does this string
denote?") may not lose precision; retrieval scoring ("how close is this date to
the query?") may. Different reasons to change, and the fix had pushed
temporal.py to 302 lines, past this repo's 300-line cap. Both stores import
from the new path. Final sizes: temporal.py 205, temporal_normalize.py 132,
temporal_timezones.py 96.

The ISO passthrough guard is now an anchored ISO 8601-1:2019 §5.4.2 pattern, and
the date-only fast path runs only for strings that state no time of day.

Acceptance criteria (#252)

Criterion Evidence
1. Resolves through an explicit tzinfos mapping, or is rejected — never re-anchored to UTC test_est_resolves_to_the_rfc5322_offset, test_est_denotes_the_right_instant (asserts the instant is 18:56Z, not 13:56Z), test_unresolvable_abbreviation_is_refused; and on the parser-less path test_zone_bearing_date_is_refused_not_flattened, test_numeric_offset_is_refused_too
2. An actionable signal, with a test asserting the emission test_refusal_signal_is_actionable_verbatim asserts the rendered message whole; test_resolvable_abbreviation_is_quiet and test_fast_path_date_is_stored_without_a_degradation_signal assert the nominal paths stay silent
3. No warnings.catch_warnings() on a store write path test_no_warnings_filter_mutation_on_the_write_path (AST walk over both modules — prose may still explain the ban), test_parse_emits_no_python_warning (no Python warning is raised at all)
4. A regression test that fails against the current implementation 14 of the new tests fail on the pre-fix source — quoted below
$ git checkout -- mcp_server/core/temporal.py   # pre-fix source, new tests
$ pytest tests_py/core/test_temporal.py -q
...
FAILED ...::TestNormalizeDateToIso::test_iso_datetime_without_seconds_keeps_its_time
FAILED ...::TestNormalizeDateToIso::test_numeric_offset_is_preserved
FAILED ...::TestNormalizeDateToIso::test_utc_is_preserved
FAILED ...::TestNormalizeDateToIsoTimezoneAbbreviations::test_est_resolves_to_the_rfc5322_offset
FAILED ...::TestNormalizeDateToIsoTimezoneAbbreviations::test_est_denotes_the_right_instant
FAILED ...::TestNormalizeDateToIsoTimezoneAbbreviations::test_rfc2822_style_with_abbreviation
FAILED ...::TestNormalizeDateToIsoTimezoneAbbreviations::test_abbreviation_containing_t_is_not_mistaken_for_iso
FAILED ...::TestNormalizeDateToIsoTimezoneAbbreviations::test_time_is_not_dropped_for_a_zone_bearing_string
FAILED ...::TestNormalizeDateToIsoTimezoneAbbreviations::test_unresolvable_abbreviation_is_refused
FAILED ...::TestNormalizeDateToIsoTimezoneAbbreviations::test_refusal_names_the_offending_input
FAILED ...::TestNormalizeDateToIsoDegradedModes::test_unparseable_time_of_day_salvages_the_date_and_says_so
FAILED ...::TestNormalizeDateToIsoDegradedModes::test_parser_overflow_is_not_propagated
FAILED ...::TestNormalizeDateToIsoDegradedModes::test_missing_dateutil_is_reported_not_swallowed
FAILED ...::TestNormalizeDateToIsoDegradedModes::test_missing_dateutil_still_salvages_a_date
14 failed, 31 passed in 0.58s

(Run before the split, when the tests still lived in test_temporal.py;
test_refusal_names_the_offending_input was then renamed
test_refusal_signal_is_actionable_verbatim as its assertion tightened to the
whole message, to kill the log-literal mutants.)

Completion Ledger

Path → test (every branch introduced by the diff)

Path Test
normalize_date_to_iso: blank / whitespace-only → None TestNormalizeDateToIso::test_empty
normalize_date_to_iso: ISO 8601 date-time → verbatim passthrough test_iso_datetime_passes_through_verbatim, test_iso_datetime_without_seconds_keeps_its_time
normalize_date_to_iso: no time of day, fast path hits test_date_only_fast_path, test_fast_path_date_is_stored_without_a_degradation_signal
normalize_date_to_iso: no time of day, fast path misses → zone policy test_date_only_string_the_fast_path_misses
normalize_date_to_iso: time of day present → zone policy test_locomo_format_keeps_its_time_and_stays_naive, test_numeric_offset_is_preserved
_parse_with_resolver: ImportError arm (parser absent) test_missing_dateutil_is_reported_not_swallowed (whole message), test_missing_dateutil_still_salvages_a_date, TestNormalizeDateToIsoWithoutDateutil (4 tests)
_parse_with_resolver: parse succeeds test_est_resolves_to_the_rfc5322_offset
_parse_with_resolver: ValueError / OverflowError arm test_nothing_salvageable_returns_none_quietly, test_parser_overflow_is_not_propagated
_normalize_under_zone_policy: unresolved abbreviation → warn + None test_unresolvable_abbreviation_is_refused, test_refusal_signal_is_actionable_verbatim
_normalize_under_zone_policy: parsed → isoformat test_rfc2822_style_with_abbreviation, test_utc_is_preserved
_normalize_under_zone_policy: stated zone that could not be applied → warn + None test_zone_bearing_date_is_refused_not_flattened (whole message), test_numeric_offset_is_refused_too
_normalize_under_zone_policy: salvage succeeds → warn + date-only test_unparseable_time_of_day_salvages_the_date_and_says_so, test_zoneless_string_still_salvages_its_date
_normalize_under_zone_policy: salvage fails → None, quiet test_nothing_salvageable_returns_none_quietly, test_unparseable
_STATED_ZONE_RE (decides refuse vs salvage) TestStatedZoneDetection::test_zone_bearing_strings_are_detected, ::test_zoneless_strings_are_not
RFC5322ZoneResolver.__call__: numeric offset passes through test_numeric_offset_wins_over_the_table, test_zero_offset_is_not_confused_with_absent, test_offset_wins_even_for_an_unknown_name
RFC5322ZoneResolver.__call__: no zone token at all (None, None) test_no_zone_at_all_is_not_an_unresolved_zone
RFC5322ZoneResolver.__call__: table hit (all 10 rows) test_each_rfc5322_row (parametrised over an independently transcribed RFC table), test_abbreviation_is_case_insensitive
RFC5322ZoneResolver.__call__: table miss → recorded test_unknown_abbreviation_is_recorded, test_recorded_name_keeps_its_original_case, test_zones_outside_rfc5322_are_refused
Per-parse state is not shared test_state_is_per_instance
RFC5322_ZONE_NAMES (operator-facing set) test_exported_names_match_the_table
pg_store._build_insert_params: free-form created_at → normalized bind value test_pg_store_created_at_timezone.py::TestCreatedAtTimezone (6 tests: offset, instant, refusal, ISO passthrough, LoCoMo shape, and the heat_base_set_at anchor derived from it)
The PG-side test runs where its driver exists, skips where it does not pytest.importorskip("psycopg")pg_store hard-imports the driver, and the CI-SQLite job has none; verified in the driverless condition (sys.modules['psycopg']=None1 skipped), not assumed
sqlite_store._insert_memory_rows: same, on the stored row test_sqlite_backend.py::TestCreatedAtTimezoneIsPersisted (4 tests, against a real store)
The two backends agree on every shape test_pg_store_created_at_timezone.py::TestBackendParity::test_sqlite_and_postgresql_agree (parametrised over 5 inputs)
_BASE_PACKAGES / _ML_PACKAGES pins are versions uv.lock records TestLauncherPinsMatchTheLock::test_base_packages_are_the_locked_versions, ::test_ml_packages_are_the_locked_versions, with ::test_the_oracle_would_notice_a_drifted_pin as the negative control
_NUMPY_VERSION's three marker branches the same parity test, run once per interpreter by CI's 3.10 / 3.11 / 3.12 / 3.13 matrix — each job asserts the branch it selects (§12.3)
The parser is in the launcher's base runtime TestLauncherPinsMatchTheLock::test_the_date_parser_is_part_of_the_base_runtime
python-dateutil declared → present in every install set scripts/generate_pip_constraints.py --checkrequirements OK (13 checked); CI's own Test jobs are the end-to-end proof (they failed without it)

§13.1 checklist

A. Correctness — A1 happy paths run end-to-end, output quoted. A2 edge cases:
empty, whitespace-only, ISO with/without seconds/offset/Z, date-only,
date+time, date+time+offset, date+time+resolvable abbreviation,
date+time+unknown abbreviation, malformed time-of-day, unparseable string, and
each of those again with the parser absent — all mapped above. A3 every failure
path asserts its observable signal, including the emission itself (four
warning sites, each asserted by its rendered message). A4 input is free-form text
from producers, validated at this store boundary; both callers guard with
isinstance(raw_created, str). A5 the function is pure and total — returns an
ISO string or None, never raises, never partially writes. A6 idempotent: an
already-ISO value passes through unchanged (test_iso_datetime_passes_through_verbatim).

B. Concurrency — no concurrency touched. Positively: the change removes the
only thread-unsafe option on this path by refusing warnings.catch_warnings()
(criterion 3), and the resolver's state lives on a per-parse instance
(test_state_is_per_instance), shared with nothing.

C. Resources — no new loops, collections or I/O. One small object per parse;
three module-level compiled regexes. The date-only fast path still short-circuits
before the dateutil import for the strings it handles.

D. Security — D1/D2 N/A: no query, command or path is built here; the input is
already-untrusted free text and the output is either datetime.isoformat() or
None. D3 no secrets. One supply-chain note: the new dependency is hash-pinned in
all 9 exported sets, per the repo's --require-hashes rule.

E. Interfaces — E1 the signature and return contract are unchanged
(str -> str | None); the values change, which is the fix. The import path
changes, which is why both consumers move in the same commit. E2 consumers
identified by name and read: pg_store._build_insert_params,
sqlite_store._insert_memory_rows (both normalize_date_to_iso(raw) or raw_created — a refusal falls back to the producer's literal text, the
pre-existing behaviour for every unparseable date, which preserves information
instead of inventing an instant); benchmark producers checked — LoCoMo's
1:56 pm on 8 May, 2023 is unchanged (test_locomo_format_keeps_its_time_and_stays_naive),
LongMemEval never reaches this code (_e2_loaders.py uses its own
parse_longmemeval_date). E3 no persisted format change; stored ISO values still
pass through verbatim. E4 platform-independent; the one version-sensitive line is
datetime.fromisoformat, covered under mutation below.

F. Observability — F1 all four degraded/failure modes emit a warning naming
the offending input, each asserted whole; the nominal paths are asserted
silent. F2 the degraded modes are named in the messages ("this value was NOT
normalized", "storing the date alone …, so its time is lost", "only date-only
formats are parseable").

G. Tests — G1 ledger complete. G2 the regression tests fail on the pre-fix
code (14, quoted). G3 deterministic: no sleeps, no shared paths, no order
dependence; the only clock-dependent tests compare against the formula with a
tolerance. G4 negative assertions present (caplog.records == [] on quiet paths,
raised == [] for Python warnings, not _STATED_ZONE_RE.search(...)). G5 full
suite + lint + type check quoted below.

H. Quality — H1 SOLID/layering: both new modules are pure core (stdlib +
core), zero I/O; sizes 205 / 132 / 96 lines, longest new function 36 lines (under both the repo's 40-line and the standard's 50-line cap), ≤ 2
parameters. H2 no dead code (_ISO_DATETIME_MIN_LEN removed with its guard), no
debug leftovers. H3 conventions match the neighbouring core modules; one test
module per source module. H4 CHANGELOG entries added; docs/module-inventory.md
gains both modules and its measured counts are refreshed. H5 three commits: the
fix, the mechanical doc-count bump, the split + dependency. H6 CI status is
reported on this PR, not asserted here. H7 boy-scout below.

Mutation testing (§12)

scripts/mutation_check.sh, scoped to each changed file:

>>> mutating: mcp_server/core/temporal_normalize.py | tests: tests_py/core/test_temporal_normalize.py
⠇ 85/85  🎉 85 🫥 0  ⏰ 0  🤔 0  🙁 0  🔇 0  🧙 0
  none — 0 surviving mutants 🎉

>>> mutating: mcp_server/core/temporal_timezones.py | tests: tests_py/core/test_temporal_timezones.py
⠇ 9/9  🎉 9 🫥 0  ⏰ 0  🤔 0  🙁 0  🔇 0  🧙 0
  none — 0 surviving mutants 🎉

>>> mutating: mcp_server/core/temporal.py | tests: tests_py/core/test_temporal.py
⠸ 166/166  🎉 159 🫥 0  ⏰ 0  🤔 0  🙁 7  🔇 0  🧙 0
    mcp_server.core.temporal.x_parse_date__mutmut_9: survived
    mcp_server.core.temporal.x_parse_date__mutmut_10: survived
    mcp_server.core.temporal.x_compute_date_distance_score__mutmut_2: survived
    mcp_server.core.temporal.x_compute_recency_boost__mutmut_14: survived
    mcp_server.core.temporal.x_compute_recency_boost__mutmut_15: survived
    mcp_server.core.temporal.x_compute_recency_boost__mutmut_28: survived
    mcp_server.core.temporal.x_compute_recency_boost__mutmut_30: survived

pg_store.py and sqlite_store.py were not put through a full-file mutation
run: each is >1000 lines and the change to them is a deleted guard clause, so the
run would spend its time on a pre-existing backlog outside these lines (§12.1
scopes the blocking tier to the lines the change touched). The changed lines
carry the strongest form of the same evidence instead — the pre-change code IS
the mutant, and the new tests kill it
:

$ # restore `and "T" not in raw_created` in both stores, then:
$ pytest tests_py/infrastructure/test_pg_store_created_at_timezone.py \
         tests_py/infrastructure/test_sqlite_backend.py -q
FAILED ...::TestCreatedAtTimezone::test_abbreviation_becomes_its_rfc5322_offset
FAILED ...::TestCreatedAtTimezone::test_bound_value_denotes_the_right_instant
FAILED ...::TestCreatedAtTimezoneIsPersisted::test_abbreviation_is_stored_as_its_rfc5322_offset
FAILED ...::TestCreatedAtTimezoneIsPersisted::test_stored_value_denotes_the_right_instant
4 failed, 58 passed in 1.22s

scripts/launcher_deps.py could not be mutated at all — mutmut refuses when the
tests import the subject under a synthetic module name, which every
tests_py/scripts/ module does. Reported, not worked around: filed as #262
(see boy-scout below). The change there is a pin table whose contract is the new
uv.lock parity test, and that test's own detection power is demonstrated — it
caught 7 drifted pins on its first run, and carries a negative control.

0 survivors on the new code. The first scoped run reported 62 survivors:
24 in the new code (killed here — the log-message literals needed whole-message
assertions and the fast path needed a quiet-path assertion) and 38 pre-existing
ones in temporal.py's untouched scoring functions, treated in this PR rather
than deferred (§14). The 7 that remain are triaged, not ignored:

  • parse_date__mutmut_9/10, compute_recency_boost__mutmut_14/15 — mutate the
    "Z" needle in .replace("Z", "+00:00"). Killed on Python 3.10 (a CI matrix
    job), equivalent on ≥ 3.11 where fromisoformat accepts the UTC designator
    natively. Verified on the actual 3.10 interpreter rather than asserted:
    python 3.10.4
    ('original  Z->+00:00', datetime.datetime(2024, 3, 15, 10, 30, tzinfo=datetime.timezone.utc))
    ('mutant 9  XXZXX    ', "ValueError: Invalid isoformat string: '2024-03-15 10:30:00Z'")
    ('mutant 10 z        ', "ValueError: Invalid isoformat string: '2024-03-15 10:30:00Z'")
    ('rb original        ', datetime.datetime(2024, 3, 15, 10, 30, tzinfo=datetime.timezone.utc))
    ('rb mutant 14       ', "ValueError: Invalid isoformat string: '2024-03-15T10:30:00Z'")
    ('rb mutant 15       ', "ValueError: Invalid isoformat string: '2024-03-15T10:30:00Z'")
    
    test_space_separated_utc_designator and test_utc_designator_is_accepted kill
    them there (§12.3: pin the contract for the config where the line is
    load-bearing).
  • compute_date_distance_score__mutmut_2 (orand in the guard) —
    provably equivalent: with a falsy doc_date_str, parse_date returns None
    and the next guard returns 0.0; with empty target_date_hints the loop body
    never runs and best_score is 0.0. Both operands reach the same return in
    every case.
  • compute_recency_boost__mutmut_28/30 (< 0<= 0, > cutoff
    >= cutoff) — equivalent under a wall clock: they differ only when
    age_days is exactly 0.0 or exactly cutoff_days, and now is sampled inside
    the function at microsecond resolution, so neither is constructible from a test.
    Killing them needs a clock seam, which would take the function to five
    parameters (§4.4 caps it at four) and change a public core API used by
    handlers/recall_helpers.py and the benchmarks — out of proportion to a
    boundary no caller can reach.

§14 boy-scout — defects seen in touched material, all fixed here

  1. The "T" in raw substring guard (defect 1) — not part of normalize_date_to_iso silently re-anchors an unresolvable timezone abbreviation to UTC #252's stated
    diagnosis, but on the same path, and the reason the issue's own reproduction
    returns the input unchanged rather than a UTC value on current main.
  2. Silent time/offset loss in the fast path (defect 2).
  3. The ImportError arm returned None with no signal at all — now reported.
  4. python-dateutil was undeclared and missing from every CI install set
    found by this PR's own first CI run, declared and pinned here.
  5. The salvage path could still emit a naive date for a zone-bearing string —
    refused now, so the guarantee holds without the optional parser too.
  6. temporal.py crossed the repo's 300-line cap (302) — split along the
    concern boundary rather than trimmed.
  7. 38 pre-existing surviving mutants in temporal.py's scoring functions —
    killed (above), not deferred.
  8. docs/module-inventory.md's measured layer counts had drifted (core 220 → 225,
    infrastructure 87 → 91) — re-measured and re-dated.
  9. Both stores' "T" not in raw_created guard — the third instance of the
    substring defect, and the one that made the other two invisible from
    outside. Found by writing the store-level test the issue's wording calls
    for; removed rather than duplicated-and-corrected.
  10. sqlite_store's try: from ... import normalize_date_to_iso / except ImportError: pass — a first-party module is not an optional feature, and
    pg_store had always imported the same one at module top. Hoisted; the
    dead arm is gone.
  11. scripts/launcher_deps.py's pin table — which plugin installs bootstrap
    from — had drifted from uv.lock on 7 of its 9 entries, including
    pgvector 0.4.2 against a locked 0.5.0 whose psycopg loader change
    pg_store._vector_to_bytes is written for, and a numpy marker split the
    two-branch constant no longer covered. Every # source: uv.lock comment
    there was a claim nothing verified; a parity test against the lock now
    fails on drift (it is what found this), and the pins are realigned.
  12. The same drift in prose: docs/deployment-scenarios.md restated three
    container versions (torch==2.11.0 against a locked 2.13.0) for an image
    that installs the hashed export instead, and pyproject.toml's own comment
    claimed ST 5.4.1 against a locked 5.6.1. Both now point at the lock.
  13. tests_py/invariants/test_I2_canonical_writer.py pins the canonical
    heat-writer sites by file:line; both store edits shifted the six sites
    below them (+5 / +2). Re-pinned with the reason recorded, same six
    writers, no new ones — the test was the oracle, as its own comments
    describe for every prior re-pin.
  14. The advertised test count moved with the 91 new tests (6426 → 6517) — every
    site the doc-claim gate scans, plus the committed badge it derives.

One deferral, filed rather than described (§14.3): #262 — mutation testing
cannot attribute mutants for ANY module under scripts/, because every
tests_py/scripts/ test path-imports its subject under a synthetic module name
(_cortex_launcher_deps), so mutmut matches no trampoline key. It fails loudly
rather than reporting a false zero, but the §12 gate is absent for that tree.
Fixing it is a test-infrastructure change across two test modules with its own
blast radius, outside this change's; the issue carries a reproduction and
acceptance criteria.

Verification run locally

$ ruff check . && ruff format --check .
All checks passed!
1071 files already formatted

$ pyright mcp_server/core/temporal.py mcp_server/core/temporal_normalize.py \
          mcp_server/core/temporal_timezones.py mcp_server/infrastructure/pg_store.py \
          mcp_server/infrastructure/sqlite_store.py
0 errors, 0 warnings, 0 informations

$ python scripts/generate_pip_constraints.py --check
requirements OK (13 checked)

$ python scripts/check_doc_claims.py --test-count 6517
doc claims OK (1 declared not-a-claim exemption(s))
  CONTRIBUTING.md:36: exempt from the tests claim

$ pytest -q
6517 passed, 117 subtests passed in 158.90s (0:02:38)

Local numbers only — this machine has a PostgreSQL that CI's SQLite job does not,
and (until this PR) a python-dateutil that CI did not. CI's own numbers are the
ones that decide; see the checks on this PR.

cdeust and others added 6 commits July 29, 2026 20:54
`created_at` values carrying a timezone were persisted as the wrong
instant, silently. Three defects stacked on the same path (#252):

1. the "already ISO" guard was the substring test `"T" in raw`, and
   every US zone abbreviation contains a T — `8 May 2023 13:56 EST`
   was returned unparsed;
2. the built-in fast path matches the date at the START of the string
   and discards the rest, so `8 May 2023 13:56 +02:00` became midnight
   — time and offset both dropped;
3. on the dateutil path an unresolvable abbreviation is dropped behind
   an `UnknownTimezoneWarning` nobody reads, leaving a naive datetime
   that PostgreSQL's `timestamptz` cast and `compute_recency_boost`
   then read as UTC.

Root cause: the function had no timezone policy at all. It now has one.
`core/temporal_timezones.RFC5322ZoneResolver` is the `tzinfos` resolver
dateutil parses under: a numeric offset passes through, an RFC 5322 §4.3
obs-zone abbreviation resolves through the cited table (the normative
answer to "which EST?", cross-checked against CPython's
`email._parseaddr._timezones`), and anything else is recorded and the
value REFUSED with a warning naming the input, the abbreviation, the
resolvable set and the fix. RFC 5322's own "read an unknown zone as
-0000" default is deliberately not applied: storing that as an instant
is the defect.

Supplying a resolver also removes dateutil's host-dependent fallback
(an abbreviation matching the machine's local zone name) and stops the
warning being raised at all, so no `warnings.catch_warnings()` — which
is process-global and not thread-safe — is taken on a store write path.

The ISO guard is now an anchored ISO 8601 §5.4.2 pattern, and the
date-only fast path is used only for strings that state no time of day;
a string whose time is malformed salvages its date and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
Three follow-ons to the #252 fix, each found by a check rather than by
reading:

* CI proved the parser was never there. `python-dateutil` was not a
  declared dependency and is absent from every `requirements/ci-*.txt`,
  so all four Test jobs ran with it missing — the dateutil branch of
  `normalize_date_to_iso` was dead code in CI, and any install that
  resolved without it silently kept dates it could not read (the LoCoMo
  shape the docstring itself cites). Declared, locked, and re-exported
  into the 9 hash-pinned requirement sets.

* The guarantee now holds on the degraded path too. When the parse
  produces nothing and the string STATES a zone, the value is refused
  instead of salvaged: salvaging returns a naive date, and a naive date
  is read as UTC by exactly the consumers this issue is about. A string
  with no zone still salvages its date, as before.

* `normalize_date_to_iso` moves to `core/temporal_normalize.py`.
  Storage normalization ("which instant does this string denote?") and
  retrieval scoring ("how close is this date to the query?") have
  different reasons to change — and the fix had pushed temporal.py to
  302 lines, past this repo's 300-line cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
The versions plugin installs bootstrap changed; that is observable from
outside, so it belongs in the changelog rather than only in the commit
that made it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
The store-level regression test for #252 failed on the first run, which
is the point of writing one: the parser fix never reached the store. Both
`PgMemoryStore._build_insert_params` and
`SqliteMemoryStore._insert_memory_rows` guarded the normalization call
with `"T" not in raw_created` — the same substring test as the "already
ISO" guard the issue is about, one layer up, and one every US zone
abbreviation trips. `8 May 2023 13:56 EST` was written to
`memories.created_at` verbatim regardless of what normalize_date_to_iso
did with it.

The guard is removed from both sites rather than corrected in both:
deciding whether a string is already ISO is normalize_date_to_iso's job,
and it returns a real ISO datetime unchanged. sqlite_store's
ImportError-guarded function-level import goes with it — pg_store has
always imported the same first-party module at top level, so the
"optional feature" it claimed to probe was never optional.

Coverage now lands where the issue states the defect — on the stored
row, on both backends, with a parity test asserting the two agree:

  tests_py/infrastructure/test_sqlite_backend.py
    ::TestCreatedAtTimezoneIsPersisted            (4 tests, real store)
  tests_py/infrastructure/test_pg_store_created_at_timezone.py
    ::TestCreatedAtTimezone / ::TestBackendParity (11 tests, no PG needed)

Restoring either guard fails 4 of them.

I2's canonical-heat-writer allow-list is pinned by file:line and both
edits shifted the sites below them (+5 / +2); re-pinned with the reason,
same six writers, no new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
The CI-SQLite job installs no psycopg, and `pg_store` hard-imports it at
module level, so importing the new test module interrupted collection
there — the test needs no live database, but it cannot import its
subject without the driver.

`pytest.importorskip("psycopg")` ahead of the import. That job still
asserts the SQLite half through test_sqlite_backend.py, and the
cross-backend parity assertion runs in the four PostgreSQL matrix jobs.
Verified in the driverless condition rather than assumed:

    $ python -c "import sys; sys.modules['psycopg']=None; \
        import pytest; pytest.main(['-q', '<the file>'])"
    1 skipped in 0.01s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…5 migration

Rebase-only bookkeeping; no behaviour change. Three commits were dropped as
already upstream — #257 landed the launcher pin realignment and its
test_launcher_pins_match_lock.py check against the CORRECT lock, and the
test-count re-advertisement was superseded.

The requirements/*.txt and launcher pins are taken from main, NOT from this
branch: this branch predates #257 and still carried transformers==4.57.6 and
huggingface-hub==0.36.2, so resolving toward it would have silently reverted
the security fix. Regenerated from uv.lock instead, which keeps
transformers==5.14.1 in all 9 files while adding the python-dateutil>=2.8.2
this branch legitimately declares.

Test count: this machine collects 6426 on main where main advertises 6439, so
the local absolute is NOT trustworthy (CI's environment collects differently).
Applied the measured DELTA instead — paired collection, same venv, same
moment: main 6426 -> this branch 6526, i.e. +100 -> 6539. CI's own gate is the
arbiter and will fail loudly with the true number if this is off.
@cdeust
cdeust force-pushed the fix-normalize-date-tz-252 branch from 9fdb0c9 to a539c36 Compare July 29, 2026 18:57
CI rejected 6539: "assets/badge-tests.svg: tests badge says 6539, canonical
is 6526".

I derived 6539 as main's advertised 6439 plus a locally measured +100, on the
assumption that this machine's collection does not match CI's. That assumption
was wrong in this direction: the local collection of THIS branch (6526) matches
CI's canonical exactly. What is actually stale is main's advertised 6439 — main
under-reports its own suite, which is a separate defect in main, not a reason to
offset from it.

Using the measured absolute.
@cdeust
cdeust merged commit ba24998 into main Jul 29, 2026
19 checks passed
@cdeust
cdeust deleted the fix-normalize-date-tz-252 branch July 29, 2026 19:35
cdeust added a commit that referenced this pull request Jul 29, 2026
Rebase bookkeeping after #259 merged; no behaviour change.

The earlier rebase resolved the six suite-size files to main's side wholesale,
which deleted 49 lines of this branch's own documentation — including the
'### Reproducing the pyright gate locally' section that
tests_py/scripts/test_typecheck_env_parity.py asserts, failing 5 of its 9 cases.
This time the branch's doc additions are re-applied as a PATCH against the new
main (git apply --3way) so both sides' prose survives; the only conflicts were
the hard-coded count, resolved to the current figure.

Count is the measured absolute (6548), not a delta from main's advertised
number: CI proved on #259 that this machine's collection matches CI's canonical
exactly, so the delta arithmetic I used before was the wrong instrument.

Verified: test_typecheck_env_parity.py 9 passed; check_doc_claims.py OK;
generate_repo_badges.py --check OK; .bestpractices.json parses and carries no
conflict markers (the class that shipped once before, now gated by
check_no_conflict_markers).
cdeust added a commit that referenced this pull request Jul 29, 2026
…r().parse(bytes) works (#253) (#261)

* fix(deps): pin tree-sitter-language-pack to the range where get_parser().parse(bytes) works (#253)

The pyright gate's verdict depended on which installer built the
environment: CI installs the hash-pinned export of uv.lock, the documented
developer install resolved pyproject.toml's >=0.24.0,<1.14 range, and the
two landed seven minor versions apart on tree-sitter-language-pack — one
error in one environment, zero in the other, on the same commit.

Probing every published wheel found a live defect the old floor admitted:
1.9.0-1.12.2 return a builtins.Parser whose parse(source: str) rejects
bytes, so get_parser("python").parse(b"...") raises TypeError uncaught and
codebase_analyze crashes. The floor moves to 1.12.5, the first release
where the whole call chain is valid and type-checkable.

- pyproject: floor 1.12.5, with the measured per-release type surface
- ast_parser: _EXTRACTORS keyed by the pack's SupportedLanguage literal,
  AST_SUPPORTED derived from it (deleting an unreachable fallback arm),
  _is_ast_language TypeGuard narrows before get_parser
- CONTRIBUTING/CLAUDE: both documented installs resolve from uv.lock
- ci.yml: the Type Check job prints the versions its verdict depends on
- tests: language contract (parses with every declared grammar) and
  typecheck-env parity (docs vs pip_constraint_sets vs ci.yml)

Boy-scout (§14): four pre-existing shellcheck findings in the touched
ci.yml (SC2015 x3, SC2034) fixed; the suite's 3 sqlite datetime
DeprecationWarnings are outside this change's blast radius and filed as

Closes #253
Closes #249
Closes #251

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u

* chore: re-apply this branch's documentation over main and advertise 6548

Rebase bookkeeping after #259 merged; no behaviour change.

The earlier rebase resolved the six suite-size files to main's side wholesale,
which deleted 49 lines of this branch's own documentation — including the
'### Reproducing the pyright gate locally' section that
tests_py/scripts/test_typecheck_env_parity.py asserts, failing 5 of its 9 cases.
This time the branch's doc additions are re-applied as a PATCH against the new
main (git apply --3way) so both sides' prose survives; the only conflicts were
the hard-coded count, resolved to the current figure.

Count is the measured absolute (6548), not a delta from main's advertised
number: CI proved on #259 that this machine's collection matches CI's canonical
exactly, so the delta arithmetic I used before was the wrong instrument.

Verified: test_typecheck_env_parity.py 9 passed; check_doc_claims.py OK;
generate_repo_badges.py --check OK; .bestpractices.json parses and carries no
conflict markers (the class that shipped once before, now gated by
check_no_conflict_markers).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

normalize_date_to_iso silently re-anchors an unresolvable timezone abbreviation to UTC

1 participant