alpha-engine-lib 0.43.0__tar.gz → 0.45.0__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 (82) hide show
  1. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/PKG-INFO +1 -1
  2. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/pyproject.toml +1 -1
  3. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/__init__.py +1 -1
  4. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/artifact_freshness.py +157 -0
  5. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/pipeline_status/registry.py +14 -0
  6. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib.egg-info/PKG-INFO +1 -1
  7. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_artifact_freshness.py +113 -0
  8. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/README.md +0 -0
  9. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/setup.cfg +0 -0
  10. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
  11. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/alerts.py +0 -0
  12. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/anthropic_payload.py +0 -0
  13. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/arcticdb.py +0 -0
  14. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/collector_results.py +0 -0
  15. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/cost.py +0 -0
  16. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/dates.py +0 -0
  17. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/decision_capture.py +0 -0
  18. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
  19. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/email_sender.py +0 -0
  20. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
  21. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/locks.py +0 -0
  22. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/logging.py +0 -0
  23. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
  24. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/pillars.py +0 -0
  25. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
  26. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
  27. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
  28. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/preflight.py +0 -0
  29. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
  30. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/rag/db.py +0 -0
  31. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
  32. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
  33. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
  34. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
  35. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
  36. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/reconcile.py +0 -0
  37. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/secrets.py +0 -0
  38. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
  39. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
  40. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
  41. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
  42. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/telegram.py +0 -0
  43. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
  44. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/transparency.py +0 -0
  45. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
  46. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib/universe.py +0 -0
  47. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +0 -0
  48. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
  49. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib.egg-info/requires.txt +0 -0
  50. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
  51. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_agent_schemas.py +0 -0
  52. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_alerts.py +0 -0
  53. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_anthropic_payload.py +0 -0
  54. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_arcticdb.py +0 -0
  55. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_collector_results.py +0 -0
  56. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_cost.py +0 -0
  57. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_dates.py +0 -0
  58. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_decision_capture.py +0 -0
  59. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_ec2_spot.py +0 -0
  60. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_email_sender.py +0 -0
  61. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_eval_artifacts.py +0 -0
  62. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_locks.py +0 -0
  63. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_logging.py +0 -0
  64. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_pillars.py +0 -0
  65. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_pipeline_status_read.py +0 -0
  66. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_pipeline_status_registry.py +0 -0
  67. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_pipeline_status_templates.py +0 -0
  68. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_preflight.py +0 -0
  69. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_rag.py +0 -0
  70. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_rag_rerank.py +0 -0
  71. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_rag_retrieval_hybrid.py +0 -0
  72. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_reconcile.py +0 -0
  73. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_secrets.py +0 -0
  74. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_sources_protocols.py +0 -0
  75. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_ssm_dispatcher.py +0 -0
  76. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_ssm_log_capture.py +0 -0
  77. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_telegram.py +0 -0
  78. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_trading_calendar.py +0 -0
  79. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_transparency.py +0 -0
  80. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_universe.py +0 -0
  81. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/tests/test_version_bump_workflow.py +0 -0
  82. {alpha_engine_lib-0.43.0 → alpha_engine_lib-0.45.0}/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.43.0
3
+ Version: 0.45.0
4
4
  Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. 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.43.0"
7
+ version = "0.45.0"
8
8
  description = "Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. 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.43.0"
3
+ __version__ = "0.45.0"
@@ -71,6 +71,7 @@ ships the freshness-monitor Lambda that wires the two together.
71
71
 
72
72
  from __future__ import annotations
73
73
 
74
+ from collections.abc import Iterable
74
75
  from dataclasses import dataclass, field
75
76
  from datetime import date, datetime, timedelta, timezone
76
77
  from typing import Any, Final, Literal
@@ -596,3 +597,159 @@ def _cycle_length_seconds(spec: ArtifactSpec) -> float:
596
597
  assert spec.interval_minutes is not None
597
598
  return spec.interval_minutes * 60
598
599
  raise ValueError(f"unknown cadence {spec.cadence!r}")
600
+
601
+
602
+ # ── Per-cycle completion rollup ───────────────────────────────────────────────
603
+
604
+
605
+ CycleState = Literal["complete", "incomplete", "indeterminate"]
606
+
607
+
608
+ @dataclass
609
+ class CycleCompletion:
610
+ """Per-cycle completion verdict — the artifact-union judgment.
611
+
612
+ Aggregates the per-artifact :class:`CheckResult` rows for one
613
+ execution cycle into a single verdict over the *required* set
614
+ (the ``severity="critical"`` rows). Answers the question the
615
+ raw orchestrator status cannot on a recovery-stitched run: *did
616
+ this cycle actually deliver every load-bearing artifact?*
617
+
618
+ Recovery substitution is already folded in upstream — a
619
+ canonical-missing artifact rescued by its ``recovery_key_template``
620
+ arrives here as ``state="fresh"``. So this rollup judges the
621
+ execution UNION without re-HEADing anything.
622
+
623
+ Attributes:
624
+ state: ``"complete"`` ⇒ every required artifact is present +
625
+ valid (``fresh``, or suppressed by ``grace_period``).
626
+ ``"incomplete"`` ⇒ at least one required artifact is
627
+ ``missing`` / ``stale`` (a real delivery gap).
628
+ ``"indeterminate"`` ⇒ no real gap, but at least one probe
629
+ ``probe_failed`` (the monitor itself is broken, so the
630
+ cycle can't be confirmed). A real gap outranks an
631
+ indeterminate probe.
632
+ complete: ``True`` iff ``state == "complete"``.
633
+ cycle_label: The cycle's window label (e.g. ``"2026-W22"``),
634
+ for reporting. Informational — the caller passes it.
635
+ n_required: Count of ``severity="critical"`` artifacts judged.
636
+ n_satisfied: Count present + valid (``fresh`` + ``grace_period``).
637
+ missing / stale / probe_failed / grace_period: ``artifact_id``
638
+ localization lists — which artifacts landed in each state.
639
+ reason: Human-readable summary; routed to the report surface.
640
+ """
641
+
642
+ state: CycleState
643
+ complete: bool
644
+ cycle_label: str | None = None
645
+ n_required: int = 0
646
+ n_satisfied: int = 0
647
+ missing: list[str] = field(default_factory=list)
648
+ stale: list[str] = field(default_factory=list)
649
+ probe_failed: list[str] = field(default_factory=list)
650
+ grace_period: list[str] = field(default_factory=list)
651
+ reason: str = ""
652
+
653
+
654
+ def cycle_completion(
655
+ spec_results: Iterable[tuple[ArtifactSpec, CheckResult]],
656
+ *,
657
+ cycle_label: str | None = None,
658
+ ) -> CycleCompletion:
659
+ """Roll per-artifact freshness results up into one cycle verdict.
660
+
661
+ ``cycle_completion(C) = ∀ required artifact a: present(a@C) ∧ valid(a@C)``
662
+ over the execution UNION, where the required set is the
663
+ ``severity="critical"`` rows. Non-critical (``warning``) artifacts
664
+ are excluded — they inform per-artifact alerting but never gate the
665
+ cycle verdict.
666
+
667
+ Pure: consumes already-computed :class:`CheckResult` rows (as
668
+ ``(spec, result)`` pairs so there's no positional-pairing hazard)
669
+ and performs no I/O. Recovery substitution and the calendar-holiday
670
+ short-circuit are already reflected in each ``result.state`` by
671
+ :func:`check_freshness`, so a holiday cycle or a recovery-rescued
672
+ artifact both count as satisfied here.
673
+
674
+ State precedence: a real delivery gap (``missing`` / ``stale``)
675
+ outranks a broken probe (``probe_failed``) — a confirmed miss is
676
+ more actionable than an unconfirmable one. ``grace_period`` counts
677
+ as satisfied (the producer is newly onboarded; suppressed by design)
678
+ but is surfaced in its own list so the caller can see it.
679
+
680
+ An empty required set returns ``state="complete"`` (vacuous truth) —
681
+ a cycle with no critical artifacts cannot be incomplete.
682
+ """
683
+ required = [(s, r) for s, r in spec_results if s.severity == "critical"]
684
+
685
+ missing: list[str] = []
686
+ stale: list[str] = []
687
+ probe_failed: list[str] = []
688
+ grace_period: list[str] = []
689
+ satisfied = 0
690
+
691
+ for spec, res in required:
692
+ if res.state == "fresh":
693
+ satisfied += 1
694
+ elif res.state == "grace_period":
695
+ satisfied += 1
696
+ grace_period.append(spec.artifact_id)
697
+ elif res.state == "stale":
698
+ stale.append(spec.artifact_id)
699
+ elif res.state == "missing":
700
+ missing.append(spec.artifact_id)
701
+ elif res.state == "probe_failed":
702
+ probe_failed.append(spec.artifact_id)
703
+
704
+ n_required = len(required)
705
+
706
+ if missing or stale:
707
+ gaps = []
708
+ if missing:
709
+ gaps.append(f"missing={missing}")
710
+ if stale:
711
+ gaps.append(f"stale={stale}")
712
+ return CycleCompletion(
713
+ state="incomplete",
714
+ complete=False,
715
+ cycle_label=cycle_label,
716
+ n_required=n_required,
717
+ n_satisfied=satisfied,
718
+ missing=missing,
719
+ stale=stale,
720
+ probe_failed=probe_failed,
721
+ grace_period=grace_period,
722
+ reason=(
723
+ f"cycle incomplete: {satisfied}/{n_required} critical artifacts "
724
+ f"present+valid; " + "; ".join(gaps)
725
+ ),
726
+ )
727
+
728
+ if probe_failed:
729
+ return CycleCompletion(
730
+ state="indeterminate",
731
+ complete=False,
732
+ cycle_label=cycle_label,
733
+ n_required=n_required,
734
+ n_satisfied=satisfied,
735
+ probe_failed=probe_failed,
736
+ grace_period=grace_period,
737
+ reason=(
738
+ f"cycle indeterminate: monitor probe failed for {probe_failed} — "
739
+ f"cannot confirm cycle ({satisfied}/{n_required} confirmed fresh)"
740
+ ),
741
+ )
742
+
743
+ grace_note = f" ({len(grace_period)} in grace period)" if grace_period else ""
744
+ return CycleCompletion(
745
+ state="complete",
746
+ complete=True,
747
+ cycle_label=cycle_label,
748
+ n_required=n_required,
749
+ n_satisfied=satisfied,
750
+ grace_period=grace_period,
751
+ reason=(
752
+ f"cycle complete: all {n_required} critical artifacts present+valid"
753
+ + grace_note
754
+ ),
755
+ )
@@ -69,6 +69,8 @@ WAIT_GROUPING: Final[dict[str, str]] = {
69
69
  "WaitForRAGIngestion": "RAGIngestion",
70
70
  "WaitForPredictorTraining": "PredictorTraining",
71
71
  "WaitForBacktester": "Backtester",
72
+ "WaitForPredictorBacktest": "PredictorBacktest",
73
+ "WaitForPortfolioOptimizerBacktest": "PortfolioOptimizerBacktest",
72
74
  "WaitForParity": "Parity",
73
75
  "WaitForEvaluator": "Evaluator",
74
76
  "WaitForSaturdayHealthCheck": "SaturdayHealthCheck",
@@ -254,6 +256,18 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, Union[ArchivePageRef, ArtifactReason]]] =
254
256
  page="21_Backtester_Evaluator_Archive",
255
257
  artifact_label="Backtester consolidated report",
256
258
  ),
259
+ # L4472 phase-split (2026-05-31): the monolithic Backtester state was
260
+ # decomposed into Backtester (simulate) → PredictorBacktest → Portfolio-
261
+ # OptimizerBacktest. All three write into the same backtest/{date}/ prefix
262
+ # surfaced on the consolidated evaluator archive page.
263
+ "PredictorBacktest": ArchivePageRef(
264
+ page="21_Backtester_Evaluator_Archive",
265
+ artifact_label="Predictor backtest + Phase 4 report",
266
+ ),
267
+ "PortfolioOptimizerBacktest": ArchivePageRef(
268
+ page="21_Backtester_Evaluator_Archive",
269
+ artifact_label="Portfolio-optimizer / cov / gamma sweep report",
270
+ ),
257
271
  "Parity": ArchivePageRef(
258
272
  page="3_Analysis",
259
273
  artifact_label="Parity replay diff",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.43.0
3
+ Version: 0.45.0
4
4
  Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -41,7 +41,9 @@ from alpha_engine_lib.artifact_freshness import (
41
41
  ArtifactSpec,
42
42
  CADENCE_SYMBOLS,
43
43
  CheckResult,
44
+ CycleCompletion,
44
45
  check_freshness,
46
+ cycle_completion,
45
47
  resolve_current_cycle,
46
48
  resolve_dedup_key,
47
49
  )
@@ -510,3 +512,114 @@ def test_cadence_symbols_match_documented_set():
510
512
  assert CADENCE_SYMBOLS == frozenset(
511
513
  {"saturday_sf", "weekday_sf", "eod_sf", "continuous"}
512
514
  )
515
+
516
+
517
+ # ── Per-cycle completion rollup (Phase 1b) ──────────────────────────────────
518
+
519
+
520
+ def _critical(artifact_id: str) -> ArtifactSpec:
521
+ return _spec(artifact_id=artifact_id, severity="critical")
522
+
523
+
524
+ def _warning(artifact_id: str) -> ArtifactSpec:
525
+ return _spec(artifact_id=artifact_id, severity="warning")
526
+
527
+
528
+ def _res(state: str) -> CheckResult:
529
+ return CheckResult(state=state, reason=f"test {state}")
530
+
531
+
532
+ class TestCycleCompletion:
533
+ def test_all_critical_fresh_is_complete(self):
534
+ pairs = [
535
+ (_critical("a"), _res("fresh")),
536
+ (_critical("b"), _res("fresh")),
537
+ (_critical("c"), _res("fresh")),
538
+ ]
539
+ v = cycle_completion(pairs, cycle_label="2026-W22")
540
+ assert isinstance(v, CycleCompletion)
541
+ assert v.state == "complete"
542
+ assert v.complete is True
543
+ assert v.n_required == 3
544
+ assert v.n_satisfied == 3
545
+ assert v.cycle_label == "2026-W22"
546
+
547
+ def test_one_missing_is_incomplete(self):
548
+ v = cycle_completion([
549
+ (_critical("a"), _res("fresh")),
550
+ (_critical("b"), _res("missing")),
551
+ ])
552
+ assert v.state == "incomplete"
553
+ assert v.complete is False
554
+ assert v.missing == ["b"]
555
+ assert v.n_satisfied == 1
556
+
557
+ def test_one_stale_is_incomplete(self):
558
+ v = cycle_completion([
559
+ (_critical("a"), _res("fresh")),
560
+ (_critical("b"), _res("stale")),
561
+ ])
562
+ assert v.state == "incomplete"
563
+ assert v.stale == ["b"]
564
+
565
+ def test_probe_failed_only_is_indeterminate(self):
566
+ v = cycle_completion([
567
+ (_critical("a"), _res("fresh")),
568
+ (_critical("b"), _res("probe_failed")),
569
+ ])
570
+ assert v.state == "indeterminate"
571
+ assert v.complete is False
572
+ assert v.probe_failed == ["b"]
573
+
574
+ def test_real_gap_outranks_probe_failure(self):
575
+ """A confirmed miss is more actionable than an unconfirmable probe."""
576
+ v = cycle_completion([
577
+ (_critical("a"), _res("missing")),
578
+ (_critical("b"), _res("probe_failed")),
579
+ ])
580
+ assert v.state == "incomplete"
581
+ assert v.missing == ["a"]
582
+ assert v.probe_failed == ["b"] # still localized, but doesn't set the verdict
583
+
584
+ def test_grace_period_counts_as_satisfied(self):
585
+ v = cycle_completion([
586
+ (_critical("a"), _res("fresh")),
587
+ (_critical("b"), _res("grace_period")),
588
+ ])
589
+ assert v.state == "complete"
590
+ assert v.complete is True
591
+ assert v.n_satisfied == 2
592
+ assert v.grace_period == ["b"]
593
+
594
+ def test_warning_severity_excluded_from_required_set(self):
595
+ """A missing WARNING artifact must not fail the cycle — only
596
+ critical rows gate the completion verdict."""
597
+ v = cycle_completion([
598
+ (_critical("a"), _res("fresh")),
599
+ (_warning("b"), _res("missing")),
600
+ ])
601
+ assert v.state == "complete"
602
+ assert v.n_required == 1
603
+ assert v.missing == []
604
+
605
+ def test_empty_required_set_is_vacuously_complete(self):
606
+ v = cycle_completion([(_warning("a"), _res("missing"))])
607
+ assert v.state == "complete"
608
+ assert v.complete is True
609
+ assert v.n_required == 0
610
+
611
+ def test_mixed_states_incomplete_localizes_all_gaps(self):
612
+ v = cycle_completion([
613
+ (_critical("a"), _res("fresh")),
614
+ (_critical("b"), _res("grace_period")),
615
+ (_critical("c"), _res("missing")),
616
+ (_critical("d"), _res("stale")),
617
+ (_critical("e"), _res("probe_failed")),
618
+ ])
619
+ assert v.state == "incomplete"
620
+ assert v.n_required == 5
621
+ assert v.n_satisfied == 2 # fresh + grace_period
622
+ assert v.missing == ["c"]
623
+ assert v.stale == ["d"]
624
+ assert v.probe_failed == ["e"]
625
+ assert v.grace_period == ["b"]