Skip to content

Commit 33fe72d

Browse files
Pigbibicodex
andcommitted
Tighten weekly artifact boundaries
Co-Authored-By: Codex <noreply@openai.com>
1 parent d828439 commit 33fe72d

7 files changed

Lines changed: 133 additions & 40 deletions

File tree

.github/workflows/rss_source_pipeline.yml

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -85,32 +85,6 @@ jobs:
8585
--watchlist "${WATCHLIST_PATH}" \
8686
--events data/output/rss_source_pipeline/source_events.csv \
8787
--output data/output/rss_source_pipeline/source_tracker.csv
88-
- name: Build completed weekly producer artifact
89-
env:
90-
PERIOD_START: ${{ github.event.inputs.period_start || '' }}
91-
AS_OF: ${{ github.event.inputs.as_of || '' }}
92-
WATCHLIST_PATH: ${{ github.event.inputs.watchlist_path || 'data/live/political_watchlist.csv' }}
93-
run: |
94-
set -euo pipefail
95-
test "${GITHUB_RUN_ATTEMPT}" = "1"
96-
mkdir -p data/output/political-event-weekly-v1
97-
ARGS=(
98-
--generated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000000Z)"
99-
--workflow-ref "${GITHUB_WORKFLOW_REF}"
100-
--source-run-id "${GITHUB_RUN_ID}"
101-
--producer-ref "${GITHUB_SHA}"
102-
--source-events data/output/rss_source_pipeline/source_events.csv
103-
--watchlist "${WATCHLIST_PATH}"
104-
--feed-status data/output/rss_source_pipeline/source_fetch_status.json
105-
--output-dir data/output/political-event-weekly-v1
106-
)
107-
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
108-
ARGS+=(--scheduled-today "$(date -u +%F)" --run-mode scheduled)
109-
else
110-
test -n "${PERIOD_START}" && test -n "${AS_OF}"
111-
ARGS+=(--period-start "${PERIOD_START}" --as-of "${AS_OF}" --run-mode manual)
112-
fi
113-
python scripts/build_weekly_artifact.py "${ARGS[@]}"
11488
- name: Publish live CSV outputs to repository
11589
env:
11690
COMMIT_OUTPUTS: ${{ github.event_name == 'schedule' && 'true' || github.event.inputs.commit_outputs || 'false' }}
@@ -143,6 +117,32 @@ jobs:
143117
git commit -m "Update live RSS source events [skip ci]"
144118
git push
145119
fi
120+
- name: Build completed weekly producer artifact
121+
env:
122+
PERIOD_START: ${{ github.event.inputs.period_start || '' }}
123+
AS_OF: ${{ github.event.inputs.as_of || '' }}
124+
WATCHLIST_PATH: ${{ github.event.inputs.watchlist_path || 'data/live/political_watchlist.csv' }}
125+
run: |
126+
set -euo pipefail
127+
test "${GITHUB_RUN_ATTEMPT}" = "1"
128+
mkdir -p data/output/political-event-weekly-v1
129+
ARGS=(
130+
--generated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000000Z)"
131+
--workflow-ref "${GITHUB_WORKFLOW_REF}"
132+
--source-run-id "${GITHUB_RUN_ID}"
133+
--producer-ref "${GITHUB_SHA}"
134+
--source-events data/output/rss_source_pipeline/source_events.csv
135+
--watchlist "${WATCHLIST_PATH}"
136+
--feed-status data/output/rss_source_pipeline/source_fetch_status.json
137+
--output-dir data/output/political-event-weekly-v1
138+
)
139+
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
140+
ARGS+=(--scheduled-today "$(date -u +%F)" --run-mode scheduled)
141+
else
142+
test -n "${PERIOD_START}" && test -n "${AS_OF}"
143+
ARGS+=(--period-start "${PERIOD_START}" --as-of "${AS_OF}" --run-mode manual)
144+
fi
145+
python scripts/build_weekly_artifact.py "${ARGS[@]}"
146146
- name: Upload RSS source artifact
147147
uses: actions/upload-artifact@v7
148148
with:

docs/weekly_producer_artifact_contract.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ political_event_weekly.json
2424
weekly_manifest.json
2525
```
2626

27+
The producer filters `political_events.csv` to the locked inclusive date range
28+
before writing it; malformed `event_date` fails closed. The original source
29+
events/watchlist input snapshot digest remains in `period_lock.json`, while the
30+
filtered event bytes and row count are bound by the artifact manifest.
31+
2732
The manifest binds the first four files by name, byte length and SHA-256. It also
2833
records CSV headers/row counts, period/as-of/generated-at, producer SHA,
2934
workflow ref, run id/attempt, source snapshot digest/provenance, and complete

src/political_event_tracking_research/rss_source_fetch.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def write_fetch_status(path: str | Path, statuses: list[FeedFetchStatus], *, ite
178178
"feed_count": len(statuses),
179179
"successful_feed_count": sum(1 for item in statuses if item.ok),
180180
"failed_feed_count": sum(1 for item in statuses if not item.ok),
181+
"complete": bool(statuses) and all(item.ok for item in statuses),
181182
"item_count": item_count,
182183
"feeds": [item.to_json() for item in statuses],
183184
}

src/political_event_tracking_research/weekly_artifact.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def pairs(items: list[tuple[str, object]]) -> dict[str, object]:
9393
return value
9494

9595

96-
def _csv_snapshot(value: object, expected_header: tuple[str, ...], code: str) -> tuple[int, tuple[str, ...]]:
96+
def _csv_snapshot(value: object, expected_header: tuple[str, ...], code: str, *, allow_empty: bool = False) -> tuple[int, tuple[str, ...]]:
9797
if type(value) is not bytes:
9898
raise _invalid(code)
9999
try:
@@ -103,15 +103,44 @@ def _csv_snapshot(value: object, expected_header: tuple[str, ...], code: str) ->
103103
raise _invalid(code) from None
104104
if not rows or tuple(rows[0]) != expected_header:
105105
raise _invalid(f"{code}_header")
106-
if len(rows) < 2 or any(len(row) != len(expected_header) for row in rows[1:]):
106+
if (not allow_empty and len(rows) < 2) or any(len(row) != len(expected_header) for row in rows[1:]):
107107
raise _invalid(f"{code}_rows")
108108
return len(rows) - 1, expected_header
109109

110110

111+
def _filter_events(value: object, period_start: date, as_of: date) -> bytes:
112+
if type(value) is not bytes:
113+
raise _invalid("events_csv_invalid")
114+
try:
115+
rows = list(csv.reader(io.StringIO(value.decode("utf-8"), newline="")))
116+
except (UnicodeError, csv.Error, ValueError, RecursionError):
117+
raise _invalid("events_csv_invalid") from None
118+
if not rows or tuple(rows[0]) != EVENT_HEADER:
119+
raise _invalid("events_csv_header")
120+
selected: list[list[str]] = []
121+
for row in rows[1:]:
122+
if len(row) != len(EVENT_HEADER):
123+
raise _invalid("events_csv_rows")
124+
event_date = row[1]
125+
if type(event_date) is not str or len(event_date) != 10:
126+
raise _invalid("events_date_invalid")
127+
try:
128+
parsed_date = date.fromisoformat(event_date)
129+
except ValueError:
130+
raise _invalid("events_date_invalid") from None
131+
if period_start <= parsed_date <= as_of:
132+
selected.append(row)
133+
output = io.StringIO(newline="")
134+
writer = csv.writer(output, lineterminator="\n")
135+
writer.writerow(EVENT_HEADER)
136+
writer.writerows(selected)
137+
return output.getvalue().encode("utf-8")
138+
139+
111140
def _feed_status(value: object) -> WeeklyFeedStatus:
112141
if not isinstance(value, Mapping):
113142
raise _invalid("feed_status_invalid")
114-
required = ("feed_count", "successful_feed_count", "failed_feed_count")
143+
required = ("feed_count", "successful_feed_count", "failed_feed_count", "complete")
115144
if any(key not in value for key in required):
116145
raise _invalid("feed_status_invalid")
117146
try:
@@ -121,7 +150,7 @@ def _feed_status(value: object) -> WeeklyFeedStatus:
121150
value["failed_feed_count"],
122151
value.get("stale_feed_count", 0),
123152
value.get("missing_feed_count", 0),
124-
True,
153+
value["complete"],
125154
)
126155
except (WeeklyContractError, TypeError, ValueError):
127156
raise _invalid("feed_status_incomplete") from None
@@ -173,7 +202,7 @@ def _file_metadata(name: str, value: bytes, *, role: str, row_count: int | None
173202

174203

175204
def _manifest_payload(lock: PoliticalEventWeeklyPeriodLockV1, contract: WeeklySourceContract, files: Mapping[str, bytes]) -> dict[str, object]:
176-
event_rows, event_header = _csv_snapshot(files[EVENTS_NAME], EVENT_HEADER, "events_csv_invalid")
205+
event_rows, event_header = _csv_snapshot(files[EVENTS_NAME], EVENT_HEADER, "events_csv_invalid", allow_empty=True)
177206
watch_rows, watch_header = _csv_snapshot(files[WATCHLIST_NAME], WATCHLIST_HEADER, "watchlist_csv_invalid")
178207
return {
179208
"manifest_version": MANIFEST_VERSION,
@@ -221,14 +250,16 @@ def build_weekly_artifact(
221250
generated_at = _timestamp(generated_at)
222251
if generated_at < datetime.combine(period_end, datetime.min.time(), timezone.utc):
223252
raise _invalid("generated_at_invalid")
224-
event_rows, _ = _csv_snapshot(source_events, EVENT_HEADER, "events_csv_invalid")
253+
raw_snapshot_digest = _snapshot_digest(source_events, watchlist)
254+
period_events = _filter_events(source_events, period_start, as_of)
255+
event_rows, _ = _csv_snapshot(period_events, EVENT_HEADER, "events_csv_invalid", allow_empty=True)
225256
watch_rows, _ = _csv_snapshot(watchlist, WATCHLIST_HEADER, "watchlist_csv_invalid")
226257
status = _feed_status(feed_status)
227258
if type(source_provenance) is not str or source_provenance != "official_rss_source_pipeline_v1":
228259
raise _invalid("source_provenance_invalid")
229260
if type(run_mode) is not str or run_mode not in {"scheduled", "manual"}:
230261
raise _invalid("run_mode_invalid")
231-
source_snapshot_digest = _snapshot_digest(source_events, watchlist)
262+
source_snapshot_digest = raw_snapshot_digest
232263
source_snapshot_id = f"rss_source_snapshot_{as_of:%Y%m%d}_{source_run_id}"
233264
try:
234265
lock = PoliticalEventWeeklyPeriodLockV1(
@@ -243,7 +274,7 @@ def build_weekly_artifact(
243274
source_snapshot_digest,
244275
source_provenance,
245276
(
246-
SourceSnapshotArtifact(EVENTS_NAME, _sha256(source_events), event_rows),
277+
SourceSnapshotArtifact(EVENTS_NAME, _sha256(period_events), event_rows),
247278
SourceSnapshotArtifact(WATCHLIST_NAME, _sha256(watchlist), watch_rows),
248279
),
249280
)
@@ -256,7 +287,7 @@ def build_weekly_artifact(
256287
producer_ref,
257288
source_provenance,
258289
(
259-
WeeklySourceArtifact(EVENTS_NAME, _sha256(source_events), event_rows),
290+
WeeklySourceArtifact(EVENTS_NAME, _sha256(period_events), event_rows),
260291
WeeklySourceArtifact(WATCHLIST_NAME, _sha256(watchlist), watch_rows),
261292
),
262293
status,
@@ -267,7 +298,7 @@ def build_weekly_artifact(
267298
raise _invalid("weekly_artifact_invalid") from None
268299
files: dict[str, bytes] = {
269300
PERIOD_LOCK_NAME: period_lock,
270-
EVENTS_NAME: source_events,
301+
EVENTS_NAME: period_events,
271302
WATCHLIST_NAME: watchlist,
272303
WEEKLY_NAME: weekly,
273304
}
@@ -293,10 +324,11 @@ def parse_weekly_artifact(files: Mapping[str, bytes]) -> dict[str, bytes]:
293324
raise _invalid("weekly_contract_invalid") from None
294325
if serialize_weekly_contract(contract) != files[WEEKLY_NAME]:
295326
raise _invalid("weekly_noncanonical")
296-
event_rows, _ = _csv_snapshot(files[EVENTS_NAME], EVENT_HEADER, "events_csv_invalid")
327+
expected_events = _filter_events(files[EVENTS_NAME], contract.period_start, contract.as_of)
328+
if expected_events != files[EVENTS_NAME]:
329+
raise _invalid("events_period_mismatch")
330+
event_rows, _ = _csv_snapshot(files[EVENTS_NAME], EVENT_HEADER, "events_csv_invalid", allow_empty=True)
297331
watch_rows, _ = _csv_snapshot(files[WATCHLIST_NAME], WATCHLIST_HEADER, "watchlist_csv_invalid")
298-
if _snapshot_digest(files[EVENTS_NAME], files[WATCHLIST_NAME]) != lock.source_snapshot_digest:
299-
raise _invalid("source_snapshot_digest_mismatch")
300332
expected_artifacts = (
301333
(EVENTS_NAME, _sha256(files[EVENTS_NAME]), event_rows),
302334
(WATCHLIST_NAME, _sha256(files[WATCHLIST_NAME]), watch_rows),
@@ -313,7 +345,6 @@ def parse_weekly_artifact(files: Mapping[str, bytes]) -> dict[str, bytes]:
313345
or lock.producer_ref != contract.producer_ref
314346
or lock.source_provenance != contract.source_provenance
315347
or lock.source_snapshot_id != expected_snapshot_id
316-
or lock.source_snapshot_digest != _snapshot_digest(files[EVENTS_NAME], files[WATCHLIST_NAME])
317348
or lock.source_attempt != 1
318349
):
319350
raise _invalid("period_contract_mismatch")

tests/test_rss_source_fetch.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def fake_fetch(url: str) -> bytes:
105105
payload = json.loads(status.read_text(encoding="utf-8"))
106106
assert payload["successful_feed_count"] == 1
107107
assert payload["failed_feed_count"] == 1
108+
assert payload["complete"] is False
108109
assert payload["feeds"][1]["feed_id"] == "bad"
109110
assert "RuntimeError" in payload["feeds"][1]["error"]
110111

tests/test_weekly_artifact.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def feed_status(**overrides: object) -> dict[str, object]:
2727
"failed_feed_count": 0,
2828
"stale_feed_count": 0,
2929
"missing_feed_count": 0,
30+
"complete": True,
3031
}
3132
value.update(overrides)
3233
return value
@@ -104,6 +105,44 @@ def test_missing_or_malformed_csv_does_not_build(field: str) -> None:
104105
build(**{field: b"not,the,approved,header\n"})
105106

106107

108+
def test_events_are_filtered_to_locked_period_deterministically() -> None:
109+
source = EVENTS + b"old,2026-07-05,MSFT,public_mention,neutral,high,https://example.test/old,note\n"
110+
files = build(source_events=source)
111+
assert files["political_events.csv"] == EVENTS
112+
base_lock = json.loads(build()["period_lock.json"])
113+
filtered_lock = json.loads(files["period_lock.json"])
114+
assert filtered_lock["source_snapshot_digest"] != base_lock["source_snapshot_digest"]
115+
manifest = json.loads(files["weekly_manifest.json"])
116+
event_file = next(item for item in manifest["files"] if item["name"] == "political_events.csv")
117+
assert event_file["row_count"] == 1
118+
119+
120+
def test_event_date_outside_period_is_filtered_including_next_monday() -> None:
121+
source = EVENTS + b"next,2026-07-13,MSFT,public_mention,neutral,high,https://example.test/next,note\n"
122+
files = build(source_events=source)
123+
assert files["political_events.csv"] == EVENTS
124+
125+
126+
def test_malformed_event_date_fails_closed() -> None:
127+
source = EVENTS + b"bad,not-a-date,MSFT,public_mention,neutral,high,https://example.test/bad,note\n"
128+
with pytest.raises(WeeklyArtifactError) as error:
129+
build(source_events=source)
130+
assert error.value.code == "events_date_invalid"
131+
132+
133+
def test_zero_event_week_is_allowed() -> None:
134+
files = build(source_events=b"event_id,event_date,symbol,event_type,direction,confidence,source_url,notes\n")
135+
manifest = json.loads(files["weekly_manifest.json"])
136+
event_file = next(item for item in manifest["files"] if item["name"] == "political_events.csv")
137+
assert event_file["row_count"] == 0
138+
assert files["political_events.csv"].endswith(b"\n")
139+
140+
141+
def test_incoming_complete_false_is_not_overridden() -> None:
142+
with pytest.raises(WeeklyArtifactError):
143+
build(feed_status=feed_status(complete=False))
144+
145+
107146
def test_manifest_file_tamper_is_rejected() -> None:
108147
files = build()
109148
tampered = dict(files)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from pathlib import Path
2+
3+
4+
WORKFLOW = Path(__file__).parents[1] / ".github/workflows/rss_source_pipeline.yml"
5+
6+
7+
def test_legacy_live_publication_precedes_weekly_artifact_failure_boundary() -> None:
8+
text = WORKFLOW.read_text(encoding="utf-8")
9+
assert text.index("Publish live CSV outputs to repository") < text.index("Build completed weekly producer artifact")
10+
11+
12+
def test_dedicated_artifact_is_separate_from_legacy_source_artifact() -> None:
13+
text = WORKFLOW.read_text(encoding="utf-8")
14+
assert "path: data/output/rss_source_pipeline/" in text
15+
assert "path: data/output/political-event-weekly-v1/" in text
16+
assert text.index("Upload RSS source artifact") < text.index("Upload political weekly artifact")

0 commit comments

Comments
 (0)