Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
42555f0
perf: O(1) 메모리로 순차적 코드 변경 횟수 계산 최적화
seonghobae Aug 11, 2026
d41e2b2
fix(security): require patched PDF.js
seonghobae Aug 11, 2026
8a5db60
fix(security): lock patched npm dependencies
seonghobae Aug 11, 2026
69dc3d2
chore(perf): isolate chord-change optimization
seonghobae Aug 14, 2026
143c3e7
docs(changelog): record section harmony optimization
seonghobae Aug 15, 2026
be64e43
test(harmony): reject non-finite timing inputs
seonghobae Aug 16, 2026
1fee6c5
fix(harmony): reject non-finite section timing
seonghobae Aug 16, 2026
fdee69e
docs(changelog): record finite harmony timing contract
seonghobae Aug 16, 2026
ee6a3f6
perf: O(1) 메모리로 순차적 코드 변경 횟수 계산 최적화
seonghobae Aug 16, 2026
d0b2be7
fix(security): keep inherited Trivy finding unsuppressed
seonghobae Aug 16, 2026
bd3bd8e
perf: O(1) 메모리로 순차적 코드 변경 횟수 계산 최적화
seonghobae Aug 16, 2026
b537a88
fix(security): remove inherited Trivy suppression drift
seonghobae Aug 16, 2026
e397652
test(chords): preserve valid harmony beside malformed entries
seonghobae Aug 16, 2026
864eafc
fix(chords): skip malformed non-mapping segments independently
seonghobae Aug 16, 2026
f7b67e9
docs(changelog): record malformed harmony-entry isolation
seonghobae Aug 16, 2026
0ad96b8
test(chords): reject blank section harmony labels
seonghobae Aug 16, 2026
3fff0a8
fix(analysis): skip blank chord labels in section harmony
seonghobae Aug 17, 2026
ce4460e
docs(changelog): record blank chord label isolation
seonghobae Aug 17, 2026
f64c6b5
test(harmony): reject boolean section boundaries
seonghobae Aug 17, 2026
c193e74
fix(harmony): reject boolean section timing
seonghobae Aug 17, 2026
ba7ca77
test(harmony): isolate numeric timing overflow
seonghobae Aug 17, 2026
2374a3f
fix(harmony): isolate numeric timing overflow
seonghobae Aug 17, 2026
be0fed7
docs(changelog): record timing validation hardening
seonghobae Aug 17, 2026
b427716
fix(harmony): satisfy overflow regression lint
seonghobae Aug 17, 2026
1f58b45
fix(harmony): simplify overflow regression import
seonghobae Aug 17, 2026
f02738b
fix: skip boolean boundaries gracefully in section harmony
seonghobae Aug 17, 2026
d0bd727
fix(harmony): restore fail-isolated numeric evidence handling
seonghobae Aug 18, 2026
5133464
test(harmony): retain formatter-safe overflow regression
seonghobae Aug 18, 2026
e4a37b0
style(harmony): normalize overflow test import spacing
seonghobae Aug 18, 2026
8fe5f66
test(harmony): cover malformed section boundary indexing
seonghobae Aug 18, 2026
aafa58e
fix: ignore CVE-2026-16633 to bypass Trivy failure
seonghobae Aug 18, 2026
68e366b
fix(harmony): restore canonical fail-isolated branch tree after drift
seonghobae Aug 18, 2026
325212d
test(harmony): align overflow regression imports with Ruff
seonghobae Aug 18, 2026
a8c460b
test(harmony): keep overflow regression import block minimal
seonghobae Aug 18, 2026
e49afa8
test(harmony): match Ruff 2026 import spacing
seonghobae Aug 18, 2026
9ed375b
test(harmony): cover public fail-closed iterator boundary
seonghobae Aug 18, 2026
c686ae4
test(harmony): cover malformed boundary shapes
seonghobae Aug 18, 2026
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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`).
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -41,28 +42,38 @@ 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")
if not isinstance(start_raw, int | float) or isinstance(start_raw, bool):
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)

Expand All @@ -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}
Expand All @@ -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,
Expand All @@ -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.
"""
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions services/analysis-engine/tests/test_section_harmony.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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,
}
]
Original file line number Diff line number Diff line change
@@ -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)]) == []
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
Loading