Skip to content

Add freshness and data quality gates#35

Closed
Pigbibi wants to merge 1 commit into
mainfrom
codex/p0-freshness-data-quality
Closed

Add freshness and data quality gates#35
Pigbibi wants to merge 1 commit into
mainfrom
codex/p0-freshness-data-quality

Conversation

@Pigbibi

@Pigbibi Pigbibi commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Require versioned metadata on shadow signals and theme momentum artifacts.
  • Add expiry validation and fail-closed latest-signal validation.
  • Flag stale prices, extreme returns, and split/adjustment suspicions with a consumer gate.
  • Add regression tests, artifact updates, workflow validation, and architecture documentation.

Validation

  • python3 -m pytest -q (42 passed)
  • python3 scripts/validate_latest_signal.py examples/latest_signal.example.json --allow-expired
  • python3 scripts/validate_theme_momentum_snapshot.py data/output/theme_momentum_snapshot.json
  • Current committed latest signal intentionally rejects as expired: expires_at=2026-06-30.

Research-only scope; no execution, portfolio, account, or production dependency changes.

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

🤖 Codex PR Review

🚫 Merge blocked: 2 serious issue(s) found in high-risk files

⚖️ Codex Review Arbitration

🚫 block: src/research_signal_context_pipelines/schema.py still calls validate_theme_momentum_snapshot(theme_payload, reference_date=reference_date if check_freshness else None). With the default validate_latest_signal(..., check_freshness=True, reference_date=None), the signal expiry is checked against dt.datetime.now(dt.UTC).date() inside validate_signal(), but the referenced theme snapshot receives reference_date=None, and validate_theme_momentum_snapshot() only enforces expires_at when reference_date is provided. That leaves linked-theme freshness unenforced by default and contradicts the documented contract in docs/architecture.md that consumers must reject expired artifacts and that validate_latest_signal() revalidates the linked theme artifact. src/research_signal_context_pipelines/theme_momentum.py also still leaves the default stale-price gate fail-open: freshness_boundary = as_of_date or actual_latest_date, so when no as_of is supplied the stale check compares actual_latest_date to itself and can never flag stale source data, despite the same architecture doc stating that stale prices must set data_quality.gate.allow_downstream_recommendation to false. The current findings therefore remain valid against the cumulative diff, and neither suggested fix reverses or contradicts the prior blocking requirements.

🚫 Blocking Issues

These issues must be fixed before this PR can be merged:

1. 🟠 [HIGH] Logic in src/research_signal_context_pipelines/schema.py

validate_latest_signal() does not enforce freshness on the referenced theme snapshot unless the caller explicitly passes reference_date. When reference_date is omitted, the signal itself is checked against the current date, but the linked theme artifact is validated with reference_date=None, so an expired theme snapshot is still accepted as the backing artifact for the current/latest signal. (line 161)

Suggestion: When check_freshness is true, pass an effective reference date to validate_theme_momentum_snapshot() just like validate_signal() does, for example reference_date or dt.datetime.now(dt.UTC).date(). That keeps latest-signal validation fail-closed for both artifacts by default.

2. 🟠 [HIGH] Logic in src/research_signal_context_pipelines/theme_momentum.py

The stale-price gate is effectively disabled for the default build path where no explicit as_of is supplied. freshness_boundary falls back to actual_latest_date, so the comparison becomes actual_latest_date - actual_latest_date, which can never flag stale source data even if the build runs days or weeks after the last downloaded price row. (line 162)

Suggestion: When as_of is not provided, compare actual_latest_date against the build date (for example timestamp.date()) rather than against itself. That ensures daily/default snapshot builds correctly block on stale market data.


Review by Codex PR Review bot • PR

@Pigbibi
Pigbibi force-pushed the codex/p0-freshness-data-quality branch from 9137b42 to c18b468 Compare July 13, 2026 01:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9137b4235c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/validate_latest_signal.py Outdated
Comment on lines +40 to +41
if expires_at < dt.date.today() and not args.allow_expired:
raise SystemExit(f"expired signal artifact: expires_at={expires_at.isoformat()}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let refresh workflow proceed past expired signals

When data/output/latest_signal.json is expired, this default failure blocks the scheduled long-horizon refresh path: .github/workflows/dispatch_shadow_signal.yml invokes python scripts/validate_latest_signal.py --allow-missing before building the context bundle or opening the shadow-signal issue. The committed artifact expires on 2026-06-30, so any run after that date (including the next monthly run) exits here and cannot create the request that would replace the expired signal; the refresh workflow should pass --allow-expired or otherwise treat expiry as a warning in that preflight step.

Useful? React with 👍 / 👎.

"as_of": snapshot_as_of,
"generated_at": (generated_at or dt.datetime.now(dt.UTC)).isoformat().replace("+00:00", "Z"),
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
"expires_at": (parse_price_date(snapshot_as_of) + dt.timedelta(days=THEME_MOMENTUM_EXPIRY_DAYS)).isoformat(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject future as-of before extending expiry

When the theme snapshot workflow is manually dispatched with a future as_of value, the builder filters prices with that cutoff but still stamps snapshot_as_of from the requested date, so this new expiry is minted from a date beyond the latest available prices and validate_theme_momentum_snapshot.py accepts it. For example, running on 2026-07-13 with prices through 2026-07-10 and --as-of 2026-12-31 produces as_of=2026-12-31, expires_at=2027-03-25, and no stale warning, causing downstream consumers that rely on expiry to treat a stale/future-dated artifact as fresh.

Useful? React with 👍 / 👎.

Comment on lines +286 to +288
"status": "blocked" if quality_warnings else "pass",
"allow_downstream_recommendation": not quality_warnings,
"reasons": quality_warnings,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block the gate when coverage has no usable ranks

If the snapshot is built from a local prices file that omits the configured exposure symbols, or all matching symbols have too little history to produce momentum scores, quality_warnings remains empty and this new gate reports pass/allow_downstream_recommendation=true even though priced_symbol_count or usable ranks are zero. That makes downstream consumers that follow the new gate treat an empty or unscorable snapshot as healthy; the gate should also consider missing coverage / insufficient-history conditions before allowing recommendations.

Useful? React with 👍 / 👎.

@Pigbibi
Pigbibi force-pushed the codex/p0-freshness-data-quality branch 2 times, most recently from f6453b7 to d7ac1f6 Compare July 13, 2026 01:51

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7ac1f65a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +166 to +169
if generated_date - parse_price_date(snapshot_as_of) > dt.timedelta(days=MAX_PRICE_AGE_DAYS):
quality_warnings.append(
f"stale price data: latest as_of {snapshot_as_of} is older than {MAX_PRICE_AGE_DAYS} days"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flag stale prices per symbol

When the input has mixed symbol recency, such as one exposure symbol ending weeks before another symbol's latest row, snapshot_as_of is the maximum date across all symbols, so this check can leave data_quality.gate as pass while the stale symbol is still included in priced_members and top_symbols. That defeats the new stale-price safety gate for partial downloads or local CSVs with lagging symbols; compare each symbol's own symbol_data["as_of"] to the generated date or exclude/flag lagging symbols.

Useful? React with 👍 / 👎.

Comment on lines +98 to +99
if check_freshness and dt.date.fromisoformat(payload["expires_at"]) < (reference_date or dt.date.today()):
raise SignalValidationError(f"signal artifact expired on {payload['expires_at']}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin freshness checks outside wall-clock tests

Because the new default compares every validation to dt.date.today(), the unit tests that load the static example artifact and call validate_signal(payload) become time-dependent: after examples/latest_signal.example.json expires on 2026-12-31, those tests will fail before exercising their intended schema assertions. CI already passes --allow-expired for the script path, so the pytest callers should similarly pin reference_date or disable freshness when testing schema behavior.

Useful? React with 👍 / 👎.

Comment on lines +93 to +95
if schema_version == "2":
for key in ("model_version", "scoring_version"):
_require_string(payload.get(key), key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require metadata even when schema_version is 1

With this branch, a fresh shadow artifact can set schema_version to "1" and omit both model_version and scoring_version, and validate_latest_signal.py still accepts it because the metadata check only runs for "2". The issue prompt still only asks the bridge to write data/output/latest_signal.json, so an old-format output can bypass the new versioned-metadata contract; require these fields for current/latest validation or reserve schema v1 only for explicit replay paths.

Useful? React with 👍 / 👎.

Comment on lines +346 to +347
if reference_date and expires_at < reference_date:
raise ValueError(f"theme momentum snapshot expired on {expires_at.isoformat()}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute stale gating during snapshot validation

A freshly built snapshot with no warnings gets allow_downstream_recommendation=true and an expiry 84 days after as_of, but after MAX_PRICE_AGE_DAYS elapses this validator still accepts it because it only checks the long expiry date. Downstream consumers that rely on validate_theme_momentum_snapshot.py and the stored gate can therefore treat 1- to 12-week-old prices as healthy; the validator should compare as_of to reference_date and fail or flip the gate once the stale-price window is exceeded.

Useful? React with 👍 / 👎.

@Pigbibi
Pigbibi force-pushed the codex/p0-freshness-data-quality branch from d7ac1f6 to ea0e692 Compare July 13, 2026 02:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea0e692246

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +157 to +160
if as_of_date and actual_latest_date and as_of_date > actual_latest_date:
raise ValueError(
f"as_of cannot be later than latest available price date {actual_latest_date.isoformat()}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow non-trading-day as_of cutoffs

When a manual run supplies --as-of for a weekend/holiday or a local price file whose last row is the previous trading day, this check raises even though the input is a valid inclusive cutoff. Because actual_latest_date is computed after filtering rows to <= as_of, any cutoff that does not exactly match a price row is treated as invalid; the workflow input describes this as a cutoff, so it should either stamp/use the latest available price date or only reject genuinely future/unavailable data.

Useful? React with 👍 / 👎.

Comment on lines +320 to +323
required = ("schema_version", "as_of", "generated_at")
missing = [key for key in required if not snapshot.get(key)]
if missing:
raise ValueError(f"theme momentum snapshot missing required keys: {', '.join(missing)}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require snapshot identity fields in validation

The new workflow validation step relies on this validator before publishing theme_momentum_snapshot.json, but the required set only covers dates and schema metadata, so a v2 file with a gate but no mode, artifact_type, theme_ranks, or research-only policy still passes as a valid theme momentum snapshot. Add value checks for the snapshot identity fields at minimum, otherwise a miswired or truncated builder output can be uploaded/committed despite not being a theme snapshot.

Useful? React with 👍 / 👎.

@Pigbibi
Pigbibi force-pushed the codex/p0-freshness-data-quality branch from ea0e692 to c03c504 Compare July 13, 2026 03:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c03c5046d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

from .theme_momentum import validate_theme_momentum_snapshot

try:
validate_theme_momentum_snapshot(theme_payload, reference_date=reference_date)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the effective freshness date to theme validation

When callers use the default CLI path without --reference-date, validate_signal(..., check_freshness=True) checks the latest signal against today's UTC date, but this call passes None into the referenced theme snapshot validator. Since validate_theme_momentum_snapshot only applies its expiry check when reference_date is truthy, a still-fresh latest signal can validate while pointing at a theme snapshot whose expires_at is already in the past; compute the effective reference date once and pass it to both validators.

Useful? React with 👍 / 👎.

Comment on lines +170 to +171
for symbol, symbol_data in symbol_scores.items():
metrics = symbol_data["metrics"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict quality gates to configured exposure symbols

When a caller passes price rows for a broader universe than the configured exposures, this new gate scans every symbol in symbol_scores; any extreme return or split-like move in an unrelated extra symbol adds a warning and blocks allow_downstream_recommendation, even though the theme ranks below only use symbols present in the exposure map. The quality gate should iterate the priced exposure symbols so unrelated rows cannot falsely block an otherwise healthy snapshot.

Useful? React with 👍 / 👎.

Comment on lines +159 to +163
theme_payload = json.loads(artifact.read_text(encoding="utf-8"))
from .theme_momentum import validate_theme_momentum_snapshot

try:
validate_theme_momentum_snapshot(theme_payload, reference_date=reference_date)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject theme evidence dated after the signal as_of

The strict validator loads the referenced theme snapshot but never checks its point-in-time date against the signal. The committed latest_signal.json is as_of 2026-06-25 while its referenced theme_momentum_snapshot.json is as_of 2026-06-26, so a replay or audit can accept a signal that used future theme data relative to its own cutoff; compare the referenced snapshot as_of to the signal as_of before accepting it.

Useful? React with 👍 / 👎.

@Pigbibi
Pigbibi force-pushed the codex/p0-freshness-data-quality branch from c03c504 to 718f834 Compare July 13, 2026 05:38
Co-Authored-By: Codex <noreply@openai.com>
@Pigbibi
Pigbibi force-pushed the codex/p0-freshness-data-quality branch from 718f834 to b43064d Compare July 13, 2026 05:40

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b43064d733

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +171 to +172
if gate.get("allow_downstream_recommendation") is not True:
raise SignalValidationError("referenced theme momentum artifact is blocked by data_quality.gate")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let refresh preflight tolerate a blocked existing gate

In the checked refresh workflow (.github/workflows/dispatch_shadow_signal.yml:53), the preflight runs validate_latest_signal.py --allow-missing before it can build the new context or open the replacement issue. This new gate check makes that preflight fail for the artifact committed in this PR because data/output/theme_momentum_snapshot.json has allow_downstream_recommendation=false; even adding --allow-expired would still exit here, so the scheduled/manual refresh path cannot replace the blocked artifact. Use schema-only validation or a preflight mode that treats the existing linked gate as a warning during refresh.

Useful? React with 👍 / 👎.

ARGS+=(--strict-downloads)
fi
python scripts/build_theme_momentum_snapshot.py "${ARGS[@]}" | tee /tmp/theme_momentum_summary.json
python scripts/validate_theme_momentum_snapshot.py data/output/theme_momentum_snapshot.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve historical as-of workflow dispatches

When workflow_dispatch supplies a historical as_of more than 84 days before the run date, the builder stamps expires_at from that cutoff, but this validation call provides neither --reference-date nor --allow-expired; scripts/validate_theme_momentum_snapshot.py therefore defaults freshness to today's UTC date and fails before upload/commit. For replaying a saved cutoff such as 2026-01-02 on 2026-07-13, pass the requested cutoff as the reference date or allow expired snapshots for historical dispatches.

Useful? React with 👍 / 👎.

Comment on lines +100 to +103
if check_freshness and dt.date.fromisoformat(payload["expires_at"]) < (
reference_date or dt.datetime.now(dt.UTC).date()
):
raise SignalValidationError(f"signal artifact expired on {payload['expires_at']}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject future-dated latest signals

When strict/latest validation is run with a reference date, this freshness gate only checks whether expires_at is before that date and never rejects an as_of that is after it. A bridge output dated 2026-12-31 with a later expiry is accepted on 2026-07-13, so current consumers can ingest future-dated signal context; compare as_of to the effective reference date before accepting latest artifacts.

Useful? React with 👍 / 👎.

Comment on lines +4 to +6
"as_of": "2026-06-25",
"generated_at": "2026-06-27T08:22:28.245044Z",
"history_artifact": "data/output/signal_history/2026-06-25.json",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix validation provenance for the updated signal

This manifest now points at the 2026-06-25 latest/history artifacts, but the unchanged validation block still records status: passed for commands that no longer pass with these artifacts: the default latest-signal validation rejects the committed expired signal, and the history command is also expired under the new freshness check. Consumers that trust the manifest provenance will treat an unvalidated artifact as validated; update the recorded commands/status to reflect the actual validation mode and outcome.

Useful? React with 👍 / 👎.

@Pigbibi

Pigbibi commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Closing this accumulated remediation branch after repeated semantic review findings and contract arbitration. The P0 work will be re-sliced from current main into smaller vertical PRs with isolated contracts and tests. No code from this branch is being merged as-is.

@Pigbibi Pigbibi closed this Jul 13, 2026
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.

1 participant