@vessel-dsp/runtime 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/reference-runtime.d.ts +223 -5
- package/dist/reference-runtime.d.ts.map +1 -1
- package/dist/reference-runtime.js +669 -107
- package/dist/reference-runtime.js.map +1 -1
- package/dist/resample.d.ts +68 -0
- package/dist/resample.d.ts.map +1 -0
- package/dist/resample.js +204 -0
- package/dist/resample.js.map +1 -0
- package/dist/v2-wasm-engine.d.ts +32 -0
- package/dist/v2-wasm-engine.d.ts.map +1 -1
- package/dist/v2-wasm-engine.js +49 -1
- package/dist/v2-wasm-engine.js.map +1 -1
- package/dist/wasm/v2_dsp.cjs +1 -1
- package/dist/wasm/v2_dsp.wasm +0 -0
- package/dist/worklet/v2-audio-worklet.js +720 -101
- package/package.json +2 -2
- package/dist/numeric-pivot.d.ts +0 -4
- package/dist/numeric-pivot.d.ts.map +0 -1
- package/dist/numeric-pivot.js +0 -192
- package/dist/numeric-pivot.js.map +0 -1
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// else, and `src/compiler/` never imports this. Either can be deleted without the other
|
|
20
20
|
// noticing. Nothing from the old spine (`src/web`, `src/admission`, `src/container`,
|
|
21
21
|
// `src/dsp`) may be imported here.
|
|
22
|
+
import { computeNumericRepivot } from "@vessel-dsp/compiler";
|
|
22
23
|
import { admissionVerdict } from "./admission.js";
|
|
23
|
-
import {
|
|
24
|
+
import { HalfBandStage2x, cascadeLatencyHostSamples, designHalfBand2x, resampleStageSpec, } from "./resample.js";
|
|
24
25
|
import { taperFraction } from "./taper.js";
|
|
25
26
|
export class RuntimeError extends Error {
|
|
26
27
|
constructor(message) {
|
|
@@ -1162,6 +1163,20 @@ export class ReferenceRuntime {
|
|
|
1162
1163
|
* correct at the sub-sample rate. `hostSampleRate()` is the rate a caller passed in.
|
|
1163
1164
|
*/
|
|
1164
1165
|
oversample = 1;
|
|
1166
|
+
/**
|
|
1167
|
+
* Band-limited resampling around the solver, one 2x half-band stage per
|
|
1168
|
+
* entry, cascaded for 4x and 8x. Empty unless `oversample` is a power of
|
|
1169
|
+
* two greater than 1; any other factor keeps the legacy hold-and-last
|
|
1170
|
+
* path below. Built fresh (zero state) in `prepare()`, so the filter
|
|
1171
|
+
* state never leaks across renders and block splits stay bit-identical.
|
|
1172
|
+
*/
|
|
1173
|
+
resampleUp = [];
|
|
1174
|
+
resampleDown = [];
|
|
1175
|
+
/** Scratch for the sub-sample streams, sized to `oversample` in `prepare()`. */
|
|
1176
|
+
resampleBufA = new Float64Array(0);
|
|
1177
|
+
resampleBufB = new Float64Array(0);
|
|
1178
|
+
/** Total resampler group delay in host samples (0 on the legacy path). */
|
|
1179
|
+
resampleLatencyHost = 0;
|
|
1165
1180
|
positions = new Map();
|
|
1166
1181
|
lastShiftedSample = new Map();
|
|
1167
1182
|
/**
|
|
@@ -1402,6 +1417,50 @@ export class ReferenceRuntime {
|
|
|
1402
1417
|
* samples 4800 of 4800 -> 22.
|
|
1403
1418
|
*/
|
|
1404
1419
|
unproductiveUntilSample = new Map();
|
|
1420
|
+
/**
|
|
1421
|
+
* Per block, the last three converged sub-sample solutions and the extrapolation order
|
|
1422
|
+
* that would have predicted the newest of them best, for the Newton start predictor.
|
|
1423
|
+
*
|
|
1424
|
+
* **The start of a Newton solve is a guess, and the previous solution is only the
|
|
1425
|
+
* cheapest guess, not the best one.** At 4x oversampling the circuit moves a quarter as
|
|
1426
|
+
* far per solve, and a quadratic extrapolation of the last three solutions lands inside
|
|
1427
|
+
* the convergence tolerance on most sub-samples, so the first Newton step is already the
|
|
1428
|
+
* answer and the check that follows it is the whole solve: one iteration where the
|
|
1429
|
+
* previous-solution start needs two (one step, one check). Measured 2026-10-09 on the six
|
|
1430
|
+
* profile packets at os4 (`docs/spikes/2026-10-09-newton-iteration-budget.md`): iterations
|
|
1431
|
+
* per host sample fall 38-47 % on the pedals with every converged solution within 0.02
|
|
1432
|
+
* tolerance units of the full-Newton solution from the same state.
|
|
1433
|
+
*
|
|
1434
|
+
* **The order is chosen by the block's own history, not by a constant.** After every
|
|
1435
|
+
* converged solve the three candidate starts the history could have produced (order 0 =
|
|
1436
|
+
* the previous solution, 1 = linear, 2 = quadratic) are scored against the solution just
|
|
1437
|
+
* reached, in tolerance units, and the best one is used for the next solve -- but only
|
|
1438
|
+
* when that solve took at most two iterations (the floor: one step and one check). A
|
|
1439
|
+
* solve that needed more was hunting across a knee, and there a closer start is not a
|
|
1440
|
+
* cheaper one (`recordNewtonSolution` has the measurement). On a hard edge the
|
|
1441
|
+
* extrapolations overshoot the clipping knee and score worse than the previous solution,
|
|
1442
|
+
* so the next solve starts exactly where it always did; on a signal it cannot predict it
|
|
1443
|
+
* costs nothing. A non-converged solve breaks the chain (no history to extrapolate from).
|
|
1444
|
+
*
|
|
1445
|
+
* Only the start moves. The convergence test, the limiter histories, the relaxation rule
|
|
1446
|
+
* and the fold reseed (which reads the caller's `start`, the previous solution) are
|
|
1447
|
+
* untouched, so what converges is still the circuit's equations at the shipped
|
|
1448
|
+
* tolerances -- a different first iterate, the same fixed point.
|
|
1449
|
+
*
|
|
1450
|
+
* **It runs only when `prepare()` was given `oversample > 1`; at factor 1 (the default)
|
|
1451
|
+
* it neither seeds nor records, so the ring stays empty and the solve is the one runtime
|
|
1452
|
+
* 0.3.1 ran.** It shipped in 0.4.0 at every factor and was gated here in 0.4.1 because at
|
|
1453
|
+
* factor 1 it buys little and moves the output more than that saving is worth: iterations
|
|
1454
|
+
* fall 2.9 % across the pedal corpus and 10.1 % across the amps (1 kHz 0.1 V, cap 64),
|
|
1455
|
+
* against `boss-dm-2` moving 6.2 % of its cold-start peak (0.5-0.8 % of the settled peak)
|
|
1456
|
+
* and `boss-mt-2` stalling the WASM console permanently from 2.752 s of two-tone drive
|
|
1457
|
+
* (it does not stall with the predictor off). Above 1 it stays on for every factor, the
|
|
1458
|
+
* held path included (iterations on the profile packets: 2x -6..-15 %, 3x 0..-1 %, 4x
|
|
1459
|
+
* -39..-47 %, 5x/6x -8..-24 %, 8x -44..-50 %; output movement at most 6e-6 relative RMS).
|
|
1460
|
+
* The factor is fixed by `prepare()` (which also clears the ring), so there is no mid-run
|
|
1461
|
+
* switch to reason about.
|
|
1462
|
+
*/
|
|
1463
|
+
newtonStartHistory = new Map();
|
|
1405
1464
|
blockNewtonCensus = new Map();
|
|
1406
1465
|
totalIterations = 0;
|
|
1407
1466
|
nonFiniteSamples = 0;
|
|
@@ -1682,6 +1741,30 @@ export class ReferenceRuntime {
|
|
|
1682
1741
|
// The solver runs at the sub-sample rate. Delay lines and pole coefficients are held in
|
|
1683
1742
|
// *seconds* and converted here, so they stay correct without knowing about oversampling.
|
|
1684
1743
|
this.sampleRate = sampleRate * oversample;
|
|
1744
|
+
// The band-limited resampler is designed here, from the stage alone: one
|
|
1745
|
+
// windowed-sinc half-band prototype per 2x stage (`RESAMPLE_STAGE_SPECS`),
|
|
1746
|
+
// fresh zero-state instances per direction, so `prepare()` clears the
|
|
1747
|
+
// filter state and a re-prepared run starts clean. Powers of two only;
|
|
1748
|
+
// any other factor keeps the legacy hold-and-last path in `process()`.
|
|
1749
|
+
const resampleStages = Math.log2(oversample);
|
|
1750
|
+
if (Number.isInteger(resampleStages) && resampleStages >= 1) {
|
|
1751
|
+
const prototypes = Array.from({ length: resampleStages }, (_, stage) => {
|
|
1752
|
+
const [taps, beta] = resampleStageSpec(stage);
|
|
1753
|
+
return designHalfBand2x(taps, beta);
|
|
1754
|
+
});
|
|
1755
|
+
this.resampleUp = prototypes.map((prototype) => new HalfBandStage2x(prototype));
|
|
1756
|
+
this.resampleDown = prototypes.map((prototype) => new HalfBandStage2x(prototype));
|
|
1757
|
+
this.resampleBufA = new Float64Array(oversample);
|
|
1758
|
+
this.resampleBufB = new Float64Array(oversample);
|
|
1759
|
+
this.resampleLatencyHost = cascadeLatencyHostSamples(resampleStages);
|
|
1760
|
+
}
|
|
1761
|
+
else {
|
|
1762
|
+
this.resampleUp = [];
|
|
1763
|
+
this.resampleDown = [];
|
|
1764
|
+
this.resampleBufA = new Float64Array(0);
|
|
1765
|
+
this.resampleBufB = new Float64Array(0);
|
|
1766
|
+
this.resampleLatencyHost = 0;
|
|
1767
|
+
}
|
|
1685
1768
|
this.maxNewtonIterations = maxNewtonIterations;
|
|
1686
1769
|
// Negative would be a source that supplies energy under load; refuse the nonsense
|
|
1687
1770
|
// rather than solve it.
|
|
@@ -1703,6 +1786,7 @@ export class ReferenceRuntime {
|
|
|
1703
1786
|
this.solvedButFlaggedSamples = 0;
|
|
1704
1787
|
this.blockNewtonCensus.clear();
|
|
1705
1788
|
this.unproductiveUntilSample.clear();
|
|
1789
|
+
this.newtonStartHistory.clear();
|
|
1706
1790
|
this.totalIterations = 0;
|
|
1707
1791
|
this.nonFiniteSamples = 0;
|
|
1708
1792
|
this.peakIterations = 0;
|
|
@@ -2478,6 +2562,7 @@ export class ReferenceRuntime {
|
|
|
2478
2562
|
rhs: new Float64Array(size),
|
|
2479
2563
|
factors: new Float64Array(schedule.factorCount),
|
|
2480
2564
|
consecutiveFallbacks: 0,
|
|
2565
|
+
repivotAttempted: false,
|
|
2481
2566
|
});
|
|
2482
2567
|
}
|
|
2483
2568
|
/**
|
|
@@ -2581,6 +2666,16 @@ export class ReferenceRuntime {
|
|
|
2581
2666
|
* smallest dropped disagreement is `boss-aw-2` at `9.8e+3`. Dense is not
|
|
2582
2667
|
* assumed right -- `tw-1` diverges on 61 samples densely while converging
|
|
2583
2668
|
* sparsely -- the gate only fires where the replay demonstrably collapses.
|
|
2669
|
+
*
|
|
2670
|
+
* A validating shipped order is still replaced when the value-aware candidate
|
|
2671
|
+
* reaches the audio-agreement bar (1e-9) an order of magnitude more cleanly
|
|
2672
|
+
* without meaningful fill cost -- the refinement in `SCHEDULE_REFINEMENT_BAR`.
|
|
2673
|
+
* Bar-clean ties keep shipped even when the audios differ: the
|
|
2674
|
+
* operating-point replay cannot adjudicate audio-time divergence (`boss-os-2`
|
|
2675
|
+
* replays 3.4e-10 shipped vs 2.5e-13 candidate while the audios differ 1000x),
|
|
2676
|
+
* so the rule only moves past the bar it can measure, never on a tie.
|
|
2677
|
+
* Measured adopters: `boss-sd-1` (1.8e-9 to 4.2e-13, audio 6.9e-8 to
|
|
2678
|
+
* 6.5e-12).
|
|
2584
2679
|
*/
|
|
2585
2680
|
settlePivotOrders() {
|
|
2586
2681
|
if (this.pivotOrdersSettled) {
|
|
@@ -2682,15 +2777,13 @@ export class ReferenceRuntime {
|
|
|
2682
2777
|
? disagreement
|
|
2683
2778
|
: null;
|
|
2684
2779
|
}
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
//
|
|
2689
|
-
//
|
|
2690
|
-
// schedule's own filled pattern, replayed and compared to the same
|
|
2691
|
-
// dense solve. Adoption needs the same tolerance the shipped order
|
|
2692
|
-
// failed; a refused or still-disagreeing candidate drops as before.
|
|
2780
|
+
// The value-aware order, computed always now rather than only when
|
|
2781
|
+
// the shipped order fails: threshold Markowitz over the schedule's
|
|
2782
|
+
// own filled pattern, replayed and compared to the same dense
|
|
2783
|
+
// solve. A refused candidate (null) or a replay that trips its own
|
|
2784
|
+
// guard (Infinity) behaves exactly like a disagreeing one below.
|
|
2693
2785
|
const candidate = computeNumericRepivot(schedule, size, matrix);
|
|
2786
|
+
let candidateDisagreement = Infinity;
|
|
2694
2787
|
if (candidate !== null) {
|
|
2695
2788
|
const candidateValues = new Float64Array(candidate.slots);
|
|
2696
2789
|
const candidateRhs = new Float64Array(size);
|
|
@@ -2704,31 +2797,44 @@ export class ReferenceRuntime {
|
|
|
2704
2797
|
candidateDiffSquares += difference * difference;
|
|
2705
2798
|
candidateDenseSquares += denseOut[index] ** 2;
|
|
2706
2799
|
}
|
|
2707
|
-
|
|
2800
|
+
candidateDisagreement = candidateReplayed
|
|
2708
2801
|
? Math.sqrt(candidateDiffSquares) /
|
|
2709
2802
|
Math.max(Math.sqrt(candidateDenseSquares), 1e-9)
|
|
2710
2803
|
: Infinity;
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
}
|
|
2804
|
+
}
|
|
2805
|
+
if (disagreement <= SCHEDULE_VALIDATION_TOL) {
|
|
2806
|
+
// The shipped order validates. Replace it only on a refinement:
|
|
2807
|
+
// the candidate reaches the audio-agreement bar while the
|
|
2808
|
+
// shipped order does not, by an order of magnitude, without
|
|
2809
|
+
// meaningful fill cost (see `shouldRefinePivotOrder`). Ties
|
|
2810
|
+
// keep shipped, so adoption never churns a schedule for noise
|
|
2811
|
+
// -- measured, `boss-hm-2`, `boss-ch-1` and `boss-od-3` tie
|
|
2812
|
+
// and keep shipped while `boss-sd-1` crosses (1.8e-9 to
|
|
2813
|
+
// 4.2e-13, audio 6.9e-8 to 6.5e-12).
|
|
2814
|
+
if (candidate !== null &&
|
|
2815
|
+
shouldRefinePivotOrder({
|
|
2816
|
+
shippedDisagreement: disagreement,
|
|
2817
|
+
candidateDisagreement,
|
|
2818
|
+
shippedOps: schedule.sparseOps,
|
|
2819
|
+
shippedSlots: schedule.slots,
|
|
2820
|
+
candidateOps: candidate.sparseOps,
|
|
2821
|
+
candidateSlots: candidate.slots,
|
|
2822
|
+
})) {
|
|
2823
|
+
this.adoptRepivotedSchedule(blockId, candidate, size, planRow, `numeric re-pivot ${candidateDisagreement.toExponential(1)} vs dense ` +
|
|
2824
|
+
`(shipped ${Number.isFinite(disagreement) ? disagreement.toExponential(1) : "replay refused"}); ` +
|
|
2825
|
+
`+${(((candidate.sparseOps / Math.max(schedule.sparseOps, 1)) - 1) * 100).toFixed(1)}% ops`, candidateDisagreement);
|
|
2730
2826
|
continue;
|
|
2731
2827
|
}
|
|
2828
|
+
continue;
|
|
2829
|
+
}
|
|
2830
|
+
// The shipped order failed on the real matrix. Before giving the
|
|
2831
|
+
// schedule up, try the value-aware order computed above: adoption
|
|
2832
|
+
// needs the same tolerance the shipped order failed; a refused or
|
|
2833
|
+
// still-disagreeing candidate drops as before.
|
|
2834
|
+
if (candidate !== null &&
|
|
2835
|
+
candidateDisagreement <= SCHEDULE_VALIDATION_TOL) {
|
|
2836
|
+
this.adoptRepivotedSchedule(blockId, candidate, size, planRow, `numeric re-pivot ${candidateDisagreement.toExponential(1)} vs dense`, candidateDisagreement);
|
|
2837
|
+
continue;
|
|
2732
2838
|
}
|
|
2733
2839
|
this.sparseSchedules.delete(blockId);
|
|
2734
2840
|
this.droppedSchedules.add(blockId);
|
|
@@ -2745,6 +2851,102 @@ export class ReferenceRuntime {
|
|
|
2745
2851
|
}
|
|
2746
2852
|
}
|
|
2747
2853
|
}
|
|
2854
|
+
/**
|
|
2855
|
+
* Adopt a value-aware replacement order for a block, from settle or from
|
|
2856
|
+
* the mid-run re-pivot, with one shared bookkeeping shape.
|
|
2857
|
+
*
|
|
2858
|
+
* The replacement's slots are a superset of the shipped filled pattern
|
|
2859
|
+
* whose extra cells no stamp writes (see `computeNumericRepivot`), so the
|
|
2860
|
+
* pattern-sized matrix refresh keeps reading correct zeros and neither
|
|
2861
|
+
* `clearPairs` nor the selective-copy machinery needs a rebuild -- the
|
|
2862
|
+
* same reason a dropped block, which does change the refresh, sets
|
|
2863
|
+
* `clearPairs` to null where this does not.
|
|
2864
|
+
*/
|
|
2865
|
+
adoptRepivotedSchedule(blockId, candidate, size, planRow, reason, pivotDisagreement) {
|
|
2866
|
+
this.sparseSchedules.set(blockId, {
|
|
2867
|
+
schedule: candidate,
|
|
2868
|
+
values: new Float64Array(candidate.slots),
|
|
2869
|
+
rhs: new Float64Array(size),
|
|
2870
|
+
factors: new Float64Array(candidate.factorCount),
|
|
2871
|
+
consecutiveFallbacks: 0,
|
|
2872
|
+
repivotAttempted: false,
|
|
2873
|
+
});
|
|
2874
|
+
this.repivotedSchedules.add(blockId);
|
|
2875
|
+
this.droppedSchedules.delete(blockId);
|
|
2876
|
+
if (planRow !== undefined) {
|
|
2877
|
+
planRow.path = "sparse";
|
|
2878
|
+
planRow.reason = reason;
|
|
2879
|
+
planRow.pivotDisagreement = pivotDisagreement;
|
|
2880
|
+
planRow.repivoted = true;
|
|
2881
|
+
planRow.patternEntries = candidate.slots;
|
|
2882
|
+
planRow.sparseOps = candidate.sparseOps;
|
|
2883
|
+
planRow.unprovenPivots = candidate.unprovenPivots;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* The mid-run half of the pivot-guard contract: at the consecutive-trip
|
|
2888
|
+
* limit, re-pivot once from the current matrix instead of abandoning.
|
|
2889
|
+
*
|
|
2890
|
+
* Takes the stamped matrix and rhs snapshotted before the dense fallback
|
|
2891
|
+
* destroyed them, plus the fallback's answer, and returns whether a
|
|
2892
|
+
* value-aware order was adopted. Adoption needs the rescue standard --
|
|
2893
|
+
* replay within `SCHEDULE_VALIDATION_TOL` of the dense answer on this
|
|
2894
|
+
* matrix -- the same bar a collapse rescue clears at settle, because this
|
|
2895
|
+
* is a rescue: the shipped order demonstrably does not suit this
|
|
2896
|
+
* operating region. A refusal (null candidate, tripped replay, or a
|
|
2897
|
+
* disagreeing replay) returns false and the caller abandons exactly as
|
|
2898
|
+
* before, with the reason naming the refusal. Either outcome lands in
|
|
2899
|
+
* the plan row, so a mid-run adoption is as visible as a settle one.
|
|
2900
|
+
*
|
|
2901
|
+
* Structured for direct unit testing (pure inputs, boolean verdict): the
|
|
2902
|
+
* corpus never trips the guard, so no corpus run exercises this path.
|
|
2903
|
+
*/
|
|
2904
|
+
adoptMidRunRepivot(blockId, entry, matrix, rhs, denseAnswer) {
|
|
2905
|
+
const planRow = this.solverPlanRows.find((candidate) => candidate.blockId === blockId);
|
|
2906
|
+
const block = this.blocksById.get(blockId);
|
|
2907
|
+
const size = block !== undefined && block.kind === "mna"
|
|
2908
|
+
? block.nodeCount + block.auxCount
|
|
2909
|
+
: 0;
|
|
2910
|
+
const refuse = (reason) => {
|
|
2911
|
+
if (planRow !== undefined) {
|
|
2912
|
+
planRow.reason += ` mid-run re-pivot ${reason}; abandoned`;
|
|
2913
|
+
}
|
|
2914
|
+
return false;
|
|
2915
|
+
};
|
|
2916
|
+
if (block === undefined || block.kind !== "mna" || size <= 0) {
|
|
2917
|
+
return refuse("refused (unknown block)");
|
|
2918
|
+
}
|
|
2919
|
+
const candidate = computeNumericRepivot(entry.schedule, size, matrix);
|
|
2920
|
+
if (candidate === null) {
|
|
2921
|
+
return refuse("refused (no candidate meets the threshold)");
|
|
2922
|
+
}
|
|
2923
|
+
const values = new Float64Array(candidate.slots);
|
|
2924
|
+
const scratchRhs = new Float64Array(size);
|
|
2925
|
+
const factors = new Float64Array(candidate.factorCount);
|
|
2926
|
+
const out = new Array(size).fill(0);
|
|
2927
|
+
const replayed = runSparseSchedule(candidate, matrix, rhs, values, scratchRhs, factors, out);
|
|
2928
|
+
let diffSquares = 0;
|
|
2929
|
+
let denseSquares = 0;
|
|
2930
|
+
for (let index = 0; index < size; index += 1) {
|
|
2931
|
+
const difference = out[index] - denseAnswer[index];
|
|
2932
|
+
diffSquares += difference * difference;
|
|
2933
|
+
denseSquares += denseAnswer[index] ** 2;
|
|
2934
|
+
}
|
|
2935
|
+
const disagreement = replayed
|
|
2936
|
+
? Math.sqrt(diffSquares) / Math.max(Math.sqrt(denseSquares), 1e-9)
|
|
2937
|
+
: Infinity;
|
|
2938
|
+
if (!(disagreement <= SCHEDULE_VALIDATION_TOL)) {
|
|
2939
|
+
return refuse(`refused (replay ${Number.isFinite(disagreement) ? disagreement.toExponential(1) : "tripped"} vs dense)`);
|
|
2940
|
+
}
|
|
2941
|
+
this.adoptRepivotedSchedule(blockId, candidate, size, planRow, `mid-run numeric re-pivot ${disagreement.toExponential(1)} vs dense after ${SCHEDULE_CONSECUTIVE_FALLBACK_LIMIT} guard trips`, disagreement);
|
|
2942
|
+
// The one attempt is spent: a second limit-hit on the adopted order
|
|
2943
|
+
// abandons rather than re-pivoting again.
|
|
2944
|
+
const adopted = this.sparseSchedules.get(blockId);
|
|
2945
|
+
if (adopted !== undefined) {
|
|
2946
|
+
adopted.repivotAttempted = true;
|
|
2947
|
+
}
|
|
2948
|
+
return true;
|
|
2949
|
+
}
|
|
2748
2950
|
/**
|
|
2749
2951
|
* Which blocks took the static schedule and which kept the dense solve, and how often the
|
|
2750
2952
|
* pivot guard sent a solve back to the dense path.
|
|
@@ -2776,73 +2978,148 @@ export class ReferenceRuntime {
|
|
|
2776
2978
|
// `samples` and the non-converged counters stay in **host** samples however many
|
|
2777
2979
|
// sub-samples each one costs, because their only job is to report the share of rendered
|
|
2778
2980
|
// audio that is untrustworthy, and a listener hears host samples.
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
// subcircuits; only their shared nodes couple them.
|
|
2783
|
-
const sample = input[index] ?? 0;
|
|
2784
|
-
let hostResult = 0;
|
|
2785
|
-
let hostNonConverged = false;
|
|
2786
|
-
let hostNonFinite = false;
|
|
2787
|
-
// The input is **held** across the sub-samples rather than interpolated. A host
|
|
2788
|
-
// sample is one measurement and inventing intermediate values is inventing signal;
|
|
2789
|
-
// holding is the zero-order reading of what was actually delivered.
|
|
2790
|
-
for (let sub = 0; sub < this.oversample; sub += 1) {
|
|
2791
|
-
// Sample `n` is the circuit at `t = n / sampleRate`, counted from `prepare`. Set here
|
|
2792
|
-
// so every block in this sample sees one time, and advanced below so buffer
|
|
2793
|
-
// boundaries are invisible.
|
|
2794
|
-
this.timeSeconds = this.elapsedSamples / this.sampleRate;
|
|
2795
|
-
let result = 0;
|
|
2796
|
-
// A sample is counted once however many of its blocks failed. Counting each
|
|
2797
|
-
// failing block instead let `nonConvergedSamples` exceed `samples` in a
|
|
2798
|
-
// multi-region program, so the number could not be read as the share of audio
|
|
2799
|
-
// that is untrustworthy -- which is the only thing it is for.
|
|
2800
|
-
this.sampleNonConverged = false;
|
|
2801
|
-
this.sampleNonFinite = false;
|
|
2802
|
-
for (const id of this.program.order) {
|
|
2803
|
-
const block = this.blocksById.get(id);
|
|
2804
|
-
if (block === undefined) {
|
|
2805
|
-
continue;
|
|
2806
|
-
}
|
|
2807
|
-
const blockOutput = this.processBlock(block, sample);
|
|
2808
|
-
if (block.kind === "mna" && block.outputNode !== null) {
|
|
2809
|
-
result = blockOutput;
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2812
|
-
if (this.sampleNonConverged) {
|
|
2813
|
-
hostNonConverged = true;
|
|
2814
|
-
}
|
|
2815
|
-
if (this.sampleNonFinite) {
|
|
2816
|
-
hostNonFinite = true;
|
|
2817
|
-
}
|
|
2818
|
-
// The rendered supply peak, read from the solved branch unknowns. Every sample, because
|
|
2819
|
-
// a rectifier's current is a pulse a few samples wide and a coarser sampling of it
|
|
2820
|
-
// would report a smaller number for no physical reason.
|
|
2821
|
-
for (const branch of this.supplyBranchSlots) {
|
|
2822
|
-
const amps = this.nodeVoltages.get(branch.blockId)?.[branch.slot] ?? 0;
|
|
2823
|
-
const magnitude = Math.abs(amps);
|
|
2824
|
-
if (magnitude > this.renderedSupplyPeakAmps) {
|
|
2825
|
-
this.renderedSupplyPeakAmps = magnitude;
|
|
2826
|
-
}
|
|
2827
|
-
}
|
|
2828
|
-
this.elapsedSamples += 1;
|
|
2829
|
-
hostResult = result;
|
|
2830
|
-
}
|
|
2831
|
-
// Zero-order decimation: the last sub-sample is the host sample. This is what the
|
|
2832
|
-
// 47-to-50 parity measurement was taken with, and on `boss-ce-5` it is bit-identical
|
|
2833
|
-
// to a boxcar average because the limit cycle oversampling removes is *gone* rather
|
|
2834
|
-
// than moved above Nyquist. A band-limiting decimator is a real improvement for
|
|
2835
|
-
// broadband content and deliberately not guessed at here.
|
|
2836
|
-
if (hostNonConverged) {
|
|
2837
|
-
this.nonConvergedSamples += 1;
|
|
2981
|
+
if (this.resampleUp.length === 0) {
|
|
2982
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
2983
|
+
output[index] = this.processHeldSample(input[index] ?? 0);
|
|
2838
2984
|
}
|
|
2839
|
-
|
|
2840
|
-
|
|
2985
|
+
}
|
|
2986
|
+
else {
|
|
2987
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
2988
|
+
output[index] = this.processResampledSample(input[index] ?? 0);
|
|
2841
2989
|
}
|
|
2842
|
-
output[index] = hostResult;
|
|
2843
2990
|
}
|
|
2844
2991
|
return output;
|
|
2845
2992
|
}
|
|
2993
|
+
/**
|
|
2994
|
+
* One host sample through the legacy path: the input held flat across the
|
|
2995
|
+
* sub-samples, the last sub-sample kept. Bit-identical to the behaviour
|
|
2996
|
+
* before the band-limited resampler existed, which is what makes factor 1
|
|
2997
|
+
* (and non-power-of-two factors) cost nothing and change nothing.
|
|
2998
|
+
*/
|
|
2999
|
+
processHeldSample(sample) {
|
|
3000
|
+
// Every block is solved, but the result comes from the block that owns the
|
|
3001
|
+
// output jack -- not from whichever ran last. Regions are independent
|
|
3002
|
+
// subcircuits; only their shared nodes couple them.
|
|
3003
|
+
let hostResult = 0;
|
|
3004
|
+
let hostNonConverged = false;
|
|
3005
|
+
let hostNonFinite = false;
|
|
3006
|
+
// The input is **held** across the sub-samples rather than interpolated. A host
|
|
3007
|
+
// sample is one measurement and inventing intermediate values is inventing signal;
|
|
3008
|
+
// holding is the zero-order reading of what was actually delivered.
|
|
3009
|
+
for (let sub = 0; sub < this.oversample; sub += 1) {
|
|
3010
|
+
hostResult = this.solveSubSample(sample);
|
|
3011
|
+
if (this.sampleNonConverged) {
|
|
3012
|
+
hostNonConverged = true;
|
|
3013
|
+
}
|
|
3014
|
+
if (this.sampleNonFinite) {
|
|
3015
|
+
hostNonFinite = true;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
// Zero-order decimation: the last sub-sample is the host sample. This is what the
|
|
3019
|
+
// 47-to-50 parity measurement was taken with, and on `boss-ce-5` it is bit-identical
|
|
3020
|
+
// to a boxcar average because the limit cycle oversampling removes is *gone* rather
|
|
3021
|
+
// than moved above Nyquist.
|
|
3022
|
+
if (hostNonConverged) {
|
|
3023
|
+
this.nonConvergedSamples += 1;
|
|
3024
|
+
}
|
|
3025
|
+
if (hostNonFinite) {
|
|
3026
|
+
this.nonFiniteSamples += 1;
|
|
3027
|
+
}
|
|
3028
|
+
return hostResult;
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* One host sample through the band-limited path: half-band interpolate up
|
|
3032
|
+
* by 2 per stage, solve each sub-sample, FIR-decimate back to one host
|
|
3033
|
+
* sample. The resampler state lives on the stages across `process()`
|
|
3034
|
+
* calls, so any block split renders bit-identically to one long call;
|
|
3035
|
+
* the group delay this adds is reported by `oversampleLatency()`.
|
|
3036
|
+
*/
|
|
3037
|
+
processResampledSample(sample) {
|
|
3038
|
+
const stages = this.resampleUp.length;
|
|
3039
|
+
// Up cascade: alternate the two scratch buffers, doubling each stage.
|
|
3040
|
+
let cur = this.resampleBufA;
|
|
3041
|
+
let next = this.resampleBufB;
|
|
3042
|
+
this.resampleUp[0].interpolate(sample, cur, 0);
|
|
3043
|
+
let width = 2;
|
|
3044
|
+
for (let stage = 1; stage < stages; stage += 1) {
|
|
3045
|
+
for (let i = 0; i < width; i += 1) {
|
|
3046
|
+
this.resampleUp[stage].interpolate(cur[i], next, 2 * i);
|
|
3047
|
+
}
|
|
3048
|
+
const swap = cur;
|
|
3049
|
+
cur = next;
|
|
3050
|
+
next = swap;
|
|
3051
|
+
width *= 2;
|
|
3052
|
+
}
|
|
3053
|
+
// Solve every sub-sample; outputs land in the idle buffer.
|
|
3054
|
+
let hostNonConverged = false;
|
|
3055
|
+
let hostNonFinite = false;
|
|
3056
|
+
for (let sub = 0; sub < width; sub += 1) {
|
|
3057
|
+
next[sub] = this.solveSubSample(cur[sub]);
|
|
3058
|
+
if (this.sampleNonConverged) {
|
|
3059
|
+
hostNonConverged = true;
|
|
3060
|
+
}
|
|
3061
|
+
if (this.sampleNonFinite) {
|
|
3062
|
+
hostNonFinite = true;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
// Down cascade, highest rate first, halving in place of `next`.
|
|
3066
|
+
const hi = next;
|
|
3067
|
+
for (let stage = stages - 1; stage >= 0; stage -= 1) {
|
|
3068
|
+
const half = width / 2;
|
|
3069
|
+
for (let i = 0; i < half; i += 1) {
|
|
3070
|
+
hi[i] = this.resampleDown[stage].decimate(hi[2 * i], hi[2 * i + 1]);
|
|
3071
|
+
}
|
|
3072
|
+
width = half;
|
|
3073
|
+
}
|
|
3074
|
+
if (hostNonConverged) {
|
|
3075
|
+
this.nonConvergedSamples += 1;
|
|
3076
|
+
}
|
|
3077
|
+
if (hostNonFinite) {
|
|
3078
|
+
this.nonFiniteSamples += 1;
|
|
3079
|
+
}
|
|
3080
|
+
return hi[0];
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* One sub-sample solve at the solver rate: every block stepped once at
|
|
3084
|
+
* `t = elapsedSamples / sampleRate`, returning the output jack's value.
|
|
3085
|
+
* Shared by the held and the resampled paths, so factor 1 executes the
|
|
3086
|
+
* same operations in the same order as before the resampler existed.
|
|
3087
|
+
*/
|
|
3088
|
+
solveSubSample(subInput) {
|
|
3089
|
+
// Sample `n` is the circuit at `t = n / sampleRate`, counted from `prepare`. Set here
|
|
3090
|
+
// so every block in this sample sees one time, and advanced below so buffer
|
|
3091
|
+
// boundaries are invisible.
|
|
3092
|
+
this.timeSeconds = this.elapsedSamples / this.sampleRate;
|
|
3093
|
+
let result = 0;
|
|
3094
|
+
// A sample is counted once however many of its blocks failed. Counting each
|
|
3095
|
+
// failing block instead let `nonConvergedSamples` exceed `samples` in a
|
|
3096
|
+
// multi-region program, so the number could not be read as the share of audio
|
|
3097
|
+
// that is untrustworthy -- which is the only thing it is for.
|
|
3098
|
+
this.sampleNonConverged = false;
|
|
3099
|
+
this.sampleNonFinite = false;
|
|
3100
|
+
for (const id of this.program.order) {
|
|
3101
|
+
const block = this.blocksById.get(id);
|
|
3102
|
+
if (block === undefined) {
|
|
3103
|
+
continue;
|
|
3104
|
+
}
|
|
3105
|
+
const blockOutput = this.processBlock(block, subInput);
|
|
3106
|
+
if (block.kind === "mna" && block.outputNode !== null) {
|
|
3107
|
+
result = blockOutput;
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
// The rendered supply peak, read from the solved branch unknowns. Every sample, because
|
|
3111
|
+
// a rectifier's current is a pulse a few samples wide and a coarser sampling of it
|
|
3112
|
+
// would report a smaller number for no physical reason.
|
|
3113
|
+
for (const branch of this.supplyBranchSlots) {
|
|
3114
|
+
const amps = this.nodeVoltages.get(branch.blockId)?.[branch.slot] ?? 0;
|
|
3115
|
+
const magnitude = Math.abs(amps);
|
|
3116
|
+
if (magnitude > this.renderedSupplyPeakAmps) {
|
|
3117
|
+
this.renderedSupplyPeakAmps = magnitude;
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
this.elapsedSamples += 1;
|
|
3121
|
+
return result;
|
|
3122
|
+
}
|
|
2846
3123
|
/**
|
|
2847
3124
|
* The rate the caller passed to `prepare`, as opposed to the solver's sub-sample rate.
|
|
2848
3125
|
*
|
|
@@ -2852,6 +3129,17 @@ export class ReferenceRuntime {
|
|
|
2852
3129
|
hostSampleRate() {
|
|
2853
3130
|
return this.sampleRate === null ? null : this.sampleRate / this.oversample;
|
|
2854
3131
|
}
|
|
3132
|
+
/**
|
|
3133
|
+
* The resampler's total group delay in **host** samples, or `null` before
|
|
3134
|
+
* `prepare()`. Zero when the resampler is bypassed (factor 1, or a factor
|
|
3135
|
+
* that is not a power of two and keeps the legacy path): the held path
|
|
3136
|
+
* adds no latency by construction. At 2x/4x/8x this is 19.5/26.25/28.625
|
|
3137
|
+
* host samples for the stage-specific half-band cascade -- a real, reported
|
|
3138
|
+
* latency the caller must absorb or declare, not a hidden one.
|
|
3139
|
+
*/
|
|
3140
|
+
oversampleLatency() {
|
|
3141
|
+
return this.sampleRate === null ? null : this.resampleLatencyHost;
|
|
3142
|
+
}
|
|
2855
3143
|
processBlock(block, input) {
|
|
2856
3144
|
if (block.kind === "composed") {
|
|
2857
3145
|
this.processComposedBlock(block);
|
|
@@ -3530,8 +3818,19 @@ export class ReferenceRuntime {
|
|
|
3530
3818
|
// itself is read-only here and is never handed back to the caller aliased to either
|
|
3531
3819
|
// buffer, so a caller that still holds it (`[...guess]` in the continuation methods,
|
|
3532
3820
|
// `previous.slice()` in `processBlock`) sees it unchanged.
|
|
3821
|
+
//
|
|
3822
|
+
// On a standard audio pass of a nonlinear block, and only when the runtime was prepared
|
|
3823
|
+
// with `oversample > 1`, the seed is the predicted start (see `newtonStartHistory`),
|
|
3824
|
+
// falling back to `start`. At factor 1 nothing seeds and nothing records. `start`
|
|
3825
|
+
// itself stays the previous solution for the fold reseed below.
|
|
3826
|
+
const predictorPass = this.oversample > 1 &&
|
|
3827
|
+
!dc &&
|
|
3828
|
+
sourceScale === 1 &&
|
|
3829
|
+
gmin === GMIN_SIEMENS &&
|
|
3830
|
+
!block.linear;
|
|
3831
|
+
const seed = (predictorPass ? this.predictedNewtonStart(block.id, size) : null) ?? start;
|
|
3533
3832
|
for (let index = 0; index < size; index += 1) {
|
|
3534
|
-
solutionA[index] =
|
|
3833
|
+
solutionA[index] = seed[index] ?? 0;
|
|
3535
3834
|
}
|
|
3536
3835
|
let current = solutionA;
|
|
3537
3836
|
let next = solutionB;
|
|
@@ -3672,11 +3971,37 @@ export class ReferenceRuntime {
|
|
|
3672
3971
|
this.scheduleFallbacks += 1;
|
|
3673
3972
|
sparse.consecutiveFallbacks += 1;
|
|
3674
3973
|
if (sparse.consecutiveFallbacks >= SCHEDULE_CONSECUTIVE_FALLBACK_LIMIT) {
|
|
3675
|
-
|
|
3676
|
-
|
|
3974
|
+
// The old shape abandoned here, silently dense for the rest
|
|
3975
|
+
// of the run after paying for both solvers on every one of
|
|
3976
|
+
// the 64 trips. Re-pivot once from the current matrix
|
|
3977
|
+
// instead: a control position the operating-point matrix
|
|
3978
|
+
// never showed can still suit a value-aware order, and
|
|
3979
|
+
// only a refused or still-disagreeing re-pivot abandons.
|
|
3980
|
+
// `solve` destroys `matrix` and `rhs`, so the re-pivot's
|
|
3981
|
+
// inputs are snapshotted first -- once per order, on this
|
|
3982
|
+
// trip only.
|
|
3983
|
+
if (!sparse.repivotAttempted) {
|
|
3984
|
+
sparse.repivotAttempted = true;
|
|
3985
|
+
const matrixCopy = matrix.map((row) => [...row]);
|
|
3986
|
+
const rhsCopy = [...rhs];
|
|
3987
|
+
scratch.denseDirty = true;
|
|
3988
|
+
solve(matrix, rhs, next);
|
|
3989
|
+
if (!this.adoptMidRunRepivot(block.id, sparse, matrixCopy, rhsCopy, next)) {
|
|
3990
|
+
this.sparseSchedules.delete(block.id);
|
|
3991
|
+
this.abandonedSchedules.add(block.id);
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
else {
|
|
3995
|
+
this.sparseSchedules.delete(block.id);
|
|
3996
|
+
this.abandonedSchedules.add(block.id);
|
|
3997
|
+
scratch.denseDirty = true;
|
|
3998
|
+
solve(matrix, rhs, next);
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
else {
|
|
4002
|
+
scratch.denseDirty = true;
|
|
4003
|
+
solve(matrix, rhs, next);
|
|
3677
4004
|
}
|
|
3678
|
-
scratch.denseDirty = true;
|
|
3679
|
-
solve(matrix, rhs, next);
|
|
3680
4005
|
}
|
|
3681
4006
|
}
|
|
3682
4007
|
if (relaxing) {
|
|
@@ -3832,6 +4157,9 @@ export class ReferenceRuntime {
|
|
|
3832
4157
|
census.peakIterations = used;
|
|
3833
4158
|
}
|
|
3834
4159
|
}
|
|
4160
|
+
if (predictorPass) {
|
|
4161
|
+
this.recordNewtonSolution(block.id, current, converged, used);
|
|
4162
|
+
}
|
|
3835
4163
|
return { solution: current, converged, used, worstNode, worstDelta };
|
|
3836
4164
|
}
|
|
3837
4165
|
/**
|
|
@@ -4024,8 +4352,17 @@ export class ReferenceRuntime {
|
|
|
4024
4352
|
full[portRows[p]] = y[p];
|
|
4025
4353
|
}
|
|
4026
4354
|
};
|
|
4355
|
+
// The predicted start (see `newtonStartHistory`), falling back to `start`: the same
|
|
4356
|
+
// rule and the same `oversample > 1` gate as `iterate`, applied through the port
|
|
4357
|
+
// unknowns the reduced solve iterates on.
|
|
4358
|
+
const predictorPass = this.oversample > 1 &&
|
|
4359
|
+
!dc &&
|
|
4360
|
+
sourceScale === 1 &&
|
|
4361
|
+
gmin === GMIN_SIEMENS &&
|
|
4362
|
+
!block.linear;
|
|
4363
|
+
const seed = (predictorPass ? this.predictedNewtonStart(block.id, size) : null) ?? start;
|
|
4027
4364
|
for (let p = 0; p < portCount; p += 1) {
|
|
4028
|
-
scratch.yCurrent[p] =
|
|
4365
|
+
scratch.yCurrent[p] = seed[portRows[p]] ?? 0;
|
|
4029
4366
|
}
|
|
4030
4367
|
reconstructFull(scratch.yCurrent, scratch.fullCurrent);
|
|
4031
4368
|
const iterations = this.maxNewtonIterations;
|
|
@@ -4164,6 +4501,9 @@ export class ReferenceRuntime {
|
|
|
4164
4501
|
census.peakIterations = used;
|
|
4165
4502
|
}
|
|
4166
4503
|
}
|
|
4504
|
+
if (predictorPass) {
|
|
4505
|
+
this.recordNewtonSolution(block.id, current, converged, used);
|
|
4506
|
+
}
|
|
4167
4507
|
return { solution: current, converged, used, worstNode, worstDelta };
|
|
4168
4508
|
}
|
|
4169
4509
|
/**
|
|
@@ -4461,6 +4801,110 @@ export class ReferenceRuntime {
|
|
|
4461
4801
|
this.opampHistory.clear();
|
|
4462
4802
|
this.triodeHistory.clear();
|
|
4463
4803
|
}
|
|
4804
|
+
/**
|
|
4805
|
+
* The Newton start `newtonStartHistory` selects for this block, or `null` to start from
|
|
4806
|
+
* the caller's previous solution (order 0, or a chain too short for the chosen order).
|
|
4807
|
+
* The returned array is the block's reused scratch: read it before the next solve.
|
|
4808
|
+
*/
|
|
4809
|
+
predictedNewtonStart(blockId, size) {
|
|
4810
|
+
const history = this.newtonStartHistory.get(blockId);
|
|
4811
|
+
if (history === undefined || history.order === 0) {
|
|
4812
|
+
return null;
|
|
4813
|
+
}
|
|
4814
|
+
if (history.candidate.length !== size) {
|
|
4815
|
+
history.candidate = new Array(size).fill(0);
|
|
4816
|
+
}
|
|
4817
|
+
return extrapolateNewtonStart(history, history.order, size, history.candidate)
|
|
4818
|
+
? history.candidate
|
|
4819
|
+
: null;
|
|
4820
|
+
}
|
|
4821
|
+
/**
|
|
4822
|
+
* Record a finished standard-pass solve: on convergence, score the three candidate
|
|
4823
|
+
* starts the history could have produced against the solution just reached (one fused
|
|
4824
|
+
* pass, no allocation) and keep the best order for the next solve, then rotate the
|
|
4825
|
+
* solution into the history ring; on failure, break the chain.
|
|
4826
|
+
*/
|
|
4827
|
+
recordNewtonSolution(blockId, solution, converged, used) {
|
|
4828
|
+
let history = this.newtonStartHistory.get(blockId);
|
|
4829
|
+
if (history === undefined) {
|
|
4830
|
+
history = { x1: null, x2: null, x3: null, chain: 0, order: 0, candidate: [] };
|
|
4831
|
+
this.newtonStartHistory.set(blockId, history);
|
|
4832
|
+
}
|
|
4833
|
+
if (!converged) {
|
|
4834
|
+
history.chain = 0;
|
|
4835
|
+
history.order = 0;
|
|
4836
|
+
return;
|
|
4837
|
+
}
|
|
4838
|
+
const size = solution.length;
|
|
4839
|
+
const { x1, x2, x3 } = history;
|
|
4840
|
+
const has1 = history.chain >= 1 && x1 !== null && x1.length === size;
|
|
4841
|
+
const has2 = has1 && history.chain >= 2 && x2 !== null && x2.length === size;
|
|
4842
|
+
const has3 = has2 && history.chain >= 3 && x3 !== null && x3.length === size;
|
|
4843
|
+
let error0 = has1 ? 0 : Number.POSITIVE_INFINITY;
|
|
4844
|
+
let error1 = has2 ? 0 : Number.POSITIVE_INFINITY;
|
|
4845
|
+
let error2 = has3 ? 0 : Number.POSITIVE_INFINITY;
|
|
4846
|
+
if (has1) {
|
|
4847
|
+
for (let index = 0; index < size; index += 1) {
|
|
4848
|
+
const s = solution[index];
|
|
4849
|
+
const magnitude = Math.abs(s);
|
|
4850
|
+
const v1 = x1[index];
|
|
4851
|
+
const ratio0 = Math.abs(v1 - s) /
|
|
4852
|
+
(NEWTON_RELATIVE_TOLERANCE * Math.max(Math.abs(v1), magnitude) +
|
|
4853
|
+
NEWTON_VOLTAGE_TOLERANCE);
|
|
4854
|
+
if (ratio0 > error0)
|
|
4855
|
+
error0 = ratio0;
|
|
4856
|
+
if (!has2)
|
|
4857
|
+
continue;
|
|
4858
|
+
const v2 = x2[index];
|
|
4859
|
+
const guess1 = 2 * v1 - v2;
|
|
4860
|
+
const ratio1 = Math.abs(guess1 - s) /
|
|
4861
|
+
(NEWTON_RELATIVE_TOLERANCE * Math.max(Math.abs(guess1), magnitude) +
|
|
4862
|
+
NEWTON_VOLTAGE_TOLERANCE);
|
|
4863
|
+
if (ratio1 > error1)
|
|
4864
|
+
error1 = ratio1;
|
|
4865
|
+
if (!has3)
|
|
4866
|
+
continue;
|
|
4867
|
+
const guess2 = 3 * v1 - 3 * v2 + x3[index];
|
|
4868
|
+
const ratio2 = Math.abs(guess2 - s) /
|
|
4869
|
+
(NEWTON_RELATIVE_TOLERANCE * Math.max(Math.abs(guess2), magnitude) +
|
|
4870
|
+
NEWTON_VOLTAGE_TOLERANCE);
|
|
4871
|
+
if (ratio2 > error2)
|
|
4872
|
+
error2 = ratio2;
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
// Strictly better only: a tie keeps the lower order, so a flat signal (where every
|
|
4876
|
+
// order predicts the same point) stays on the previous-solution start.
|
|
4877
|
+
let best = 0;
|
|
4878
|
+
let bestError = error0;
|
|
4879
|
+
if (error1 < bestError) {
|
|
4880
|
+
best = 1;
|
|
4881
|
+
bestError = error1;
|
|
4882
|
+
}
|
|
4883
|
+
if (error2 < bestError) {
|
|
4884
|
+
best = 2;
|
|
4885
|
+
}
|
|
4886
|
+
// Smooth-regime gate: extrapolate only after a solve that took at most two
|
|
4887
|
+
// iterations (one step and one check -- the floor). A solve that needed more was
|
|
4888
|
+
// hunting across a knee, and there a start that is closer in tolerance units is not
|
|
4889
|
+
// a cheaper start: measured without this gate, `analog-man-prince-of-tone` cost
|
|
4890
|
+
// +50 % iterations at x1 and `boss-hm-2` +39 % (peak 33 -> 63) with every
|
|
4891
|
+
// extrapolation scoring better than the previous solution; with it, -4 % and 0 %,
|
|
4892
|
+
// and every os4 gain is kept (2026-10-09 report, §3/§12).
|
|
4893
|
+
if (used > 2) {
|
|
4894
|
+
best = 0;
|
|
4895
|
+
}
|
|
4896
|
+
history.order = best;
|
|
4897
|
+
// Rotate the ring without allocating: the oldest buffer receives the new solution.
|
|
4898
|
+
const oldest = history.x3;
|
|
4899
|
+
history.x3 = history.x2;
|
|
4900
|
+
history.x2 = history.x1;
|
|
4901
|
+
history.x1 =
|
|
4902
|
+
oldest !== null && oldest.length === size ? oldest : new Array(size).fill(0);
|
|
4903
|
+
for (let index = 0; index < size; index += 1) {
|
|
4904
|
+
history.x1[index] = solution[index];
|
|
4905
|
+
}
|
|
4906
|
+
history.chain += 1;
|
|
4907
|
+
}
|
|
4464
4908
|
snapshotLimiterHistories() {
|
|
4465
4909
|
return {
|
|
4466
4910
|
diode: new Map(this.diodeHistory),
|
|
@@ -6404,11 +6848,30 @@ function solve(matrix, rhs, out) {
|
|
|
6404
6848
|
/** The opcode stream's per-instruction width, in `Int32Array` slots. */
|
|
6405
6849
|
const SCHEDULE_OP_WIDTH = 4;
|
|
6406
6850
|
/**
|
|
6407
|
-
*
|
|
6408
|
-
*
|
|
6409
|
-
*
|
|
6851
|
+
* Pivot guard floor: a pivot below this absolute value sends the solve back
|
|
6852
|
+
* to the dense path.
|
|
6853
|
+
*
|
|
6854
|
+
* This is deliberately ABSOLUTE, not relative, and that is a measured
|
|
6855
|
+
* position rather than a default. A relative guard (`|pivot| < tau *
|
|
6856
|
+
* max|gathered|`) was implemented and reverted 2026-10-08: at tau 1e-12 it
|
|
6857
|
+
* dropped healthy `boss-ce-5` to dense outright; at 1e-14 it gratuitously
|
|
6858
|
+
* re-pivoted `boss-ch-1`; at 1e-16 it abandoned `boss-dd-3b` and
|
|
6859
|
+
* `mxr-blue-box`, dropped `boss-tw-1` (losing its 0.8x budget fix) and
|
|
6860
|
+
* `mxr-dyna-comp`'s rescue, regressed `boss-nf-1` audio 1000x (7.0e-11 to
|
|
6861
|
+
* 7.7e-05), and put 528k fallback solves onto `moogerfooger-mf-102` for no
|
|
6862
|
+
* audio change. The census behind it: healthy `ce-5`/`ch-1`/carbon-copy
|
|
6863
|
+
* bottom out at 1.0-2.5e-15 while fatal `aw-2` sits at 7.7e-15 -- magnitude
|
|
6864
|
+
* alone cannot separate a healthy late-order gmin pivot from a fatal one,
|
|
6865
|
+
* only a replay against dense can, which is the settle gate's job, not this
|
|
6866
|
+
* one's. So this floor catches exactly what it can judge -- collapse -- and
|
|
6867
|
+
* the re-pivot-once below handles the shape that collapse takes mid-run.
|
|
6868
|
+
* See `docs/spikes/2026-10-08-numeric-pivoting.md`.
|
|
6869
|
+
*
|
|
6870
|
+
* Exported for the unit tests alongside the replay: the trip formula is
|
|
6871
|
+
* load-bearing and the corpus never trips it, so only a synthetic matrix
|
|
6872
|
+
* pins it.
|
|
6410
6873
|
*/
|
|
6411
|
-
const SCHEDULE_PIVOT_FLOOR = 1e-18;
|
|
6874
|
+
export const SCHEDULE_PIVOT_FLOOR = 1e-18;
|
|
6412
6875
|
/**
|
|
6413
6876
|
* How far the sparse replay's answer for the assembled operating-point matrix
|
|
6414
6877
|
* may disagree with the dense solve's before the shipped order is dropped.
|
|
@@ -6424,6 +6887,37 @@ const SCHEDULE_PIVOT_FLOOR = 1e-18;
|
|
|
6424
6887
|
* must validate clean.
|
|
6425
6888
|
*/
|
|
6426
6889
|
const SCHEDULE_VALIDATION_TOL = 1e-3;
|
|
6890
|
+
/**
|
|
6891
|
+
* Audio-agreement bar for adopting a value-aware order over a shipped order
|
|
6892
|
+
* that already validates.
|
|
6893
|
+
*
|
|
6894
|
+
* The shipped order replays the operating-point matrix within
|
|
6895
|
+
* `SCHEDULE_VALIDATION_TOL`; the refinement below replaces it only when the
|
|
6896
|
+
* numeric candidate reaches the corpus acceptance bar (sparse vs dense at
|
|
6897
|
+
* most 1e-9 relative) an order of magnitude more cleanly, without meaningful
|
|
6898
|
+
* fill cost. Measured 2026-10-08: `boss-sd-1` crosses (operating-point replay
|
|
6899
|
+
* 1.8e-9 to 4.2e-13, audio 6.9e-8 to 6.5e-12); `boss-hm-2`, `boss-ch-1`,
|
|
6900
|
+
* `boss-od-3` tie and keep shipped; `marshall-jcm800` and `boss-tw-1` cannot
|
|
6901
|
+
* cross at any tau and keep shipped. See
|
|
6902
|
+
* `docs/spikes/2026-10-08-numeric-pivoting.md`.
|
|
6903
|
+
*/
|
|
6904
|
+
const SCHEDULE_REFINEMENT_BAR = 1e-9;
|
|
6905
|
+
/**
|
|
6906
|
+
* How much cleaner the candidate's operating-point replay must be than the
|
|
6907
|
+
* shipped order's before the refinement above fires. Ties (the common case --
|
|
6908
|
+
* both orders replay a healthy matrix to the same rounding) keep shipped, so
|
|
6909
|
+
* adoption never churns a schedule for noise. 10x sits in the measured
|
|
6910
|
+
* canyon: ties are within ~1x, real wins are 1000x or more.
|
|
6911
|
+
*/
|
|
6912
|
+
const SCHEDULE_REFINEMENT_RATIO = 10;
|
|
6913
|
+
/**
|
|
6914
|
+
* Fill-growth ceiling for a refinement adoption, in slots and in ops. A
|
|
6915
|
+
* better replay that costs a third more elimination (`boss-mt-2`: +34% ops
|
|
6916
|
+
* for no audio gain; `mxr-phase-90`: +30% ops for an audio *regression*)
|
|
6917
|
+
* fails the cost criterion it was meant to serve, so the rule refuses it and
|
|
6918
|
+
* keeps shipped. Measured adopters sit far below: `boss-sd-1` +6.5% ops.
|
|
6919
|
+
*/
|
|
6920
|
+
const SCHEDULE_REFINEMENT_MAX_COST_GROWTH = 1.1;
|
|
6427
6921
|
// A **residual check on the schedule's answer was implemented, measured and removed.** The
|
|
6428
6922
|
// reasoning that motivated it was that a static order might produce a bad answer where a
|
|
6429
6923
|
// pivoting one does not, and that the answer should therefore be verified rather than trusted.
|
|
@@ -6453,16 +6947,53 @@ const SCHEDULE_MINIMUM_SAVING = 4;
|
|
|
6453
6947
|
* about what to do before it fills. 64 in a row is not an unlucky operating point.
|
|
6454
6948
|
*/
|
|
6455
6949
|
const SCHEDULE_CONSECUTIVE_FALLBACK_LIMIT = 64;
|
|
6950
|
+
/**
|
|
6951
|
+
* Whether a validating shipped order should be replaced by the numeric
|
|
6952
|
+
* candidate: the refinement half of `settlePivotOrders`, extracted pure so
|
|
6953
|
+
* both consoles and the unit tests share the exact predicate.
|
|
6954
|
+
*
|
|
6955
|
+
* Adopt only when the shipped order is above the audio-agreement bar while
|
|
6956
|
+
* the candidate reaches it, by an order of magnitude, without meaningful
|
|
6957
|
+
* fill cost. The shipped-side floor matters as much as the candidate-side
|
|
6958
|
+
* bar: without it, bar-clean ties with a 1000x replay gap (`trainwreck`,
|
|
6959
|
+
* `fender-5e3`, `vox-ac15`) churn schedules -- and lose their generated
|
|
6960
|
+
* kernels on the C++ console -- for zero audio gain, and `hiwatt-dr103`
|
|
6961
|
+
* adopts on one console but not the other from libm-level matrix noise.
|
|
6962
|
+
* Every input is an operating-point-replay disagreement or a slot/op count
|
|
6963
|
+
* -- no audio renders, no per-packet gates.
|
|
6964
|
+
*/
|
|
6965
|
+
export function shouldRefinePivotOrder(input) {
|
|
6966
|
+
// Total on purpose: settle only calls this when the shipped order
|
|
6967
|
+
// validates (finite, within tolerance), but a refused replay reads as
|
|
6968
|
+
// Infinity and must never refine -- that case belongs to the rescue
|
|
6969
|
+
// branch, with its looser bar, or to the drop.
|
|
6970
|
+
if (!Number.isFinite(input.shippedDisagreement) ||
|
|
6971
|
+
!Number.isFinite(input.candidateDisagreement)) {
|
|
6972
|
+
return false;
|
|
6973
|
+
}
|
|
6974
|
+
return (input.shippedDisagreement > SCHEDULE_REFINEMENT_BAR &&
|
|
6975
|
+
input.candidateDisagreement <= SCHEDULE_REFINEMENT_BAR &&
|
|
6976
|
+
input.candidateDisagreement <
|
|
6977
|
+
input.shippedDisagreement / SCHEDULE_REFINEMENT_RATIO &&
|
|
6978
|
+
input.candidateOps <=
|
|
6979
|
+
SCHEDULE_REFINEMENT_MAX_COST_GROWTH * input.shippedOps &&
|
|
6980
|
+
input.candidateSlots <=
|
|
6981
|
+
SCHEDULE_REFINEMENT_MAX_COST_GROWTH * input.shippedSlots);
|
|
6982
|
+
}
|
|
6456
6983
|
/**
|
|
6457
6984
|
* Replay a schedule against one stamped matrix.
|
|
6458
6985
|
*
|
|
6459
|
-
* Returns `false` without finishing when a pivot has collapsed below
|
|
6460
|
-
* which is the caller's signal to run the dense solve
|
|
6461
|
-
*
|
|
6462
|
-
*
|
|
6463
|
-
*
|
|
6986
|
+
* Returns `false` without finishing when a pivot has collapsed below
|
|
6987
|
+
* `SCHEDULE_PIVOT_FLOOR`, which is the caller's signal to run the dense solve
|
|
6988
|
+
* for this iteration instead. Nothing the caller still needs is destroyed on
|
|
6989
|
+
* the way: `matrix` is only read (the gather copies out of it), `rhs` is
|
|
6990
|
+
* copied into the schedule's own buffer, and `out` is a scratch buffer the
|
|
6991
|
+
* dense fallback overwrites in full.
|
|
6992
|
+
*
|
|
6993
|
+
* Exported for the unit tests: the trip formula is load-bearing and the
|
|
6994
|
+
* corpus never trips it, so only a synthetic matrix pins the boundary.
|
|
6464
6995
|
*/
|
|
6465
|
-
function runSparseSchedule(schedule, matrix, rhs, values, scratchRhs, factors, out) {
|
|
6996
|
+
export function runSparseSchedule(schedule, matrix, rhs, values, scratchRhs, factors, out) {
|
|
6466
6997
|
const { ops, gatherRow, gatherColumn, slots, size } = schedule;
|
|
6467
6998
|
for (let index = 0; index < slots; index += 1) {
|
|
6468
6999
|
values[index] = matrix[gatherRow[index]][gatherColumn[index]];
|
|
@@ -6686,6 +7217,37 @@ function relativeResidual(matrix, rhs, x, pairs, size) {
|
|
|
6686
7217
|
}
|
|
6687
7218
|
return Number.isFinite(worst) ? worst : Number.POSITIVE_INFINITY;
|
|
6688
7219
|
}
|
|
7220
|
+
/**
|
|
7221
|
+
* Order-`order` extrapolation of a block's converged-solution history (see
|
|
7222
|
+
* `newtonStartHistory`) written into `out`: the previous solution, the linear
|
|
7223
|
+
* `2*x1 - x2`, or the quadratic `3*x1 - 3*x2 + x3`. Returns `false`, writing nothing, when
|
|
7224
|
+
* the chain is shorter than the order needs.
|
|
7225
|
+
*/
|
|
7226
|
+
function extrapolateNewtonStart(history, order, size, out) {
|
|
7227
|
+
const { x1, x2, x3, chain } = history;
|
|
7228
|
+
if (order === 0) {
|
|
7229
|
+
if (chain < 1 || x1 === null)
|
|
7230
|
+
return false;
|
|
7231
|
+
for (let index = 0; index < size; index += 1)
|
|
7232
|
+
out[index] = x1[index];
|
|
7233
|
+
return true;
|
|
7234
|
+
}
|
|
7235
|
+
if (order === 1) {
|
|
7236
|
+
if (chain < 2 || x1 === null || x2 === null)
|
|
7237
|
+
return false;
|
|
7238
|
+
for (let index = 0; index < size; index += 1) {
|
|
7239
|
+
out[index] = 2 * x1[index] - x2[index];
|
|
7240
|
+
}
|
|
7241
|
+
return true;
|
|
7242
|
+
}
|
|
7243
|
+
if (chain < 3 || x1 === null || x2 === null || x3 === null)
|
|
7244
|
+
return false;
|
|
7245
|
+
for (let index = 0; index < size; index += 1) {
|
|
7246
|
+
out[index] =
|
|
7247
|
+
3 * x1[index] - 3 * x2[index] + x3[index];
|
|
7248
|
+
}
|
|
7249
|
+
return true;
|
|
7250
|
+
}
|
|
6689
7251
|
function withinTolerance(next, previous) {
|
|
6690
7252
|
for (let index = 0; index < next.length; index += 1) {
|
|
6691
7253
|
const a = next[index] ?? 0;
|