Dates: true_instant on metadata responses for instant matching

The mobile viewer anchored its "messages around this photo" window on
the legacy date_taken (a wall clock stored as UTC for EXIF rows), while
SMS-API dates are true instants: the window was off by the photo's UTC
offset (4-5 h for photos taken at home; ImageApi #113 user story 3).
Verified SMS-API side: importers store backup epochs as UTC, and 40/40
sampled rows equal their source epochs exactly.

`/image/metadata` (+ GPS / date mutation responses) now carries
`true_instant` from `date_view::file_true_instant`: `row_true_instant`,
else the filename-class instant, else the earliest fs time. Server-side
for the same reason as `wall_clock`: the drain's permanent floor rows
have no `date_utc`. `set_capture_dates` fills both fields at all four
response sites.

dev/e2e: the manifest records each fixture's expected instant (the wall
read in its GPS zone, else America/New_York) and `e2e.sh check` asserts
it (97/97).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 17:55:29 -04:00
1 parent bc41c737e5
commit 9269b5c9fd
5 files changed
+117 -18

No files matched your search

+9 -4
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Regression check for the displayed capture time (ADR-0001 `wall_clock`). """Regression check for capture dates (ADR-0001 `wall_clock` / `true_instant`).
For every indexed fixture, the manifest's expected wall clock must equal the For every indexed fixture, the manifest's expected wall clock must equal the
`wall_clock` the server reports on each endpoint that includes the file: `wall_clock` the server reports on each endpoint that includes the file:
`/image/metadata`, `/memories` (today's day span) and `/photos/gps-summary`. `/image/metadata`, `/memories` (today's day span) and `/photos/gps-summary`;
and `/image/metadata`'s `true_instant` must equal the expected instant.
One disagreement anywhere is exactly the class of bug this field exists to One disagreement anywhere is exactly the class of bug this field exists to
end, so any mismatch fails the run. end, so any mismatch fails the run.
@@ -35,17 +36,21 @@ def main() -> int:
failures = 0 failures = 0
for item in items: for item in items:
path, want = item["path"], item["wall_clock"] path, want = item["path"], item["wall_clock"]
got = {"metadata": api("image/metadata?path=" + urllib.parse.quote(path)).get("wall_clock")} meta = api("image/metadata?path=" + urllib.parse.quote(path))
got = {"metadata": meta.get("wall_clock")}
if path in memories: if path in memories:
got["memories"] = memories[path] got["memories"] = memories[path]
if path in gps: if path in gps:
got["gps-summary"] = gps[path] got["gps-summary"] = gps[path]
bad = {k: v for k, v in got.items() if v != want} bad = {k: v for k, v in got.items() if v != want}
# The matching anchor (SMS / location windows) must be the true instant.
if meta.get("true_instant") != item["true_instant"]:
bad["metadata true_instant"] = (meta.get("true_instant"), "expected", item["true_instant"])
if bad: if bad:
failures += 1 failures += 1
print(f"MISMATCH {path}: expected {want}, got {bad}") print(f"MISMATCH {path}: expected {want}, got {bad}")
print(f"{len(items) - failures}/{len(items)} fixtures report the expected wall clock " print(f"{len(items) - failures}/{len(items)} fixtures report the expected wall clock + instant "
f"({len(memories)} in memories, {len(gps)} in gps-summary)") f"({len(memories)} in memories, {len(gps)} in gps-summary)")
return 1 if failures else 0 return 1 if failures else 0
+19
View File
@@ -47,6 +47,15 @@ PLACES = {
"tokyo": (35.6595, 139.7005), "tokyo": (35.6595, 139.7005),
} }
# IANA zone of each place — what the server's GPS rung of the offset cascade
# resolves, so the expected true instant is the wall read in this zone.
PLACE_ZONES = {
"santa_monica": "America/Los_Angeles",
"paris": "Europe/Paris",
"yosemite": "America/Los_Angeles",
"tokyo": "Asia/Tokyo",
}
PALETTE = [ PALETTE = [
"0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100", "0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100",
"0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457", "0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457",
@@ -182,6 +191,15 @@ def expected_wall_clock(it: Item) -> str:
return wall.strftime("%Y-%m-%dT%H:%M:%S") return wall.strftime("%Y-%m-%dT%H:%M:%S")
def expected_true_instant(it: Item) -> int:
"""The true instant the server should report: the expected wall read in
the zone the camera was in (GPS place, else HOME_TZ — the server's
assumed zone). Filename walls outrank EXIF, but still take GPS evidence."""
wall = dt.datetime.fromisoformat(expected_wall_clock(it))
zone = ZoneInfo(PLACE_ZONES[it.gps]) if it.gps else HOME_TZ
return int(wall.replace(tzinfo=zone).timestamp())
def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime: def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime:
# Feb 29 in a non-leap year would be invalid; fall back to Feb 28. # Feb 29 in a non-leap year would be invalid; fall back to Feb 28.
try: try:
@@ -332,6 +350,7 @@ def main() -> int:
"tags": it.tags, "duplicate_of": it.copy_of, "tags": it.tags, "duplicate_of": it.copy_of,
# A duplicate carries its source's bytes, EXIF included. # A duplicate carries its source's bytes, EXIF included.
"wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)), "wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)),
"true_instant": expected_true_instant(by_path.get(it.copy_of, it)),
} }
for it in items for it in items
], ],
+6 -1
View File
@@ -263,6 +263,10 @@ pub struct MetadataResponse {
/// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it /// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it
/// verbatim; never zone-convert it (ADR-0001). /// verbatim; never zone-convert it (ADR-0001).
pub wall_clock: Option<String>, pub wall_clock: Option<String>,
/// True instant of capture (unix seconds) — `date_view::file_true_instant`.
/// For instant-to-instant matching (SMS / location windows); never
/// render it, render `wall_clock`.
pub true_instant: Option<i64>,
} }
impl From<fs::Metadata> for MetadataResponse { impl From<fs::Metadata> for MetadataResponse {
@@ -281,7 +285,8 @@ impl From<fs::Metadata> for MetadataResponse {
filename_date: None, // Will be set in endpoint handler filename_date: None, // Will be set in endpoint handler
library_id: None, library_id: None,
library_name: None, library_name: None,
wall_clock: None, // Set in the endpoint handler wall_clock: None, // Set in the endpoint handler
true_instant: None, // Set in the endpoint handler
} }
} }
} }
+59
View File
@@ -242,6 +242,26 @@ pub fn file_wall_clock(
wall.map(format_wall_clock) wall.map(format_wall_clock)
} }
/// True instant for a file that may have no `image_exif` row yet — the
/// [`file_wall_clock`] precedence (row, then filename-class date, then
/// earliest fs time). The anchor for instant-to-instant matching (the
/// "messages around this photo" window), never for display.
pub fn file_true_instant(
row: Option<&ImageExif>,
rel_path: &str,
fs_earliest: Option<i64>,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<i64> {
if let Some(instant) = row.and_then(|r| row_true_instant(r, display_zone, server_zone)) {
return Some(instant);
}
match crate::memories::extract_date_from_filename(rel_path) {
Some(fd) => filename_true_instant(&fd, server_zone),
None => fs_earliest,
}
}
/// Render a unix instant as a naive wall in `zone`. /// Render a unix instant as a naive wall in `zone`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> { fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0) zone.timestamp_opt(instant, 0)
@@ -720,4 +740,43 @@ mod tests {
None None
); );
} }
#[test]
fn file_true_instant_uses_the_row_then_filename_then_fs_time() {
// Row: the converted instant, not the wall (Tokyo 14:00 = 05:00Z).
let r = row(
"otd.jpg",
Some(utc_epoch(2018, 9, 29, 14, 0)),
Some("exif"),
Some(utc_epoch(2018, 9, 29, 5, 0)),
Some(540),
);
assert_eq!(
file_true_instant(Some(&r), "otd.jpg", Some(0), ny(), Some(ny())),
Some(utc_epoch(2018, 9, 29, 5, 0))
);
// No row: a wall-pattern filename is read in the server zone
// (10:15 EDT = 14:15Z).
assert_eq!(
file_true_instant(
None,
"Screenshots/Screenshot_20220315-101500.png",
Some(0),
ny(),
Some(ny())
),
Some(utc_epoch(2022, 3, 15, 14, 15))
);
// No row, no filename date: fs time is already an instant.
assert_eq!(
file_true_instant(
None,
"Undated/scan.jpg",
Some(1_425_229_200),
ny(),
Some(ny())
),
Some(1_425_229_200)
);
}
} }
+24 -13
View File
@@ -528,12 +528,11 @@ pub async fn get_file_metadata(
.lock() .lock()
.ok() .ok()
.and_then(|mut dao| dao.get_exif(&path.path).ok().flatten()); .and_then(|mut dao| dao.get_exif(&path.path).ok().flatten());
response.wall_clock = crate::date_view::file_wall_clock( set_capture_dates(
&mut response,
row.as_ref(), row.as_ref(),
&path.path, &path.path,
earliest_fs_time(&response),
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
crate::camera_tz::server_zone(),
); );
response.exif = row.map(Into::into); response.exif = row.map(Into::into);
@@ -714,14 +713,14 @@ pub async fn set_image_gps(
library_id: None, library_id: None,
library_name: None, library_name: None,
wall_clock: None, wall_clock: None,
true_instant: None,
}, },
}; };
response.wall_clock = crate::date_view::file_wall_clock( set_capture_dates(
&mut response,
Some(&row), Some(&row),
&normalized_path, &normalized_path,
earliest_fs_time(&response),
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
crate::camera_tz::server_zone(),
); );
response.exif = Some(row.into()); response.exif = Some(row.into());
response.library_id = Some(resolved_library.id); response.library_id = Some(resolved_library.id);
@@ -881,15 +880,10 @@ fn build_metadata_response_for_date_mutation(
library_id: None, library_id: None,
library_name: None, library_name: None,
wall_clock: None, wall_clock: None,
true_instant: None,
}, },
}; };
response.wall_clock = crate::date_view::file_wall_clock( set_capture_dates(&mut response, Some(&exif), rel_path, display_zone);
Some(&exif),
rel_path,
earliest_fs_time(&response),
display_zone,
crate::camera_tz::server_zone(),
);
response.exif = Some(exif.into()); response.exif = Some(exif.into());
response.library_id = Some(library.id); response.library_id = Some(library.id);
response.library_name = Some(library.name.clone()); response.library_name = Some(library.name.clone());
@@ -898,6 +892,23 @@ fn build_metadata_response_for_date_mutation(
response response
} }
/// Fill the capture-date fields every metadata response carries (ADR-0001):
/// `wall_clock` to display and `true_instant` to match against. Needs the
/// fs times already on the response (the unindexed-file fallback).
fn set_capture_dates(
response: &mut MetadataResponse,
row: Option<&ImageExif>,
rel_path: &str,
display_zone: chrono_tz::Tz,
) {
let server_zone = crate::camera_tz::server_zone();
let fs_earliest = earliest_fs_time(response);
response.wall_clock =
crate::date_view::file_wall_clock(row, rel_path, fs_earliest, display_zone, server_zone);
response.true_instant =
crate::date_view::file_true_instant(row, rel_path, fs_earliest, display_zone, server_zone);
}
/// Earlier of the file's created / modified times — the ingest waterfall's /// Earlier of the file's created / modified times — the ingest waterfall's
/// `fs_time` rule — for the wall-clock fallback of unindexed files. /// `fs_time` rule — for the wall-clock fallback of unindexed files.
fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> { fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {