diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cbc46e..92016f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 song reduces to the existing uniform model. Analysis only — nothing bakes timestamps (#25) or surfaces in the UI (#26) yet. +- **Per-section sync apply (#25): bake the accepted map into the chart.** + `applyPerSectionSync` and the undoable `TempoPerSectionCmd` turn the accepted + proposals into one continuous piecewise map and apply it song-wide: the beat + grid is remapped and every timed object is walked once, while objects starting + in a skipped or low-confidence section keep their exact original seconds. + Adjacent accepted sections are reconciled to a single target anchor at their + shared boundary, so ordinary fit noise never blocks Apply; a run pinned to + identity at both ends by skipped neighbours is still reported as a conflict. + API only — no UI wiring yet (#26). + - **The undo stack is no longer invisible: a last-action label and a Revert button sit beside Undo/Redo (#43).** Every committed edit now names itself — the label is derived from the command's own class (or an explicit `label` on diff --git a/src/history.js b/src/history.js index 2278bb1..e8e12ad 100644 --- a/src/history.js +++ b/src/history.js @@ -30,6 +30,7 @@ import { isKeysMode, updatePianoRange, _rollReadOnly, _rollLockNotice } from './ const _COMMAND_LABEL_OVERRIDES = Object.freeze({ AudioShiftCmd: 'shift the recording', TempoOffsetCmd: 'nudge the audio offset', + TempoPerSectionCmd: 'apply per-section sync', TempoGridCmd: 'rebuild the beat grid', TempoMapCmd: 'edit the tempo map', TempoLockCmd: 'lock barlines', diff --git a/src/main.js b/src/main.js index d939a1a..5cf1ad2 100644 --- a/src/main.js +++ b/src/main.js @@ -86,6 +86,8 @@ import { editorApplySync, editorHideSyncDialog, editorSyncTempo, editorSyncUpdateFactor, getTabBPM } from './sync-tempo.js'; +import { applyPerSectionSync, _applyPerSectionSyncPure } from './tempo.js'; +import { _buildPiecewiseMapPure } from './section-sync.js'; import { getMousePos, onDblClick, onMouseDown, onMouseMove, onMouseUp, onWheel } from './mouse.js'; @@ -737,6 +739,9 @@ window.editorToggleMapHealth = (force) => editorToggleMapHealth(force); window.editorSyncUpdateFactor = editorSyncUpdateFactor; window.editorHideSyncDialog = editorHideSyncDialog; window.editorApplySync = editorApplySync; +window.applyPerSectionSync = applyPerSectionSync; +window._applyPerSectionSyncPure = _applyPerSectionSyncPure; +window._buildPiecewiseMapPure = _buildPiecewiseMapPure; // Tab preview (tab-preview.js owns the logic; HTML calls these by name). window.editorShowTabPreview = _editorShowTabPreview; diff --git a/src/section-sync.js b/src/section-sync.js index e1b3dd9..8287412 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -602,4 +602,138 @@ export function _alignSectionsPure(params) { proposals, }; } + +/** + * A proposal is applicable when the engine marked it ok, it clears the + * confidence floor, and its affine line is finite with a strictly positive + * slope. A nonpositive slope would make the map non-monotone — event order + * could reverse — so it is treated as inapplicable and its section skipped, + * matching the design's rejection rule (PER-SECTION-SYNC-DESIGN.md L78-80). + */ +function _proposalAcceptedPure(p, confidenceFloor) { + return Boolean(p.ok) + && p.confidence >= confidenceFloor + && Number.isFinite(p.slope) && p.slope > 0 + && Number.isFinite(p.intercept); +} + +// Reconcile every interior seam of one accepted run in place: shift each later +// section's phase so its line meets the previous section's mapped edge at the +// shared boundary. Every section keeps its own slope (its tempo); only phase +// moves, and a constant shift preserves the run's already-reconciled interior. +// The fit noise this removes — independently fitted sections disagreeing by a +// few ms at their seam — is not a conflict. +function _reconcileRunSeamsPure(temp, runStart, runEnd) { + for (let k = runStart; k < runEnd; k++) { + const seam = temp[k].end; + const left = temp[k].slope * seam + temp[k].intercept; + for (let j = k + 1; j <= runEnd; j++) { + temp[j].intercept += left - (temp[j].slope * seam + temp[j].intercept); + } + } +} + +// Pin one accepted run to identity where it abuts retained content — a skipped +// neighbour or a domain edge — so a skipped interval's hard W(t)=t holds at the +// shared seam. A run pinned at BOTH ends must be identity at both; a slope != 1 +// run cannot be, which is a conflict we expose (never demote silently). Being +// first or last in the list is NOT itself a pin — a lone accepted section +// covering the whole chart keeps its uniform affine map. +function _pinRunToIdentityPure(temp, runStart, runEnd, domain, conflicts) { + const first = temp[runStart]; + const last = temp[runEnd]; + const leftPinned = (runStart > 0 && !temp[runStart - 1].accepted) + || first.start > domain.start + EPS; + const rightPinned = (runEnd < temp.length - 1 && !temp[runEnd + 1].accepted) + || last.end < domain.end - EPS; + if (!leftPinned && !rightPinned) return; + const anchor = leftPinned ? first.start : last.end; + const at = leftPinned + ? first.slope * first.start + first.intercept + : last.slope * last.end + last.intercept; + for (let k = runStart; k <= runEnd; k++) temp[k].intercept += anchor - at; + if (!leftPinned || !rightPinned) return; + const atRight = last.slope * last.end + last.intercept; + if (Math.abs(atRight - last.end) <= 1e-6) return; + conflicts.push({ start: first.start, end: last.end, reason: 'identity-conflict' }); + for (let k = runStart; k <= runEnd; k++) { + temp[k].accepted = false; + temp[k].slope = 1; + temp[k].intercept = 0; + } +} + +// Walk the accepted runs of `temp` in order, reconciling interior seams and +// pinning each run to identity at any retained-content border. Mutates `temp` +// in place; irreducible identity conflicts are appended to `conflicts`. +function _enforceRunContinuityPure(temp, domain, conflicts) { + let runStart = 0; + while (runStart < temp.length) { + if (!temp[runStart].accepted) { runStart++; continue; } + let runEnd = runStart; + while (runEnd + 1 < temp.length && temp[runEnd + 1].accepted) runEnd++; + _reconcileRunSeamsPure(temp, runStart, runEnd); + _pinRunToIdentityPure(temp, runStart, runEnd, domain, conflicts); + runStart = runEnd + 1; + } +} + +// Evaluate the piecewise map at `t`: the containing interval's affine line, the +// first line below the domain and the last line above it. Intervals are sorted +// and contiguous, so the first one whose end is past `t` contains it; a point on +// a shared seam falls to the interval that starts there (both agree when the +// seam was reconciled). +function _piecewiseAtPure(intervals, t) { + if (!Number.isFinite(t) || intervals.length === 0) return t; + const first = intervals[0]; + if (t <= first.start + 1e-9) return first.slope * t + first.intercept; + for (const iv of intervals) { + if (t < iv.end - 1e-9) return iv.slope * t + iv.intercept; + } + const last = intervals[intervals.length - 1]; + return last.slope * t + last.intercept; +} + +/** + * Build a piecewise map W(t) from accepted proposals for the given domain. + * For each section interval [start,end) that is accepted (confidence >= floor), + * the affine map is slope*x + intercept; skipped intervals map as identity. + * Continuity is enforced per accepted RUN: an interior seam between two accepted + * sections is reconciled to one target anchor (the later line's phase is shifted + * to meet the earlier one), so independently fitted sections form one continuous + * map instead of jumping. A run abutting a skipped interval is then shifted so + * the shared seam maps to identity, while a run with no such seam keeps its + * (now continuous) map, a lone accepted section using the uniform affine map. A + * run pinned to identity at BOTH ends by skipped neighbours is a conflict unless + * it is already identity — exposed in `conflicts` rather than silently demoted. + */ +export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) { + if (!domain || !Number.isFinite(domain.start) || !Number.isFinite(domain.end)) { + return { ok: false, reason: 'bad-domain' }; + } + const sorted = [...proposals].sort((a, b) => { + if (a.start < b.start - EPS) return -1; + if (a.start > b.start + EPS) return 1; + return 0; + }); + const intervals = sorted.map((p) => { + const accepted = _proposalAcceptedPure(p, confidenceFloor); + return { + start: p.start, + end: p.end, + slope: accepted ? p.slope : 1, + intercept: accepted ? p.intercept : 0, + accepted, + }; + }); + const conflicts = []; + _enforceRunContinuityPure(intervals, domain, conflicts); + return { + ok: true, + domain, + intervals, + conflicts, + map: (t) => _piecewiseAtPure(intervals, t), + }; +} /* @pure:section-sync:end */ diff --git a/src/tempo.js b/src/tempo.js index cc434f3..8e46361 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -31,6 +31,7 @@ import { _zonesGet, _zonesSelect, _zonesShow, _zonesStripHit, _zonesZoneAt, } from './tempo-zones.js'; import { beatOf, timeOf } from './beats.js'; +import { _buildPiecewiseMapPure, _chartDomainPure } from './section-sync.js'; import { DPR, canvas, ctx } from './canvas.js'; import { _drumLaneIdxForPiece, _drumPieceCount } from './drum.js'; import { LABEL_W, TIMELINE_TOP, WAVEFORM_H, timeToX, xToTime } from './geometry.js'; @@ -3962,3 +3963,131 @@ export class TempoOffsetCmd extends TempoMapCmd { this._syncOffsetInput(); } } + +export function _applyPerSectionSyncPure(mapResult, proposals, confidenceFloor = 0.7) { + if (!mapResult || !mapResult.ok) return { applied: false, reason: 'bad-map' }; + if (Array.isArray(mapResult.conflicts) && mapResult.conflicts.length) { + return { applied: false, reason: 'conflict', conflicts: mapResult.conflicts }; + } + const oldBeats = S.beats.map(b => ({ ...b })); + const newBeats = S.beats.map(b => { + const nt = mapResult.map(b.time); + return { ...b, time: typeof nt === 'number' && isFinite(nt) ? nt : b.time }; + }); + const preTimes = []; + _eachTimed((o, tf, endKind) => { + preTimes.push([o, tf, o[tf], + endKind === 'sustain' ? o.sustain : endKind === 'span' ? o.end_time : undefined, + endKind]); + }); + const cmd = new TempoPerSectionCmd(oldBeats, newBeats, preTimes, proposals, mapResult, confidenceFloor); + S.history.exec(cmd); + return { applied: true }; +} + +export class TempoPerSectionCmd { + constructor(oldBeats, newBeats, preTimes, proposals, mapResult, confidenceFloor = 0.7, label = 'per-section sync') { + this.oldBeats = oldBeats.map(b => ({ ...b })); + this.newBeats = newBeats.map(b => ({ ...b })); + this.preTimes = preTimes || []; + this.proposals = proposals || []; + this.mapResult = mapResult || { ok: false }; + this.confidenceFloor = confidenceFloor; + this.label = label; + this.songScope = true; + } + exec() { + _liftAllBeats(this.oldBeats); + S.beats = this.newBeats.map(b => ({ ...b })); + if (this.mapResult && this.mapResult.ok) { + _eachTimed((o, tf, endKind) => this._mapTimed(o, tf, endKind)); + } + host.loopReprojectFromBeats(S.beats); + host.renderLoopStrip(); + host.updateLoopIn3DBtn(); + } + + // True when `tOrig` — an object's ORIGINAL onset — falls inside a section + // the builder did not accept (low confidence, nonpositive/non-finite slope, + // or a conflict demotion). Such an object keeps every timing field exactly + // (its span endpoint included), per the design's skip guarantee. Reading the + // builder's own per-interval decision keeps the apply predicate from + // drifting from the map: a section mapped as identity is left untouched. + _isSkipped(tOrig) { + const intervals = this.mapResult.intervals; + if (!Array.isArray(intervals)) return false; + const domEnd = this.mapResult.domain ? this.mapResult.domain.end : tOrig; + for (const iv of intervals) { + const inInterval = tOrig >= iv.start - 1e-9 + && (tOrig < iv.end - 1e-9 || (tOrig <= iv.end + 1e-9 && iv.end >= domEnd - 1e-9)); + if (inInterval) return !iv.accepted; + } + return false; + } + + // Mapped sustain for a note starting at mapped `nt`; null when the mapped + // end is non-finite or the mapped duration is not strictly positive. The + // design rejects a zero/negative result rather than clamping or truncating + // a span that crosses into a skipped (identity) region. + _mappedSustain(o, tOrig, nt) { + const ntEnd = this.mapResult.map(tOrig + o.sustain); + if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return null; + const mapped = _r3(ntEnd - nt); + return mapped > 0 ? mapped : null; + } + + // Mapped end_time for a span starting at mapped `nt`; null when the mapped + // end is non-finite or lands before the mapped start. + _mappedSpanEnd(o, nt) { + const ntEnd = this.mapResult.map(o.end_time); + if (!(typeof ntEnd === 'number' && isFinite(ntEnd))) return null; + if (ntEnd < nt) return null; + return _r3(ntEnd); + } + + // Map one timed object in place. A skipped object is left untouched; a + // rejected sustain/span (or non-finite mapped start) refuses the whole + // object so no field is ever written from a partial mapping. + _mapTimed(o, tf, endKind) { + if (typeof o[tf] !== 'number') return; + const tOrig = o[tf]; + if (this._isSkipped(tOrig)) return; + const nt = this.mapResult.map(tOrig); + if (!(typeof nt === 'number' && isFinite(nt))) return; + let mappedSustain = null; + let mappedEndTime = null; + if (endKind === 'sustain' && typeof o.sustain === 'number' && o.sustain > 0) { + mappedSustain = this._mappedSustain(o, tOrig, nt); + if (mappedSustain === null) return; + } else if (endKind === 'span' && typeof o.end_time === 'number') { + mappedEndTime = this._mappedSpanEnd(o, nt); + if (mappedEndTime === null) return; + } + o[tf] = _r3(nt); + if (mappedSustain !== null) o.sustain = mappedSustain; + if (mappedEndTime !== null) o.end_time = mappedEndTime; + } + + rollback() { + S.beats = this.oldBeats.map(b => ({ ...b })); + for (const [o, tf, sec, endVal, endKind] of (this.preTimes || [])) { + o[tf] = sec; + if (endKind === 'sustain') { + if (endVal !== undefined) o.sustain = endVal; + } else if (endKind === 'span') { + if (endVal !== undefined) o.end_time = endVal; + } + } + host.loopReprojectFromBeats(S.beats); + host.renderLoopStrip(); + host.updateLoopIn3DBtn(); + } +} + +export function applyPerSectionSync(params) { + const proposals = params.proposals; + const confidenceFloor = params.confidenceFloor != null ? params.confidenceFloor : 0.7; + const domain = params.domain || _chartDomainPure(S.beats); + const mapResult = _buildPiecewiseMapPure(proposals || [], domain, confidenceFloor); + return _applyPerSectionSyncPure(mapResult, proposals || [], confidenceFloor); +} diff --git a/tests/per_section_apply.test.mjs b/tests/per_section_apply.test.mjs new file mode 100644 index 0000000..f75af4c --- /dev/null +++ b/tests/per_section_apply.test.mjs @@ -0,0 +1,146 @@ +/* + * Per-section sync APPLY path (#25): baking the piecewise map into note, span, + * drum-hit and beat seconds, with exact skip preservation and an undoable, + * symmetric command. + * + * The map builder itself is unit-tested in section_sync.test.mjs; this pins the + * apply contract from PER-SECTION-SYNC-DESIGN.md that a pure builder test can't + * reach: + * • an object starting in a skipped section keeps every timing field exactly + * (its sustain/span endpoint included) while its neighbours are baked + * (design L130-136); + * • a both-sided identity-pinned run blocks Apply with a surfaced conflict + * rather than a silent demotion (design L90-98, L143-144); + * • exec → undo → redo is symmetric — exact rollback, reproduced redo. + * + * Run: node tests/per_section_apply.test.mjs + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EditHistory } from '../src/history.js'; +import { seedState } from './_history_env.mjs'; +import { _applyPerSectionSyncPure } from '../src/tempo.js'; +import { _buildPiecewiseMapPure } from '../src/section-sync.js'; + +function accept(start, end, slope, intercept) { + return { start, end, slope, intercept, ok: true, confidence: 0.9 }; +} +function skip(start, end) { + return { start, end, slope: 1, intercept: 0, ok: false, confidence: 0 }; +} + +// A 0..30 s grid and three notes spread across a skipped leading section and an +// accepted run, so skip preservation and mapping are both observable at once. +function scenario() { + const s = seedState(); + s.beats = []; + for (let t = 0; t <= 30; t += 5) { + s.beats.push({ time: t, measure: t % 20 === 0 ? (t / 20) + 1 : -1, den: 4 }); + } + s.sections = []; + s.arrangements = [{ + notes: [ + { time: 5, sustain: 3, midi: 60 }, // skipped section — must not move + { time: 12, sustain: 2, midi: 62 }, // accepted section + { time: 22, sustain: 1, midi: 64 }, // accepted section + ], + chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [], + }]; + s.drumTab = { hits: [{ t: 7 }] }; + s.history = new EditHistory(); + return s; +} + +const times = (arr, key) => arr.map((o) => o[key]); + +test('apply bakes accepted sections and leaves skipped ones bit-for-bit intact', () => { + const s = scenario(); + const props = [skip(0, 10), accept(10, 30, 2, 0)]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + assert.deepStrictEqual(map.conflicts, []); + // The run abuts a skipped section, so it is pinned to identity at the seam. + assert.strictEqual(map.map(10), 10); + assert.strictEqual(map.map(20), 30); + + const preNote = { ...s.arrangements[0].notes[0] }; + const preHit = { ...s.drumTab.hits[0] }; + const res = _applyPerSectionSyncPure(map, props, 0.7); + assert.strictEqual(res.applied, true); + + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, preNote.time, 'skipped note onset untouched'); + assert.strictEqual(notes[0].sustain, preNote.sustain, 'skipped note sustain untouched'); + assert.strictEqual(notes[1].time, 14, 'accepted note onset mapped'); + assert.strictEqual(notes[1].sustain, 4, 'sustain derived from the mapped endpoints'); + assert.strictEqual(notes[2].time, 34, 'later accepted note mapped'); + assert.strictEqual(s.drumTab.hits[0].t, preHit.t, 'skipped drum hit untouched'); + + const bt = times(s.beats, 'time'); + assert.ok(bt.every((t, i) => i === 0 || t > bt[i - 1]), `grid strictly increasing: ${bt}`); +}); + +test('apply maps span endpoints and round-trips through undo then redo', () => { + const s = scenario(); + s.arrangements[0].handshapes = [{ start_time: 15, end_time: 25 }]; + const props = [skip(0, 10), accept(10, 30, 2, 0)]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + const before = { + beats: times(s.beats, 'time'), + notes: times(s.arrangements[0].notes, 'time'), + hit: s.drumTab.hits[0].t, + }; + + assert.strictEqual(_applyPerSectionSyncPure(map, props, 0.7).applied, true); + assert.strictEqual(s.arrangements[0].handshapes[0].start_time, 20, 'span start mapped'); + assert.strictEqual(s.arrangements[0].handshapes[0].end_time, 40, 'span end mapped'); + + s.history.doUndo(); + assert.deepStrictEqual(times(s.arrangements[0].notes, 'time'), before.notes, 'undo restores note onsets'); + assert.deepStrictEqual(times(s.beats, 'time'), before.beats, 'undo restores the grid'); + assert.strictEqual(s.drumTab.hits[0].t, before.hit, 'undo restores the skipped hit'); + + s.history.doRedo(); + assert.strictEqual(s.arrangements[0].notes[1].time, 14, 'redo reproduces the apply'); + assert.strictEqual(s.arrangements[0].handshapes[0].end_time, 40, 'redo reproduces the mapped span'); +}); + +test('a nonpositive-slope proposal is skipped on apply, not only by the builder', () => { + const s = scenario(); + s.arrangements[0].notes = [ + { time: 8, sustain: 5, midi: 60 }, // section A: ok but slope 0 → inapplicable + { time: 15, sustain: 1, midi: 62 }, // accepted section + ]; + const props = [ + { start: 0, end: 10, slope: 0, intercept: 5, ok: true, confidence: 0.9 }, + accept(10, 30, 2, 0), + ]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + assert.deepStrictEqual(map.conflicts, []); + // Slope 0 makes the proposal inapplicable, so section A maps as identity + // even though the neighbour's line would extrapolate past the seam. + assert.strictEqual(map.map(8), 8); + assert.strictEqual(map.map(13), 16); + + const pre = { ...s.arrangements[0].notes[0] }; + const res = _applyPerSectionSyncPure(map, props, 0.7); + assert.strictEqual(res.applied, true); + + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, pre.time, 'inapplicable-slope note onset untouched'); + assert.strictEqual(notes[0].sustain, pre.sustain, 'sustain not recomputed from the accepted neighbour'); + assert.strictEqual(notes[1].time, 20, 'accepted neighbour still baked'); +}); + +test('a both-sided identity-pinned run blocks Apply with a surfaced conflict', () => { + const s = scenario(); + const props = [skip(0, 10), accept(10, 20, 2, 0), skip(20, 30)]; + const map = _buildPiecewiseMapPure(props, { start: 0, end: 30 }); + assert.strictEqual(map.conflicts.length, 1); + assert.strictEqual(map.conflicts[0].reason, 'identity-conflict'); + + const res = _applyPerSectionSyncPure(map, props, 0.7); + assert.strictEqual(res.applied, false); + assert.strictEqual(res.reason, 'conflict'); + assert.strictEqual(res.conflicts.length, 1); + assert.strictEqual(s.arrangements[0].notes[1].time, 12, 'nothing was baked when Apply was refused'); +}); diff --git a/tests/section_sync.test.mjs b/tests/section_sync.test.mjs index cfbd8d5..57906f8 100644 --- a/tests/section_sync.test.mjs +++ b/tests/section_sync.test.mjs @@ -40,6 +40,7 @@ import { _sectionRegionsPure, _localBpmFromBeatsPure, _dtwAlignPure, _slopeFromPairsPure, _fitAffinePure, _chartDomainPure, _collectSourceOnsetsPure, _alignSectionPure, _alignSectionsPure, + _buildPiecewiseMapPure, } from '../src/section-sync.js'; let pass = 0, fail = 0; @@ -382,5 +383,81 @@ t('a section whose first note is not on its marker keeps the marker place', () = `first anchor lands on the first note (5,5), got ${JSON.stringify(first)}`); }); +// ── 5. Piecewise map builder: continuity only where skipped content pins it ── +function acceptedIv(start, end, slope, intercept) { + return { start, end, slope, intercept, ok: true, confidence: 0.9 }; +} +function skippedIv(start, end) { + return { start, end, slope: 1, intercept: 0, ok: false, confidence: 0 }; +} + +t('piecewise: a lone accepted section keeps its uniform affine map', () => { + const res = _buildPiecewiseMapPure([acceptedIv(0, 30, 2, 0)], { start: 0, end: 30 }); + assert.strictEqual(res.ok, true); + assert.deepStrictEqual(res.conflicts, []); + assert.strictEqual(res.map(10), 20, 'a domain edge is not an identity pin, so the sole section is not demoted'); +}); + +t('piecewise: adjacent accepted sections keep their proposals', () => { + const res = _buildPiecewiseMapPure([acceptedIv(0, 15, 2, 0), acceptedIv(15, 30, 2, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.ok(Math.abs(res.map(14.999) - 29.998) < 1e-6, `map(14.999) ${res.map(14.999)}`); + assert.ok(Math.abs(res.map(15) - 30) < 1e-9, `map(15) ${res.map(15)} — no backwards jump at the seam`); +}); + +t('piecewise: a run abutting a skipped section is pinned to identity at the seam', () => { + const res = _buildPiecewiseMapPure([skippedIv(0, 10), acceptedIv(10, 30, 2, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.ok(Math.abs(res.map(10) - 10) < 1e-9, `map(10) ${res.map(10)} — pinned to identity`); + assert.ok(Math.abs(res.map(20) - 30) < 1e-9, `map(20) ${res.map(20)} — run slope carried past the seam`); +}); + +t('piecewise: a both-sided-pinned incompatible run is exposed, never silently demoted', () => { + const res = _buildPiecewiseMapPure( + [skippedIv(0, 10), acceptedIv(10, 20, 2, 0), skippedIv(20, 30)], { start: 0, end: 30 }); + assert.strictEqual(res.conflicts.length, 1, 'the identity conflict is exposed'); + assert.strictEqual(res.conflicts[0].start, 10); + assert.strictEqual(res.conflicts[0].end, 20); +}); + +t('piecewise: the engine\'s own two-section fit reconciles its seam, never blocks Apply', () => { + // Regression: independently fitted sections disagree at their shared + // boundary by ordinary fit noise (~25 ms here). That is not a conflict — + // the builder must reconcile the seam into one continuous map, not refuse. + const res = analyze(); + const map = _buildPiecewiseMapPure(res.proposals, res.domain, 0.7); + assert.deepStrictEqual(map.conflicts, [], 'fit noise at an accepted seam is reconciled, not flagged'); + const seam = byName(res, 'Solo').start; + const left = map.map(seam - 1e-6), right = map.map(seam); + assert.ok(Math.abs(left - right) < 1e-3, + `continuous at the shared seam (${left.toFixed(4)} vs ${right.toFixed(4)})`); +}); + +t('piecewise: a nonpositive-slope proposal is skipped, never used to reverse order', () => { + // The design makes a nonpositive slope inapplicable (L78-80): accepting it + // would bake a flat or backwards section and reverse event order on apply. + const res = _buildPiecewiseMapPure( + [acceptedIv(0, 15, 0, 5), acceptedIv(15, 30, 2, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.strictEqual(res.map(10), 10, 'the slope-0 section falls back to identity, not the constant 5'); + assert.strictEqual(res.map(15), 15, 'the accepted run is pinned to identity at the skipped seam'); + assert.strictEqual(res.map(30), 45, 'and keeps its slope past the seam'); +}); + +t('piecewise: a reconciled seam of differing slopes stays strictly increasing', () => { + // Independently fitted sections with different tempos are reconciled to one + // continuous line; with positive slopes that guarantees no event-order + // reversal at the seam. + const res = _buildPiecewiseMapPure( + [acceptedIv(0, 15, 1.2, 0), acceptedIv(15, 30, 0.8, 0)], { start: 0, end: 30 }); + assert.deepStrictEqual(res.conflicts, []); + assert.ok(Math.abs(res.map(15) - 18) < 1e-9, `seam target ${res.map(15)}`); + const xs = [0, 5, 10, 14.999, 15, 15.001, 20, 25, 30]; + const ys = xs.map((x) => res.map(x)); + for (let i = 1; i < ys.length; i++) { + assert.ok(ys[i] > ys[i - 1], `strictly increasing at ${xs[i]} (${ys[i - 1]} -> ${ys[i]})`); + } +}); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);