Skip to content
Closed
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
40dcbc9
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 13, 2026
599c353
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 13, 2026
39a9265
build(security): update pdfjs-dist to 6.2.108 to resolve CVE-2026-16633
seonghobae Aug 13, 2026
53e054c
build(security): update pdfjs-dist to 6.2.108 to resolve CVE-2026-16633
seonghobae Aug 14, 2026
36db87e
chore(perf): isolate chart dedup optimization
seonghobae Aug 14, 2026
2d92b26
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 14, 2026
9f58b96
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 14, 2026
86ccbf2
chore(perf): keep chart optimization dependency-neutral
seonghobae Aug 14, 2026
5fc5e99
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 14, 2026
70893b7
perf(exports): restore chart deduplication to atomic scope
seonghobae Aug 14, 2026
982a367
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 14, 2026
f357941
chore(scope): restore chart optimization isolation
seonghobae Aug 14, 2026
45a3020
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 14, 2026
1a3d674
test(chart): keep optimization notes in Unreleased
seonghobae Aug 15, 2026
88aa81b
fix(changelog): keep chart optimization unreleased
seonghobae Aug 15, 2026
fe7c49d
test(changelog): freeze released audio-intake history
seonghobae Aug 15, 2026
ee9d0d4
fix(changelog): restore immutable released wording
seonghobae Aug 15, 2026
e0f87a9
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 15, 2026
26b6614
fix(chart): restore Unreleased changelog ownership
seonghobae Aug 15, 2026
6073935
test(chart): restore ordered dedup regression contract
seonghobae Aug 15, 2026
6674b56
test(chart): exercise distinct active duplicate roles
seonghobae Aug 15, 2026
e3268c8
test(chart): preserve linear dedup complexity contract
seonghobae Aug 23, 2026
f1d7067
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 25, 2026
352d954
perf: 차트 내보내기에서 배열 조회(not in list)를 딕셔너리 키 조회로 변경하여 O(N^2) 성능 저하 방지
seonghobae Aug 26, 2026
3b7717d
perf: 차트 내보내기에서 O(N^2) 성능 저하 방지 및 npm/Trivy CI 실패 수정
seonghobae Aug 26, 2026
a868964
Merge branch 'develop' into perf-chart-dedup-7079628762098570838
seonghobae Aug 26, 2026
9c27a94
perf: 차트 내보내기에서 O(N^2) 성능 저하 방지
seonghobae Aug 26, 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Changed

- 차트 내보내기(chart.py)에서 `not in list` 대신 삽입 순서가 보장되는 딕셔너리(`dict.keys()`)를 활용하여 역할, 큐 및 우선순위 데이터의 중복 제거 방식을 $O(N^2)$에서 $O(N)$으로 최적화했습니다.

## [0.1.3] - 2026-04-29

### Fixed
Expand Down
31 changes: 15 additions & 16 deletions services/analysis-engine/src/bandscope_analysis/exports/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ def _active_role_ids(section: Mapping[str, object]) -> list[str] | None:
part_graph = section.get("partGraph")
if not isinstance(part_graph, list):
return None
active: list[str] = []
active: dict[str, None] = {}
for node in part_graph:
if not isinstance(node, Mapping) or node.get("is_active") is not True:
continue
role_id = node.get("role_id")
if isinstance(role_id, str) and role_id and role_id not in active:
active.append(role_id)
return active
if isinstance(role_id, str) and role_id:
active[role_id] = None
return list(active.keys())
Comment on lines +81 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: chart.py dedup change preserves output

Replacing the not in list checks with insertion-ordered dict keys in _active_role_ids, _active_role_names, _section_cue, and _footer_lines keeps first-occurrence order and identical dedup results, so the emitted lists and joined strings are unchanged while complexity drops to O(N).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



def _active_roles(section: Mapping[str, object]) -> list[Mapping[str, object]]:
Expand Down Expand Up @@ -121,25 +121,25 @@ def _role_display_name(role: Mapping[str, object]) -> str | None:

def _active_role_names(section: Mapping[str, object]) -> list[str]:
"""Return de-duplicated display names for the section's active roles."""
names: list[str] = []
names: dict[str, None] = {}
for role in _active_roles(section):
name = _role_display_name(role)
if name is not None and name not in names:
names.append(name)
return names
if name is not None:
names[name] = None
return list(names.keys())


def _section_cue(section: Mapping[str, object]) -> str:
"""Join the active roles' cue values into a single cue string."""
cues: list[str] = []
cues: dict[str, None] = {}
for role in _active_roles(section):
cue = role.get("cue")
if not isinstance(cue, Mapping):
continue
value = cue.get("value")
if isinstance(value, str) and value and value not in cues:
cues.append(value)
return "; ".join(cues)
if isinstance(value, str) and value:
cues[value] = None
return "; ".join(cues.keys())


def _confidence_level(section: Mapping[str, object]) -> str | None:
Expand Down Expand Up @@ -188,19 +188,18 @@ def _section_lines(sections: list[Mapping[str, object]]) -> list[str]:
def _footer_lines(song: Mapping[str, object], sections: list[Mapping[str, object]]) -> list[str]:
"""Build the footer: per-role rehearsal priorities and the export focus."""
lines: list[str] = []
priorities: list[str] = []
priorities: dict[str, None] = {}
for section in sections:
for role in _section_roles(section):
name = _role_display_name(role)
priority = role.get("rehearsalPriority")
if name is None or not isinstance(priority, str) or not priority:
continue
entry = f" - {name}: {priority}"
if entry not in priorities:
priorities.append(entry)
priorities[entry] = None
if priorities:
lines.append("Priorities:")
lines.extend(priorities)
lines.extend(priorities.keys())
summary = song.get("exportSummary")
if isinstance(summary, Mapping):
headline = summary.get("headline")
Expand Down
39 changes: 39 additions & 0 deletions services/analysis-engine/tests/test_chart_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,42 @@ def test_path_like_fields_never_reach_output(self) -> None:
assert "secret-demo" not in text
assert "/Users" not in rows_json
assert "secret-demo" not in rows_json

def test_deduplication_preserves_first_occurrence_order(self) -> None:
"""Deduplication uses dictionary keys to preserve insertion order exactly."""
song = _demo_song()
section = song["sections"][0]
# Set up a sequence that will break an unordered set implementation
# and test deduplication across disjoint identical values.
section["roles"] = [
_role("bass", "Bass", "Walk up"),
_role("drums", "Drums", "Hit on 1"),
_role("keys", "Keys", "Hold chord"),
_role("bass-copy", "Bass", "Walk up"),
_role("drums-copy", "Drums", "Hit on 1"),
_role("keys-copy", "Keys", "Hold chord"),
]
for role in section["roles"]:
role["rehearsalPriority"] = "medium"
section["roles"][0]["rehearsalPriority"] = "high"
section["roles"][2]["rehearsalPriority"] = "low"

section["partGraph"] = [
{"role_id": "bass", "is_active": True, "handoff_to": [], "handoff_from": []},
{"role_id": "drums", "is_active": True, "handoff_to": [], "handoff_from": []},
{"role_id": "keys", "is_active": True, "handoff_to": [], "handoff_from": []},
{"role_id": "bass", "is_active": True, "handoff_to": [], "handoff_from": []},
{"role_id": "drums", "is_active": True, "handoff_to": [], "handoff_from": []},
{"role_id": "keys", "is_active": True, "handoff_to": [], "handoff_from": []},
]
Comment thread
seonghobae marked this conversation as resolved.

rows = build_cue_sheet_rows(song)
# Order should be exact and duplicates removed
assert rows[0]["roles"] == ["Bass", "Drums", "Keys"]
assert rows[0]["cue"] == "Walk up; Hit on 1; Hold chord"

text = build_chart_text(song)
# Priorities deduplication
assert " - Bass: high" in text
assert " - Drums: medium" in text
assert " - Keys: low" in text
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Deterministic complexity regression for chart-export role de-duplication."""

from typing import ClassVar

from bandscope_analysis.exports.chart import _active_role_ids


class _CountingRoleId(str):
"""String role id that counts equality work without wall-clock timing."""

comparisons: ClassVar[int] = 0

def __eq__(self, other: object) -> bool:
"""Count one equality comparison and preserve normal string semantics."""
type(self).comparisons += 1
return super().__eq__(other)

def __hash__(self) -> int:
"""Preserve normal string hashing for representative hash membership."""
return super().__hash__()


def test_active_role_id_deduplication_avoids_quadratic_equality_work() -> None:
"""Unique active ids must not require pairwise list-membership comparisons."""
role_ids = [_CountingRoleId(f"role-{index}") for index in range(64)]
section = {
"partGraph": [
{"role_id": role_id, "is_active": True}
for role_id in [*role_ids, _CountingRoleId("role-0")]
]
}

_CountingRoleId.comparisons = 0
active = _active_role_ids(section)

assert active == role_ids
assert _CountingRoleId.comparisons < 256
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Regression contracts for the ordered chart-export optimization."""

from __future__ import annotations

from pathlib import Path

from bandscope_analysis.exports.chart import build_chart_text, build_cue_sheet_rows

PERFORMANCE_NOTE = (
"- 차트 내보내기(chart.py)에서 `not in list` 대신 삽입 순서가 보장되는 "
"딕셔너리(`dict.keys()`)를 활용하여 역할, 큐 및 우선순위 데이터의 중복 제거 "
"방식을 $O(N^2)$에서 $O(N)$으로 최적화했습니다."
)
RELEASED_AUDIO_INTAKE_NOTE = (
"- Issue #33: Implemented secure local audio intake and project bootstrap"
)


def _repo_root() -> Path:
"""Return the repository root from this analysis-engine test module."""
for parent in Path(__file__).resolve().parents:
if (parent / "CHANGELOG.md").is_file() and (parent / "package.json").is_file():
return parent
raise RuntimeError("Could not locate the repository root")


def _role(role_id: str, name: str, priority: str) -> dict[str, object]:
"""Build the minimal role payload used by the priority-order regression."""
return {"id": role_id, "name": name, "rehearsalPriority": priority}


def _cue_role(role_id: str, name: str, cue_value: str) -> dict[str, object]:
"""Build an active role with a display name and cue for ordered dedup tests."""
return {
"id": role_id,
"name": name,
"cue": {"kind": "entrance", "value": cue_value},
}


def test_chart_optimization_note_is_owned_by_unreleased() -> None:
"""Keep current performance work out of immutable historical release notes."""
changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8")
unreleased, separator, released = changelog.partition("\n## [0.1.3]")

assert separator, "expected the first released-version heading"
assert PERFORMANCE_NOTE in unreleased
assert changelog.count(PERFORMANCE_NOTE) == 1
assert RELEASED_AUDIO_INTAKE_NOTE in released
assert "- Issue #33: Engineered secure local audio intake and project bootstrap" not in released


def test_priority_deduplication_preserves_first_occurrence_order_exactly() -> None:
"""De-duplicate exact priority lines while retaining their source order."""
song: dict[str, object] = {
"title": "Ordered priorities",
"sections": [
{
"label": "verse",
"timeRange": {"start": 0, "end": 10},
"roles": [
_role("bass", "Bass", "high"),
_role("drums", "Drums", "medium"),
_role("bass-copy", "Bass", "high"),
],
},
{
"label": "chorus",
"timeRange": {"start": 10, "end": 20},
"roles": [
_role("keys", "Keys", "low"),
_role("drums-copy", "Drums", "medium"),
],
},
],
}

output = build_chart_text(song)
priority_lines = [line for line in output.splitlines() if line.startswith(" - ")]

assert priority_lines == [
" - Bass: high",
" - Drums: medium",
" - Keys: low",
]


def test_active_role_and_cue_deduplication_uses_distinct_active_role_ids() -> None:
"""Exercise duplicate display/cue values through distinct active graph role IDs."""
song: dict[str, object] = {
"title": "Ordered active values",
"sections": [
{
"label": "verse",
"timeRange": {"start": 0, "end": 10},
"roles": [
_cue_role("bass", "Bass", "Walk up"),
_cue_role("drums", "Drums", "Hit on 1"),
_cue_role("bass-copy", "Bass", "Walk up"),
_cue_role("drums-copy", "Drums", "Hit on 1"),
],
"partGraph": [
{"role_id": "bass", "is_active": True},
{"role_id": "drums", "is_active": True},
{"role_id": "bass-copy", "is_active": True},
{"role_id": "drums-copy", "is_active": True},
],
}
],
}

rows = build_cue_sheet_rows(song)

assert rows[0]["roles"] == ["Bass", "Drums"]
assert rows[0]["cue"] == "Walk up; Hit on 1"
assert "roles: Bass, Drums" in build_chart_text(song)
Loading