alpha-engine-lib 0.32.0__py3-none-any.whl

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 (40) hide show
  1. alpha_engine_lib/__init__.py +3 -0
  2. alpha_engine_lib/agent_schemas.py +663 -0
  3. alpha_engine_lib/alerts.py +576 -0
  4. alpha_engine_lib/arcticdb.py +340 -0
  5. alpha_engine_lib/collector_results.py +69 -0
  6. alpha_engine_lib/cost.py +665 -0
  7. alpha_engine_lib/dates.py +273 -0
  8. alpha_engine_lib/decision_capture.py +462 -0
  9. alpha_engine_lib/ec2_spot.py +363 -0
  10. alpha_engine_lib/email_sender.py +206 -0
  11. alpha_engine_lib/eval_artifacts.py +361 -0
  12. alpha_engine_lib/logging.py +303 -0
  13. alpha_engine_lib/model_pricing.yaml +73 -0
  14. alpha_engine_lib/pillars.py +756 -0
  15. alpha_engine_lib/pipeline_status/__init__.py +70 -0
  16. alpha_engine_lib/pipeline_status/read.py +541 -0
  17. alpha_engine_lib/pipeline_status/registry.py +368 -0
  18. alpha_engine_lib/pipeline_status/templates.py +120 -0
  19. alpha_engine_lib/preflight.py +444 -0
  20. alpha_engine_lib/rag/__init__.py +39 -0
  21. alpha_engine_lib/rag/db.py +96 -0
  22. alpha_engine_lib/rag/embeddings.py +63 -0
  23. alpha_engine_lib/rag/migrations/0001_content_tsv.sql +39 -0
  24. alpha_engine_lib/rag/rerank.py +377 -0
  25. alpha_engine_lib/rag/retrieval.py +465 -0
  26. alpha_engine_lib/rag/schema.sql +65 -0
  27. alpha_engine_lib/reconcile.py +203 -0
  28. alpha_engine_lib/secrets.py +186 -0
  29. alpha_engine_lib/sources/__init__.py +35 -0
  30. alpha_engine_lib/sources/protocols.py +227 -0
  31. alpha_engine_lib/ssm_log_capture.py +274 -0
  32. alpha_engine_lib/telegram.py +165 -0
  33. alpha_engine_lib/trading_calendar.py +236 -0
  34. alpha_engine_lib/transparency.py +746 -0
  35. alpha_engine_lib/transparency_inventory.yaml +260 -0
  36. alpha_engine_lib/universe.py +83 -0
  37. alpha_engine_lib-0.32.0.dist-info/METADATA +217 -0
  38. alpha_engine_lib-0.32.0.dist-info/RECORD +40 -0
  39. alpha_engine_lib-0.32.0.dist-info/WHEEL +5 -0
  40. alpha_engine_lib-0.32.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,368 @@
1
+ """SF-state → archive-page registry + substantive-state filter primitives.
2
+
3
+ This module is the single source of truth for two cross-consumer questions:
4
+
5
+ 1. **Which SF states are substantive?** — the `Wait*` polling companions and
6
+ bare `Pass` / `Choice` / `Succeed` plumbing should not appear as their
7
+ own rows on the operator console; they're internal control flow. The
8
+ :data:`SUBSTANTIVE_RESOURCES` set + :data:`WAIT_GROUPING` map define
9
+ the filter.
10
+
11
+ 2. **Where does each state's persisted artifact live on the dashboard?** —
12
+ each substantive Task state either produces an artifact that has a
13
+ dedicated archive page (deep-link target) OR it's substrate-only. Per
14
+ ``feedback_no_silent_fails`` the registry never returns a generic "no
15
+ artifact" placeholder — substrate-only states carry an explicit
16
+ :class:`ArtifactReason` string the page renders verbatim.
17
+
18
+ The registry is materialized as a flat dict-of-dataclasses rather than a
19
+ walked-from-SF-JSON projection because (a) the SF JSONs live in
20
+ ``alpha-engine-data`` (cross-repo coupling we want to avoid at the lib
21
+ layer), and (b) the operator-meaningful labels + page slugs are editorial
22
+ choices that don't belong in the SF JSON anyway. A CI test in the
23
+ consuming repo (alpha-engine-dashboard or alpha-engine-data) asserts every
24
+ substantive Task state in the live SF JSONs has a registry entry; that's
25
+ how the two stay in sync without a runtime coupling.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from typing import Final, Optional
32
+
33
+
34
+ # ── Substantive-state filtering (§3.2 of the plan doc) ────────────────────
35
+
36
+
37
+ SUBSTANTIVE_RESOURCES: Final[frozenset[str]] = frozenset(
38
+ {
39
+ # Lambda invokes
40
+ "arn:aws:states:::lambda:invoke",
41
+ # SSM sendCommand (EC2 spot + trading instance commands)
42
+ "arn:aws:states:::aws-sdk:ssm:sendCommand",
43
+ # SNS publish (terminal-state emails — kept substantive so the
44
+ # console shows whether the success/failure email actually fired)
45
+ "arn:aws:states:::sns:publish",
46
+ # EC2 lifecycle (StartExecutorEC2 + StopTradingInstance + ForceStopInstance)
47
+ "arn:aws:states:::aws-sdk:ec2:startInstances",
48
+ "arn:aws:states:::aws-sdk:ec2:stopInstances",
49
+ }
50
+ )
51
+
52
+
53
+ # Every ``Wait*`` state in the SF JSONs is the polling companion to a parent
54
+ # ``sendCommand`` Task — the parent fires the SSM command and returns
55
+ # instantly; the wait state polls ``getCommandInvocation`` until terminal.
56
+ # For console rendering we want one row per logical step, durations measured
57
+ # parent_entry → wait_exit (see ``read._materialize_tasks`` for the math).
58
+ #
59
+ # This map is intentionally exhaustive (every Wait* state across all 3 SF
60
+ # JSONs) so the read layer can absorb wait companions without a runtime
61
+ # fallback. New Wait* states added in future SF edits must be added here AND
62
+ # to the registry below; the CI test (planned in dashboard Phase 2) asserts
63
+ # this round-trip.
64
+ WAIT_GROUPING: Final[dict[str, str]] = {
65
+ # Saturday SF
66
+ "WaitForMorningEnrich": "MorningEnrich",
67
+ "WaitForDataPhase1": "DataPhase1",
68
+ "WaitForRAGIngestion": "RAGIngestion",
69
+ "WaitForPredictorTraining": "PredictorTraining",
70
+ "WaitForBacktester": "Backtester",
71
+ "WaitForParity": "Parity",
72
+ "WaitForEvaluator": "Evaluator",
73
+ "WaitForSaturdayHealthCheck": "SaturdayHealthCheck",
74
+ "WaitForWeeklySubstrateHealthCheck": "WeeklySubstrateHealthCheck",
75
+ # Weekday SF
76
+ "WaitForMorningPlanner": "RunMorningPlanner",
77
+ "WaitForTradingDayCheck": "CheckTradingDay",
78
+ "WaitForInstanceReady": "StartExecutorEC2",
79
+ # Note: weekday SF's MorningEnrich shares its WaitForMorningEnrich with
80
+ # the Saturday map above (same state name). Lookup-by-name is OK because
81
+ # the parent name is the same in both SFs.
82
+ # EOD SF
83
+ "WaitForPostMarketData": "PostMarketData",
84
+ "WaitForCaptureSnapshot": "CaptureSnapshot",
85
+ "WaitForEOD": "EODReconcile",
86
+ "WaitForDailySubstrateHealthCheck": "DailySubstrateHealthCheck",
87
+ }
88
+
89
+
90
+ # ── Pretty-label registry (mirrors sf-telegram-notifier verbatim) ─────────
91
+
92
+
93
+ PIPELINE_LABELS: Final[dict[str, str]] = {
94
+ "alpha-engine-saturday-pipeline": "Saturday SF",
95
+ "alpha-engine-weekday-pipeline": "Weekday SF",
96
+ "alpha-engine-eod-pipeline": "EOD SF",
97
+ }
98
+
99
+
100
+ # ── Artifact registry types ───────────────────────────────────────────────
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class ArchivePageRef:
105
+ """Deep-link target for a substantive Task state that produces an
106
+ operator-readable artifact.
107
+
108
+ The ``page`` slug is the dashboard page module name (e.g.
109
+ ``"19_EOD_Reconcile_Archive"`` — corresponds to
110
+ ``alpha-engine-dashboard/pages/19_EOD_Reconcile_Archive.py``). The
111
+ dashboard consumer constructs the full URL from its base host +
112
+ page slug at render time; the lib does not bake URL hosts because the
113
+ same page is reachable at ``console.nousergon.ai`` (private) and may
114
+ or may not be reachable at ``live.nousergon.ai`` (public) depending
115
+ on the page.
116
+
117
+ ``artifact_label`` is the human-readable label for the deep-link cell
118
+ on page 25 — e.g. "Morning briefing" rather than the bare page slug.
119
+ """
120
+
121
+ page: str
122
+ artifact_label: str
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class ArtifactReason:
127
+ """Explicit non-generic reason a substantive Task state has no archive
128
+ page deep-link.
129
+
130
+ Per ``feedback_no_silent_fails`` — substrate-only states must surface
131
+ a specific reason ("Substrate refresh; no per-run artifact"), never
132
+ a generic "no artifact" placeholder. The reason text is what the
133
+ page 25 cell renders.
134
+ """
135
+
136
+ reason: str
137
+
138
+
139
+ # Type alias for the registry value — either a deep-link or an explicit reason.
140
+ RegistryEntry = "ArchivePageRef | ArtifactReason"
141
+
142
+
143
+ # ── The registry ──────────────────────────────────────────────────────────
144
+
145
+
146
+ # Every substantive Task state across the 3 SF JSONs maps to either an
147
+ # ArchivePageRef (operator-readable artifact has a dedicated page) or an
148
+ # ArtifactReason (substrate-only — explicit reason rendered verbatim).
149
+ #
150
+ # Sourced from plan doc §2.1 inventory + a `jq` walk of all 3 SF JSONs
151
+ # (Saturday 89 / Weekday 36 / EOD 21 total states; nested Parallel branches
152
+ # walked). Reviewed against ROADMAP L3050 + the post-2026-05-15
153
+ # 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) ──────────────────────────
156
+ "MorningEnrich": ArtifactReason(
157
+ "Daily OHLCV write to predictor/daily_closes/{date}.parquet; "
158
+ "no per-run rendered artifact — substrate for downstream stages."
159
+ ),
160
+ "DataPhase1": ArtifactReason(
161
+ "Bulk weekly write to predictor/price_cache/, archive/macro/, "
162
+ "ArcticDB universe library; no per-run rendered artifact — "
163
+ "substrate refresh."
164
+ ),
165
+ "RAGIngestion": ArtifactReason(
166
+ "SEC/8-K/earnings/theses corpus refresh in rag/corpus/; "
167
+ "substrate-only — consumed at Research time."
168
+ ),
169
+ "RegimeSubstrate": ArchivePageRef(
170
+ page="15_Regime",
171
+ artifact_label="Regime substrate",
172
+ ),
173
+ "RegimeRetrospectiveEval": ArchivePageRef(
174
+ page="15_Regime",
175
+ artifact_label="Regime retrospective eval",
176
+ ),
177
+ "Research": ArchivePageRef(
178
+ page="17_Research_Briefing_Archive",
179
+ artifact_label="Morning research briefing",
180
+ ),
181
+ "DataPhase2": ArtifactReason(
182
+ "Alt-data + fundamentals refresh; substrate-only, no per-run "
183
+ "rendered artifact."
184
+ ),
185
+ "EvalJudgeSubmitFirstSaturday": ArchivePageRef(
186
+ page="8_Eval_Quality",
187
+ artifact_label="Eval judge (first Saturday batch)",
188
+ ),
189
+ "EvalJudgeSubmitWeekly": ArchivePageRef(
190
+ page="8_Eval_Quality",
191
+ artifact_label="Eval judge (weekly batch)",
192
+ ),
193
+ "EvalJudgePoll": ArtifactReason(
194
+ "Polling state for the EvalJudge batch job; no per-run artifact — "
195
+ "see EvalJudgeProcess for the materialized rubric output."
196
+ ),
197
+ "EvalJudgeProcess": ArchivePageRef(
198
+ page="8_Eval_Quality",
199
+ artifact_label="Eval judge processed rubrics",
200
+ ),
201
+ "EvalRollingMean": ArchivePageRef(
202
+ page="8_Eval_Quality",
203
+ artifact_label="Eval 4-week rolling mean",
204
+ ),
205
+ "RationaleClustering": ArtifactReason(
206
+ "Rationale cluster artifact written to S3; no dedicated page yet "
207
+ "(P3 follow-up — backlog)."
208
+ ),
209
+ "ReplayConcordance": ArtifactReason(
210
+ "Concordance metric written to backtest/{date}/; surfaced inline "
211
+ "in Backtester evaluator report (page 21)."
212
+ ),
213
+ "Counterfactual": ArtifactReason(
214
+ "Counterfactual artifact written to backtest/{date}/; surfaced "
215
+ "inline in Backtester evaluator report (page 21)."
216
+ ),
217
+ "PredictorTraining": ArchivePageRef(
218
+ page="20_Predictor_Training_Archive",
219
+ artifact_label="Predictor training summary",
220
+ ),
221
+ "Backtester": ArchivePageRef(
222
+ page="21_Backtester_Evaluator_Archive",
223
+ artifact_label="Backtester consolidated report",
224
+ ),
225
+ "Parity": ArchivePageRef(
226
+ page="3_Analysis",
227
+ artifact_label="Parity replay diff",
228
+ ),
229
+ "Evaluator": ArchivePageRef(
230
+ page="21_Backtester_Evaluator_Archive",
231
+ artifact_label="Backtester evaluator report",
232
+ ),
233
+ "DriftDetection": ArchivePageRef(
234
+ page="4_System_Health",
235
+ artifact_label="SF-vs-CFN drift report",
236
+ ),
237
+ "SaturdayHealthCheck": ArchivePageRef(
238
+ page="4_System_Health",
239
+ artifact_label="Saturday per-repo health check",
240
+ ),
241
+ "WeeklySubstrateHealthCheck": ArchivePageRef(
242
+ page="4_System_Health",
243
+ artifact_label="Weekly substrate health check",
244
+ ),
245
+ "NotifyComplete": ArtifactReason(
246
+ "Terminal success SNS publish to alpha-engine-alerts; "
247
+ "no persisted artifact (the email IS the surface)."
248
+ ),
249
+ "NotifyShellRunComplete": ArtifactReason(
250
+ "Friday-PM shell-run dry-pass terminal SNS publish; "
251
+ "no persisted artifact (the email IS the surface)."
252
+ ),
253
+ "HandleFailure": ArtifactReason(
254
+ "Terminal failure SNS publish to alpha-engine-alerts; "
255
+ "no persisted artifact (the email IS the surface)."
256
+ ),
257
+ "PublishResearchFailureImmediate": ArtifactReason(
258
+ "Early-signal SNS publish fired the moment the Research branch "
259
+ "fails inside ResearchPredictorParallel — BEFORE the sibling "
260
+ "PredictorTraining branch completes its work and the parallel "
261
+ "aggregation joins. No persisted artifact (the email IS the "
262
+ "surface). Salvage-at-join semantics preserved: the branch still "
263
+ "terminates via BranchAFailed Pass and the SF fails at "
264
+ "CheckBranchOutcomes."
265
+ ),
266
+ "PublishPredictorFailureImmediate": ArtifactReason(
267
+ "Early-signal SNS publish fired the moment the PredictorTraining "
268
+ "branch fails inside ResearchPredictorParallel — BEFORE the "
269
+ "sibling Research branch's eval-judge / RollingMean / "
270
+ "Counterfactual chain completes. No persisted artifact (the "
271
+ "email IS the surface). Salvage-at-join semantics preserved: "
272
+ "the branch still terminates via BranchBFailed Pass and the SF "
273
+ "fails at CheckBranchOutcomes."
274
+ ),
275
+ # ── Weekday SF (13 substantive Task steps) ───────────────────────────
276
+ "DeployDriftCheck": ArchivePageRef(
277
+ page="4_System_Health",
278
+ artifact_label="Deploy-drift assertions",
279
+ ),
280
+ "StartExecutorEC2": ArtifactReason(
281
+ "EC2 startInstances on the trading instance; no artifact — "
282
+ "operational only."
283
+ ),
284
+ "DescribeInstanceInfo": ArtifactReason(
285
+ "Boot diagnostic call against the trading instance; "
286
+ "no artifact — operational only."
287
+ ),
288
+ "CheckTradingDay": ArtifactReason(
289
+ "NYSE-holiday gate via SSM command; no artifact — gate outcome "
290
+ "is encoded in the SF branch taken."
291
+ ),
292
+ "NotifyHolidaySkip": ArtifactReason(
293
+ "Holiday-skip SNS publish; no persisted artifact (the email IS "
294
+ "the surface)."
295
+ ),
296
+ "StopExecutorOnHoliday": ArtifactReason(
297
+ "EC2 stopInstances on the trading instance after a holiday-skip; "
298
+ "no artifact — operational only."
299
+ ),
300
+ "TradingDayCheckFailed": ArtifactReason(
301
+ "SF Pass state recording a holiday-skip outcome; no artifact."
302
+ ),
303
+ # MorningEnrich (weekday) — same state name as Saturday; same entry above wins.
304
+ "PredictorInference": ArchivePageRef(
305
+ page="18_Predictor_Briefing_Archive",
306
+ artifact_label="Predictor morning briefing",
307
+ ),
308
+ "CheckPredictorCoverage": ArtifactReason(
309
+ "Coverage-gate Lambda; outcome encoded in the SF branch taken — "
310
+ "see PredictorHealthCheck for any persisted health JSON."
311
+ ),
312
+ "ReinvokePredictor": ArtifactReason(
313
+ "Re-invocation Lambda when CheckPredictorCoverage finds a gap; "
314
+ "no per-run artifact — replaces the PredictorInference output."
315
+ ),
316
+ "RecheckCoverage": ArtifactReason(
317
+ "Second coverage-gate Lambda after ReinvokePredictor; outcome "
318
+ "encoded in the SF branch taken."
319
+ ),
320
+ "PredictorHealthCheck": ArchivePageRef(
321
+ page="4_System_Health",
322
+ artifact_label="Predictor health check",
323
+ ),
324
+ "RunMorningPlanner": ArchivePageRef(
325
+ page="16_Order_Book_Rationale",
326
+ artifact_label="Order book + rationale",
327
+ ),
328
+ "RunDaemon": ArchivePageRef(
329
+ page="22_Intraday_Surveillance",
330
+ artifact_label="Intraday surveillance (daemon)",
331
+ ),
332
+ # ── EOD SF (5 substantive Task steps) ────────────────────────────────
333
+ "PostMarketData": ArtifactReason(
334
+ "Polygon T+1 daily aggregate write to predictor/daily_closes/; "
335
+ "substrate-only — consumed by EODReconcile."
336
+ ),
337
+ "CaptureSnapshot": ArchivePageRef(
338
+ page="1_Portfolio",
339
+ artifact_label="NAV + positions snapshot",
340
+ ),
341
+ "EODReconcile": ArchivePageRef(
342
+ page="19_EOD_Reconcile_Archive",
343
+ artifact_label="EOD reconcile briefing",
344
+ ),
345
+ "DailySubstrateHealthCheck": ArchivePageRef(
346
+ page="4_System_Health",
347
+ artifact_label="Daily substrate health check",
348
+ ),
349
+ "StopTradingInstance": ArtifactReason(
350
+ "EC2 stopInstances on the trading instance; no artifact — "
351
+ "operational only."
352
+ ),
353
+ "ForceStopInstance": ArtifactReason(
354
+ "EC2 stopInstances fallback on a non-graceful EOD; no artifact — "
355
+ "operational only."
356
+ ),
357
+ }
358
+
359
+
360
+ def lookup_registry(state_name: str) -> Optional["ArchivePageRef | ArtifactReason"]:
361
+ """Return the registry entry for ``state_name`` (None if absent).
362
+
363
+ ``None`` here signals "this state is not in the registry" — distinct
364
+ from :class:`ArtifactReason` ("registered as substrate-only with this
365
+ reason"). The dashboard consumer should treat ``None`` as a CI-time
366
+ test failure (registry drift); it should NEVER render in production.
367
+ """
368
+ return STATE_TO_ARCHIVE_PAGE.get(state_name)
@@ -0,0 +1,120 @@
1
+ """Verbatim Python parity for the SF JSON ``States.Format`` message templates.
2
+
3
+ The Step Function JSON files (touched in Phase 3 of the revamp) inline
4
+ the success + failure email message bodies via ``States.Format``. These
5
+ Python functions render the SAME bodies — used by:
6
+
7
+ 1. Unit tests that assert the SF JSON template's substituted output equals
8
+ the Python rendering byte-for-byte (parity guard against the two
9
+ drifting).
10
+ 2. Future non-SF consumers (Slack subscriber, ``ae pipeline status`` CLI)
11
+ that want to render the same body without re-implementing the template.
12
+
13
+ The functions never raise — bad inputs render best-effort placeholder
14
+ strings rather than failing the email path, mirroring the SF JSON's
15
+ behavior (``States.Format`` substitutes ``$.field`` even if absent).
16
+
17
+ **Console URL**: the dashboard host is hardcoded here as the lib-canonical
18
+ deep-link base. If the dashboard host changes (e.g., new vanity domain),
19
+ edit :data:`CONSOLE_BASE_URL` + the SF JSON templates in lockstep — the
20
+ parity tests catch the drift.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Final
26
+
27
+ # Hardcoded console base URL. The dashboard is reachable at three hosts:
28
+ #
29
+ # - console.nousergon.ai — private (Cloudflare Access gated)
30
+ # - live.nousergon.ai — public Streamlit page (subset of pages)
31
+ # - <ec2-host>:8501 — direct (debug only)
32
+ #
33
+ # Page 25 (Pipeline Status) lives on the PRIVATE console — operator-only
34
+ # surface. The success / failure emails are operator-only too, so
35
+ # console.nousergon.ai is the right deep-link target.
36
+ CONSOLE_BASE_URL: Final[str] = "https://console.nousergon.ai"
37
+ PIPELINE_STATUS_PAGE: Final[str] = "Pipeline_Status"
38
+
39
+ # Cause truncation — kept in lockstep with sf-telegram-notifier
40
+ # (alpha-engine-data/infrastructure/lambdas/sf-telegram-notifier/index.py L69).
41
+ # The SF JSON's ``States.Format`` doesn't truncate; the truncation here is
42
+ # only meaningful when a Python consumer (Slack, CLI) renders. The SF JSON
43
+ # templates in Phase 3 will use ``States.StringSplit`` + the first N chars
44
+ # to approximate.
45
+ _CAUSE_MAX_CHARS = 280
46
+
47
+
48
+ def _console_link(execution_arn: str) -> str:
49
+ """Return the page-25 deep-link for a given execution ARN.
50
+
51
+ Streamlit's query-string convention is ``?<key>=<value>``; the page
52
+ consumes ``?run=<arn>`` and filters its rendered tables to that
53
+ execution.
54
+ """
55
+ # Streamlit query-string handling tolerates colons + slashes in the
56
+ # value, so no URL encoding is needed for the ARN. Keep this simple
57
+ # so the SF JSON ``States.Format`` template renders the same string.
58
+ return f"{CONSOLE_BASE_URL}/{PIPELINE_STATUS_PAGE}?run={execution_arn}"
59
+
60
+
61
+ def format_success_message(
62
+ *,
63
+ pretty_label: str,
64
+ execution_arn: str,
65
+ ) -> str:
66
+ """Render the 2-line success email body.
67
+
68
+ Body shape (verbatim — the SF JSON ``States.Format`` template renders
69
+ this same string):
70
+
71
+ {pretty_label} SUCCEEDED
72
+ Console: {console_link}
73
+
74
+ Parameters
75
+ ----------
76
+ pretty_label:
77
+ Human-readable SF label, e.g. ``"Saturday SF"``. Sourced from
78
+ :data:`alpha_engine_lib.pipeline_status.registry.PIPELINE_LABELS`.
79
+ execution_arn:
80
+ Full SF execution ARN. Page 25 filters its tables to this ARN
81
+ via the ``?run=`` query string.
82
+ """
83
+ link = _console_link(execution_arn)
84
+ return f"{pretty_label} SUCCEEDED\nConsole: {link}"
85
+
86
+
87
+ def format_failure_message(
88
+ *,
89
+ pretty_label: str,
90
+ execution_arn: str,
91
+ failing_state: str,
92
+ cause: str,
93
+ ) -> str:
94
+ """Render the 4-line failure email body.
95
+
96
+ Body shape (verbatim):
97
+
98
+ {pretty_label} FAILED at state {failing_state}
99
+ Console: {console_link}
100
+
101
+ Cause (first 280 chars):
102
+ {truncated_cause}
103
+
104
+ The Python rendering truncates ``cause`` at :data:`_CAUSE_MAX_CHARS`
105
+ chars with an ellipsis suffix on overflow. The SF JSON template
106
+ approximates via ``States.StringSplit`` + the first N chars; the
107
+ Phase-3 parity test asserts the two render the same string for
108
+ representative cause values.
109
+ """
110
+ link = _console_link(execution_arn)
111
+ snippet = (cause or "").strip()
112
+ if len(snippet) > _CAUSE_MAX_CHARS:
113
+ snippet = snippet[: _CAUSE_MAX_CHARS - 1] + "…"
114
+ return (
115
+ f"{pretty_label} FAILED at state {failing_state}\n"
116
+ f"Console: {link}\n"
117
+ f"\n"
118
+ f"Cause (first {_CAUSE_MAX_CHARS} chars):\n"
119
+ f"{snippet}"
120
+ )