diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..62b6ad6bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Changed + +- Compute per-section sequential chord-change counts in one pass without retaining a separate list of overlapping chord labels. + +### Fixed + +- Reject non-finite chord timestamps and non-finite or non-positive section windows before harmony aggregation so malformed timing cannot emit `NaN`/infinite durations or invalid section ranges. +- Skip malformed non-mapping chord entries independently so one bad analysis record cannot discard valid neighboring section-harmony evidence. +- Skip whitespace-only chord labels independently so malformed label evidence cannot become a buyer-visible harmony entry or erase valid neighboring chords. +- Reject Boolean and unrepresentable numeric chord/section timing independently so malformed timing cannot be coerced into buyer-visible section ranges or erase neighboring valid harmony evidence. + ## [0.1.3] - 2026-04-29 ### Fixed @@ -65,4 +76,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file diff --git a/services/analysis-engine/src/bandscope_analysis/chords/section_harmony.py b/services/analysis-engine/src/bandscope_analysis/chords/section_harmony.py index a61a7d001..5221aaab0 100644 --- a/services/analysis-engine/src/bandscope_analysis/chords/section_harmony.py +++ b/services/analysis-engine/src/bandscope_analysis/chords/section_harmony.py @@ -12,12 +12,13 @@ network access, or shell execution. - Bounded: work is O(len(chord_segments) * len(boundaries)) with no recursion or unbounded allocation. -- Safe failure: malformed segments are skipped and empty inputs produce - empty (per-section) summaries; no exceptions escape the public API. +- Safe failure: malformed or non-finite timing is skipped and empty inputs + produce empty (per-section) summaries; no exceptions escape the public API. """ from __future__ import annotations +import math from collections.abc import Mapping, Sequence from typing import TypedDict @@ -41,16 +42,23 @@ class SectionHarmony(TypedDict): chord_changes: int -def _coerce_segment(segment: Mapping[str, object]) -> tuple[float, float, str] | None: - """Extract (start, end, chord) from a chord segment mapping. +def _coerce_segment(segment: object) -> tuple[float, float, str] | None: + """Extract ``(start, end, chord)`` from a possible chord-segment mapping. Args: - segment: Mapping with ``start_time``, ``end_time``, and ``chord`` keys. + segment: Candidate mapping with ``start_time``, ``end_time``, and + ``chord`` keys. Non-mapping values and blank chord labels are + malformed entries and are skipped without discarding neighboring + valid segments. Returns: A ``(start, end, chord)`` tuple, or ``None`` if the segment is - malformed (missing keys, non-numeric times, or non-positive span). + malformed, non-finite, unrepresentable, blank-labeled, or has a + non-positive span. """ + if not isinstance(segment, Mapping): + return None + start_raw = segment.get("start_time") end_raw = segment.get("end_time") chord_raw = segment.get("chord") @@ -58,11 +66,14 @@ def _coerce_segment(segment: Mapping[str, object]) -> tuple[float, float, str] | return None if not isinstance(end_raw, int | float) or isinstance(end_raw, bool): return None - if not isinstance(chord_raw, str): + if not isinstance(chord_raw, str) or not chord_raw.strip(): return None - start = float(start_raw) - end = float(end_raw) - if end <= start: + try: + start = float(start_raw) + end = float(end_raw) + except OverflowError: + return None + if not math.isfinite(start) or not math.isfinite(end) or end <= start: return None return (start, end, chord_raw) @@ -76,22 +87,25 @@ def _summarize_one_section( Args: segments: Validated ``(start, end, chord)`` tuples in input order. - section_start: Section window start time in seconds. - section_end: Section window end time in seconds. + section_start: Finite section window start time in seconds. + section_end: Finite section window end time in seconds. Returns: A :class:`SectionHarmony` for the window. Segments contribute only the portion of their duration that overlaps the window. """ durations: dict[str, float] = {} - overlapping_chords: list[str] = [] + chord_changes = 0 + previous_chord: str | None = None for seg_start, seg_end, chord in segments: overlap = min(seg_end, section_end) - max(seg_start, section_start) if overlap <= 0.0: continue durations[chord] = durations.get(chord, 0.0) + overlap - overlapping_chords.append(chord) + if previous_chord is not None and chord != previous_chord: + chord_changes += 1 + previous_chord = chord chords: list[ChordDuration] = [ {"chord": chord, "duration": duration} @@ -104,12 +118,6 @@ def _summarize_one_section( main_chord = entry["chord"] break - chord_changes = sum( - 1 - for previous, current in zip(overlapping_chords, overlapping_chords[1:], strict=False) - if previous != current - ) - return { "start_time": section_start, "end_time": section_end, @@ -134,13 +142,16 @@ def summarize_section_harmony( Args: chord_segments: Chord segments shaped like ``{"start_time": float, "end_time": float, "chord": str, ...}`` - (e.g. ``TrackedChord`` from the chord recognizer). Malformed + (e.g. ``TrackedChord`` from the chord recognizer). Malformed, + blank-labeled, non-finite, unrepresentable, and non-positive-span entries are skipped. boundaries: Section windows as ``(start, end)`` pairs in seconds. + Windows must use finite, representable, non-Boolean endpoints and + have ``end > start``; invalid windows are skipped. Returns: - One :class:`SectionHarmony` per boundary, in boundary order. Empty - ``boundaries`` yields ``[]``; empty or fully malformed + One :class:`SectionHarmony` per valid boundary, in boundary order. + Empty ``boundaries`` yields ``[]``; empty or fully malformed ``chord_segments`` yields per-section empty summaries with ``main_chord == ""``. Never raises. """ @@ -154,9 +165,22 @@ def summarize_section_harmony( summaries: list[SectionHarmony] = [] for boundary in boundaries: try: - section_start = float(boundary[0]) - section_end = float(boundary[1]) - except (IndexError, TypeError, ValueError): + section_start_raw = boundary[0] + section_end_raw = boundary[1] + except (IndexError, TypeError): + continue + if isinstance(section_start_raw, bool) or isinstance(section_end_raw, bool): + continue + try: + section_start = float(section_start_raw) + section_end = float(section_end_raw) + except (OverflowError, TypeError, ValueError): + continue + if ( + not math.isfinite(section_start) + or not math.isfinite(section_end) + or section_end <= section_start + ): continue summaries.append(_summarize_one_section(segments, section_start, section_end)) return summaries diff --git a/services/analysis-engine/tests/test_section_harmony.py b/services/analysis-engine/tests/test_section_harmony.py index 121843fa2..7ab48eb8b 100644 --- a/services/analysis-engine/tests/test_section_harmony.py +++ b/services/analysis-engine/tests/test_section_harmony.py @@ -140,6 +140,37 @@ def test_malformed_segments_are_skipped() -> None: assert result[0]["chord_changes"] == 0 +def test_non_mapping_segment_is_skipped_without_discarding_valid_neighbors() -> None: + """A malformed non-mapping entry must not erase adjacent valid harmony evidence.""" + segments: Any = [None, _segment(0.0, 3.0, "Em")] + + result = summarize_section_harmony(segments, [(0.0, 3.0)]) + + assert result[0]["main_chord"] == "Em" + assert result[0]["chords"] == [{"chord": "Em", "duration": pytest.approx(3.0)}] + assert result[0]["chord_changes"] == 0 + + +@pytest.mark.parametrize( + ("start", "end"), + [ + (float("nan"), 1.0), + (0.0, float("nan")), + (float("-inf"), 1.0), + (0.0, float("inf")), + ], +) +def test_non_finite_segments_are_skipped(start: float, end: float) -> None: + """Non-finite timing cannot poison an otherwise valid harmony summary.""" + segments = [_segment(start, end, "G"), _segment(0.0, 2.0, "C")] + + result = summarize_section_harmony(segments, [(0.0, 2.0)]) + + assert result[0]["main_chord"] == "C" + assert result[0]["chords"] == [{"chord": "C", "duration": pytest.approx(2.0)}] + assert result[0]["chord_changes"] == 0 + + def test_malformed_boundary_is_skipped() -> None: """A boundary that cannot be coerced to floats is dropped, others survive.""" boundaries: Any = [("x", "y"), (0.0, 2.0)] @@ -150,6 +181,31 @@ def test_malformed_boundary_is_skipped() -> None: assert result[0]["main_chord"] == "D" +@pytest.mark.parametrize( + "invalid_boundary", + [ + (float("nan"), 2.0), + (0.0, float("nan")), + (float("-inf"), 2.0), + (0.0, float("inf")), + (2.0, 2.0), + (3.0, 2.0), + ], +) +def test_invalid_numeric_boundary_is_skipped( + invalid_boundary: tuple[float, float], +) -> None: + """Only finite positive-span section windows can enter result summaries.""" + boundaries = [invalid_boundary, (0.0, 2.0)] + + result = summarize_section_harmony([_segment(0.0, 2.0, "D")], boundaries) + + assert len(result) == 1 + assert result[0]["start_time"] == 0.0 + assert result[0]["end_time"] == 2.0 + assert result[0]["main_chord"] == "D" + + def test_non_iterable_segments_fail_safe() -> None: """A non-iterable chord_segments input returns [] instead of raising.""" bad_segments: Any = 42 diff --git a/services/analysis-engine/tests/test_section_harmony_boolean_boundaries.py b/services/analysis-engine/tests/test_section_harmony_boolean_boundaries.py new file mode 100644 index 000000000..ee5e7242a --- /dev/null +++ b/services/analysis-engine/tests/test_section_harmony_boolean_boundaries.py @@ -0,0 +1,26 @@ +"""Regression tests for Boolean section-boundary timing authority.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bandscope_analysis.chords.section_harmony import summarize_section_harmony + + +@pytest.mark.parametrize("invalid_boundary", [(False, 2.0), (0.0, True)]) +def test_boolean_section_boundary_is_skipped(invalid_boundary: tuple[object, object]) -> None: + """Boolean endpoints must not be coerced into buyer-visible section timing.""" + boundaries: Any = [invalid_boundary, (2.0, 4.0)] + segments = [ + {"start_time": 0.0, "end_time": 2.0, "chord": "C"}, + {"start_time": 2.0, "end_time": 4.0, "chord": "G"}, + ] + + result = summarize_section_harmony(segments, boundaries) + + assert len(result) == 1 + assert result[0]["start_time"] == 2.0 + assert result[0]["end_time"] == 4.0 + assert result[0]["main_chord"] == "G" diff --git a/services/analysis-engine/tests/test_section_harmony_boundary_shape.py b/services/analysis-engine/tests/test_section_harmony_boundary_shape.py new file mode 100644 index 000000000..97650d14e --- /dev/null +++ b/services/analysis-engine/tests/test_section_harmony_boundary_shape.py @@ -0,0 +1,25 @@ +"""Regressions for malformed section-window shapes.""" + +from typing import Any + +import pytest + +from bandscope_analysis.chords.section_harmony import summarize_section_harmony + + +@pytest.mark.parametrize("invalid_boundary", [(0.0,), None]) +def test_malformed_boundary_shape_isolated_from_valid_neighbor(invalid_boundary: Any) -> None: + """Short and non-subscriptable windows are skipped without erasing valid output.""" + boundaries: Any = [invalid_boundary, (0.0, 1.0)] + + result = summarize_section_harmony([], boundaries) + + assert result == [ + { + "start_time": 0.0, + "end_time": 1.0, + "main_chord": "", + "chords": [], + "chord_changes": 0, + } + ] diff --git a/services/analysis-engine/tests/test_section_harmony_fail_closed_iterator.py b/services/analysis-engine/tests/test_section_harmony_fail_closed_iterator.py new file mode 100644 index 000000000..fd5c0db0d --- /dev/null +++ b/services/analysis-engine/tests/test_section_harmony_fail_closed_iterator.py @@ -0,0 +1,16 @@ +"""Regressions for fail-closed section-harmony iterable failures.""" + +from collections.abc import Iterator, Mapping + +from bandscope_analysis.chords.section_harmony import summarize_section_harmony + + +def _exploding_segments() -> Iterator[Mapping[str, object]]: + """Raise after iteration begins to model a corrupted lazy segment source.""" + yield from () + raise RuntimeError("segment source failed during iteration") + + +def test_segment_iterator_failure_returns_empty_summary_instead_of_raising() -> None: + """Unexpected iterable failures remain contained at the public summarizer boundary.""" + assert summarize_section_harmony(_exploding_segments(), [(0.0, 1.0)]) == [] diff --git a/services/analysis-engine/tests/test_section_harmony_malformed_labels.py b/services/analysis-engine/tests/test_section_harmony_malformed_labels.py new file mode 100644 index 000000000..174914fb9 --- /dev/null +++ b/services/analysis-engine/tests/test_section_harmony_malformed_labels.py @@ -0,0 +1,22 @@ +"""Fail-isolated regressions for malformed section-harmony chord labels.""" + +from __future__ import annotations + +import pytest + +from bandscope_analysis.chords.section_harmony import summarize_section_harmony + + +def test_blank_chord_label_is_skipped_without_erasing_neighboring_harmony() -> None: + """Whitespace-only chord evidence must not become a buyer-visible chord entry.""" + result = summarize_section_harmony( + [ + {"start_time": 0.0, "end_time": 1.0, "chord": " "}, + {"start_time": 1.0, "end_time": 3.0, "chord": "G"}, + ], + [(0.0, 3.0)], + ) + + assert result[0]["main_chord"] == "G" + assert result[0]["chords"] == [{"chord": "G", "duration": pytest.approx(2.0)}] + assert result[0]["chord_changes"] == 0 diff --git a/services/analysis-engine/tests/test_section_harmony_numeric_overflow.py b/services/analysis-engine/tests/test_section_harmony_numeric_overflow.py new file mode 100644 index 000000000..706795714 --- /dev/null +++ b/services/analysis-engine/tests/test_section_harmony_numeric_overflow.py @@ -0,0 +1,40 @@ +"""Fail-isolated regressions for unrepresentable section-harmony timing.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from bandscope_analysis.chords.section_harmony import summarize_section_harmony + +_HUGE_INTEGER = 10**10_000 + + +def test_overflowing_segment_timing_is_skipped_without_erasing_neighboring_harmony() -> None: + """An unrepresentable segment endpoint must not discard valid chord evidence.""" + segments: Any = [ + {"start_time": _HUGE_INTEGER, "end_time": _HUGE_INTEGER + 1, "chord": "C"}, + {"start_time": 0.0, "end_time": 2.0, "chord": "G"}, + ] + + result = summarize_section_harmony(segments, [(0.0, 2.0)]) + + assert result[0]["main_chord"] == "G" + assert result[0]["chords"] == [{"chord": "G", "duration": pytest.approx(2.0)}] + assert result[0]["chord_changes"] == 0 + + +def test_overflowing_boundary_is_skipped_without_erasing_neighboring_section() -> None: + """An unrepresentable boundary endpoint must not discard later valid sections.""" + boundaries: Any = [(_HUGE_INTEGER, _HUGE_INTEGER + 1), (0.0, 2.0)] + + result = summarize_section_harmony( + [{"start_time": 0.0, "end_time": 2.0, "chord": "G"}], + boundaries, + ) + + assert len(result) == 1 + assert result[0]["start_time"] == 0.0 + assert result[0]["end_time"] == 2.0 + assert result[0]["main_chord"] == "G"