Skip to content

Commit 5a73aad

Browse files
fix(semantic): suggest instead of auto-applying 24:00 -> 00:00 in time-only columns (#398)
TimeCanonicalExpert proposed '24:00' -> '00:00' at base_confidence 0.96, above the 0.95 auto threshold, so semantic_mode='auto' and 'review' both applied it. Its rationale, "the instant is unchanged", only holds when a date travels with the value: 24:00 on day D is 00:00 on day D+1. In a column of clock times alone the rewrite turns end-of-day into start-of-day, so a shift_end of 24:00 sorted before its shift_start. The proposal now scores 0.80, between the review (0.70) and auto (0.95) thresholds, so it is recorded as a suggestion and routed to a human in every mode. The rationale and docstring say why it is held for review. Suggestions are never learned into cleaning memory, so the replay case for time_canonical is replaced by a test that nothing is learned or replayed. The TruthBench logistics oracle for log-06 transport_time moves from REPAIR to REVIEW. That was the domain's only REPAIR cell and every domain must cover all dispositions, so log-07 tracking_status 'on time' -> 'on-time' is added as the domain's exact repair. Closes #305
1 parent 7bdedc3 commit 5a73aad

5 files changed

Lines changed: 134 additions & 7 deletions

File tree

‎benchmarks/truthbench/fixtures/logistics.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,21 @@ def build(seed: int = 1729) -> TruthFixture:
138138
"log-06",
139139
"transport_time",
140140
"24:00",
141-
Disposition.REPAIR,
142-
expected="00:00",
141+
# Corrected oracle: the column holds clock times without dates, so
142+
# 00:00 would move end-of-day to start-of-day; routed to a human.
143+
Disposition.REVIEW,
143144
family="twentyfour-hour-transport",
144145
)
146+
# The domain's exact repair: a status label whose separator drifted from
147+
# the column's single dominant spelling has one correct form.
148+
builder.inject(
149+
"log-07",
150+
"tracking_status",
151+
"on time",
152+
Disposition.REPAIR,
153+
expected="on-time",
154+
family="spaced-tracking-status",
155+
)
145156
builder.inject(
146157
"log-09",
147158
"address",

‎src/freshdata/semantic/canonical.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,14 @@ def propose(self, series: pd.Series, info: SemanticColumnInfo) -> list[SemanticP
543543

544544

545545
class TimeCanonicalExpert:
546-
"""Canonicalize ISO-8601 end-of-day ``24:00`` to ``00:00`` in time columns."""
546+
"""Propose ``00:00`` for an end-of-day ``24:00`` in a time column, for review.
547+
548+
``24:00`` on day D is the same instant as ``00:00`` on day D+1 only when a
549+
date travels with the value. These columns hold clock times alone, so the
550+
rewrite would turn end-of-day into start-of-day (a ``shift_end`` of
551+
``24:00`` would sort before its ``shift_start``). The proposal is scored
552+
below the auto-apply threshold and is always held for a human.
553+
"""
547554

548555
name = "time_canonical"
549556
issue_type = "format_alignment"
@@ -585,12 +592,12 @@ def propose(self, series: pd.Series, info: SemanticColumnInfo) -> list[SemanticP
585592
proposed_value=value,
586593
issue_type=self.issue_type,
587594
expert=self.name,
588-
base_confidence=0.96,
595+
base_confidence=0.80,
589596
evidence=evidence,
590597
count=int(count),
591598
rationale=(
592-
"ISO 8601 end-of-day 24:00 canonicalizes to midnight "
593-
"00:00; the instant is unchanged"
599+
"24:00 marks end of day; in a time-only column 00:00 "
600+
"would read as start of day, so this is held for review"
594601
),
595602
info=info,
596603
)

‎tests/test_semantic_repair_safety.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,29 @@ def test_replay_expert_falls_back_to_issue_type_only_when_unambiguous() -> None:
203203
"code": ["555-0101", "555-0102", "555-0103", "555-0104", "555-0105", "555 0106"]
204204
},
205205
"numeric_format": {"score_percent": ["95", "90", "87.5", "82", "95%"]},
206-
"time_canonical": {"start_time": ["09:00", "10:30", "11:15", "12:00", "24:00"]},
207206
}
208207

209208

209+
def test_suggested_time_canonical_repair_is_not_learned() -> None:
210+
# 24:00 -> 00:00 in a time-only column is only suggested (#305), and
211+
# suggestions never enter cleaning memory, so nothing replays.
212+
data = {"start_time": ["09:00", "10:30", "11:15", "12:00", "24:00"]}
213+
df = pd.DataFrame(data)
214+
out, report = _clean(df)
215+
assert out["start_time"].iloc[4] == "24:00"
216+
actions = [a for a in _semantic(report) if a.metadata.get("expert") == "time_canonical"]
217+
assert [a.status for a in actions] == ["suggested"]
218+
219+
memory = fd.learn_cleaning_memory(df, decisions=report, dataset_id="d")
220+
assert memory.value_patterns.get("semantic_repairs", []) == []
221+
ctx = build_semantic_context(df, CleanConfig(semantic_mode="auto"))
222+
assert semantic_memory_proposals(df, ctx, memory).proposals == []
223+
224+
replayed, replay_report = _clean(pd.DataFrame(data), memory=memory)
225+
assert replayed["start_time"].iloc[4] == "24:00"
226+
assert not any(a.memory_influenced for a in _semantic(replay_report))
227+
228+
210229
@pytest.mark.parametrize("expert_name", sorted(_REPLAY_FRAMES))
211230
def test_learned_repair_replays_on_the_identical_frame(expert_name: str) -> None:
212231
data = _REPLAY_FRAMES[expert_name]

‎tests/test_semantic_time_24h.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""#305: ``24:00`` in a date-less time column is suggested, never auto-applied.
2+
3+
Without a date, ``24:00`` (end of day) and ``00:00`` (start of day) are
4+
different clock times, so the rewrite is held for review in every mode.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import pandas as pd
10+
import pytest
11+
12+
import freshdata as fd
13+
from freshdata.config import CleanConfig
14+
from freshdata.semantic.canonical import TimeCanonicalExpert
15+
from freshdata.semantic.context import build_semantic_context
16+
17+
_RATIONALE = (
18+
"24:00 marks end of day; in a time-only column 00:00 would read as start "
19+
"of day, so this is held for review"
20+
)
21+
22+
23+
def _shift_frame() -> pd.DataFrame:
24+
return pd.DataFrame(
25+
{
26+
"shift_start": ["14:00", "15:00", "13:30", "16:00", "12:00"],
27+
"shift_end": ["22:00", "23:00", "21:30", "24:00", "20:00"],
28+
}
29+
)
30+
31+
32+
def _time_actions(report: fd.CleanReport, column: str) -> list[fd.Action]:
33+
return [
34+
a
35+
for a in report.actions
36+
if a.step == "semantic"
37+
and a.column == column
38+
and a.metadata.get("expert") == "time_canonical"
39+
]
40+
41+
42+
@pytest.mark.parametrize("mode", ["auto", "review", "assist"])
43+
def test_issue_305_repro_keeps_24_00_and_suggests(mode: str) -> None:
44+
df = _shift_frame()
45+
out, report = fd.clean(df, semantic_mode=mode, return_report=True, verbose=False)
46+
47+
assert out["shift_end"].iloc[3] == "24:00"
48+
assert out["shift_end"].tolist() == df["shift_end"].tolist()
49+
actions = _time_actions(report, "shift_end")
50+
assert [a.status for a in actions] == ["suggested"]
51+
assert actions[0].rationale == _RATIONALE
52+
assert actions[0].metadata.get("raw_value") == "24:00"
53+
54+
55+
@pytest.mark.parametrize("raw, proposed", [("24:00", "00:00"), ("24:00:00", "00:00:00")])
56+
def test_proposal_scores_between_review_and_auto_thresholds(raw: str, proposed: str) -> None:
57+
df = pd.DataFrame({"end_time": ["08:00", "09:30", "10:15", "11:00", raw]})
58+
config = CleanConfig(semantic_mode="auto")
59+
ctx = build_semantic_context(df, config)
60+
info = ctx.columns["end_time"]
61+
expert = TimeCanonicalExpert()
62+
assert expert.applies(info)
63+
64+
proposals = expert.propose(df["end_time"], info)
65+
assert len(proposals) == 1
66+
proposal = proposals[0]
67+
assert proposal.raw_value == raw
68+
assert proposal.proposed_value == proposed
69+
assert config.semantic_review_threshold <= proposal.confidence
70+
assert proposal.confidence < config.semantic_auto_threshold
71+
assert proposal.rationale == _RATIONALE
72+
assert "instant is unchanged" not in proposal.rationale
73+
74+
75+
def test_seconds_form_is_kept_in_auto_mode() -> None:
76+
df = pd.DataFrame({"end_time": ["08:00:00", "09:30:00", "10:15:00", "11:00:00", "24:00:00"]})
77+
out, report = fd.clean(df, semantic_mode="auto", return_report=True, verbose=False)
78+
assert out["end_time"].iloc[4] == "24:00:00"
79+
assert [a.status for a in _time_actions(report, "end_time")] == ["suggested"]

‎tests/truthbench/test_fixtures.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,18 @@ def test_logistics_content_families_are_labeled_on_actual_cells() -> None:
344344
"transport_time",
345345
"twentyfour-hour-transport",
346346
"24:00",
347+
Disposition.REVIEW,
348+
)
349+
spaced = _assert_cell(
350+
fixture,
351+
"log-07",
352+
"tracking_status",
353+
"spaced-tracking-status",
354+
"on time",
347355
Disposition.REPAIR,
348356
)
357+
assert spaced.expected_output is not None
358+
assert spaced.expected_output.value == "on-time"
349359
for row_id, column, family in (
350360
("log-09", "address", "address-pii"),
351361
("log-15", "tracking_status", "late-tracking-canary"),
@@ -562,6 +572,7 @@ def test_eight_domain_corpus_contains_required_trap_categories() -> None:
562572
"temperature-unit-f",
563573
"cross-timezone-window",
564574
"twentyfour-hour-transport",
575+
"spaced-tracking-status",
565576
"address-pii",
566577
"late-tracking-canary",
567578
"protected-shipment-id-conflict",

0 commit comments

Comments
 (0)