Skip to content

Hand back a new pitch from getPitchFromNodeDegree - #2028

Merged
mscuthbert merged 2 commits into
cuthbertLab:masterfrom
float3:fix-interval-network-cache-aliasing
Sep 11, 2026
Merged

Hand back a new pitch from getPitchFromNodeDegree#2028
mscuthbert merged 2 commits into
cuthbertLab:masterfrom
float3:fix-interval-network-cache-aliasing

Conversation

@float3

@float3 float3 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

realizeAscending() and realizeDescending() return the very list they put in _ascendingCache / _descendingCache, so the pitches inside it belong to the cache. getPitchFromNodeDegree() handed one of those pitches straight back, and nextPitch() writes the origin's octave onto the pitch it gets ("transfer octave from origin to new pitch derived from node"), so it was editing the cached realization in place.

The effect is that a scale answers differently depending on what has been asked of it before:

>>> sc = scale.RagAsawari('c4')
>>> str(sc.pitchFromDegree(1))
'C4'
>>> str(sc.nextPitch('c1', scale.Direction.ASCENDING))
'D1'
>>> str(sc.pitchFromDegree(1))
'C1'

The cached entry for ('C4', 'C4', 'C5') has its first pitch moved from C4 to C1 by the walk near C1, and every later reader of that realization sees it. realize() already guards the list against this ("Make new objects, because this value might be cached ... and mutating it would be dangerous"); this is the same hazard one level down, on the pitches.

test_scale_main.testRagAsawari asserted the corrupted 'C1'. It asserts 'C4' now, which is what the same call returns earlier in that very test.

realizeAscending() and realizeDescending() return the very list they put
in _ascendingCache / _descendingCache, so the pitches inside it belong to
the cache. getPitchFromNodeDegree() handed one of those pitches straight
back, and nextPitch() writes the origin's octave onto the pitch it gets
("transfer octave from origin to new pitch derived from node"), so it was
editing the cached realization in place.

The effect is that a scale answers differently depending on what has been
asked of it before:

    >>> sc = scale.RagAsawari('c4')
    >>> str(sc.pitchFromDegree(1))
    'C4'
    >>> str(sc.nextPitch('c1', scale.Direction.ASCENDING))
    'D1'
    >>> str(sc.pitchFromDegree(1))
    'C1'

The cached entry for ('C4', 'C4', 'C5') has its first pitch moved from C4
to C1 by the walk near C1, and every later reader of that realization
sees it. realize() already guards the list against this ("Make new
objects, because this value might be cached ... and mutating it would be
dangerous"); this is the same hazard one level down, on the pitches.

test_scale_main.testRagAsawari asserted the corrupted 'C1'. It asserts
'C4' now, which is what the same call returns earlier in that very test.
Two regression tests go with it: one that the pitch handed back is the
caller's own, one for the reported symptom.

The whole suite is unchanged otherwise: 4,597 tests, one failure fewer
and nothing newly failing.
@mscuthbert

Copy link
Copy Markdown
Member

Hi -- the reuse of cached objects here is well -known. Please ensure that the timing considerations from making these changes are considered and demonstrated in the proposal. I specifically have not implemented most changes like this so far because they slow down the generation of scales and work on scales so much that the safety isn't worth the practical considerations. Please write what tests you performed on timing and how the system reacted before and after. Primarily with the creation of MajorScale and various minor scales, since those are the two scales created en masse.

@mscuthbert
mscuthbert marked this pull request as draft September 8, 2026 19:04
@coveralls

coveralls commented Sep 8, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 93.349% (+0.001%) from 93.348% — float3:fix-interval-network-cache-aliasing into cuthbertLab:master

@float3

float3 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Timings below. Short version: the four scales you named never reach the changed
function on the paths that matter, because DiatonicScale sets
usePitchDegreeCache = True — and that cache already returns a new object
(return pitch.Pitch(_pitchDegreeCache[cacheKey]), scale/__init__.py:1796). So
for MajorScale/MinorScale/HarmonicMinorScale/MelodicMinorScale,
pitchFromDegree is a dict hit plus a Pitch constructed from a string, and this
PR changes nothing about it. The copy only lands where that cache does not apply,
which is nextPitch() and the non-diatonic scales.

Method: python 3.12.13, Windows 11. Each case run 5 times, best of 5 reported
(the minimum is the run least disturbed by other work on the machine). calls is
how many times one iteration reaches getPitchFromNodeDegree() from a cold
_pitchDegreeCache.

case calls before µs after µs delta
MajorScale('c4') 0 262.79 263.26 +0.2%
MinorScale('c4') 0 263.58 265.15 +0.6%
HarmonicMinorScale('c4') 0 285.78 286.57 +0.3%
MelodicMinorScale('c4') 0 321.94 323.44 +0.5%
fresh .pitches 0 2815.33 2798.16 −0.6%
fresh .getPitches('c2','c6') 0 1439.59 1433.40 −0.4%
fresh scale .pitchFromDegree(5) 1 270.83 272.68 +0.7%
degree, cache cleared every iteration 1 2798.62 2788.91 −0.3%
warm .nextPitch('c4') 1 340.97 342.60 +0.5%
fresh .nextPitch('c4') 1 3495.19 3501.10 +0.2%
12 fresh MinorScale .pitches 0 35638.97 35566.68 −0.2%
12 fresh MajorScale, 7 degrees each 84 3523.27 3572.39 +1.4%

The three bold rows are the load-bearing ones — they reach the changed function on
every iteration, not just the first. degree, cache cleared every iteration
empties _pitchDegreeCache each time so the call goes all the way through; that is
worst case and not something music21 does. nextPitch() never consults the degree
cache at all.

Several deltas are negative, so here is the noise floor — the same baseline build
measured twice:

case before vs before
MajorScale('c4') −0.1%
fresh .pitches −1.3%
fresh .getPitches('c2','c6') −1.1%
degree, cache cleared every iteration −0.9%
warm .nextPitch('c4') +0.5%
12 fresh MajorScale, 7 degrees each +1.0%

Run-to-run spread on identical code is ±1.3%, which is the same size as every
before/after delta. The change is below the measurement floor.

Why it is invisible where it does apply: copy.deepcopy() of a Pitch is 2.70 µs
here, against a nextPitch() call that already costs 341 µs — 0.8%, and
nextPitch() is the only thing on the hot path that reaches it. Scale creation,
.pitches and .getPitches never call getPitchFromNodeDegree() at all (the
calls column is 0 for all of them), so bulk creation of scales is untouched.

If the 2.70 µs is still unwanted on the nextPitch() path, the narrower fix is to
copy in nextPitch() immediately before it writes the octave, rather than in
getPitchFromNodeDegree(). That fixes the reported corruption at the same cost on
that one path and leaves pitchFromDegree() returning a live cached object for the
non-diatonic scales — happy to switch to that if you prefer the smaller blast
radius.

@mscuthbert

Copy link
Copy Markdown
Member

Hi Float3 -- I know the answer, and you know the answer, but since it's very "load bearing" on your contributions. Please read the Contributing guide and declare use of AI or not and then reopen. I'm appreciating the quality, but you need to own your contributions and edit them so a human can read them. It's a volunteer project and just the rules here. (cool website btw).

@mscuthbert mscuthbert closed this Sep 8, 2026
@float3

float3 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

hi, yeah, I didn't read the contributing.md, I've now read it. Sorry about that. To retroactively declare AI-use I have used AI in some part or another of the process of all my recent PRs here. I told Claude to post the timing table in the comment and it posted more than I wanted. Sorry about that. I really like music21 and hope I can continue to contribute in the future, now following the rules. (I'm glad you like the website)

@mscuthbert mscuthbert reopened this Sep 8, 2026
@mscuthbert
mscuthbert marked this pull request as ready for review September 8, 2026 22:08
@mscuthbert

Copy link
Copy Markdown
Member

hi, yeah, I didn't read the contributing.md, I've now read it. Sorry about that. To retroactively declare AI-use I have used AI in some part or another of the process of all my recent PRs here.

No worries! I'm trying to be a stickler on that because of some other really non-productive requests and I'd been looking aside for a bit but the "load bearing" reply was just too obvious to ignore. In generally though if you could ask your Claude to be more concise and useful for overburdened human code maintainers, that'd be helpful.

Glad to know that this can now be done w/o major timing problems. Will review soon and either get back w/ suggestions or merge.

@mscuthbert mscuthbert left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great!

two followups here:

AbstractScale.getPitchFromNodeDegree, AbstractScale.getNewTonicPitch -- make deepcopies that are no longer needed -- there is one place with an actual 12% slowdown : .pitchFromDegree with minPitch and maxPitch specified, but that's not that common, and only 1 microsec. so fine.

doc stryle: adversarial review did catch "The three-line comment added in test_scale_main.py and the docstring of the second new test narrate the old bug. House style keeps that history in the commit message, which already tells it well." -- so it is something that your agent should have picked up on -- same with intervalNetwork 2710-2713 -- narrating old bug.

getNeighbor=Direction.DESCENDING)),
'F1')

self.assertEqual(str(sc.pitchFromDegree(1)), 'C1')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

from .agents/skills/writing-docs/SKILL.md


Say what is, not what was or what not to do

Describe current behavior. Do not narrate the bug you just fixed, the old
spelling of an API, or when upstream changed something.


more there for the agent to digest... :-)

getPitchFromNodeDegree hands back a new pitch, so the AbstractScale
wrappers over it, getPitchFromNodeDegree and getNewTonicPitch, no longer
need a copy of their own. The comments in intervalNetwork,
test_intervalNetwork and testRagAsawari told the story of the bug that
copy fixed; the fix's own commit already tells it, so they go.

@mscuthbert mscuthbert left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LG! thanks!

@mscuthbert
mscuthbert merged commit ac6cf27 into cuthbertLab:master Sep 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants