From 82d7a9c8b7b0ae0211beb17ce84cb6f29aaf0c01 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 10 Jul 2026 16:01:41 +0700 Subject: [PATCH] feat: Phase X X5 phase X learning gate schema (XLG-) -- top-level gate asserting MBL+CIT+LPR+RCC all present; closes Phase X learning loop (#853) REQUIRED_X_COMPONENTS=(MBL,CIT,LPR,RCC), VALID_XLG_VERDICTS (3: learning_verified/learning_in_progress/learning_not_started). XComponentCheck helper; verdict: learning_verified (all 4), learning_in_progress (2-3), learning_not_started (0-1). Each artifact_id prefix-validated (MBL-/CIT-/LPR-/RCC-) when non-empty; dry_lab_only=True. 55 tests; BASELINE updated 10443->10498. Completes Phase X. --- docs/research/NEXT_100_PR_MAP.md | 2 +- .../evidence/phase_x_learning_gate.py | 156 +++++++++ tests/evidence/test_phase_x_learning_gate.py | 318 ++++++++++++++++++ tests/test_test_count_regression.py | 2 +- 4 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 src/openamp_foundry/evidence/phase_x_learning_gate.py create mode 100644 tests/evidence/test_phase_x_learning_gate.py diff --git a/docs/research/NEXT_100_PR_MAP.md b/docs/research/NEXT_100_PR_MAP.md index f5578020..b367bfdf 100644 --- a/docs/research/NEXT_100_PR_MAP.md +++ b/docs/research/NEXT_100_PR_MAP.md @@ -292,4 +292,4 @@ Track whether the pipeline actually improves across batches by capturing per-bat | X2 | Add calibration improvement tracker schema (CIT-) (complete). — src/openamp_foundry/evidence/calibration_improvement_tracker.py: VALID_CIT_TREND_DIRECTIONS (4: improving/stable/degrading/insufficient_data), VALID_CIT_SUMMARY_GRADES (5: A-D/N/A), MIN_BATCHES_FOR_TREND=2, IMPROVEMENT_THRESHOLD=0.05; BatchHitRateEntry helper; trend auto-computed from first→latest hit_rate delta; insufficient_data forces N/A grade; dry_lab_only=True; 49 tests. | Aggregates MBL records across batches; computes hit-rate trend direction (improving/stable/degrading/insufficient_data); minimum 2 batches required; flags when calibration is not producing measurable improvement. | C | | X3 | Add learning progress report schema (LPR-) (complete). — src/openamp_foundry/evidence/learning_progress_report.py: VALID_LPR_VERDICTS (4: learning_confirmed/learning_inconclusive/no_learning_signal/insufficient_data), VALID_FEATURE_PREDICTIVITY (3: predictive/not_predictive/uncertain), VALID_FEATURE_CATEGORIES (8); FeatureLearningEntry helper; verdict auto-computed: learning_confirmed (n_pred>n_non), learning_inconclusive (equal+>0), no_learning_signal (n_pred None: + if not xlg.xlg_id.startswith("XLG-"): + raise ValueError(f"xlg_id must start with 'XLG-': {xlg.xlg_id!r}") + if not xlg.pipeline_version: + raise ValueError("pipeline_version must be non-empty") + if len(xlg.component_checks) != len(REQUIRED_X_COMPONENTS): + raise ValueError( + f"component_checks must have exactly {len(REQUIRED_X_COMPONENTS)} entries" + ) + for check in xlg.component_checks: + if check.component_type not in REQUIRED_X_COMPONENTS: + raise ValueError( + f"component_type {check.component_type!r} not in REQUIRED_X_COMPONENTS" + ) + expected_prefix = f"{check.component_type}-" + if check.artifact_id and not check.artifact_id.startswith(expected_prefix): + raise ValueError( + f"artifact_id {check.artifact_id!r} must start with {expected_prefix!r}" + ) + n_present = sum(1 for c in xlg.component_checks if c.present) + if xlg.n_components_present != n_present: + raise ValueError("n_components_present mismatch") + if xlg.xlg_verdict not in VALID_XLG_VERDICTS: + raise ValueError( + f"xlg_verdict {xlg.xlg_verdict!r} not in VALID_XLG_VERDICTS" + ) + if not xlg.dry_lab_only: + raise ValueError("dry_lab_only must be True") + if not xlg.limitations: + raise ValueError("limitations must be non-empty") + if not xlg.created_at: + raise ValueError("created_at must be non-empty") + + +def _compute_verdict(n_present: int) -> str: + if n_present >= LEARNING_VERIFIED_REQUIRED_PRESENT: + return "learning_verified" + if n_present >= LEARNING_IN_PROGRESS_MIN_PRESENT: + return "learning_in_progress" + return "learning_not_started" + + +def build_phase_x_learning_gate( + *, + xlg_id: str, + pipeline_version: str, + mbl_artifact_id: str = "", + cit_artifact_id: str = "", + lpr_artifact_id: str = "", + rcc_artifact_id: str = "", + limitations: list[str], + created_at: str, +) -> PhaseXLearningGate: + """Build a PhaseXLearningGate. + + Pass non-empty artifact_id for each component that is present. + An empty artifact_id means the component is absent (present=False). + """ + checks = [ + XComponentCheck( + component_type="MBL", + artifact_id=mbl_artifact_id, + present=bool(mbl_artifact_id), + ), + XComponentCheck( + component_type="CIT", + artifact_id=cit_artifact_id, + present=bool(cit_artifact_id), + ), + XComponentCheck( + component_type="LPR", + artifact_id=lpr_artifact_id, + present=bool(lpr_artifact_id), + ), + XComponentCheck( + component_type="RCC", + artifact_id=rcc_artifact_id, + present=bool(rcc_artifact_id), + ), + ] + n_present = sum(1 for c in checks if c.present) + verdict = _compute_verdict(n_present) + xlg = PhaseXLearningGate( + xlg_id=xlg_id, + pipeline_version=pipeline_version, + component_checks=checks, + n_components_present=n_present, + xlg_verdict=verdict, + dry_lab_only=True, + limitations=limitations, + created_at=created_at, + ) + validate_phase_x_learning_gate(xlg) + return xlg + + +def format_phase_x_learning_gate(xlg: PhaseXLearningGate) -> str: + lines = [ + f"Phase X Learning Gate — {xlg.xlg_id}", + f"Pipeline: {xlg.pipeline_version}", + f"Verdict: {xlg.xlg_verdict}", + f"Components present: {xlg.n_components_present}/{len(REQUIRED_X_COMPONENTS)}", + ] + lines.append("Component checks:") + for check in xlg.component_checks: + status = "PRESENT" if check.present else "ABSENT" + artifact = f" [{check.artifact_id}]" if check.artifact_id else "" + lines.append(f" {check.component_type}: {status}{artifact}") + lines.append(f"Created: {xlg.created_at}") + lines.append(f"Limitations: {'; '.join(xlg.limitations)}") + lines.append(f"dry_lab_only: {xlg.dry_lab_only}") + return "\n".join(lines) diff --git a/tests/evidence/test_phase_x_learning_gate.py b/tests/evidence/test_phase_x_learning_gate.py new file mode 100644 index 00000000..71945ef0 --- /dev/null +++ b/tests/evidence/test_phase_x_learning_gate.py @@ -0,0 +1,318 @@ +"""Tests for XLG- Phase X learning gate schema.""" + +import pytest +from openamp_foundry.evidence.phase_x_learning_gate import ( + PhaseXLearningGate, + XComponentCheck, + REQUIRED_X_COMPONENTS, + VALID_XLG_VERDICTS, + LEARNING_VERIFIED_REQUIRED_PRESENT, + LEARNING_IN_PROGRESS_MIN_PRESENT, + build_phase_x_learning_gate, + format_phase_x_learning_gate, + validate_phase_x_learning_gate, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build(**kwargs): + defaults = dict( + xlg_id="XLG-001", + pipeline_version="v1.0", + mbl_artifact_id="MBL-001", + cit_artifact_id="CIT-001", + lpr_artifact_id="LPR-001", + rcc_artifact_id="RCC-001", + limitations=["dry-lab only"], + created_at="2026-07-10", + ) + defaults.update(kwargs) + return build_phase_x_learning_gate(**defaults) + + +# --------------------------------------------------------------------------- +# 1. Constants +# --------------------------------------------------------------------------- + + +def test_required_x_components_is_tuple(): + assert isinstance(REQUIRED_X_COMPONENTS, tuple) + + +def test_required_x_components_contains_mbl(): + assert "MBL" in REQUIRED_X_COMPONENTS + + +def test_required_x_components_contains_cit(): + assert "CIT" in REQUIRED_X_COMPONENTS + + +def test_required_x_components_contains_lpr(): + assert "LPR" in REQUIRED_X_COMPONENTS + + +def test_required_x_components_contains_rcc(): + assert "RCC" in REQUIRED_X_COMPONENTS + + +def test_required_x_components_has_four_entries(): + assert len(REQUIRED_X_COMPONENTS) == 4 + + +def test_valid_xlg_verdicts_is_frozenset(): + assert isinstance(VALID_XLG_VERDICTS, frozenset) + + +def test_valid_xlg_verdicts_contains_learning_verified(): + assert "learning_verified" in VALID_XLG_VERDICTS + + +def test_valid_xlg_verdicts_contains_learning_in_progress(): + assert "learning_in_progress" in VALID_XLG_VERDICTS + + +def test_valid_xlg_verdicts_contains_learning_not_started(): + assert "learning_not_started" in VALID_XLG_VERDICTS + + +def test_learning_verified_required_present(): + assert LEARNING_VERIFIED_REQUIRED_PRESENT == 4 + + +def test_learning_in_progress_min_present(): + assert LEARNING_IN_PROGRESS_MIN_PRESENT == 2 + + +# --------------------------------------------------------------------------- +# 2. build – happy paths +# --------------------------------------------------------------------------- + + +def test_build_returns_phase_x_learning_gate(): + assert isinstance(_build(), PhaseXLearningGate) + + +def test_build_xlg_id_stored(): + assert _build().xlg_id == "XLG-001" + + +def test_build_pipeline_version_stored(): + assert _build().pipeline_version == "v1.0" + + +def test_build_dry_lab_only_true(): + assert _build().dry_lab_only is True + + +def test_build_all_present_gives_learning_verified(): + assert _build().xlg_verdict == "learning_verified" + + +def test_build_all_present_n_components_4(): + assert _build().n_components_present == 4 + + +def test_build_three_present_gives_learning_in_progress(): + r = _build(rcc_artifact_id="") + assert r.xlg_verdict == "learning_in_progress" + + +def test_build_two_present_gives_learning_in_progress(): + r = _build(lpr_artifact_id="", rcc_artifact_id="") + assert r.xlg_verdict == "learning_in_progress" + + +def test_build_one_present_gives_learning_not_started(): + r = _build(cit_artifact_id="", lpr_artifact_id="", rcc_artifact_id="") + assert r.xlg_verdict == "learning_not_started" + + +def test_build_none_present_gives_learning_not_started(): + r = _build( + mbl_artifact_id="", + cit_artifact_id="", + lpr_artifact_id="", + rcc_artifact_id="", + ) + assert r.xlg_verdict == "learning_not_started" + + +def test_build_none_present_n_components_0(): + r = _build( + mbl_artifact_id="", + cit_artifact_id="", + lpr_artifact_id="", + rcc_artifact_id="", + ) + assert r.n_components_present == 0 + + +def test_build_component_checks_are_x_component_check(): + for c in _build().component_checks: + assert isinstance(c, XComponentCheck) + + +def test_build_component_checks_count(): + assert len(_build().component_checks) == 4 + + +def test_build_mbl_present_true(): + r = _build() + mbl_check = next(c for c in r.component_checks if c.component_type == "MBL") + assert mbl_check.present is True + + +def test_build_mbl_absent_false(): + r = _build(mbl_artifact_id="") + mbl_check = next(c for c in r.component_checks if c.component_type == "MBL") + assert mbl_check.present is False + + +def test_build_artifact_id_stored(): + r = _build() + cit_check = next(c for c in r.component_checks if c.component_type == "CIT") + assert cit_check.artifact_id == "CIT-001" + + +def test_build_empty_artifact_id_means_absent(): + r = _build(rcc_artifact_id="") + rcc_check = next(c for c in r.component_checks if c.component_type == "RCC") + assert rcc_check.artifact_id == "" + assert rcc_check.present is False + + +def test_build_limitations_stored(): + assert _build().limitations == ["dry-lab only"] + + +def test_build_created_at_stored(): + assert _build().created_at == "2026-07-10" + + +# --------------------------------------------------------------------------- +# 3. validate – rejection cases +# --------------------------------------------------------------------------- + + +def test_validate_rejects_bad_xlg_id_prefix(): + with pytest.raises(ValueError, match="XLG-"): + _build(xlg_id="BAD-001") + + +def test_validate_rejects_empty_pipeline_version(): + with pytest.raises(ValueError): + _build(pipeline_version="") + + +def test_validate_rejects_n_components_mismatch(): + xlg = _build() + xlg.n_components_present = 99 + with pytest.raises(ValueError, match="n_components_present"): + validate_phase_x_learning_gate(xlg) + + +def test_validate_rejects_invalid_xlg_verdict(): + xlg = _build() + xlg.xlg_verdict = "UNKNOWN" + with pytest.raises(ValueError, match="xlg_verdict"): + validate_phase_x_learning_gate(xlg) + + +def test_validate_rejects_wrong_artifact_prefix_mbl(): + with pytest.raises(ValueError, match="MBL-"): + _build(mbl_artifact_id="BAD-001") + + +def test_validate_rejects_wrong_artifact_prefix_cit(): + with pytest.raises(ValueError, match="CIT-"): + _build(cit_artifact_id="BAD-001") + + +def test_validate_rejects_wrong_artifact_prefix_lpr(): + with pytest.raises(ValueError, match="LPR-"): + _build(lpr_artifact_id="BAD-001") + + +def test_validate_rejects_wrong_artifact_prefix_rcc(): + with pytest.raises(ValueError, match="RCC-"): + _build(rcc_artifact_id="BAD-001") + + +def test_validate_rejects_dry_lab_only_false(): + xlg = _build() + xlg.dry_lab_only = False + with pytest.raises(ValueError, match="dry_lab_only"): + validate_phase_x_learning_gate(xlg) + + +def test_validate_rejects_empty_limitations(): + with pytest.raises(ValueError, match="limitations"): + _build(limitations=[]) + + +def test_validate_rejects_empty_created_at(): + with pytest.raises(ValueError): + _build(created_at="") + + +def test_validate_rejects_wrong_component_count(): + xlg = _build() + xlg.component_checks = xlg.component_checks[:2] + with pytest.raises(ValueError, match="component_checks"): + validate_phase_x_learning_gate(xlg) + + +# --------------------------------------------------------------------------- +# 4. format +# --------------------------------------------------------------------------- + + +def test_format_contains_xlg_id(): + assert "XLG-001" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_pipeline_version(): + assert "v1.0" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_verdict(): + assert "learning_verified" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_mbl_component(): + assert "MBL" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_cit_component(): + assert "CIT" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_lpr_component(): + assert "LPR" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_rcc_component(): + assert "RCC" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_present_status(): + assert "PRESENT" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_absent_status(): + assert "ABSENT" in format_phase_x_learning_gate(_build(rcc_artifact_id="")) + + +def test_format_contains_limitations(): + assert "dry-lab only" in format_phase_x_learning_gate(_build()) + + +def test_format_contains_dry_lab_only(): + assert "dry_lab_only: True" in format_phase_x_learning_gate(_build()) + + +def test_format_is_string(): + assert isinstance(format_phase_x_learning_gate(_build()), str) diff --git a/tests/test_test_count_regression.py b/tests/test_test_count_regression.py index 435f002b..3ee3db32 100644 --- a/tests/test_test_count_regression.py +++ b/tests/test_test_count_regression.py @@ -4,7 +4,7 @@ import sys import math -BASELINE = 10443 +BASELINE = 10498 def test_test_count_regression():