From f633878a837deab3ac5f73338475d2c5c2a5a0a7 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:04:28 +0600 Subject: [PATCH 01/15] PR1.2: add input suitability diagnostics package --- src/practicelens/diagnostics/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/practicelens/diagnostics/__init__.py diff --git a/src/practicelens/diagnostics/__init__.py b/src/practicelens/diagnostics/__init__.py new file mode 100644 index 0000000..1f82ca1 --- /dev/null +++ b/src/practicelens/diagnostics/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from practicelens.diagnostics.input_suitability import summarize_input_suitability + +__all__ = ["summarize_input_suitability"] From 3cb1e8e9e7d1995c78faf8edf768e63a3a78ea94 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:10:24 +0600 Subject: [PATCH 02/15] PR1.2: add deterministic input suitability summary --- .../diagnostics/input_suitability.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/practicelens/diagnostics/input_suitability.py diff --git a/src/practicelens/diagnostics/input_suitability.py b/src/practicelens/diagnostics/input_suitability.py new file mode 100644 index 0000000..3f2b3be --- /dev/null +++ b/src/practicelens/diagnostics/input_suitability.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from practicelens.alignment import AlignmentPath +from practicelens.domain.models import InputSuitabilitySummary +from practicelens.domain.types import Seconds +from practicelens.features import FeatureBundle + +_DURATION_RATIO_WARNING_MIN = 0.75 +_DURATION_RATIO_WARNING_MAX = 1.35 +_DURATION_RATIO_LOW_MIN = 0.50 +_DURATION_RATIO_LOW_MAX = 2.00 +_ALIGNMENT_WARNING_MIN = 0.85 +_ALIGNMENT_LOW_MIN = 0.60 +_VOICED_WARNING_MIN = 0.35 +_VOICED_LOW_MIN = 0.15 +_ONSET_PRESENT_MIN = 2 +_SCORE_DIGITS = 6 + + +def summarize_input_suitability( + reference: FeatureBundle, + take: FeatureBundle, + alignment: AlignmentPath, +) -> InputSuitabilitySummary: + """Build a deterministic evidence summary for the analyzed input pair.""" + + reference_duration_s = _duration_s(reference) + take_duration_s = _duration_s(take) + duration_ratio = _duration_ratio(reference_duration_s, take_duration_s) + alignment_coverage = _round_ratio(alignment.coverage_ratio) + reference_voiced_coverage = _round_ratio(_voiced_ratio(reference)) + take_voiced_coverage = _round_ratio(_voiced_ratio(take)) + voiced_frame_coverage = _round_ratio(min(reference_voiced_coverage, take_voiced_coverage)) + reference_onset_count = len(reference.onset_times_s) + take_onset_count = len(take.onset_times_s) + onset_evidence = _onset_evidence(reference_onset_count, take_onset_count) + + risk_points = 0 + low_confidence = False + reasons: list[str] = [] + + if reference_duration_s <= 0.0 or take_duration_s <= 0.0: + low_confidence = True + reasons.append("Reference or take duration is unavailable.") + elif _DURATION_RATIO_WARNING_MIN <= duration_ratio <= _DURATION_RATIO_WARNING_MAX: + reasons.append("Take duration is comparable to the reference.") + else: + reasons.append("Take duration differs from the reference.") + risk_points += 1 + if duration_ratio < _DURATION_RATIO_LOW_MIN or duration_ratio > _DURATION_RATIO_LOW_MAX: + low_confidence = True + + if alignment_coverage >= _ALIGNMENT_WARNING_MIN: + reasons.append("Alignment coverage is broad.") + else: + reasons.append("Alignment coverage is limited.") + risk_points += 1 + if alignment_coverage < _ALIGNMENT_LOW_MIN: + low_confidence = True + + if voiced_frame_coverage >= _VOICED_WARNING_MIN: + reasons.append("Voiced-frame coverage is sufficient.") + else: + reasons.append("Voiced-frame coverage is limited.") + risk_points += 1 + if voiced_frame_coverage < _VOICED_LOW_MIN: + low_confidence = True + + if onset_evidence == "present": + reasons.append("Onset evidence is present.") + elif onset_evidence == "sparse": + reasons.append("Onset evidence is sparse.") + risk_points += 1 + else: + reasons.append("Onset evidence is absent.") + risk_points += 1 + + status = _status(low_confidence=low_confidence, risk_points=risk_points) + return InputSuitabilitySummary( + status=status, + reference_duration_s=Seconds(_round_ratio(reference_duration_s)), + take_duration_s=Seconds(_round_ratio(take_duration_s)), + duration_ratio=duration_ratio, + alignment_coverage=alignment_coverage, + voiced_frame_coverage=voiced_frame_coverage, + reference_voiced_frame_coverage=reference_voiced_coverage, + take_voiced_frame_coverage=take_voiced_coverage, + onset_evidence=onset_evidence, + reference_onset_count=reference_onset_count, + take_onset_count=take_onset_count, + reasons=tuple(reasons), + ) + + +def _duration_s(bundle: FeatureBundle) -> float: + if not bundle.time_axis_s: + return 0.0 + if len(bundle.time_axis_s) == 1: + return max(0.0, bundle.time_axis_s[0]) + return max(0.0, bundle.time_axis_s[-1] - bundle.time_axis_s[0]) + + +def _duration_ratio(reference_duration_s: float, take_duration_s: float) -> float: + if reference_duration_s <= 0.0: + return 0.0 + return _round_ratio(take_duration_s / reference_duration_s) + + +def _voiced_ratio(bundle: FeatureBundle) -> float: + if not bundle.voiced_mask: + return 0.0 + return sum(1 for voiced in bundle.voiced_mask if voiced) / float(len(bundle.voiced_mask)) + + +def _onset_evidence(reference_onset_count: int, take_onset_count: int) -> str: + shared_onset_count = min(reference_onset_count, take_onset_count) + if shared_onset_count >= _ONSET_PRESENT_MIN: + return "present" + if shared_onset_count == 1: + return "sparse" + return "absent" + + +def _status(*, low_confidence: bool, risk_points: int) -> str: + if low_confidence or risk_points >= 3: + return "low_confidence" + if risk_points > 0: + return "warning" + return "ok" + + +def _round_ratio(value: float) -> float: + return round(float(value), _SCORE_DIGITS) From bdbd95af7a3df06847bbbf6bee1e965bce2f8d13 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:10:53 +0600 Subject: [PATCH 03/15] PR1.2: add input suitability payload helper --- .../reporting/input_suitability_payload.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/practicelens/reporting/input_suitability_payload.py diff --git a/src/practicelens/reporting/input_suitability_payload.py b/src/practicelens/reporting/input_suitability_payload.py new file mode 100644 index 0000000..822a29d --- /dev/null +++ b/src/practicelens/reporting/input_suitability_payload.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from practicelens.domain.models import InputSuitabilitySummary + + +def input_suitability_to_payload(summary: InputSuitabilitySummary) -> dict[str, object]: + return { + "schema_version": int(summary.schema_version), + "status": summary.status, + "reference_duration_s": summary.reference_duration_s, + "take_duration_s": summary.take_duration_s, + "duration_ratio": summary.duration_ratio, + "alignment_coverage": summary.alignment_coverage, + "voiced_frame_coverage": summary.voiced_frame_coverage, + "reference_voiced_frame_coverage": summary.reference_voiced_frame_coverage, + "take_voiced_frame_coverage": summary.take_voiced_frame_coverage, + "onset_evidence": summary.onset_evidence, + "reference_onset_count": summary.reference_onset_count, + "take_onset_count": summary.take_onset_count, + "reasons": list(summary.reasons), + } From 69926004bf5a39e7bc90e7a3bff4a752e47a394a Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:14:07 +0600 Subject: [PATCH 04/15] PR1.2: add input suitability report model --- src/practicelens/domain/models.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/practicelens/domain/models.py b/src/practicelens/domain/models.py index 1a90afb..446d707 100644 --- a/src/practicelens/domain/models.py +++ b/src/practicelens/domain/models.py @@ -135,6 +135,25 @@ class AnalysisConfidence: limitations: tuple[str, ...] = () +@dataclass(slots=True, frozen=True) +class InputSuitabilitySummary: + """Compact evidence summary describing whether the input pair is suitable for review.""" + + schema_version: SchemaVersion = SchemaVersion(1) + status: str = "low_confidence" + reference_duration_s: Seconds = Seconds(0.0) + take_duration_s: Seconds = Seconds(0.0) + duration_ratio: float = 0.0 + alignment_coverage: float = 0.0 + voiced_frame_coverage: float = 0.0 + reference_voiced_frame_coverage: float = 0.0 + take_voiced_frame_coverage: float = 0.0 + onset_evidence: str = "absent" + reference_onset_count: int = 0 + take_onset_count: int = 0 + reasons: tuple[str, ...] = () + + @dataclass(slots=True, frozen=True) class AnalysisOverview: """Compact, stable top-level overview contract for a finished analysis.""" @@ -167,6 +186,7 @@ class AnalysisReport: metrics: tuple[MetricResult, ...] sections: tuple[SectionReport, ...] analysis_confidence: AnalysisConfidence = AnalysisConfidence() + input_suitability: InputSuitabilitySummary = InputSuitabilitySummary() practice_loops: tuple[PracticeLoop, ...] = () top_strengths: tuple[str, ...] = () top_weaknesses: tuple[str, ...] = () From 29d5151debb176c3070ceb3a6002e69f49e744ac Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:16:24 +0600 Subject: [PATCH 05/15] PR1.2: compute input suitability in analysis pipeline --- src/practicelens/application/offline_pipeline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/practicelens/application/offline_pipeline.py b/src/practicelens/application/offline_pipeline.py index b6c9a74..4a7e880 100644 --- a/src/practicelens/application/offline_pipeline.py +++ b/src/practicelens/application/offline_pipeline.py @@ -5,6 +5,7 @@ from practicelens.alignment import AlignmentPath, align_feature_bundles from practicelens.application.contracts import AnalyzeRequest, AnalyzeResult from practicelens.application.pipeline import AnalysisPipeline +from practicelens.diagnostics import summarize_input_suitability from practicelens.domain.models import AnalysisConfidence, AnalysisConfig, AnalysisOverview, AnalysisReport, FeatureFlags from practicelens.features import FeatureBundle, extract_feature_bundle from practicelens.io import ensure_finite_audio, load_wav_audio @@ -42,6 +43,7 @@ def analyze(self, request: AnalyzeRequest) -> AnalyzeResult: metrics=scoring.metrics, sections=scoring.sections, analysis_confidence=_analysis_confidence(reference_features, take_features, alignment, scoring), + input_suitability=summarize_input_suitability(reference_features, take_features, alignment), practice_loops=scoring.practice_loops, top_strengths=scoring.top_strengths, top_weaknesses=scoring.top_weaknesses, @@ -150,4 +152,4 @@ def _analysis_confidence( def _voiced_ratio(bundle: FeatureBundle) -> float: if not bundle.voiced_mask: return 0.0 - return sum(1 for voiced in bundle.voiced_mask if voiced) / float(len(bundle.voiced_mask)) \ No newline at end of file + return sum(1 for voiced in bundle.voiced_mask if voiced) / float(len(bundle.voiced_mask)) From 9e1c23afbe9acfc773ae69863cc519fdfd59a01d Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:17:57 +0600 Subject: [PATCH 06/15] PR1.2: preserve input suitability when writing artifacts --- src/practicelens/reporting/artifacts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/practicelens/reporting/artifacts.py b/src/practicelens/reporting/artifacts.py index d8ee859..a3652a2 100644 --- a/src/practicelens/reporting/artifacts.py +++ b/src/practicelens/reporting/artifacts.py @@ -67,6 +67,7 @@ def write_report_artifacts( metrics=report.metrics, sections=report.sections, analysis_confidence=report.analysis_confidence, + input_suitability=report.input_suitability, practice_loops=report.practice_loops, top_strengths=report.top_strengths, top_weaknesses=report.top_weaknesses, @@ -82,4 +83,4 @@ def write_report_artifacts( svg_path.write_text(report_to_svg(report_with_artifacts), encoding="utf-8") practice_plan_path.write_text(report_to_practice_plan_markdown(report_with_artifacts), encoding="utf-8") debug_payload_path.write_text(report_to_debug_payload_text(report_with_artifacts), encoding="utf-8") - return report_with_artifacts, artifacts \ No newline at end of file + return report_with_artifacts, artifacts From 4a8ebfe9b85e28c430121e8fc64030e622a0928f Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:20:44 +0600 Subject: [PATCH 07/15] PR1.2: expose input suitability in JSON report --- src/practicelens/reporting/json_report.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/practicelens/reporting/json_report.py b/src/practicelens/reporting/json_report.py index 968653c..26e383f 100644 --- a/src/practicelens/reporting/json_report.py +++ b/src/practicelens/reporting/json_report.py @@ -3,6 +3,7 @@ import json from practicelens.domain.models import AnalysisReport +from practicelens.reporting.input_suitability_payload import input_suitability_to_payload def report_to_json_payload(report: AnalysisReport) -> dict[str, object]: @@ -78,6 +79,7 @@ def report_to_json_payload(report: AnalysisReport) -> dict[str, object]: "reasons": list(report.analysis_confidence.reasons), "limitations": list(report.analysis_confidence.limitations), }, + "input_suitability": input_suitability_to_payload(report.input_suitability), "practice_loops": [ { "section_index": loop.section_index, From 4507d91b10e36681d27254cb20a33b35421fd088 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:23:33 +0600 Subject: [PATCH 08/15] PR1.2: expose input suitability in debug payload --- src/practicelens/reporting/debug_payload.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/practicelens/reporting/debug_payload.py b/src/practicelens/reporting/debug_payload.py index ec5b837..dbaac44 100644 --- a/src/practicelens/reporting/debug_payload.py +++ b/src/practicelens/reporting/debug_payload.py @@ -4,6 +4,7 @@ from practicelens.domain.enums import MetricName from practicelens.domain.models import AnalysisReport, MetricResult +from practicelens.reporting.input_suitability_payload import input_suitability_to_payload _SCORE_DIGITS = 6 @@ -40,6 +41,7 @@ def report_to_debug_payload(report: AnalysisReport) -> dict[str, object]: }, "evidence_summary": { "alignment_coverage": _metric_score(report, MetricName.ALIGNMENT_COVERAGE), + "input_suitability": input_suitability_to_payload(report.input_suitability), "section_count": len(report.sections), "practice_loop_count": len(report.practice_loops), "feedback_count": len(report.feedback), @@ -102,4 +104,4 @@ def _find_metric(report: AnalysisReport, metric_name: MetricName) -> MetricResul def _round_score(value: float) -> float: - return round(value, _SCORE_DIGITS) \ No newline at end of file + return round(value, _SCORE_DIGITS) From a8b7675abf0f143c05e251488612e87afee8ecbf Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:24:28 +0600 Subject: [PATCH 09/15] PR1.2: expose input suitability in batch JSON --- src/practicelens/reporting/batch_report.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/practicelens/reporting/batch_report.py b/src/practicelens/reporting/batch_report.py index 2f22069..1167503 100644 --- a/src/practicelens/reporting/batch_report.py +++ b/src/practicelens/reporting/batch_report.py @@ -7,6 +7,7 @@ from practicelens.application.contracts import BatchCompareResult, BatchSessionSummary, SessionPracticeLoopSummary, SessionTakeSummary from practicelens.domain.enums import ArtifactKind from practicelens.domain.models import PracticeLoop +from practicelens.reporting.input_suitability_payload import input_suitability_to_payload def batch_compare_result_to_json_payload(result: BatchCompareResult) -> dict[str, object]: @@ -16,6 +17,7 @@ def batch_compare_result_to_json_payload(result: BatchCompareResult) -> dict[str "take_path": str(entry.take_path), "overall_score": entry.overall_score, "summary": entry.summary, + "input_suitability": input_suitability_to_payload(entry.result.report.input_suitability), "output_dir": str(entry.output_dir) if entry.output_dir is not None else None, "practice_loops": [_practice_loop_payload(loop) for loop in entry.result.report.practice_loops], "artifacts": [ From ebf10ec6a27bbce7918be537504c138ba30a945d Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:30:22 +0600 Subject: [PATCH 10/15] PR1.2: type input suitability API payloads --- src/practicelens/api/contracts.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/practicelens/api/contracts.py b/src/practicelens/api/contracts.py index ecf5ac8..82fceeb 100644 --- a/src/practicelens/api/contracts.py +++ b/src/practicelens/api/contracts.py @@ -94,6 +94,22 @@ class AnalysisConfidencePayload(TypedDict): limitations: list[str] +class InputSuitabilitySummaryPayload(TypedDict): + schema_version: int + status: str + reference_duration_s: float + take_duration_s: float + duration_ratio: float + alignment_coverage: float + voiced_frame_coverage: float + reference_voiced_frame_coverage: float + take_voiced_frame_coverage: float + onset_evidence: str + reference_onset_count: int + take_onset_count: int + reasons: list[str] + + class PracticeLoopPayload(TypedDict): section_index: int start_s: float @@ -146,6 +162,7 @@ class AnalyzeResponsePayload(TypedDict): metrics: list[MetricPayload] sections: list[SectionPayload] analysis_confidence: AnalysisConfidencePayload + input_suitability: InputSuitabilitySummaryPayload practice_loops: list[PracticeLoopPayload] top_strengths: list[str] top_weaknesses: list[str] @@ -160,6 +177,7 @@ class BatchEntryPayload(TypedDict): take_path: str overall_score: float summary: str | None + input_suitability: InputSuitabilitySummaryPayload output_dir: str | None practice_loops: list[PracticeLoopPayload] artifacts: list[ArtifactPayload] From e030620452a73f4cbc0fb29c1656ea8c38194cfc Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:32:20 +0600 Subject: [PATCH 11/15] PR1.2: test input suitability statuses --- tests/unit/test_input_suitability.py | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/unit/test_input_suitability.py diff --git a/tests/unit/test_input_suitability.py b/tests/unit/test_input_suitability.py new file mode 100644 index 0000000..2d949d8 --- /dev/null +++ b/tests/unit/test_input_suitability.py @@ -0,0 +1,86 @@ +from practicelens.alignment import AlignmentPath +from practicelens.diagnostics import summarize_input_suitability +from practicelens.features import FeatureBundle + + +def test_input_suitability_summary_reports_ok_when_evidence_is_strong() -> None: + summary = summarize_input_suitability( + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0, 3.0), + voiced_mask=(True, True, True, True), + onset_times_s=(0.5, 1.5), + ), + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0, 3.0), + voiced_mask=(True, True, True, True), + onset_times_s=(0.5, 1.5), + ), + AlignmentPath(pairs=(), total_cost=0.0, coverage_ratio=0.9), + ) + + assert summary.status == "ok" + assert summary.reference_duration_s == 3.0 + assert summary.take_duration_s == 3.0 + assert summary.duration_ratio == 1.0 + assert summary.alignment_coverage == 0.9 + assert summary.voiced_frame_coverage == 1.0 + assert summary.onset_evidence == "present" + + +def test_input_suitability_summary_reports_warning_when_duration_differs() -> None: + summary = summarize_input_suitability( + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0, 3.0), + voiced_mask=(True, True, True, True), + onset_times_s=(0.5, 1.5), + ), + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0), + voiced_mask=(True, True, True), + onset_times_s=(0.5, 1.5), + ), + AlignmentPath(pairs=(), total_cost=0.0, coverage_ratio=0.9), + ) + + assert summary.status == "warning" + assert summary.duration_ratio == 0.666667 + assert "Take duration differs from the reference." in summary.reasons + + +def test_input_suitability_summary_reports_low_confidence_when_evidence_is_thin() -> None: + summary = summarize_input_suitability( + _feature_bundle( + time_axis_s=(0.0, 1.0, 2.0, 3.0), + voiced_mask=(True, False, False, False), + onset_times_s=(), + ), + _feature_bundle( + time_axis_s=(0.0, 0.5), + voiced_mask=(False, False), + onset_times_s=(), + ), + AlignmentPath(pairs=(), total_cost=0.0, coverage_ratio=0.4), + ) + + assert summary.status == "low_confidence" + assert summary.alignment_coverage == 0.4 + assert summary.voiced_frame_coverage == 0.0 + assert summary.onset_evidence == "absent" + + +def _feature_bundle( + *, + time_axis_s: tuple[float, ...], + voiced_mask: tuple[bool, ...], + onset_times_s: tuple[float, ...], +) -> FeatureBundle: + frame_count = len(time_axis_s) + return FeatureBundle( + time_axis_s=time_axis_s, + energy_curve=(1.0,) * frame_count, + zero_crossing_rate=(0.1,) * frame_count, + pitch_contour_hz=tuple(220.0 if voiced else 0.0 for voiced in voiced_mask), + voiced_mask=voiced_mask, + onset_times_s=onset_times_s, + estimated_tempo_bpm=120.0 if len(onset_times_s) >= 2 else None, + ) From 89295a8d48320133f8478645f69cdf54a71e1a0b Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:33:49 +0600 Subject: [PATCH 12/15] PR1.2: assert input suitability API payloads --- tests/unit/test_api_service.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_api_service.py b/tests/unit/test_api_service.py index cc1342e..333d0f4 100644 --- a/tests/unit/test_api_service.py +++ b/tests/unit/test_api_service.py @@ -105,6 +105,10 @@ def test_analyze_payload_returns_contract_shaped_report(tmp_path: Path) -> None: assert isinstance(payload["metrics"], list) assert isinstance(payload["sections"], list) assert isinstance(payload["artifacts"], list) + assert payload["input_suitability"]["schema_version"] == 1 + assert payload["input_suitability"]["status"] in {"ok", "warning", "low_confidence"} + assert payload["input_suitability"]["reference_duration_s"] > 0.0 + assert payload["input_suitability"]["take_duration_s"] > 0.0 assert (out_dir / "report.json").exists() assert (out_dir / "report.md").exists() @@ -139,6 +143,8 @@ def test_compare_batch_payload_returns_ranked_contract_report(tmp_path: Path) -> assert payload["entries"] assert payload["entries"][0]["rank"] == 1 assert payload["entries"][0]["take_path"].endswith("take_best.wav") + assert payload["entries"][0]["input_suitability"]["schema_version"] == 1 + assert payload["entries"][0]["input_suitability"]["status"] in {"ok", "warning", "low_confidence"} assert isinstance(payload["entries"][0]["artifacts"], list) assert isinstance(payload["artifacts"], list) assert (out_dir / "batch_report.json").exists() From 10b9eba7bf21a66ee72fce53c417e5891ce12f53 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 18:59:04 +0600 Subject: [PATCH 13/15] PR1.2: update offline pipeline contract test --- tests/integration/test_offline_pipeline.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_offline_pipeline.py b/tests/integration/test_offline_pipeline.py index 608dae8..f4a674a 100644 --- a/tests/integration/test_offline_pipeline.py +++ b/tests/integration/test_offline_pipeline.py @@ -44,6 +44,7 @@ def test_offline_pipeline_generates_report_artifacts(tmp_path: Path) -> None: assert result.report.analysis_confidence.level in {"high", "medium", "low"} assert result.report.analysis_confidence.reasons assert result.report.analysis_confidence.limitations + assert result.report.input_suitability.status in {"ok", "warning", "low_confidence"} assert isinstance(result.report.practice_loops, tuple) assert result.report.top_strengths assert result.report.top_weaknesses @@ -67,6 +68,7 @@ def test_offline_pipeline_generates_report_artifacts(tmp_path: Path) -> None: assert debug_payload["schema_version"] == 1 assert debug_payload["score_summary"]["overall_score"] >= 0.0 assert debug_payload["evidence_summary"]["section_count"] == len(result.report.sections) + assert debug_payload["evidence_summary"]["input_suitability"]["status"] in {"ok", "warning", "low_confidence"} assert debug_payload["confidence"]["level"] in {"high", "medium", "low"} assert debug_payload["practice_guidance"]["next_practice_step"] == result.report.next_practice_step @@ -80,6 +82,7 @@ def test_offline_pipeline_generates_report_artifacts(tmp_path: Path) -> None: "metrics", "sections", "analysis_confidence", + "input_suitability", "practice_loops", "top_strengths", "top_weaknesses", @@ -98,6 +101,10 @@ def test_offline_pipeline_generates_report_artifacts(tmp_path: Path) -> None: assert payload["analysis_confidence"]["level"] in {"high", "medium", "low"} assert payload["analysis_confidence"]["reasons"] assert payload["analysis_confidence"]["limitations"] + assert payload["input_suitability"]["schema_version"] == 1 + assert payload["input_suitability"]["status"] in {"ok", "warning", "low_confidence"} + assert payload["input_suitability"]["reference_duration_s"] > 0.0 + assert payload["input_suitability"]["take_duration_s"] > 0.0 assert isinstance(payload["practice_loops"], list) assert {artifact["kind"] for artifact in payload["artifacts"]} == { "json_report", @@ -141,4 +148,4 @@ def tracking_extract(*args, **kwargs): pipeline.analyze(AnalyzeRequest(reference_path=reference, take_path=take_one, config=config)) pipeline.analyze(AnalyzeRequest(reference_path=reference, take_path=take_two, config=config)) - assert len(extracted_bundles) == 3 \ No newline at end of file + assert len(extracted_bundles) == 3 From 5dc171468bd8f58322ab5e2640e2bc4ad548ba62 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 19:02:58 +0600 Subject: [PATCH 14/15] PR1.2: update JSON report contract test --- tests/unit/test_json_report.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/unit/test_json_report.py b/tests/unit/test_json_report.py index 02a3e21..4998fda 100644 --- a/tests/unit/test_json_report.py +++ b/tests/unit/test_json_report.py @@ -10,6 +10,7 @@ ArtifactLink, ComponentScore, FeatureFlags, + InputSuitabilitySummary, MetricResult, PracticeLoop, SectionFinding, @@ -39,6 +40,20 @@ def _sample_report() -> AnalysisReport: reasons=("Alignment coverage is broad enough for a stable reference-aware comparison.",), limitations=("PracticeLens v0.1 uses deterministic signal-processing heuristics, not human musical judgment.",), ), + input_suitability=InputSuitabilitySummary( + status="ok", + reference_duration_s=8.0, + take_duration_s=8.0, + duration_ratio=1.0, + alignment_coverage=0.95, + voiced_frame_coverage=0.8, + reference_voiced_frame_coverage=0.85, + take_voiced_frame_coverage=0.8, + onset_evidence="present", + reference_onset_count=4, + take_onset_count=4, + reasons=("Take duration is comparable to the reference.",), + ), practice_loops=( PracticeLoop( section_index=0, @@ -70,6 +85,7 @@ def test_report_json_payload_has_stable_top_level_contract() -> None: "metrics", "sections", "analysis_confidence", + "input_suitability", "practice_loops", "top_strengths", "top_weaknesses", @@ -91,6 +107,21 @@ def test_report_json_payload_has_stable_top_level_contract() -> None: "reasons": ["Alignment coverage is broad enough for a stable reference-aware comparison."], "limitations": ["PracticeLens v0.1 uses deterministic signal-processing heuristics, not human musical judgment."], } + assert payload["input_suitability"] == { + "schema_version": 1, + "status": "ok", + "reference_duration_s": 8.0, + "take_duration_s": 8.0, + "duration_ratio": 1.0, + "alignment_coverage": 0.95, + "voiced_frame_coverage": 0.8, + "reference_voiced_frame_coverage": 0.85, + "take_voiced_frame_coverage": 0.8, + "onset_evidence": "present", + "reference_onset_count": 4, + "take_onset_count": 4, + "reasons": ["Take duration is comparable to the reference."], + } assert payload["practice_loops"] == [ { "section_index": 0, From 966a39a80a8365ee7f62387dea387a9fa8b757c0 Mon Sep 17 00:00:00 2001 From: Kymuco Date: Sat, 30 May 2026 19:04:50 +0600 Subject: [PATCH 15/15] PR1.2: update debug payload contract test --- tests/unit/test_reporting.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_reporting.py b/tests/unit/test_reporting.py index ca8e620..c72b70e 100644 --- a/tests/unit/test_reporting.py +++ b/tests/unit/test_reporting.py @@ -9,6 +9,7 @@ ArtifactLink, ComponentScore, FeatureFlags, + InputSuitabilitySummary, MetricResult, PracticeLoop, SectionFinding, @@ -54,6 +55,20 @@ def _sample_report() -> AnalysisReport: findings=(SectionFinding(0.0, 8.0, Severity.NOTICE, "Stable section"),), ), ), + input_suitability=InputSuitabilitySummary( + status="ok", + reference_duration_s=8.0, + take_duration_s=8.0, + duration_ratio=1.0, + alignment_coverage=0.95, + voiced_frame_coverage=0.8, + reference_voiced_frame_coverage=0.85, + take_voiced_frame_coverage=0.8, + onset_evidence="present", + reference_onset_count=4, + take_onset_count=4, + reasons=("Take duration is comparable to the reference.",), + ), practice_loops=( PracticeLoop( section_index=0, @@ -78,6 +93,7 @@ def test_report_to_json_payload_is_serializable() -> None: assert payload["overview"]["mode"] == "reference" assert payload["scores"][0]["name"] == "pitch_fidelity" + assert payload["input_suitability"]["status"] == "ok" assert payload["artifacts"][1]["kind"] == "csv_report" @@ -128,10 +144,25 @@ def test_report_to_debug_payload_is_serializable() -> None: "severity": "info", "detail": "Coverage detail", }, + "input_suitability": { + "schema_version": 1, + "status": "ok", + "reference_duration_s": 8.0, + "take_duration_s": 8.0, + "duration_ratio": 1.0, + "alignment_coverage": 0.95, + "voiced_frame_coverage": 0.8, + "reference_voiced_frame_coverage": 0.85, + "take_voiced_frame_coverage": 0.8, + "onset_evidence": "present", + "reference_onset_count": 4, + "take_onset_count": 4, + "reasons": ["Take duration is comparable to the reference."], + }, "section_count": 1, "practice_loop_count": 1, "feedback_count": 1, "artifact_count": 3, } assert payload["confidence"]["level"] == "medium" - assert payload["practice_guidance"]["practice_loops"][0]["focus"] == "pitch_fidelity" \ No newline at end of file + assert payload["practice_guidance"]["practice_loops"][0]["focus"] == "pitch_fidelity"