@vessel-dsp/runtime 0.3.1 → 0.4.0

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.
@@ -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 { computeNumericRepivot } from "./numeric-pivot.js";
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,37 @@ 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
+ newtonStartHistory = new Map();
1405
1451
  blockNewtonCensus = new Map();
1406
1452
  totalIterations = 0;
1407
1453
  nonFiniteSamples = 0;
@@ -1682,6 +1728,30 @@ export class ReferenceRuntime {
1682
1728
  // The solver runs at the sub-sample rate. Delay lines and pole coefficients are held in
1683
1729
  // *seconds* and converted here, so they stay correct without knowing about oversampling.
1684
1730
  this.sampleRate = sampleRate * oversample;
1731
+ // The band-limited resampler is designed here, from the stage alone: one
1732
+ // windowed-sinc half-band prototype per 2x stage (`RESAMPLE_STAGE_SPECS`),
1733
+ // fresh zero-state instances per direction, so `prepare()` clears the
1734
+ // filter state and a re-prepared run starts clean. Powers of two only;
1735
+ // any other factor keeps the legacy hold-and-last path in `process()`.
1736
+ const resampleStages = Math.log2(oversample);
1737
+ if (Number.isInteger(resampleStages) && resampleStages >= 1) {
1738
+ const prototypes = Array.from({ length: resampleStages }, (_, stage) => {
1739
+ const [taps, beta] = resampleStageSpec(stage);
1740
+ return designHalfBand2x(taps, beta);
1741
+ });
1742
+ this.resampleUp = prototypes.map((prototype) => new HalfBandStage2x(prototype));
1743
+ this.resampleDown = prototypes.map((prototype) => new HalfBandStage2x(prototype));
1744
+ this.resampleBufA = new Float64Array(oversample);
1745
+ this.resampleBufB = new Float64Array(oversample);
1746
+ this.resampleLatencyHost = cascadeLatencyHostSamples(resampleStages);
1747
+ }
1748
+ else {
1749
+ this.resampleUp = [];
1750
+ this.resampleDown = [];
1751
+ this.resampleBufA = new Float64Array(0);
1752
+ this.resampleBufB = new Float64Array(0);
1753
+ this.resampleLatencyHost = 0;
1754
+ }
1685
1755
  this.maxNewtonIterations = maxNewtonIterations;
1686
1756
  // Negative would be a source that supplies energy under load; refuse the nonsense
1687
1757
  // rather than solve it.
@@ -1703,6 +1773,7 @@ export class ReferenceRuntime {
1703
1773
  this.solvedButFlaggedSamples = 0;
1704
1774
  this.blockNewtonCensus.clear();
1705
1775
  this.unproductiveUntilSample.clear();
1776
+ this.newtonStartHistory.clear();
1706
1777
  this.totalIterations = 0;
1707
1778
  this.nonFiniteSamples = 0;
1708
1779
  this.peakIterations = 0;
@@ -2478,6 +2549,7 @@ export class ReferenceRuntime {
2478
2549
  rhs: new Float64Array(size),
2479
2550
  factors: new Float64Array(schedule.factorCount),
2480
2551
  consecutiveFallbacks: 0,
2552
+ repivotAttempted: false,
2481
2553
  });
2482
2554
  }
2483
2555
  /**
@@ -2581,6 +2653,16 @@ export class ReferenceRuntime {
2581
2653
  * smallest dropped disagreement is `boss-aw-2` at `9.8e+3`. Dense is not
2582
2654
  * assumed right -- `tw-1` diverges on 61 samples densely while converging
2583
2655
  * sparsely -- the gate only fires where the replay demonstrably collapses.
2656
+ *
2657
+ * A validating shipped order is still replaced when the value-aware candidate
2658
+ * reaches the audio-agreement bar (1e-9) an order of magnitude more cleanly
2659
+ * without meaningful fill cost -- the refinement in `SCHEDULE_REFINEMENT_BAR`.
2660
+ * Bar-clean ties keep shipped even when the audios differ: the
2661
+ * operating-point replay cannot adjudicate audio-time divergence (`boss-os-2`
2662
+ * replays 3.4e-10 shipped vs 2.5e-13 candidate while the audios differ 1000x),
2663
+ * so the rule only moves past the bar it can measure, never on a tie.
2664
+ * Measured adopters: `boss-sd-1` (1.8e-9 to 4.2e-13, audio 6.9e-8 to
2665
+ * 6.5e-12).
2584
2666
  */
2585
2667
  settlePivotOrders() {
2586
2668
  if (this.pivotOrdersSettled) {
@@ -2682,15 +2764,13 @@ export class ReferenceRuntime {
2682
2764
  ? disagreement
2683
2765
  : null;
2684
2766
  }
2685
- if (disagreement <= SCHEDULE_VALIDATION_TOL) {
2686
- continue;
2687
- }
2688
- // The shipped order failed on the real matrix. Before giving the
2689
- // schedule up, try a value-aware order: threshold Markowitz over the
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.
2767
+ // The value-aware order, computed always now rather than only when
2768
+ // the shipped order fails: threshold Markowitz over the schedule's
2769
+ // own filled pattern, replayed and compared to the same dense
2770
+ // solve. A refused candidate (null) or a replay that trips its own
2771
+ // guard (Infinity) behaves exactly like a disagreeing one below.
2693
2772
  const candidate = computeNumericRepivot(schedule, size, matrix);
2773
+ let candidateDisagreement = Infinity;
2694
2774
  if (candidate !== null) {
2695
2775
  const candidateValues = new Float64Array(candidate.slots);
2696
2776
  const candidateRhs = new Float64Array(size);
@@ -2704,31 +2784,44 @@ export class ReferenceRuntime {
2704
2784
  candidateDiffSquares += difference * difference;
2705
2785
  candidateDenseSquares += denseOut[index] ** 2;
2706
2786
  }
2707
- const candidateDisagreement = candidateReplayed
2787
+ candidateDisagreement = candidateReplayed
2708
2788
  ? Math.sqrt(candidateDiffSquares) /
2709
2789
  Math.max(Math.sqrt(candidateDenseSquares), 1e-9)
2710
2790
  : Infinity;
2711
- if (candidateDisagreement <= SCHEDULE_VALIDATION_TOL) {
2712
- this.sparseSchedules.set(blockId, {
2713
- schedule: candidate,
2714
- values: new Float64Array(candidate.slots),
2715
- rhs: new Float64Array(size),
2716
- factors: new Float64Array(candidate.factorCount),
2717
- consecutiveFallbacks: 0,
2718
- });
2719
- this.repivotedSchedules.add(blockId);
2720
- this.droppedSchedules.delete(blockId);
2721
- if (planRow !== undefined) {
2722
- planRow.path = "sparse";
2723
- planRow.reason = `numeric re-pivot ${candidateDisagreement.toExponential(1)} vs dense`;
2724
- planRow.pivotDisagreement = candidateDisagreement;
2725
- planRow.repivoted = true;
2726
- planRow.patternEntries = candidate.slots;
2727
- planRow.sparseOps = candidate.sparseOps;
2728
- planRow.unprovenPivots = candidate.unprovenPivots;
2729
- }
2791
+ }
2792
+ if (disagreement <= SCHEDULE_VALIDATION_TOL) {
2793
+ // The shipped order validates. Replace it only on a refinement:
2794
+ // the candidate reaches the audio-agreement bar while the
2795
+ // shipped order does not, by an order of magnitude, without
2796
+ // meaningful fill cost (see `shouldRefinePivotOrder`). Ties
2797
+ // keep shipped, so adoption never churns a schedule for noise
2798
+ // -- measured, `boss-hm-2`, `boss-ch-1` and `boss-od-3` tie
2799
+ // and keep shipped while `boss-sd-1` crosses (1.8e-9 to
2800
+ // 4.2e-13, audio 6.9e-8 to 6.5e-12).
2801
+ if (candidate !== null &&
2802
+ shouldRefinePivotOrder({
2803
+ shippedDisagreement: disagreement,
2804
+ candidateDisagreement,
2805
+ shippedOps: schedule.sparseOps,
2806
+ shippedSlots: schedule.slots,
2807
+ candidateOps: candidate.sparseOps,
2808
+ candidateSlots: candidate.slots,
2809
+ })) {
2810
+ this.adoptRepivotedSchedule(blockId, candidate, size, planRow, `numeric re-pivot ${candidateDisagreement.toExponential(1)} vs dense ` +
2811
+ `(shipped ${Number.isFinite(disagreement) ? disagreement.toExponential(1) : "replay refused"}); ` +
2812
+ `+${(((candidate.sparseOps / Math.max(schedule.sparseOps, 1)) - 1) * 100).toFixed(1)}% ops`, candidateDisagreement);
2730
2813
  continue;
2731
2814
  }
2815
+ continue;
2816
+ }
2817
+ // The shipped order failed on the real matrix. Before giving the
2818
+ // schedule up, try the value-aware order computed above: adoption
2819
+ // needs the same tolerance the shipped order failed; a refused or
2820
+ // still-disagreeing candidate drops as before.
2821
+ if (candidate !== null &&
2822
+ candidateDisagreement <= SCHEDULE_VALIDATION_TOL) {
2823
+ this.adoptRepivotedSchedule(blockId, candidate, size, planRow, `numeric re-pivot ${candidateDisagreement.toExponential(1)} vs dense`, candidateDisagreement);
2824
+ continue;
2732
2825
  }
2733
2826
  this.sparseSchedules.delete(blockId);
2734
2827
  this.droppedSchedules.add(blockId);
@@ -2745,6 +2838,102 @@ export class ReferenceRuntime {
2745
2838
  }
2746
2839
  }
2747
2840
  }
2841
+ /**
2842
+ * Adopt a value-aware replacement order for a block, from settle or from
2843
+ * the mid-run re-pivot, with one shared bookkeeping shape.
2844
+ *
2845
+ * The replacement's slots are a superset of the shipped filled pattern
2846
+ * whose extra cells no stamp writes (see `computeNumericRepivot`), so the
2847
+ * pattern-sized matrix refresh keeps reading correct zeros and neither
2848
+ * `clearPairs` nor the selective-copy machinery needs a rebuild -- the
2849
+ * same reason a dropped block, which does change the refresh, sets
2850
+ * `clearPairs` to null where this does not.
2851
+ */
2852
+ adoptRepivotedSchedule(blockId, candidate, size, planRow, reason, pivotDisagreement) {
2853
+ this.sparseSchedules.set(blockId, {
2854
+ schedule: candidate,
2855
+ values: new Float64Array(candidate.slots),
2856
+ rhs: new Float64Array(size),
2857
+ factors: new Float64Array(candidate.factorCount),
2858
+ consecutiveFallbacks: 0,
2859
+ repivotAttempted: false,
2860
+ });
2861
+ this.repivotedSchedules.add(blockId);
2862
+ this.droppedSchedules.delete(blockId);
2863
+ if (planRow !== undefined) {
2864
+ planRow.path = "sparse";
2865
+ planRow.reason = reason;
2866
+ planRow.pivotDisagreement = pivotDisagreement;
2867
+ planRow.repivoted = true;
2868
+ planRow.patternEntries = candidate.slots;
2869
+ planRow.sparseOps = candidate.sparseOps;
2870
+ planRow.unprovenPivots = candidate.unprovenPivots;
2871
+ }
2872
+ }
2873
+ /**
2874
+ * The mid-run half of the pivot-guard contract: at the consecutive-trip
2875
+ * limit, re-pivot once from the current matrix instead of abandoning.
2876
+ *
2877
+ * Takes the stamped matrix and rhs snapshotted before the dense fallback
2878
+ * destroyed them, plus the fallback's answer, and returns whether a
2879
+ * value-aware order was adopted. Adoption needs the rescue standard --
2880
+ * replay within `SCHEDULE_VALIDATION_TOL` of the dense answer on this
2881
+ * matrix -- the same bar a collapse rescue clears at settle, because this
2882
+ * is a rescue: the shipped order demonstrably does not suit this
2883
+ * operating region. A refusal (null candidate, tripped replay, or a
2884
+ * disagreeing replay) returns false and the caller abandons exactly as
2885
+ * before, with the reason naming the refusal. Either outcome lands in
2886
+ * the plan row, so a mid-run adoption is as visible as a settle one.
2887
+ *
2888
+ * Structured for direct unit testing (pure inputs, boolean verdict): the
2889
+ * corpus never trips the guard, so no corpus run exercises this path.
2890
+ */
2891
+ adoptMidRunRepivot(blockId, entry, matrix, rhs, denseAnswer) {
2892
+ const planRow = this.solverPlanRows.find((candidate) => candidate.blockId === blockId);
2893
+ const block = this.blocksById.get(blockId);
2894
+ const size = block !== undefined && block.kind === "mna"
2895
+ ? block.nodeCount + block.auxCount
2896
+ : 0;
2897
+ const refuse = (reason) => {
2898
+ if (planRow !== undefined) {
2899
+ planRow.reason += ` mid-run re-pivot ${reason}; abandoned`;
2900
+ }
2901
+ return false;
2902
+ };
2903
+ if (block === undefined || block.kind !== "mna" || size <= 0) {
2904
+ return refuse("refused (unknown block)");
2905
+ }
2906
+ const candidate = computeNumericRepivot(entry.schedule, size, matrix);
2907
+ if (candidate === null) {
2908
+ return refuse("refused (no candidate meets the threshold)");
2909
+ }
2910
+ const values = new Float64Array(candidate.slots);
2911
+ const scratchRhs = new Float64Array(size);
2912
+ const factors = new Float64Array(candidate.factorCount);
2913
+ const out = new Array(size).fill(0);
2914
+ const replayed = runSparseSchedule(candidate, matrix, rhs, values, scratchRhs, factors, out);
2915
+ let diffSquares = 0;
2916
+ let denseSquares = 0;
2917
+ for (let index = 0; index < size; index += 1) {
2918
+ const difference = out[index] - denseAnswer[index];
2919
+ diffSquares += difference * difference;
2920
+ denseSquares += denseAnswer[index] ** 2;
2921
+ }
2922
+ const disagreement = replayed
2923
+ ? Math.sqrt(diffSquares) / Math.max(Math.sqrt(denseSquares), 1e-9)
2924
+ : Infinity;
2925
+ if (!(disagreement <= SCHEDULE_VALIDATION_TOL)) {
2926
+ return refuse(`refused (replay ${Number.isFinite(disagreement) ? disagreement.toExponential(1) : "tripped"} vs dense)`);
2927
+ }
2928
+ this.adoptRepivotedSchedule(blockId, candidate, size, planRow, `mid-run numeric re-pivot ${disagreement.toExponential(1)} vs dense after ${SCHEDULE_CONSECUTIVE_FALLBACK_LIMIT} guard trips`, disagreement);
2929
+ // The one attempt is spent: a second limit-hit on the adopted order
2930
+ // abandons rather than re-pivoting again.
2931
+ const adopted = this.sparseSchedules.get(blockId);
2932
+ if (adopted !== undefined) {
2933
+ adopted.repivotAttempted = true;
2934
+ }
2935
+ return true;
2936
+ }
2748
2937
  /**
2749
2938
  * Which blocks took the static schedule and which kept the dense solve, and how often the
2750
2939
  * pivot guard sent a solve back to the dense path.
@@ -2776,73 +2965,148 @@ export class ReferenceRuntime {
2776
2965
  // `samples` and the non-converged counters stay in **host** samples however many
2777
2966
  // sub-samples each one costs, because their only job is to report the share of rendered
2778
2967
  // audio that is untrustworthy, and a listener hears host samples.
2779
- for (let index = 0; index < input.length; index += 1) {
2780
- // Every block is solved, but the result comes from the block that owns the
2781
- // output jack -- not from whichever ran last. Regions are independent
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;
2968
+ if (this.resampleUp.length === 0) {
2969
+ for (let index = 0; index < input.length; index += 1) {
2970
+ output[index] = this.processHeldSample(input[index] ?? 0);
2838
2971
  }
2839
- if (hostNonFinite) {
2840
- this.nonFiniteSamples += 1;
2972
+ }
2973
+ else {
2974
+ for (let index = 0; index < input.length; index += 1) {
2975
+ output[index] = this.processResampledSample(input[index] ?? 0);
2841
2976
  }
2842
- output[index] = hostResult;
2843
2977
  }
2844
2978
  return output;
2845
2979
  }
2980
+ /**
2981
+ * One host sample through the legacy path: the input held flat across the
2982
+ * sub-samples, the last sub-sample kept. Bit-identical to the behaviour
2983
+ * before the band-limited resampler existed, which is what makes factor 1
2984
+ * (and non-power-of-two factors) cost nothing and change nothing.
2985
+ */
2986
+ processHeldSample(sample) {
2987
+ // Every block is solved, but the result comes from the block that owns the
2988
+ // output jack -- not from whichever ran last. Regions are independent
2989
+ // subcircuits; only their shared nodes couple them.
2990
+ let hostResult = 0;
2991
+ let hostNonConverged = false;
2992
+ let hostNonFinite = false;
2993
+ // The input is **held** across the sub-samples rather than interpolated. A host
2994
+ // sample is one measurement and inventing intermediate values is inventing signal;
2995
+ // holding is the zero-order reading of what was actually delivered.
2996
+ for (let sub = 0; sub < this.oversample; sub += 1) {
2997
+ hostResult = this.solveSubSample(sample);
2998
+ if (this.sampleNonConverged) {
2999
+ hostNonConverged = true;
3000
+ }
3001
+ if (this.sampleNonFinite) {
3002
+ hostNonFinite = true;
3003
+ }
3004
+ }
3005
+ // Zero-order decimation: the last sub-sample is the host sample. This is what the
3006
+ // 47-to-50 parity measurement was taken with, and on `boss-ce-5` it is bit-identical
3007
+ // to a boxcar average because the limit cycle oversampling removes is *gone* rather
3008
+ // than moved above Nyquist.
3009
+ if (hostNonConverged) {
3010
+ this.nonConvergedSamples += 1;
3011
+ }
3012
+ if (hostNonFinite) {
3013
+ this.nonFiniteSamples += 1;
3014
+ }
3015
+ return hostResult;
3016
+ }
3017
+ /**
3018
+ * One host sample through the band-limited path: half-band interpolate up
3019
+ * by 2 per stage, solve each sub-sample, FIR-decimate back to one host
3020
+ * sample. The resampler state lives on the stages across `process()`
3021
+ * calls, so any block split renders bit-identically to one long call;
3022
+ * the group delay this adds is reported by `oversampleLatency()`.
3023
+ */
3024
+ processResampledSample(sample) {
3025
+ const stages = this.resampleUp.length;
3026
+ // Up cascade: alternate the two scratch buffers, doubling each stage.
3027
+ let cur = this.resampleBufA;
3028
+ let next = this.resampleBufB;
3029
+ this.resampleUp[0].interpolate(sample, cur, 0);
3030
+ let width = 2;
3031
+ for (let stage = 1; stage < stages; stage += 1) {
3032
+ for (let i = 0; i < width; i += 1) {
3033
+ this.resampleUp[stage].interpolate(cur[i], next, 2 * i);
3034
+ }
3035
+ const swap = cur;
3036
+ cur = next;
3037
+ next = swap;
3038
+ width *= 2;
3039
+ }
3040
+ // Solve every sub-sample; outputs land in the idle buffer.
3041
+ let hostNonConverged = false;
3042
+ let hostNonFinite = false;
3043
+ for (let sub = 0; sub < width; sub += 1) {
3044
+ next[sub] = this.solveSubSample(cur[sub]);
3045
+ if (this.sampleNonConverged) {
3046
+ hostNonConverged = true;
3047
+ }
3048
+ if (this.sampleNonFinite) {
3049
+ hostNonFinite = true;
3050
+ }
3051
+ }
3052
+ // Down cascade, highest rate first, halving in place of `next`.
3053
+ const hi = next;
3054
+ for (let stage = stages - 1; stage >= 0; stage -= 1) {
3055
+ const half = width / 2;
3056
+ for (let i = 0; i < half; i += 1) {
3057
+ hi[i] = this.resampleDown[stage].decimate(hi[2 * i], hi[2 * i + 1]);
3058
+ }
3059
+ width = half;
3060
+ }
3061
+ if (hostNonConverged) {
3062
+ this.nonConvergedSamples += 1;
3063
+ }
3064
+ if (hostNonFinite) {
3065
+ this.nonFiniteSamples += 1;
3066
+ }
3067
+ return hi[0];
3068
+ }
3069
+ /**
3070
+ * One sub-sample solve at the solver rate: every block stepped once at
3071
+ * `t = elapsedSamples / sampleRate`, returning the output jack's value.
3072
+ * Shared by the held and the resampled paths, so factor 1 executes the
3073
+ * same operations in the same order as before the resampler existed.
3074
+ */
3075
+ solveSubSample(subInput) {
3076
+ // Sample `n` is the circuit at `t = n / sampleRate`, counted from `prepare`. Set here
3077
+ // so every block in this sample sees one time, and advanced below so buffer
3078
+ // boundaries are invisible.
3079
+ this.timeSeconds = this.elapsedSamples / this.sampleRate;
3080
+ let result = 0;
3081
+ // A sample is counted once however many of its blocks failed. Counting each
3082
+ // failing block instead let `nonConvergedSamples` exceed `samples` in a
3083
+ // multi-region program, so the number could not be read as the share of audio
3084
+ // that is untrustworthy -- which is the only thing it is for.
3085
+ this.sampleNonConverged = false;
3086
+ this.sampleNonFinite = false;
3087
+ for (const id of this.program.order) {
3088
+ const block = this.blocksById.get(id);
3089
+ if (block === undefined) {
3090
+ continue;
3091
+ }
3092
+ const blockOutput = this.processBlock(block, subInput);
3093
+ if (block.kind === "mna" && block.outputNode !== null) {
3094
+ result = blockOutput;
3095
+ }
3096
+ }
3097
+ // The rendered supply peak, read from the solved branch unknowns. Every sample, because
3098
+ // a rectifier's current is a pulse a few samples wide and a coarser sampling of it
3099
+ // would report a smaller number for no physical reason.
3100
+ for (const branch of this.supplyBranchSlots) {
3101
+ const amps = this.nodeVoltages.get(branch.blockId)?.[branch.slot] ?? 0;
3102
+ const magnitude = Math.abs(amps);
3103
+ if (magnitude > this.renderedSupplyPeakAmps) {
3104
+ this.renderedSupplyPeakAmps = magnitude;
3105
+ }
3106
+ }
3107
+ this.elapsedSamples += 1;
3108
+ return result;
3109
+ }
2846
3110
  /**
2847
3111
  * The rate the caller passed to `prepare`, as opposed to the solver's sub-sample rate.
2848
3112
  *
@@ -2852,6 +3116,17 @@ export class ReferenceRuntime {
2852
3116
  hostSampleRate() {
2853
3117
  return this.sampleRate === null ? null : this.sampleRate / this.oversample;
2854
3118
  }
3119
+ /**
3120
+ * The resampler's total group delay in **host** samples, or `null` before
3121
+ * `prepare()`. Zero when the resampler is bypassed (factor 1, or a factor
3122
+ * that is not a power of two and keeps the legacy path): the held path
3123
+ * adds no latency by construction. At 2x/4x/8x this is 19.5/26.25/28.625
3124
+ * host samples for the stage-specific half-band cascade -- a real, reported
3125
+ * latency the caller must absorb or declare, not a hidden one.
3126
+ */
3127
+ oversampleLatency() {
3128
+ return this.sampleRate === null ? null : this.resampleLatencyHost;
3129
+ }
2855
3130
  processBlock(block, input) {
2856
3131
  if (block.kind === "composed") {
2857
3132
  this.processComposedBlock(block);
@@ -3530,8 +3805,14 @@ export class ReferenceRuntime {
3530
3805
  // itself is read-only here and is never handed back to the caller aliased to either
3531
3806
  // buffer, so a caller that still holds it (`[...guess]` in the continuation methods,
3532
3807
  // `previous.slice()` in `processBlock`) sees it unchanged.
3808
+ //
3809
+ // On a standard audio pass of a nonlinear block the seed is the predicted start (see
3810
+ // `newtonStartHistory`), falling back to `start`. `start` itself stays the previous
3811
+ // solution for the fold reseed below.
3812
+ const predictorPass = !dc && sourceScale === 1 && gmin === GMIN_SIEMENS && !block.linear;
3813
+ const seed = (predictorPass ? this.predictedNewtonStart(block.id, size) : null) ?? start;
3533
3814
  for (let index = 0; index < size; index += 1) {
3534
- solutionA[index] = start[index] ?? 0;
3815
+ solutionA[index] = seed[index] ?? 0;
3535
3816
  }
3536
3817
  let current = solutionA;
3537
3818
  let next = solutionB;
@@ -3672,11 +3953,37 @@ export class ReferenceRuntime {
3672
3953
  this.scheduleFallbacks += 1;
3673
3954
  sparse.consecutiveFallbacks += 1;
3674
3955
  if (sparse.consecutiveFallbacks >= SCHEDULE_CONSECUTIVE_FALLBACK_LIMIT) {
3675
- this.sparseSchedules.delete(block.id);
3676
- this.abandonedSchedules.add(block.id);
3956
+ // The old shape abandoned here, silently dense for the rest
3957
+ // of the run after paying for both solvers on every one of
3958
+ // the 64 trips. Re-pivot once from the current matrix
3959
+ // instead: a control position the operating-point matrix
3960
+ // never showed can still suit a value-aware order, and
3961
+ // only a refused or still-disagreeing re-pivot abandons.
3962
+ // `solve` destroys `matrix` and `rhs`, so the re-pivot's
3963
+ // inputs are snapshotted first -- once per order, on this
3964
+ // trip only.
3965
+ if (!sparse.repivotAttempted) {
3966
+ sparse.repivotAttempted = true;
3967
+ const matrixCopy = matrix.map((row) => [...row]);
3968
+ const rhsCopy = [...rhs];
3969
+ scratch.denseDirty = true;
3970
+ solve(matrix, rhs, next);
3971
+ if (!this.adoptMidRunRepivot(block.id, sparse, matrixCopy, rhsCopy, next)) {
3972
+ this.sparseSchedules.delete(block.id);
3973
+ this.abandonedSchedules.add(block.id);
3974
+ }
3975
+ }
3976
+ else {
3977
+ this.sparseSchedules.delete(block.id);
3978
+ this.abandonedSchedules.add(block.id);
3979
+ scratch.denseDirty = true;
3980
+ solve(matrix, rhs, next);
3981
+ }
3982
+ }
3983
+ else {
3984
+ scratch.denseDirty = true;
3985
+ solve(matrix, rhs, next);
3677
3986
  }
3678
- scratch.denseDirty = true;
3679
- solve(matrix, rhs, next);
3680
3987
  }
3681
3988
  }
3682
3989
  if (relaxing) {
@@ -3832,6 +4139,9 @@ export class ReferenceRuntime {
3832
4139
  census.peakIterations = used;
3833
4140
  }
3834
4141
  }
4142
+ if (predictorPass) {
4143
+ this.recordNewtonSolution(block.id, current, converged, used);
4144
+ }
3835
4145
  return { solution: current, converged, used, worstNode, worstDelta };
3836
4146
  }
3837
4147
  /**
@@ -4024,8 +4334,12 @@ export class ReferenceRuntime {
4024
4334
  full[portRows[p]] = y[p];
4025
4335
  }
4026
4336
  };
4337
+ // The predicted start (see `newtonStartHistory`), falling back to `start`: the same
4338
+ // rule as `iterate`, applied through the port unknowns the reduced solve iterates on.
4339
+ const predictorPass = !dc && sourceScale === 1 && gmin === GMIN_SIEMENS && !block.linear;
4340
+ const seed = (predictorPass ? this.predictedNewtonStart(block.id, size) : null) ?? start;
4027
4341
  for (let p = 0; p < portCount; p += 1) {
4028
- scratch.yCurrent[p] = start[portRows[p]] ?? 0;
4342
+ scratch.yCurrent[p] = seed[portRows[p]] ?? 0;
4029
4343
  }
4030
4344
  reconstructFull(scratch.yCurrent, scratch.fullCurrent);
4031
4345
  const iterations = this.maxNewtonIterations;
@@ -4164,6 +4478,9 @@ export class ReferenceRuntime {
4164
4478
  census.peakIterations = used;
4165
4479
  }
4166
4480
  }
4481
+ if (predictorPass) {
4482
+ this.recordNewtonSolution(block.id, current, converged, used);
4483
+ }
4167
4484
  return { solution: current, converged, used, worstNode, worstDelta };
4168
4485
  }
4169
4486
  /**
@@ -4461,6 +4778,110 @@ export class ReferenceRuntime {
4461
4778
  this.opampHistory.clear();
4462
4779
  this.triodeHistory.clear();
4463
4780
  }
4781
+ /**
4782
+ * The Newton start `newtonStartHistory` selects for this block, or `null` to start from
4783
+ * the caller's previous solution (order 0, or a chain too short for the chosen order).
4784
+ * The returned array is the block's reused scratch: read it before the next solve.
4785
+ */
4786
+ predictedNewtonStart(blockId, size) {
4787
+ const history = this.newtonStartHistory.get(blockId);
4788
+ if (history === undefined || history.order === 0) {
4789
+ return null;
4790
+ }
4791
+ if (history.candidate.length !== size) {
4792
+ history.candidate = new Array(size).fill(0);
4793
+ }
4794
+ return extrapolateNewtonStart(history, history.order, size, history.candidate)
4795
+ ? history.candidate
4796
+ : null;
4797
+ }
4798
+ /**
4799
+ * Record a finished standard-pass solve: on convergence, score the three candidate
4800
+ * starts the history could have produced against the solution just reached (one fused
4801
+ * pass, no allocation) and keep the best order for the next solve, then rotate the
4802
+ * solution into the history ring; on failure, break the chain.
4803
+ */
4804
+ recordNewtonSolution(blockId, solution, converged, used) {
4805
+ let history = this.newtonStartHistory.get(blockId);
4806
+ if (history === undefined) {
4807
+ history = { x1: null, x2: null, x3: null, chain: 0, order: 0, candidate: [] };
4808
+ this.newtonStartHistory.set(blockId, history);
4809
+ }
4810
+ if (!converged) {
4811
+ history.chain = 0;
4812
+ history.order = 0;
4813
+ return;
4814
+ }
4815
+ const size = solution.length;
4816
+ const { x1, x2, x3 } = history;
4817
+ const has1 = history.chain >= 1 && x1 !== null && x1.length === size;
4818
+ const has2 = has1 && history.chain >= 2 && x2 !== null && x2.length === size;
4819
+ const has3 = has2 && history.chain >= 3 && x3 !== null && x3.length === size;
4820
+ let error0 = has1 ? 0 : Number.POSITIVE_INFINITY;
4821
+ let error1 = has2 ? 0 : Number.POSITIVE_INFINITY;
4822
+ let error2 = has3 ? 0 : Number.POSITIVE_INFINITY;
4823
+ if (has1) {
4824
+ for (let index = 0; index < size; index += 1) {
4825
+ const s = solution[index];
4826
+ const magnitude = Math.abs(s);
4827
+ const v1 = x1[index];
4828
+ const ratio0 = Math.abs(v1 - s) /
4829
+ (NEWTON_RELATIVE_TOLERANCE * Math.max(Math.abs(v1), magnitude) +
4830
+ NEWTON_VOLTAGE_TOLERANCE);
4831
+ if (ratio0 > error0)
4832
+ error0 = ratio0;
4833
+ if (!has2)
4834
+ continue;
4835
+ const v2 = x2[index];
4836
+ const guess1 = 2 * v1 - v2;
4837
+ const ratio1 = Math.abs(guess1 - s) /
4838
+ (NEWTON_RELATIVE_TOLERANCE * Math.max(Math.abs(guess1), magnitude) +
4839
+ NEWTON_VOLTAGE_TOLERANCE);
4840
+ if (ratio1 > error1)
4841
+ error1 = ratio1;
4842
+ if (!has3)
4843
+ continue;
4844
+ const guess2 = 3 * v1 - 3 * v2 + x3[index];
4845
+ const ratio2 = Math.abs(guess2 - s) /
4846
+ (NEWTON_RELATIVE_TOLERANCE * Math.max(Math.abs(guess2), magnitude) +
4847
+ NEWTON_VOLTAGE_TOLERANCE);
4848
+ if (ratio2 > error2)
4849
+ error2 = ratio2;
4850
+ }
4851
+ }
4852
+ // Strictly better only: a tie keeps the lower order, so a flat signal (where every
4853
+ // order predicts the same point) stays on the previous-solution start.
4854
+ let best = 0;
4855
+ let bestError = error0;
4856
+ if (error1 < bestError) {
4857
+ best = 1;
4858
+ bestError = error1;
4859
+ }
4860
+ if (error2 < bestError) {
4861
+ best = 2;
4862
+ }
4863
+ // Smooth-regime gate: extrapolate only after a solve that took at most two
4864
+ // iterations (one step and one check -- the floor). A solve that needed more was
4865
+ // hunting across a knee, and there a start that is closer in tolerance units is not
4866
+ // a cheaper start: measured without this gate, `analog-man-prince-of-tone` cost
4867
+ // +50 % iterations at x1 and `boss-hm-2` +39 % (peak 33 -> 63) with every
4868
+ // extrapolation scoring better than the previous solution; with it, -4 % and 0 %,
4869
+ // and every os4 gain is kept (2026-10-09 report, §3/§12).
4870
+ if (used > 2) {
4871
+ best = 0;
4872
+ }
4873
+ history.order = best;
4874
+ // Rotate the ring without allocating: the oldest buffer receives the new solution.
4875
+ const oldest = history.x3;
4876
+ history.x3 = history.x2;
4877
+ history.x2 = history.x1;
4878
+ history.x1 =
4879
+ oldest !== null && oldest.length === size ? oldest : new Array(size).fill(0);
4880
+ for (let index = 0; index < size; index += 1) {
4881
+ history.x1[index] = solution[index];
4882
+ }
4883
+ history.chain += 1;
4884
+ }
4464
4885
  snapshotLimiterHistories() {
4465
4886
  return {
4466
4887
  diode: new Map(this.diodeHistory),
@@ -6404,11 +6825,30 @@ function solve(matrix, rhs, out) {
6404
6825
  /** The opcode stream's per-instruction width, in `Int32Array` slots. */
6405
6826
  const SCHEDULE_OP_WIDTH = 4;
6406
6827
  /**
6407
- * A pivot this small is what the dense `solve` refuses to divide by (its own `continue`), and
6408
- * it is what a *static* order can walk into that a value-aware one cannot: an entry that is
6409
- * structurally present but numerically zero at this control position or this Newton iterate.
6828
+ * Pivot guard floor: a pivot below this absolute value sends the solve back
6829
+ * to the dense path.
6830
+ *
6831
+ * This is deliberately ABSOLUTE, not relative, and that is a measured
6832
+ * position rather than a default. A relative guard (`|pivot| < tau *
6833
+ * max|gathered|`) was implemented and reverted 2026-10-08: at tau 1e-12 it
6834
+ * dropped healthy `boss-ce-5` to dense outright; at 1e-14 it gratuitously
6835
+ * re-pivoted `boss-ch-1`; at 1e-16 it abandoned `boss-dd-3b` and
6836
+ * `mxr-blue-box`, dropped `boss-tw-1` (losing its 0.8x budget fix) and
6837
+ * `mxr-dyna-comp`'s rescue, regressed `boss-nf-1` audio 1000x (7.0e-11 to
6838
+ * 7.7e-05), and put 528k fallback solves onto `moogerfooger-mf-102` for no
6839
+ * audio change. The census behind it: healthy `ce-5`/`ch-1`/carbon-copy
6840
+ * bottom out at 1.0-2.5e-15 while fatal `aw-2` sits at 7.7e-15 -- magnitude
6841
+ * alone cannot separate a healthy late-order gmin pivot from a fatal one,
6842
+ * only a replay against dense can, which is the settle gate's job, not this
6843
+ * one's. So this floor catches exactly what it can judge -- collapse -- and
6844
+ * the re-pivot-once below handles the shape that collapse takes mid-run.
6845
+ * See `docs/spikes/2026-10-08-numeric-pivoting.md`.
6846
+ *
6847
+ * Exported for the unit tests alongside the replay: the trip formula is
6848
+ * load-bearing and the corpus never trips it, so only a synthetic matrix
6849
+ * pins it.
6410
6850
  */
6411
- const SCHEDULE_PIVOT_FLOOR = 1e-18;
6851
+ export const SCHEDULE_PIVOT_FLOOR = 1e-18;
6412
6852
  /**
6413
6853
  * How far the sparse replay's answer for the assembled operating-point matrix
6414
6854
  * may disagree with the dense solve's before the shipped order is dropped.
@@ -6424,6 +6864,37 @@ const SCHEDULE_PIVOT_FLOOR = 1e-18;
6424
6864
  * must validate clean.
6425
6865
  */
6426
6866
  const SCHEDULE_VALIDATION_TOL = 1e-3;
6867
+ /**
6868
+ * Audio-agreement bar for adopting a value-aware order over a shipped order
6869
+ * that already validates.
6870
+ *
6871
+ * The shipped order replays the operating-point matrix within
6872
+ * `SCHEDULE_VALIDATION_TOL`; the refinement below replaces it only when the
6873
+ * numeric candidate reaches the corpus acceptance bar (sparse vs dense at
6874
+ * most 1e-9 relative) an order of magnitude more cleanly, without meaningful
6875
+ * fill cost. Measured 2026-10-08: `boss-sd-1` crosses (operating-point replay
6876
+ * 1.8e-9 to 4.2e-13, audio 6.9e-8 to 6.5e-12); `boss-hm-2`, `boss-ch-1`,
6877
+ * `boss-od-3` tie and keep shipped; `marshall-jcm800` and `boss-tw-1` cannot
6878
+ * cross at any tau and keep shipped. See
6879
+ * `docs/spikes/2026-10-08-numeric-pivoting.md`.
6880
+ */
6881
+ const SCHEDULE_REFINEMENT_BAR = 1e-9;
6882
+ /**
6883
+ * How much cleaner the candidate's operating-point replay must be than the
6884
+ * shipped order's before the refinement above fires. Ties (the common case --
6885
+ * both orders replay a healthy matrix to the same rounding) keep shipped, so
6886
+ * adoption never churns a schedule for noise. 10x sits in the measured
6887
+ * canyon: ties are within ~1x, real wins are 1000x or more.
6888
+ */
6889
+ const SCHEDULE_REFINEMENT_RATIO = 10;
6890
+ /**
6891
+ * Fill-growth ceiling for a refinement adoption, in slots and in ops. A
6892
+ * better replay that costs a third more elimination (`boss-mt-2`: +34% ops
6893
+ * for no audio gain; `mxr-phase-90`: +30% ops for an audio *regression*)
6894
+ * fails the cost criterion it was meant to serve, so the rule refuses it and
6895
+ * keeps shipped. Measured adopters sit far below: `boss-sd-1` +6.5% ops.
6896
+ */
6897
+ const SCHEDULE_REFINEMENT_MAX_COST_GROWTH = 1.1;
6427
6898
  // A **residual check on the schedule's answer was implemented, measured and removed.** The
6428
6899
  // reasoning that motivated it was that a static order might produce a bad answer where a
6429
6900
  // pivoting one does not, and that the answer should therefore be verified rather than trusted.
@@ -6453,16 +6924,53 @@ const SCHEDULE_MINIMUM_SAVING = 4;
6453
6924
  * about what to do before it fills. 64 in a row is not an unlucky operating point.
6454
6925
  */
6455
6926
  const SCHEDULE_CONSECUTIVE_FALLBACK_LIMIT = 64;
6927
+ /**
6928
+ * Whether a validating shipped order should be replaced by the numeric
6929
+ * candidate: the refinement half of `settlePivotOrders`, extracted pure so
6930
+ * both consoles and the unit tests share the exact predicate.
6931
+ *
6932
+ * Adopt only when the shipped order is above the audio-agreement bar while
6933
+ * the candidate reaches it, by an order of magnitude, without meaningful
6934
+ * fill cost. The shipped-side floor matters as much as the candidate-side
6935
+ * bar: without it, bar-clean ties with a 1000x replay gap (`trainwreck`,
6936
+ * `fender-5e3`, `vox-ac15`) churn schedules -- and lose their generated
6937
+ * kernels on the C++ console -- for zero audio gain, and `hiwatt-dr103`
6938
+ * adopts on one console but not the other from libm-level matrix noise.
6939
+ * Every input is an operating-point-replay disagreement or a slot/op count
6940
+ * -- no audio renders, no per-packet gates.
6941
+ */
6942
+ export function shouldRefinePivotOrder(input) {
6943
+ // Total on purpose: settle only calls this when the shipped order
6944
+ // validates (finite, within tolerance), but a refused replay reads as
6945
+ // Infinity and must never refine -- that case belongs to the rescue
6946
+ // branch, with its looser bar, or to the drop.
6947
+ if (!Number.isFinite(input.shippedDisagreement) ||
6948
+ !Number.isFinite(input.candidateDisagreement)) {
6949
+ return false;
6950
+ }
6951
+ return (input.shippedDisagreement > SCHEDULE_REFINEMENT_BAR &&
6952
+ input.candidateDisagreement <= SCHEDULE_REFINEMENT_BAR &&
6953
+ input.candidateDisagreement <
6954
+ input.shippedDisagreement / SCHEDULE_REFINEMENT_RATIO &&
6955
+ input.candidateOps <=
6956
+ SCHEDULE_REFINEMENT_MAX_COST_GROWTH * input.shippedOps &&
6957
+ input.candidateSlots <=
6958
+ SCHEDULE_REFINEMENT_MAX_COST_GROWTH * input.shippedSlots);
6959
+ }
6456
6960
  /**
6457
6961
  * Replay a schedule against one stamped matrix.
6458
6962
  *
6459
- * Returns `false` without finishing when a pivot has collapsed below `SCHEDULE_PIVOT_FLOOR`,
6460
- * which is the caller's signal to run the dense solve for this iteration instead. Nothing the
6461
- * caller still needs is destroyed on the way: `matrix` is only read (the gather copies out of
6462
- * it), `rhs` is copied into the schedule's own buffer, and `out` is a scratch buffer the dense
6463
- * fallback overwrites in full.
6963
+ * Returns `false` without finishing when a pivot has collapsed below
6964
+ * `SCHEDULE_PIVOT_FLOOR`, which is the caller's signal to run the dense solve
6965
+ * for this iteration instead. Nothing the caller still needs is destroyed on
6966
+ * the way: `matrix` is only read (the gather copies out of it), `rhs` is
6967
+ * copied into the schedule's own buffer, and `out` is a scratch buffer the
6968
+ * dense fallback overwrites in full.
6969
+ *
6970
+ * Exported for the unit tests: the trip formula is load-bearing and the
6971
+ * corpus never trips it, so only a synthetic matrix pins the boundary.
6464
6972
  */
6465
- function runSparseSchedule(schedule, matrix, rhs, values, scratchRhs, factors, out) {
6973
+ export function runSparseSchedule(schedule, matrix, rhs, values, scratchRhs, factors, out) {
6466
6974
  const { ops, gatherRow, gatherColumn, slots, size } = schedule;
6467
6975
  for (let index = 0; index < slots; index += 1) {
6468
6976
  values[index] = matrix[gatherRow[index]][gatherColumn[index]];
@@ -6686,6 +7194,37 @@ function relativeResidual(matrix, rhs, x, pairs, size) {
6686
7194
  }
6687
7195
  return Number.isFinite(worst) ? worst : Number.POSITIVE_INFINITY;
6688
7196
  }
7197
+ /**
7198
+ * Order-`order` extrapolation of a block's converged-solution history (see
7199
+ * `newtonStartHistory`) written into `out`: the previous solution, the linear
7200
+ * `2*x1 - x2`, or the quadratic `3*x1 - 3*x2 + x3`. Returns `false`, writing nothing, when
7201
+ * the chain is shorter than the order needs.
7202
+ */
7203
+ function extrapolateNewtonStart(history, order, size, out) {
7204
+ const { x1, x2, x3, chain } = history;
7205
+ if (order === 0) {
7206
+ if (chain < 1 || x1 === null)
7207
+ return false;
7208
+ for (let index = 0; index < size; index += 1)
7209
+ out[index] = x1[index];
7210
+ return true;
7211
+ }
7212
+ if (order === 1) {
7213
+ if (chain < 2 || x1 === null || x2 === null)
7214
+ return false;
7215
+ for (let index = 0; index < size; index += 1) {
7216
+ out[index] = 2 * x1[index] - x2[index];
7217
+ }
7218
+ return true;
7219
+ }
7220
+ if (chain < 3 || x1 === null || x2 === null || x3 === null)
7221
+ return false;
7222
+ for (let index = 0; index < size; index += 1) {
7223
+ out[index] =
7224
+ 3 * x1[index] - 3 * x2[index] + x3[index];
7225
+ }
7226
+ return true;
7227
+ }
6689
7228
  function withinTolerance(next, previous) {
6690
7229
  for (let index = 0; index < next.length; index += 1) {
6691
7230
  const a = next[index] ?? 0;