alpha-engine-lib 0.35.0__tar.gz → 0.35.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/PKG-INFO +1 -1
  2. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/pyproject.toml +1 -1
  3. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/__init__.py +1 -1
  4. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/pipeline_status/read.py +16 -2
  5. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/pipeline_status/registry.py +64 -36
  6. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib.egg-info/PKG-INFO +1 -1
  7. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_pipeline_status_read.py +73 -0
  8. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_pipeline_status_registry.py +15 -5
  9. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/README.md +0 -0
  10. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/setup.cfg +0 -0
  11. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/agent_schemas.py +0 -0
  12. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/alerts.py +0 -0
  13. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/arcticdb.py +0 -0
  14. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/collector_results.py +0 -0
  15. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/cost.py +0 -0
  16. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/dates.py +0 -0
  17. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/decision_capture.py +0 -0
  18. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/ec2_spot.py +0 -0
  19. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/email_sender.py +0 -0
  20. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/eval_artifacts.py +0 -0
  21. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/logging.py +0 -0
  22. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/model_pricing.yaml +0 -0
  23. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/pillars.py +0 -0
  24. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
  25. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
  26. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/preflight.py +0 -0
  27. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/rag/__init__.py +0 -0
  28. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/rag/db.py +0 -0
  29. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/rag/embeddings.py +0 -0
  30. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
  31. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/rag/rerank.py +0 -0
  32. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/rag/retrieval.py +0 -0
  33. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/rag/schema.sql +0 -0
  34. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/reconcile.py +0 -0
  35. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/secrets.py +0 -0
  36. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/sources/__init__.py +0 -0
  37. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/sources/protocols.py +0 -0
  38. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
  39. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
  40. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/telegram.py +0 -0
  41. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/trading_calendar.py +0 -0
  42. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/transparency.py +0 -0
  43. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
  44. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib/universe.py +0 -0
  45. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib.egg-info/SOURCES.txt +0 -0
  46. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
  47. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib.egg-info/requires.txt +0 -0
  48. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
  49. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_agent_schemas.py +0 -0
  50. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_alerts.py +0 -0
  51. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_arcticdb.py +0 -0
  52. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_collector_results.py +0 -0
  53. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_cost.py +0 -0
  54. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_dates.py +0 -0
  55. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_decision_capture.py +0 -0
  56. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_ec2_spot.py +0 -0
  57. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_email_sender.py +0 -0
  58. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_eval_artifacts.py +0 -0
  59. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_logging.py +0 -0
  60. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_pillars.py +0 -0
  61. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_pipeline_status_templates.py +0 -0
  62. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_preflight.py +0 -0
  63. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_rag.py +0 -0
  64. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_rag_rerank.py +0 -0
  65. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_rag_retrieval_hybrid.py +0 -0
  66. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_reconcile.py +0 -0
  67. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_secrets.py +0 -0
  68. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_sources_protocols.py +0 -0
  69. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_ssm_dispatcher.py +0 -0
  70. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_ssm_log_capture.py +0 -0
  71. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_telegram.py +0 -0
  72. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_trading_calendar.py +0 -0
  73. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_transparency.py +0 -0
  74. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_universe.py +0 -0
  75. {alpha_engine_lib-0.35.0 → alpha_engine_lib-0.35.1}/tests/test_version_pin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.35.0
3
+ Version: 0.35.1
4
4
  Summary: Shared utilities for the Alpha Engine modules: preflight, structured logging with secret-redaction, ArcticDB universe access, NYSE-calendar dates + freshness predicates, decision capture, cost telemetry, RAG, agent output schemas, SSM-backed secrets, Telegram alerts + SNS fan-out, EC2 spot-launch resilience, SSM log-capture chokepoint, SSM send-command + poll chokepoint, and Step-Functions execution-state projection. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpha-engine-lib"
7
- version = "0.35.0"
7
+ version = "0.35.1"
8
8
  description = "Shared utilities for the Alpha Engine modules: preflight, structured logging with secret-redaction, ArcticDB universe access, NYSE-calendar dates + freshness predicates, decision capture, cost telemetry, RAG, agent output schemas, SSM-backed secrets, Telegram alerts + SNS fan-out, EC2 spot-launch resilience, SSM log-capture chokepoint, SSM send-command + poll chokepoint, and Step-Functions execution-state projection. Full surface documented in README."
9
9
  readme = "README.md"
10
10
  # EC2 still runs Python 3.9 on the always-on micro instance (boto3 drops
@@ -1,3 +1,3 @@
1
1
  """alpha-engine-lib — shared utilities for Alpha Engine modules."""
2
2
 
3
- __version__ = "0.35.0"
3
+ __version__ = "0.35.1"
@@ -35,7 +35,7 @@ import logging
35
35
  from dataclasses import dataclass
36
36
  from datetime import datetime, timezone
37
37
  from enum import Enum
38
- from typing import TYPE_CHECKING, Any, Optional
38
+ from typing import TYPE_CHECKING, Annotated, Any, Optional, Union
39
39
 
40
40
  from pydantic import BaseModel, ConfigDict, Field
41
41
 
@@ -160,7 +160,21 @@ class TaskRow(BaseModel):
160
160
  # substrate-only reason). ``None`` here means "state name not in the
161
161
  # registry" and is a CI-time bug — the consumer should treat it as a
162
162
  # registry-drift signal, not a renderable placeholder.
163
- archive: Optional[Any] = None # ArchivePageRef | ArtifactReason | None
163
+ #
164
+ # The Annotated[Union[...], Field(discriminator="kind")] form is the
165
+ # SOTA tagged-union pattern for Pydantic V2: ``model_dump(mode="json")``
166
+ # serializes the ``kind`` field on each variant, and ``model_validate``
167
+ # routes dict input to the right class via that tag. Prior to this
168
+ # tagging, ``archive`` was typed ``Optional[Any]``, so a JSON round-trip
169
+ # left it as a plain dict — page-25's ``isinstance`` checks then
170
+ # misfired and rendered "Registry drift" for every state, even those
171
+ # with valid registry entries.
172
+ archive: Optional[
173
+ Annotated[
174
+ Union[ArchivePageRef, ArtifactReason],
175
+ Field(discriminator="kind"),
176
+ ]
177
+ ] = None
164
178
  failure_cause: Optional[str] = None # populated only when status == FAILED
165
179
 
166
180
 
@@ -27,8 +27,9 @@ how the two stay in sync without a runtime coupling.
27
27
 
28
28
  from __future__ import annotations
29
29
 
30
- from dataclasses import dataclass
31
- from typing import Final, Optional
30
+ from typing import Annotated, Final, Literal, Optional, Union
31
+
32
+ from pydantic import BaseModel, ConfigDict, Field
32
33
 
33
34
 
34
35
  # ── Substantive-state filtering (§3.2 of the plan doc) ────────────────────
@@ -100,8 +101,7 @@ PIPELINE_LABELS: Final[dict[str, str]] = {
100
101
  # ── Artifact registry types ───────────────────────────────────────────────
101
102
 
102
103
 
103
- @dataclass(frozen=True)
104
- class ArchivePageRef:
104
+ class ArchivePageRef(BaseModel):
105
105
  """Deep-link target for a substantive Task state that produces an
106
106
  operator-readable artifact.
107
107
 
@@ -116,14 +116,19 @@ class ArchivePageRef:
116
116
 
117
117
  ``artifact_label`` is the human-readable label for the deep-link cell
118
118
  on page 25 — e.g. "Morning briefing" rather than the bare page slug.
119
+
120
+ ``kind`` is the discriminator field for the tagged-union round-trip
121
+ in :class:`alpha_engine_lib.pipeline_status.read.TaskRow.archive`.
119
122
  """
120
123
 
124
+ model_config = ConfigDict(frozen=True, extra="forbid")
125
+
126
+ kind: Literal["archive_page_ref"] = "archive_page_ref"
121
127
  page: str
122
128
  artifact_label: str
123
129
 
124
130
 
125
- @dataclass(frozen=True)
126
- class ArtifactReason:
131
+ class ArtifactReason(BaseModel):
127
132
  """Explicit non-generic reason a substantive Task state has no archive
128
133
  page deep-link.
129
134
 
@@ -131,13 +136,26 @@ class ArtifactReason:
131
136
  a specific reason ("Substrate refresh; no per-run artifact"), never
132
137
  a generic "no artifact" placeholder. The reason text is what the
133
138
  page 25 cell renders.
139
+
140
+ ``kind`` is the discriminator field for the tagged-union round-trip
141
+ in :class:`alpha_engine_lib.pipeline_status.read.TaskRow.archive`.
134
142
  """
135
143
 
144
+ model_config = ConfigDict(frozen=True, extra="forbid")
145
+
146
+ kind: Literal["artifact_reason"] = "artifact_reason"
136
147
  reason: str
137
148
 
138
149
 
139
- # Type alias for the registry value either a deep-link or an explicit reason.
140
- RegistryEntry = "ArchivePageRef | ArtifactReason"
150
+ # Discriminated union for :class:`TaskRow.archive`Pydantic V2 routes
151
+ # dict input to the right variant via the ``kind`` tag, so
152
+ # ``model_dump(mode="json")`` → ``model_validate`` round-trips reconstruct
153
+ # the typed instance (instead of leaving the dict raw, which the page-25
154
+ # ``isinstance`` checks would mis-classify as registry drift).
155
+ RegistryEntry = Annotated[
156
+ Union[ArchivePageRef, ArtifactReason],
157
+ Field(discriminator="kind"),
158
+ ]
141
159
 
142
160
 
143
161
  # ── The registry ──────────────────────────────────────────────────────────
@@ -151,19 +169,29 @@ RegistryEntry = "ArchivePageRef | ArtifactReason"
151
169
  # (Saturday 89 / Weekday 36 / EOD 21 total states; nested Parallel branches
152
170
  # walked). Reviewed against ROADMAP L3050 + the post-2026-05-15
153
171
  # artifact-archive pages (dashboard #86: pages 16-22).
154
- STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
155
- # ── Saturday SF (23 substantive Task steps) ──────────────────────────
172
+ STATE_TO_ARCHIVE_PAGE: Final[dict[str, Union[ArchivePageRef, ArtifactReason]]] = {
173
+ # ── Saturday SF (24 substantive Task steps) ──────────────────────────
156
174
  "MorningEnrich": ArtifactReason(
157
- "Daily OHLCV write to predictor/daily_closes/{date}.parquet; "
175
+ reason="Daily OHLCV write to predictor/daily_closes/{date}.parquet; "
158
176
  "no per-run rendered artifact — substrate for downstream stages."
159
177
  ),
160
178
  "DataPhase1": ArtifactReason(
161
- "Bulk weekly write to predictor/price_cache/, archive/macro/, "
179
+ reason="Bulk weekly write to predictor/price_cache/, archive/macro/, "
162
180
  "ArcticDB universe library; no per-run rendered artifact — "
163
181
  "substrate refresh."
164
182
  ),
183
+ "Scanner": ArtifactReason(
184
+ reason="Standalone scanner Lambda (ROADMAP L1995 Phase 1-2, "
185
+ "alpha-engine-research #235): writes candidates.json for the "
186
+ "run_date as observe-only output, gated by "
187
+ "$.enable_standalone_scanner. No consumer reads it today (Phase "
188
+ "4 will flip RAG to read it; Phase 5 will flip Research). "
189
+ "Failure is non-blocking — the SF Catch routes forward to "
190
+ "CheckSkipRAGIngestion. Once Phase 4/5 lands, swap this entry "
191
+ "for an ArchivePageRef pointing at the scanner-candidates page."
192
+ ),
165
193
  "RAGIngestion": ArtifactReason(
166
- "SEC/8-K/earnings/theses corpus refresh in rag/corpus/; "
194
+ reason="SEC/8-K/earnings/theses corpus refresh in rag/corpus/; "
167
195
  "substrate-only — consumed at Research time."
168
196
  ),
169
197
  "RegimeSubstrate": ArchivePageRef(
@@ -179,7 +207,7 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
179
207
  artifact_label="Morning research briefing",
180
208
  ),
181
209
  "DataPhase2": ArtifactReason(
182
- "Alt-data + fundamentals refresh; substrate-only, no per-run "
210
+ reason="Alt-data + fundamentals refresh; substrate-only, no per-run "
183
211
  "rendered artifact."
184
212
  ),
185
213
  "EvalJudgeSubmitFirstSaturday": ArchivePageRef(
@@ -191,7 +219,7 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
191
219
  artifact_label="Eval judge (weekly batch)",
192
220
  ),
193
221
  "EvalJudgePoll": ArtifactReason(
194
- "Polling state for the EvalJudge batch job; no per-run artifact — "
222
+ reason="Polling state for the EvalJudge batch job; no per-run artifact — "
195
223
  "see EvalJudgeProcess for the materialized rubric output."
196
224
  ),
197
225
  "EvalJudgeProcess": ArchivePageRef(
@@ -203,15 +231,15 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
203
231
  artifact_label="Eval 4-week rolling mean",
204
232
  ),
205
233
  "RationaleClustering": ArtifactReason(
206
- "Rationale cluster artifact written to S3; no dedicated page yet "
234
+ reason="Rationale cluster artifact written to S3; no dedicated page yet "
207
235
  "(P3 follow-up — backlog)."
208
236
  ),
209
237
  "ReplayConcordance": ArtifactReason(
210
- "Concordance metric written to backtest/{date}/; surfaced inline "
238
+ reason="Concordance metric written to backtest/{date}/; surfaced inline "
211
239
  "in Backtester evaluator report (page 21)."
212
240
  ),
213
241
  "Counterfactual": ArtifactReason(
214
- "Counterfactual artifact written to backtest/{date}/; surfaced "
242
+ reason="Counterfactual artifact written to backtest/{date}/; surfaced "
215
243
  "inline in Backtester evaluator report (page 21)."
216
244
  ),
217
245
  "PredictorTraining": ArchivePageRef(
@@ -243,19 +271,19 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
243
271
  artifact_label="Weekly substrate health check",
244
272
  ),
245
273
  "NotifyComplete": ArtifactReason(
246
- "Terminal success SNS publish to alpha-engine-alerts; "
274
+ reason="Terminal success SNS publish to alpha-engine-alerts; "
247
275
  "no persisted artifact (the email IS the surface)."
248
276
  ),
249
277
  "NotifyShellRunComplete": ArtifactReason(
250
- "Friday-PM shell-run dry-pass terminal SNS publish; "
278
+ reason="Friday-PM shell-run dry-pass terminal SNS publish; "
251
279
  "no persisted artifact (the email IS the surface)."
252
280
  ),
253
281
  "HandleFailure": ArtifactReason(
254
- "Terminal failure SNS publish to alpha-engine-alerts; "
282
+ reason="Terminal failure SNS publish to alpha-engine-alerts; "
255
283
  "no persisted artifact (the email IS the surface)."
256
284
  ),
257
285
  "PublishResearchFailureImmediate": ArtifactReason(
258
- "Early-signal SNS publish fired the moment the Research branch "
286
+ reason="Early-signal SNS publish fired the moment the Research branch "
259
287
  "fails inside ResearchPredictorParallel — BEFORE the sibling "
260
288
  "PredictorTraining branch completes its work and the parallel "
261
289
  "aggregation joins. No persisted artifact (the email IS the "
@@ -264,7 +292,7 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
264
292
  "CheckBranchOutcomes."
265
293
  ),
266
294
  "PublishPredictorFailureImmediate": ArtifactReason(
267
- "Early-signal SNS publish fired the moment the PredictorTraining "
295
+ reason="Early-signal SNS publish fired the moment the PredictorTraining "
268
296
  "branch fails inside ResearchPredictorParallel — BEFORE the "
269
297
  "sibling Research branch's eval-judge / RollingMean / "
270
298
  "Counterfactual chain completes. No persisted artifact (the "
@@ -278,27 +306,27 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
278
306
  artifact_label="Deploy-drift assertions",
279
307
  ),
280
308
  "StartExecutorEC2": ArtifactReason(
281
- "EC2 startInstances on the trading instance; no artifact — "
309
+ reason="EC2 startInstances on the trading instance; no artifact — "
282
310
  "operational only."
283
311
  ),
284
312
  "DescribeInstanceInfo": ArtifactReason(
285
- "Boot diagnostic call against the trading instance; "
313
+ reason="Boot diagnostic call against the trading instance; "
286
314
  "no artifact — operational only."
287
315
  ),
288
316
  "CheckTradingDay": ArtifactReason(
289
- "NYSE-holiday gate via SSM command; no artifact — gate outcome "
317
+ reason="NYSE-holiday gate via SSM command; no artifact — gate outcome "
290
318
  "is encoded in the SF branch taken."
291
319
  ),
292
320
  "NotifyHolidaySkip": ArtifactReason(
293
- "Holiday-skip SNS publish; no persisted artifact (the email IS "
321
+ reason="Holiday-skip SNS publish; no persisted artifact (the email IS "
294
322
  "the surface)."
295
323
  ),
296
324
  "StopExecutorOnHoliday": ArtifactReason(
297
- "EC2 stopInstances on the trading instance after a holiday-skip; "
325
+ reason="EC2 stopInstances on the trading instance after a holiday-skip; "
298
326
  "no artifact — operational only."
299
327
  ),
300
328
  "TradingDayCheckFailed": ArtifactReason(
301
- "SF Pass state recording a holiday-skip outcome; no artifact."
329
+ reason="SF Pass state recording a holiday-skip outcome; no artifact."
302
330
  ),
303
331
  # MorningEnrich (weekday) — same state name as Saturday; same entry above wins.
304
332
  "PredictorInference": ArchivePageRef(
@@ -306,15 +334,15 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
306
334
  artifact_label="Predictor morning briefing",
307
335
  ),
308
336
  "CheckPredictorCoverage": ArtifactReason(
309
- "Coverage-gate Lambda; outcome encoded in the SF branch taken — "
337
+ reason="Coverage-gate Lambda; outcome encoded in the SF branch taken — "
310
338
  "see PredictorHealthCheck for any persisted health JSON."
311
339
  ),
312
340
  "ReinvokePredictor": ArtifactReason(
313
- "Re-invocation Lambda when CheckPredictorCoverage finds a gap; "
341
+ reason="Re-invocation Lambda when CheckPredictorCoverage finds a gap; "
314
342
  "no per-run artifact — replaces the PredictorInference output."
315
343
  ),
316
344
  "RecheckCoverage": ArtifactReason(
317
- "Second coverage-gate Lambda after ReinvokePredictor; outcome "
345
+ reason="Second coverage-gate Lambda after ReinvokePredictor; outcome "
318
346
  "encoded in the SF branch taken."
319
347
  ),
320
348
  "PredictorHealthCheck": ArchivePageRef(
@@ -331,7 +359,7 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
331
359
  ),
332
360
  # ── EOD SF (5 substantive Task steps) ────────────────────────────────
333
361
  "PostMarketData": ArtifactReason(
334
- "Polygon T+1 daily aggregate write to predictor/daily_closes/; "
362
+ reason="Polygon T+1 daily aggregate write to predictor/daily_closes/; "
335
363
  "substrate-only — consumed by EODReconcile."
336
364
  ),
337
365
  "CaptureSnapshot": ArchivePageRef(
@@ -347,17 +375,17 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, "ArchivePageRef | ArtifactReason"]] = {
347
375
  artifact_label="Daily substrate health check",
348
376
  ),
349
377
  "StopTradingInstance": ArtifactReason(
350
- "EC2 stopInstances on the trading instance; no artifact — "
378
+ reason="EC2 stopInstances on the trading instance; no artifact — "
351
379
  "operational only."
352
380
  ),
353
381
  "ForceStopInstance": ArtifactReason(
354
- "EC2 stopInstances fallback on a non-graceful EOD; no artifact — "
382
+ reason="EC2 stopInstances fallback on a non-graceful EOD; no artifact — "
355
383
  "operational only."
356
384
  ),
357
385
  }
358
386
 
359
387
 
360
- def lookup_registry(state_name: str) -> Optional["ArchivePageRef | ArtifactReason"]:
388
+ def lookup_registry(state_name: str) -> Optional[Union[ArchivePageRef, ArtifactReason]]:
361
389
  """Return the registry entry for ``state_name`` (None if absent).
362
390
 
363
391
  ``None`` here signals "this state is not in the registry" — distinct
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.35.0
3
+ Version: 0.35.1
4
4
  Summary: Shared utilities for the Alpha Engine modules: preflight, structured logging with secret-redaction, ArcticDB universe access, NYSE-calendar dates + freshness predicates, decision capture, cost telemetry, RAG, agent output schemas, SSM-backed secrets, Telegram alerts + SNS fan-out, EC2 spot-launch resilience, SSM log-capture chokepoint, SSM send-command + poll chokepoint, and Step-Functions execution-state projection. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -462,3 +462,76 @@ def test_region_from_arn_returns_none_for_malformed():
462
462
  assert _region_from_arn("not-an-arn") is None
463
463
  assert _region_from_arn("arn:aws:states") is None
464
464
  assert _region_from_arn("arn:aws:states::123:stateMachine:x") is None
465
+
466
+
467
+ # ── Archive-union JSON round-trip (regression for "registry drift" false positive) ──
468
+
469
+
470
+ def _entered_and_succeeded(name: str, t0: datetime, t1: datetime) -> list[dict]:
471
+ return [
472
+ _entered(name, t0),
473
+ {
474
+ "type": "TaskStateExited",
475
+ "timestamp": t1,
476
+ "stateExitedEventDetails": {"name": name},
477
+ },
478
+ ]
479
+
480
+
481
+ def test_task_row_archive_round_trips_through_json_for_archive_page_ref():
482
+ """The dashboard's st.cache_data wraps read_pipeline_state by doing
483
+ ``model_dump(mode="json")`` → cache → ``model_validate(dict)``. Before
484
+ this regression-guard, ``TaskRow.archive`` was typed ``Optional[Any]``,
485
+ so the JSON round-trip flattened ArchivePageRef instances to plain
486
+ dicts; page-25's ``isinstance(archive, ArchivePageRef)`` then misfired
487
+ and rendered ``⚠️ Registry drift`` for every state with a valid
488
+ registry entry. The discriminated-union typing
489
+ (``Annotated[Union[...], Field(discriminator='kind')]``) reconstructs
490
+ the typed instance on validate; this test guards the contract."""
491
+ t0 = datetime(2026, 5, 24, 9, 0, tzinfo=timezone.utc)
492
+ t1 = datetime(2026, 5, 24, 9, 5, tzinfo=timezone.utc)
493
+ client = _make_sfn_mock(
494
+ history_response={"events": _entered_and_succeeded("Research", t0, t1)}
495
+ )
496
+
497
+ run = read_pipeline_state(SATURDAY_ARN, client=client)
498
+ research_task = next(t for t in run.tasks if t.state_name == "Research")
499
+ assert isinstance(research_task.archive, ArchivePageRef)
500
+
501
+ # The actual code path that broke production: JSON round-trip.
502
+ round_tripped = PipelineRun.model_validate(run.model_dump(mode="json"))
503
+ round_tripped_task = next(
504
+ t for t in round_tripped.tasks if t.state_name == "Research"
505
+ )
506
+ assert isinstance(round_tripped_task.archive, ArchivePageRef), (
507
+ "TaskRow.archive must reconstruct as ArchivePageRef on JSON "
508
+ "round-trip — otherwise page 25's isinstance check falls through "
509
+ "to the registry-drift sentinel for every state."
510
+ )
511
+ assert round_tripped_task.archive.page == "17_Research_Briefing_Archive"
512
+
513
+
514
+ def test_task_row_archive_round_trips_through_json_for_artifact_reason():
515
+ """Mirrors the ArchivePageRef round-trip but for the ArtifactReason
516
+ variant (substrate-only states like NotifyComplete + Scanner). Both
517
+ variants must reconstruct correctly on JSON round-trip; the
518
+ discriminated union differentiates them via the ``kind`` field."""
519
+ t0 = datetime(2026, 5, 24, 9, 0, tzinfo=timezone.utc)
520
+ t1 = datetime(2026, 5, 24, 9, 0, 1, tzinfo=timezone.utc)
521
+ client = _make_sfn_mock(
522
+ history_response={"events": _entered_and_succeeded("NotifyComplete", t0, t1)}
523
+ )
524
+
525
+ run = read_pipeline_state(SATURDAY_ARN, client=client)
526
+ notify_task = next(t for t in run.tasks if t.state_name == "NotifyComplete")
527
+ assert isinstance(notify_task.archive, ArtifactReason)
528
+
529
+ round_tripped = PipelineRun.model_validate(run.model_dump(mode="json"))
530
+ round_tripped_task = next(
531
+ t for t in round_tripped.tasks if t.state_name == "NotifyComplete"
532
+ )
533
+ assert isinstance(round_tripped_task.archive, ArtifactReason), (
534
+ "TaskRow.archive must reconstruct as ArtifactReason on JSON "
535
+ "round-trip — same regression class as the ArchivePageRef test."
536
+ )
537
+ assert "Terminal success" in round_tripped_task.archive.reason
@@ -104,6 +104,7 @@ def test_registry_covers_known_saturday_substantive_states():
104
104
  required = {
105
105
  "MorningEnrich",
106
106
  "DataPhase1",
107
+ "Scanner",
107
108
  "RAGIngestion",
108
109
  "RegimeSubstrate",
109
110
  "Research",
@@ -116,6 +117,7 @@ def test_registry_covers_known_saturday_substantive_states():
116
117
  "Parity",
117
118
  "Evaluator",
118
119
  "DriftDetection",
120
+ "SaturdayHealthCheck",
119
121
  "WeeklySubstrateHealthCheck",
120
122
  "NotifyComplete",
121
123
  "HandleFailure",
@@ -166,15 +168,23 @@ def test_lookup_registry_returns_entry_for_known_state():
166
168
 
167
169
 
168
170
  def test_archive_page_ref_is_frozen():
169
- """ArchivePageRef + ArtifactReason are frozen dataclasses mutation
170
- is forbidden so consumers can rely on registry stability across
171
- a process lifetime."""
171
+ """ArchivePageRef + ArtifactReason are frozen Pydantic models
172
+ mutation is forbidden so consumers can rely on registry stability
173
+ across a process lifetime. Pydantic V2 raises ValidationError with
174
+ ``type='frozen_instance'`` on attribute set; older stdlib-dataclass
175
+ consumers would have seen TypeError / FrozenInstanceError, both
176
+ accepted here so the test survives the dataclass → BaseModel
177
+ transition without spurious churn."""
178
+ from pydantic import ValidationError
179
+
172
180
  ref = registry.ArchivePageRef(page="x", artifact_label="y")
173
- with pytest.raises((TypeError, AttributeError)):
181
+ with pytest.raises((TypeError, AttributeError, ValidationError)):
174
182
  ref.page = "z" # type: ignore[misc]
175
183
 
176
184
 
177
185
  def test_artifact_reason_is_frozen():
186
+ from pydantic import ValidationError
187
+
178
188
  reason = registry.ArtifactReason(reason="x")
179
- with pytest.raises((TypeError, AttributeError)):
189
+ with pytest.raises((TypeError, AttributeError, ValidationError)):
180
190
  reason.reason = "y" # type: ignore[misc]