Stage-relabeling invariance for Runge–Kutta methods

In normal Runge–Kutta / numerical methods language, the idea is usually described like this:

1) Runge–Kutta stage reordering does not change the method

An explicit Runge–Kutta method works by doing several internal “sub-steps” (called stages) inside one time step.
The theorem says:

If you relabel or reorder those stages (stage 1 becomes stage 2, stage 2 becomes stage 1, etc.)

and you also rearrange the method’s coefficient tables to match that new ordering then the method still produces the same final result for the step.

So even though the internal bookkeeping changes, the actual computed step result does not change (except for tiny rounding differences).

This is commonly called: Stage permutation invariance or invariance under stage relabeling

2) This still stays true when you chain methods

Your code doesn’t just run one RK method; it chains two of them: Run method 2 for one step, then run method 1 for one step

The theorem also says:

If you replace method 1 with an equivalent “stage-reordered version” and/or replace method 2 with an equivalent “stage-reordered version”
then the chained result is still the same.

This is often described as:

Composition is compatible with stage reordering or more formally: composition is well-defined under the equivalence

If you want one “research-style umbrella description” (still readable):

Reordering stages does not change RK results, and it also does not change the results of chaining RK methods.

Short introduction to theorem_382A (in terms of your code)

Your code is testing this exact claim:

You build two normal RK methods:

Heun RK2 (2 stages) Classical RK4 (4 stages)

You build reordered versions of the same methods by shuffling their stage order, while also shuffling the tables correctly.

You run both versions on the same ODE step and compare:

Original chain: RK4 step then Heun step

Reordered chain: permuted RK4 step then permuted Heun step

The theorem predicts:

The two final answers should match (tiny floating-point rounding differences are okay)

So the code shows you can reorder internal stages for implementation reasons (like memory layout or simpler storage) without changing what the method computes.

How demo_theorem_382A_5cases_main.c shows this, step by step
Step 1 — Build the original RK methods

Your code creates two well-known methods:

make_heun_rk2() fills M1_s, M1_A, M1_b, M1_c
This represents Heun’s RK2.

make_rk4() fills M2_s, M2_A, M2_b, M2_c
This represents classical RK4.

Each method is stored in the standard RK “tableau” parts:

s = how many stages the method has

c[] = where each stage evaluates time inside the step

b[] = how the final result mixes the stage derivatives

A = how later stages use earlier stage derivatives

Important detail in your implementation:
You store only the lower triangle of A (Atri), because explicit RK never needs “future stage depends on future stage” terms.

That’s why you use:

Aidx(i,j), getA(), setA() for triangular indexing.

Step 2 — Build equivalent “stage-reordered” versions

Inside buildAll() you define permutations:

p1 reorders Heun’s stages

p2 reorders RK4’s stages

Then permute_method_into(...) produces:

M1B_* from M1_*

M2B_* from M2_*

What that function does (in plain words):

It rearranges b[] and c[] so stage i in the new method corresponds to the correct original stage.

It also rearranges the stored A entries so that “stage i depends on stage j” is preserved under the new labels.

So the permuted versions are not “different methods.”
They are the same methods written with a different stage numbering.

That is exactly what theorem_382A is about.

Step 3 — Define composition exactly as the theorem describes

The theorem talks about chaining methods.

Your code implements chaining here:

var compose_step(...)
{
var y_mid = rk_step(method2, ...);
var y_out = rk_step(method1, ..., y_mid, ...);
return y_out;
}


So composition means:

Apply method 2 for one step ? get intermediate result

Apply method 1 for one step ? get final result

Then your comparison is:

yA = composition using original methods (M1 and M2)

yB = composition using permuted methods (M1B and M2B)

The theorem predicts: yA and yB should match.

Step 4 — Test it on 5 different ODE examples

run_case(k, ...) sets G_CASE = k, which changes what f_ode(t,y) returns.

You test five different behaviors: simple decay, simple growth, faster decay (stiffer), nonlinear growth with saturation (logistic)
forcing term that depends on time.

This shows the theorem isn’t only working for one “easy” equation — it works across different ODE styles.

Step 5 — What the printed output means

Each test prints: yA result (original chain), yB result (permuted chain), diff = how far apart they are
If theorem_382A is correct (and your permutation is applied correctly): diff should be extremely small (often near machine precision)

Code
// demo_theorem_5cases.c (Zorro lite-C)
// Demonstrates theorem_382A: stage-permuted RK methods are equivalent.

#define MAXS 4
#define TRI_A 6

static var G_k[MAXS];

// ---- RK tableaus (Heun2 and RK4) + permuted versions ----
static int   M1_s = 0; static float M1_A[TRI_A]; static float M1_b[MAXS]; static float M1_c[MAXS];
static int   M2_s = 0; static float M2_A[TRI_A]; static float M2_b[MAXS]; static float M2_c[MAXS];
static int   M1B_s = 0; static float M1B_A[TRI_A]; static float M1B_b[MAXS]; static float M1B_c[MAXS];
static int   M2B_s = 0; static float M2B_A[TRI_A]; static float M2B_b[MAXS]; static float M2B_c[MAXS];

// ---- selects which ODE example to run ----
static int G_CASE = 0;

int Aidx(int i, int j)
{
  return (i*(i-1))/2 + j;
}

var getA(float* Atri, int i, int j)
{
  if(i <= j) return 0;
  return (var)Atri[Aidx(i,j)];
}

void setA(float* Atri, int i, int j, var v)
{
  if(i <= j) return;
  Atri[Aidx(i,j)] = (float)v;
}

void zeroTableau(int* s, float* Atri, float* b, float* c)
{
  int i;
  *s = 0;
  for(i=0;i<TRI_A;i++) Atri[i] = 0;
  for(i=0;i<MAXS;i++) { b[i]=0; c[i]=0; }
}

// --------- 5 different ODEs (scalar) ---------
// Case 1: y' = -y                      (stable linear)
// Case 2: y' =  y                      (unstable linear)
// Case 3: y' = -10y                    (stiffer linear)
// Case 4: y' = y*(1-y)                 (logistic nonlinear)
// Case 5: y' = -2y + sin(t)            (time-dependent forcing)
var f_ode(var t, var y)
{
  if(G_CASE == 1) return -y;
  if(G_CASE == 2) return  y;
  if(G_CASE == 3) return -10*y;
  if(G_CASE == 4) return y*(1-y);
  // case 5
  return -2*y + sin(t);
}

var rk_step(int s, float* Atri, float* b, float* c, var t, var y, var h)
{
  int i,j;

  if(s < 1) return y;
  if(s > MAXS) s = MAXS;

  for(i=0;i<s;i++) G_k[i] = 0;

  for(i=0;i<s;i++)
  {
    var yi = y;
    for(j=0;j<i;j++)
      yi += h * getA(Atri,i,j) * G_k[j];

    G_k[i] = f_ode(t + (var)c[i]*h, yi);
  }

  var y1 = y;
  for(i=0;i<s;i++)
    y1 += h * (var)b[i] * G_k[i];

  return y1;
}

var compose_step(int s1,float* A1,float* b1,float* c1,
                 int s2,float* A2,float* b2,float* c2,
                 var t, var y, var h)
{
  var y_mid = rk_step(s2,A2,b2,c2,t,y,h);
  var y_out = rk_step(s1,A1,b1,c1,t,y_mid,h);
  return y_out;
}

void permute_method_into(int s, float* A, float* b, float* c,
                         int* p,
                         int* sOut, float* Aout, float* bout, float* cout)
{
  int i,j;

  *sOut = s;
  if(*sOut < 0) *sOut = 0;
  if(*sOut > MAXS) *sOut = MAXS;

  for(i=0;i<TRI_A;i++) Aout[i] = 0;
  for(i=0;i<MAXS;i++)  { bout[i]=0; cout[i]=0; }

  for(i=0;i<*sOut;i++)
  {
    int pi = p[i];
    if(pi < 0) pi = 0;
    if(pi >= s) pi = s-1;
    bout[i] = b[pi];
    cout[i] = c[pi];
  }

  for(i=0;i<*sOut;i++)
    for(j=0;j<i;j++)
    {
      int pi = p[i];
      int pj = p[j];
      if(pi < 0) pi = 0;
      if(pj < 0) pj = 0;
      if(pi >= s) pi = s-1;
      if(pj >= s) pj = s-1;
      setA(Aout, i, j, getA(A, pi, pj));
    }
}

void make_heun_rk2()
{
  zeroTableau(&M1_s, M1_A, M1_b, M1_c);
  M1_s = 2;
  M1_c[0] = 0.0; M1_c[1] = 1.0;
  setA(M1_A, 1, 0, 1.0);
  M1_b[0] = 0.5; M1_b[1] = 0.5;
}

void make_rk4()
{
  zeroTableau(&M2_s, M2_A, M2_b, M2_c);
  M2_s = 4;

  M2_c[0]=0.0; M2_c[1]=0.5; M2_c[2]=0.5; M2_c[3]=1.0;
  setA(M2_A, 1, 0, 0.5);
  setA(M2_A, 2, 1, 0.5);
  setA(M2_A, 3, 2, 1.0);

  M2_b[0]=(float)(1./6.);
  M2_b[1]=(float)(1./3.);
  M2_b[2]=(float)(1./3.);
  M2_b[3]=(float)(1./6.);
}

void buildAll()
{
  make_heun_rk2();
  make_rk4();

  // example permutations (you can change these freely; theorem_382A says results stay the same)
  int p1[MAXS]; p1[0]=1; p1[1]=0; p1[2]=0; p1[3]=0;
  int p2[MAXS]; p2[0]=0; p2[1]=2; p2[2]=1; p2[3]=3;

  permute_method_into(M1_s, M1_A, M1_b, M1_c, p1, &M1B_s, M1B_A, M1B_b, M1B_c);
  permute_method_into(M2_s, M2_A, M2_b, M2_c, p2, &M2B_s, M2B_A, M2B_b, M2B_c);
}

void run_case(int k, var t, var y0, var h)
{
  G_CASE = k;

  var yA = compose_step(M1_s,  M1_A,  M1_b,  M1_c,
                        M2_s,  M2_A,  M2_b,  M2_c,
                        t, y0, h);

  var yB = compose_step(M1B_s, M1B_A, M1B_b, M1B_c,
                        M2B_s, M2B_A, M2B_b, M2B_c,
                        t, y0, h);

  printf("\nCase %d: yA=%.17g  yB=%.17g  |diff|=%.3e", k, yA, yB, abs(yA-yB));
}

function main()
{
  buildAll();

  // 5 different examples demonstrating theorem_382A invariance under stage permutation
  run_case(1, 0.0, 1.0, 0.1);   // stable linear
  run_case(2, 0.0, 1.0, 0.1);   // unstable linear
  run_case(3, 0.0, 1.0, 0.05);  // stiffer linear (smaller h)
  run_case(4, 0.0, 0.2, 0.1);   // nonlinear logistic
  run_case(5, 1.0, 1.0, 0.1);   // time-dependent forcing (t!=0)

  printf("\n");
  quit("done");
}

Last edited by TipmyPip; 3 hours ago.