Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/practicelens/api/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion src/practicelens/application/offline_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
return sum(1 for voiced in bundle.voiced_mask if voiced) / float(len(bundle.voiced_mask))
5 changes: 5 additions & 0 deletions src/practicelens/diagnostics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from __future__ import annotations

from practicelens.diagnostics.input_suitability import summarize_input_suitability

__all__ = ["summarize_input_suitability"]
133 changes: 133 additions & 0 deletions src/practicelens/diagnostics/input_suitability.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions src/practicelens/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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, ...] = ()
Expand Down
3 changes: 2 additions & 1 deletion src/practicelens/reporting/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
return report_with_artifacts, artifacts
2 changes: 2 additions & 0 deletions src/practicelens/reporting/batch_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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": [
Expand Down
4 changes: 3 additions & 1 deletion src/practicelens/reporting/debug_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the debug payload contract expectation

In this context, PYTHONPATH=src pytest -q tests/unit fails in test_report_to_debug_payload_is_serializable because evidence_summary now includes input_suitability but the stable debug-payload expected value was not updated. Since debug_payload.json is treated as a stable diagnostic artifact, please update the expected payload/fixtures with this new nested field before merging.

Useful? React with 👍 / 👎.

"section_count": len(report.sections),
"practice_loop_count": len(report.practice_loops),
"feedback_count": len(report.feedback),
Expand Down Expand Up @@ -102,4 +104,4 @@ def _find_metric(report: AnalysisReport, metric_name: MetricName) -> MetricResul


def _round_score(value: float) -> float:
return round(value, _SCORE_DIGITS)
return round(value, _SCORE_DIGITS)
21 changes: 21 additions & 0 deletions src/practicelens/reporting/input_suitability_payload.py
Original file line number Diff line number Diff line change
@@ -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),
}
2 changes: 2 additions & 0 deletions src/practicelens/reporting/json_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the report contract expectations

In this context, running PYTHONPATH=src pytest -q tests/unit/test_json_report.py tests/integration/test_offline_pipeline.py now fails because this new top-level input_suitability key is not reflected in the stable report.json contract assertions/examples, which still expect the old shape. Please update the contract tests and generated example artifacts alongside this schema addition; otherwise CI remains red for the published report payload.

Useful? React with 👍 / 👎.

"practice_loops": [
{
"section_index": loop.section_index,
Expand Down
9 changes: 8 additions & 1 deletion tests/integration/test_offline_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
assert len(extracted_bundles) == 3
6 changes: 6 additions & 0 deletions tests/unit/test_api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading