athena_graph_nop_layer.f90 Source File


Source Code

module athena__graph_nop_layer
  !! Module containing implementation of a Graph Neural Operator (GNO) layer
  !!
  !! This module implements a Graph Neural Operator layer that learns a
  !! continuous kernel on graph edges.  It combines a learnable kernel
  !! network (small MLP) evaluated on relative coordinates with a linear
  !! transform of the node features:
  !!
  !! \[ h_i^{(l+1)} = \sigma\!\left(
  !!    \mathbf{W}\,h_i^{(l)}
  !!  + \sum_{j \in \mathcal{N}(i)}
  !!      \kappa_\theta(x_i - x_j)\,h_j^{(l)}
  !! \right) \]
  !!
  !! where:
  !!   - \(h_i^{(l)} \in \mathbb{R}^{F_{in}}\) is the node feature at layer l
  !!   - \(x_i \in \mathbb{R}^{d}\) is the node coordinate / attribute
  !!   - \(\kappa_\theta \colon \mathbb{R}^d \to \mathbb{R}^{F_{out} \times F_{in}}\)
  !!     is a learnable kernel MLP
  !!   - \(\mathbf{W} \in \mathbb{R}^{F_{out} \times F_{in}}\) is a learnable
  !!     linear (bypass) transform
  !!   - \(\sigma\) is the activation function
  !!
  !! The kernel MLP has one hidden layer:
  !!   \(\kappa_\theta(\Delta x) = V\,\text{relu}(U\,\Delta x + b_u) + b_v\)
  !! where \(U \in \mathbb{R}^{H \times d}\),
  !!       \(V \in \mathbb{R}^{(F_{out} F_{in}) \times H}\),
  !!       and \(H\) is the hidden width of the kernel network.
  !!
  !! Input layout:
  !!   input(1,s) = node features                    [F_in x num_vertices]
  !!   input(2,s) = edge geometry / relative coords [d x num_edges]
  !!
  !! Number of learnable parameters:
  !!   Kernel MLP:  \(H d + H + (F_{out} F_{in}) H + F_{out} F_{in}\)
  !!   Linear:      \(F_{out} F_{in}\)
  !!   Bias:        \(F_{out}\)  (optional)
  !!
  !! This layer extends the message passing layer type and uses the diffstruc
  !! autodiff framework to support physics-informed neural networks (PINNs).
  !! The forward pass builds a computation graph through two differentiable
  !! operations: `gno_kernel_eval` (kernel MLP evaluation on every edge) and
  !! `gno_aggregate` (neighbour message aggregation), followed by the standard
  !! `matmul`, `add_bias`, and activation operations.
  use coreutils, only: real32, stop_program
  use graphstruc, only: graph_type
  use athena__base_layer, only: base_layer_type
  use athena__msgpass_layer, only: msgpass_layer_type
  use athena__misc_types, only: base_actv_type, base_init_type
  use diffstruc, only: array_type, matmul, operator(+)
  use athena__diffstruc_extd, only: add_bias, gno_kernel_eval, gno_aggregate
  implicit none


  private

  public :: graph_nop_layer_type
  public :: read_graph_nop_layer


  type, extends(msgpass_layer_type) :: graph_nop_layer_type
     !! Type for a Graph Neural Operator layer
     integer :: coord_dim = 0
     !! Dimensionality of edge geometric features (d)
     integer :: kernel_hidden = 0
     !! Hidden width of the kernel MLP (H)
   contains
     procedure, pass(this) :: get_num_params => get_num_params_gno
     procedure, pass(this) :: set_hyperparams => set_hyperparams_gno
     procedure, pass(this) :: init => init_gno
     procedure, pass(this) :: print_to_unit => print_to_unit_gno
     procedure, pass(this) :: read => read_gno

     procedure, pass(this) :: update_message => update_message_gno
     procedure, pass(this) :: update_readout => update_readout_gno
  end type graph_nop_layer_type

  interface graph_nop_layer_type
     module function layer_setup( &
          num_outputs, coord_dim, kernel_hidden, &
          num_inputs, use_bias, &
          activation, &
          kernel_initialiser, bias_initialiser, &
          verbose &
     ) result(layer)
       integer, intent(in) :: num_outputs
       !! Number of output node features
       integer, intent(in) :: coord_dim
       !! Dimensionality of edge geometric features
       integer, optional, intent(in) :: kernel_hidden
       !! Hidden width of kernel MLP (default: num_outputs)
       integer, optional, intent(in) :: num_inputs
       !! Number of input node features (deferred if absent)
       logical, optional, intent(in) :: use_bias
       !! Whether to use bias (default: .true.)
       class(*), optional, intent(in) :: activation
       !! Activation function
       class(*), optional, intent(in) :: kernel_initialiser, bias_initialiser
       !! Parameter initialisers
       integer, optional, intent(in) :: verbose
       !! Verbosity level
       type(graph_nop_layer_type) :: layer
     end function layer_setup
  end interface graph_nop_layer_type


contains


!###############################################################################
  pure function get_num_params_gno(this) result(num_params)
    !! Get the number of learnable parameters
    !!
    !! Parameters:
    !!   params(1): packed kernel MLP [H*d + H + F*H + F, 1]
    !!              where F = F_out * F_in
    !!              Layout: U [H*d] | b_u [H] | V [F*H] | b_v [F]
    !!   params(2): W   - linear bypass weights  [F_out * F_in, 1]
    !!   params(3): b   - output bias            [F_out, 1]  (optional)
    implicit none

    ! Arguments
    class(graph_nop_layer_type), intent(in) :: this
    !! Layer instance
    integer :: num_params
    !! Total number of learnable parameters

    ! Local variables
    integer :: F_in, F_out, d, H, F
    !! Input/output feature counts, coordinate size, hidden width and flattened kernel width

    F_in  = this%num_vertex_features(0)
    F_out = this%num_vertex_features(1)
    d     = this%coord_dim
    H     = this%kernel_hidden
    F     = F_out * F_in

    num_params = &
         H * d + H + F * H + F + &   ! kernel MLP (U, b_u, V, b_v)
         F_out * F_in                  ! W (linear bypass)
    if(this%use_bias) num_params = num_params + F_out  ! b

  end function get_num_params_gno
!###############################################################################


!###############################################################################
  module function layer_setup( &
       num_outputs, coord_dim, kernel_hidden, &
       num_inputs, use_bias, &
       activation, &
       kernel_initialiser, bias_initialiser, &
       verbose &
  ) result(layer)
    use athena__activation, only: activation_setup
    use athena__initialiser, only: initialiser_setup
    implicit none

    ! Arguments
    integer, intent(in) :: num_outputs
    !! Number of output node features
    integer, intent(in) :: coord_dim
    !! Dimension of edge coordinate features
    integer, optional, intent(in) :: kernel_hidden
    !! Hidden width of the kernel MLP
    integer, optional, intent(in) :: num_inputs
    !! Number of input node features when known at construction time
    logical, optional, intent(in) :: use_bias
    !! Whether to allocate an output bias
    class(*), optional, intent(in) :: activation
    !! Activation function specification
    class(*), optional, intent(in) :: kernel_initialiser, bias_initialiser
    !! Kernel and bias initialiser specifications
    integer, optional, intent(in) :: verbose
    !! Verbosity level

    type(graph_nop_layer_type) :: layer
    !! Constructed graph neural operator layer

    ! Local variables
    integer :: verbose_ = 0
    !! Effective verbosity level
    logical :: use_bias_ = .true.
    !! Effective bias flag
    integer :: kernel_hidden_
    !! Effective kernel hidden width
    class(base_actv_type), allocatable :: activation_
    !! Materialised activation object
    class(base_init_type), allocatable :: kernel_initialiser_, bias_initialiser_
    !! Materialised kernel and bias initialisers

    if(present(verbose)) verbose_ = verbose
    if(present(use_bias)) use_bias_ = use_bias
    kernel_hidden_ = num_outputs
    if(present(kernel_hidden)) kernel_hidden_ = kernel_hidden

    if(present(activation))then
       activation_ = activation_setup(activation)
    else
       activation_ = activation_setup("none")
    end if

    if(present(kernel_initialiser))then
       kernel_initialiser_ = initialiser_setup(kernel_initialiser)
    end if
    if(present(bias_initialiser))then
       bias_initialiser_ = initialiser_setup(bias_initialiser)
    end if

    call layer%set_hyperparams( &
         num_outputs = num_outputs, &
         coord_dim = coord_dim, &
         kernel_hidden = kernel_hidden_, &
         use_bias = use_bias_, &
         activation = activation_, &
         kernel_initialiser = kernel_initialiser_, &
         bias_initialiser = bias_initialiser_, &
         verbose = verbose_ &
    )

    if(present(num_inputs)) call layer%init(input_shape=[num_inputs, 0])

  end function layer_setup
!###############################################################################


!###############################################################################
  subroutine set_hyperparams_gno( &
       this, num_outputs, coord_dim, kernel_hidden, &
       use_bias, &
       activation, &
       kernel_initialiser, bias_initialiser, &
       verbose &
  )
    use athena__activation, only: activation_setup
    use athena__initialiser, only: get_default_initialiser, initialiser_setup
    implicit none

    ! Arguments
    class(graph_nop_layer_type), intent(inout) :: this
    !! Layer instance to configure
    integer, intent(in) :: num_outputs
    !! Number of output node features
    integer, intent(in) :: coord_dim
    !! Dimension of edge coordinate features
    integer, intent(in) :: kernel_hidden
    !! Hidden width of the kernel MLP
    logical, intent(in) :: use_bias
    !! Whether to use a bias term
    class(base_actv_type), allocatable, intent(in) :: activation
    !! Activation function object
    class(base_init_type), allocatable, intent(in) :: &
         kernel_initialiser, bias_initialiser
    !! Kernel and bias initialiser objects
    integer, optional, intent(in) :: verbose
    !! Verbosity level

    ! Local variables
    character(len=256) :: buffer
    !! Buffer for default initialiser lookup

    this%name = "graph_nop"
    this%type = "gnop"
    this%input_rank = 2
    this%output_rank = 2
    this%use_graph_input = .true.
    this%use_graph_output = .true.
    this%use_bias = use_bias
    this%num_outputs = num_outputs
    this%coord_dim = coord_dim
    this%kernel_hidden = kernel_hidden
    this%num_time_steps = 1

    if(allocated(this%activation)) deallocate(this%activation)
    if(.not.allocated(activation))then
       this%activation = activation_setup("none")
    else
       allocate(this%activation, source=activation)
    end if
    if(allocated(this%kernel_init)) deallocate(this%kernel_init)
    if(.not.allocated(kernel_initialiser))then
       buffer = get_default_initialiser(this%activation%name)
       this%kernel_init = initialiser_setup(buffer)
    else
       allocate(this%kernel_init, source=kernel_initialiser)
    end if
    if(allocated(this%bias_init)) deallocate(this%bias_init)
    if(.not.allocated(bias_initialiser))then
       buffer = get_default_initialiser( &
            this%activation%name, &
            is_bias=.true. &
       )
       this%bias_init = initialiser_setup(buffer)
    else
       allocate(this%bias_init, source=bias_initialiser)
    end if

    if(present(verbose))then
       if(abs(verbose).gt.0)then
          write(*,'("GRAPH_NOP activation: ",A)') &
               trim(this%activation%name)
       end if
    end if

  end subroutine set_hyperparams_gno
!###############################################################################


!###############################################################################
  subroutine init_gno(this, input_shape, verbose)
    !! Initialise the Graph Neural Operator layer
    !!
    !! input_shape(1) = num_inputs (F_in)
    !! input_shape(2) = num_vertices (set to 0 if variable)
    implicit none

    ! Arguments
    class(graph_nop_layer_type), intent(inout) :: this
    !! Layer instance to initialise
    integer, dimension(:), intent(in) :: input_shape
    !! Input feature/vertex shape
    integer, optional, intent(in) :: verbose
    !! Verbosity level

    ! Local variables
    integer :: num_inputs, H, F_out, F_in, d, F
    !! Effective input count and kernel dimensions
    integer :: kernel_size, off_U, off_bu, off_V, off_bv
    !! Packed-kernel size and section offsets
    integer :: verbose_ = 0
    !! Effective verbosity level

    if(present(verbose)) verbose_ = verbose

    !---------------------------------------------------------------------------
    ! Set shapes
    !---------------------------------------------------------------------------
    if(.not.allocated(this%input_shape)) call this%set_shape(input_shape)

    F_in  = input_shape(1)
    F_out = this%num_outputs
    d     = this%coord_dim
    H     = this%kernel_hidden
    F     = F_out * F_in

    !---------------------------------------------------------------------------
    ! Set msgpass fields
    !---------------------------------------------------------------------------
    if(allocated(this%num_vertex_features)) deallocate(this%num_vertex_features)
    allocate(this%num_vertex_features(0:1))
    this%num_vertex_features(0) = F_in
    this%num_vertex_features(1) = F_out

    if(allocated(this%num_edge_features)) deallocate(this%num_edge_features)
    allocate(this%num_edge_features(0:1), source=0)

    kernel_size = H * d + H + F * H + F

    if(allocated(this%num_params_msg)) deallocate(this%num_params_msg)
    allocate(this%num_params_msg(1))
    this%num_params_msg(1) = kernel_size + F_out * F_in
    if(this%use_bias) this%num_params_msg(1) = this%num_params_msg(1) + F_out
    this%num_params_readout = 0

    this%output_shape = [this%num_outputs, 0]
    this%num_params = this%get_num_params()

    !---------------------------------------------------------------------------
    ! Allocate learnable parameters
    !
    ! params(1): packed kernel MLP  [kernel_size, 1]
    !            Layout: U [H*d] | b_u [H] | V [F*H] | b_v [F]
    ! params(2): W   [F_out*F_in, 1]  - linear bypass weights
    ! params(3): b   [F_out, 1]       - output bias (optional)
    !---------------------------------------------------------------------------
    if(allocated(this%weight_shape)) deallocate(this%weight_shape)
    if(allocated(this%params)) deallocate(this%params)
    if(this%use_bias)then
       if(allocated(this%bias_shape)) deallocate(this%bias_shape)
       this%bias_shape = [ F_out ]
       allocate(this%weight_shape(2, 3))
       this%weight_shape(:,3) = [ F_out, 1 ]
       allocate(this%params(3))
    else
       allocate(this%weight_shape(2, 2))
       allocate(this%params(2))
    end if
    this%weight_shape(:,1) = [ kernel_size, 1 ]
    this%weight_shape(:,2) = [ F_out, F_in ]

    ! params(1): packed kernel MLP params
    call this%params(1)%allocate([kernel_size, 1])
    call this%params(1)%set_requires_grad(.true.)
    this%params(1)%fix_pointer = .true.
    this%params(1)%is_sample_dependent = .false.
    this%params(1)%is_temporary = .false.

    ! params(2): W [F_out x F_in]
    call this%params(2)%allocate([F_out, F_in, 1])
    call this%params(2)%set_requires_grad(.true.)
    this%params(2)%fix_pointer = .true.
    this%params(2)%is_sample_dependent = .false.
    this%params(2)%is_temporary = .false.

    if(this%use_bias)then
       ! params(3): b [F_out]
       call this%params(3)%allocate([F_out, 1])
       call this%params(3)%set_requires_grad(.true.)
       this%params(3)%fix_pointer = .true.
       this%params(3)%is_sample_dependent = .false.
       this%params(3)%is_temporary = .false.
    end if


    !---------------------------------------------------------------------------
    ! Initialise learnable parameters
    !---------------------------------------------------------------------------
    off_U  = 0
    off_bu = H * d
    off_V  = off_bu + H
    off_bv = off_V + F * H

    ! U [H x d] — kernel first-layer weights
    call this%kernel_init%initialise( &
         this%params(1)%val(off_U+1:off_bu, 1), &
         fan_in = d, fan_out = H, &
         spacing = [ H ] &
    )
    ! b_u [H] — kernel first-layer bias
    call this%bias_init%initialise( &
         this%params(1)%val(off_bu+1:off_V, 1), &
         fan_in = d, fan_out = H &
    )
    ! V [F x H] — kernel second-layer weights
    call this%kernel_init%initialise( &
         this%params(1)%val(off_V+1:off_bv, 1), &
         fan_in = H, fan_out = F, &
         spacing = [ F ] &
    )
    ! b_v [F] — kernel second-layer bias
    call this%bias_init%initialise( &
         this%params(1)%val(off_bv+1:, 1), &
         fan_in = H, fan_out = F &
    )
    ! W [F_out x F_in] — linear bypass
    num_inputs = F_in
    if(this%use_bias) num_inputs = F_in + 1
    call this%kernel_init%initialise( &
         this%params(2)%val(:,1), &
         fan_in = num_inputs, fan_out = F_out, &
         spacing = [ F_out ] &
    )
    if(this%use_bias)then
       call this%bias_init%initialise( &
            this%params(3)%val(:,1), &
            fan_in = num_inputs, fan_out = F_out &
       )
    end if


    !---------------------------------------------------------------------------
    ! Allocate output arrays
    !---------------------------------------------------------------------------
    if(allocated(this%output)) deallocate(this%output)

  end subroutine init_gno
!###############################################################################


!###############################################################################
  subroutine print_to_unit_gno(this, unit)
    !! Print graph neural operator settings and parameters to a unit
    use coreutils, only: to_upper
    implicit none

    ! Arguments
    class(graph_nop_layer_type), intent(in) :: this
    !! Layer instance to print
    integer, intent(in) :: unit
    !! Output unit number

    ! Local variables
    integer :: p
    !! Parameter block index

    write(unit,'(3X,"NUM_INPUTS = ",I0)') this%num_vertex_features(0)
    write(unit,'(3X,"NUM_OUTPUTS = ",I0)') this%num_outputs
    write(unit,'(3X,"COORD_DIM = ",I0)') this%coord_dim
    write(unit,'(3X,"KERNEL_HIDDEN = ",I0)') this%kernel_hidden
    write(unit,'(3X,"USE_BIAS = ",L1)') this%use_bias
    if(this%activation%name .ne. 'none')then
       call this%activation%print_to_unit(unit)
    end if

    write(unit,'("WEIGHTS")')
    do p = 1, size(this%params)
       write(unit,'(5(E16.8E2))') this%params(p)%val(:,1)
    end do
    write(unit,'("END WEIGHTS")')

  end subroutine print_to_unit_gno
!###############################################################################


!###############################################################################
  subroutine read_gno(this, unit, verbose)
    use athena__tools_infile, only: assign_val, move
    use coreutils, only: to_lower, to_upper, icount
    use athena__activation, only: read_activation
    use athena__initialiser, only: initialiser_setup
    implicit none

    ! Arguments
    class(graph_nop_layer_type), intent(inout) :: this
    !! Layer instance to populate from file data
    integer, intent(in) :: unit
    !! Input unit number
    integer, optional, intent(in) :: verbose
    !! Verbosity level

    ! Local variables
    integer :: stat, verbose_ = 0
    !! I/O status and effective verbosity level
    integer :: j, k, c, itmp1, iline
    !! Loop counters and parser scratch integers
    integer :: num_inputs, num_outputs, coord_dim, kernel_hidden
    !! Parsed layer dimensions
    logical :: use_bias = .true.
    !! Parsed bias flag
    character(14) :: kernel_initialiser_name='', bias_initialiser_name=''
    !! Parsed initialiser names
    class(base_actv_type), allocatable :: activation
    !! Parsed activation object
    class(base_init_type), allocatable :: kernel_initialiser, bias_initialiser
    !! Parsed initialiser objects
    character(256) :: buffer, tag, err_msg
    !! Input buffer, parsed tag and formatted error message
    real(real32), allocatable, dimension(:) :: data_list
    !! Temporary storage for flattened parameter blocks
    integer :: param_line, final_line, num_vals, p
    !! Weights-section line markers, current block size and parameter index

    if(present(verbose)) verbose_ = verbose

    iline = 0
    param_line = 0
    final_line = 0
    tag_loop: do
       read(unit,'(A)',iostat=stat) buffer
       if(stat.ne.0)then
          write(err_msg, &
               '("file encountered error (EoF?) before END ",A)') &
               to_upper(this%name)
          call stop_program(err_msg)
          return
       end if
       if(trim(adjustl(buffer)).eq."") cycle tag_loop

       if(trim(adjustl(buffer)).eq."END "//to_upper(trim(this%name)))then
          final_line = iline
          backspace(unit)
          exit tag_loop
       end if
       iline = iline + 1

       tag = trim(adjustl(buffer))
       if(scan(buffer,"=").ne.0) tag = trim(tag(:scan(tag,"=")-1))

       select case(trim(tag))
       case("NUM_INPUTS")
          call assign_val(buffer, num_inputs, itmp1)
       case("NUM_OUTPUTS")
          call assign_val(buffer, num_outputs, itmp1)
       case("COORD_DIM")
          call assign_val(buffer, coord_dim, itmp1)
       case("KERNEL_HIDDEN")
          call assign_val(buffer, kernel_hidden, itmp1)
       case("USE_BIAS")
          call assign_val(buffer, use_bias, itmp1)
       case("ACTIVATION")
          iline = iline - 1
          backspace(unit)
          activation = read_activation(unit, iline)
       case("KERNEL_INITIALISER", "KERNEL_INIT", "KERNEL_INITIALIZER")
          call assign_val(buffer, kernel_initialiser_name, itmp1)
       case("BIAS_INITIALISER", "BIAS_INIT", "BIAS_INITIALIZER")
          call assign_val(buffer, bias_initialiser_name, itmp1)
       case("WEIGHTS")
          kernel_initialiser_name = 'zeros'
          bias_initialiser_name   = 'zeros'
          param_line = iline
       case default
          if(scan(to_lower(trim(adjustl(buffer))),&
               'abcdfghijklmnopqrstuvwxyz').eq.0)then
             cycle tag_loop
          elseif(tag(:3).eq.'END')then
             cycle tag_loop
          end if
          write(err_msg,'("Unrecognised line in input file: ",A)') &
               trim(adjustl(buffer))
          call stop_program(err_msg)
          return
       end select
    end do tag_loop
    kernel_initialiser = initialiser_setup(kernel_initialiser_name)
    bias_initialiser = initialiser_setup(bias_initialiser_name)

    call this%set_hyperparams( &
         num_outputs = num_outputs, &
         coord_dim = coord_dim, &
         kernel_hidden = kernel_hidden, &
         use_bias = use_bias, &
         activation = activation, &
         kernel_initialiser = kernel_initialiser, &
         bias_initialiser = bias_initialiser, &
         verbose = verbose_ &
    )
    call this%init(input_shape=[num_inputs, 0])


    ! Read weights
    !---------------------------------------------------------------------------
    if(param_line.eq.0)then
       write(0,*) "WARNING: WEIGHTS card in GRAPH_NOP not found"
    else
       call move(unit, param_line - iline, iostat=stat)

       do p = 1, size(this%params)
          num_vals = size(this%params(p)%val(:,1))
          allocate(data_list(num_vals), source=0._real32)
          c = 1
          do while(c .le. num_vals)
             read(unit,'(A)',iostat=stat) buffer
             if(stat.ne.0) exit
             k = icount(buffer)
             read(buffer,*,iostat=stat) (data_list(j), j=c, c+k-1)
             c = c + k
          end do
          this%params(p)%val(:,1) = data_list
          deallocate(data_list)
       end do

       read(unit,'(A)') buffer
       if(trim(adjustl(buffer)).ne."END WEIGHTS")then
          call stop_program("END WEIGHTS not where expected")
          return
       end if
    end if

    call move(unit, final_line - iline, iostat=stat)
    read(unit,'(A)') buffer
    if(trim(adjustl(buffer)).ne."END "//to_upper(trim(this%name)))then
       write(err_msg,'("END ",A," not where expected")') &
            to_upper(this%name)
       call stop_program(err_msg)
       return
    end if

  end subroutine read_gno
!###############################################################################


!###############################################################################
  function read_graph_nop_layer(unit, verbose) result(layer)
    !! Read a graph NOP layer from file and return
    implicit none

    ! Arguments
    integer, intent(in) :: unit
    !! Input unit number
    integer, optional, intent(in) :: verbose
    !! Verbosity level
    class(base_layer_type), allocatable :: layer
    !! Allocated base-layer instance containing the result

    ! Local variables
    integer :: verbose_ = 0
    !! Effective verbosity level

    if(present(verbose)) verbose_ = verbose
    allocate(layer, source=graph_nop_layer_type( &
         num_outputs=0, coord_dim=1))
    call layer%read(unit, verbose=verbose_)

  end function read_graph_nop_layer
!###############################################################################


!###############################################################################
  subroutine update_message_gno(this, input)
    !! Update message for the Graph Neural Operator layer
    !!
    !! Builds a differentiable computation graph through the diffstruc
    !! autodiff framework:
    !!
    !! input(1,s) : node features                    [F_in x num_vertices]
    !! input(2,s) : edge geometry / relative coords [coord_dim x num_edges]
    !!
    !! Pointer chain per sample s:
    !!   1. edge_kernels = gno_kernel_eval(coords, kernel_params, adj)
    !!   2. agg          = gno_aggregate(features, edge_kernels, adj)
    !!   3. bypass       = matmul(W, features)
    !!   4. z            = agg + bypass
    !!   5. z            = add_bias(z, b)          (if use_bias)
    !!   6. output       = activation(z)
    implicit none

    ! Arguments
    class(graph_nop_layer_type), intent(inout), target :: this
    !! Layer instance to execute
    class(array_type), dimension(:,:), intent(in), target :: input
    !! Input node-feature and edge-feature tensors

    ! Local variables
    integer :: s, F_in, F_out
    !! Sample index and input/output feature counts
    type(array_type), pointer :: ptr1, ptr2, ptr3, ptr4
    !! Intermediate kernel, aggregate, bypass and combined tensors

    F_in  = this%num_vertex_features(0)
    F_out = this%num_vertex_features(1)

    ! Allocate output array
    if(size(input, 1) .lt. 2)then
       call stop_program( &
            'graph_nop layer expects vertex and edge feature inputs' &
       )
       return
    end if

    if(allocated(this%output))then
       if(any(shape(this%output).ne.[2, size(input,2)]))then
          deallocate(this%output)
          allocate(this%output(2, size(input,2)))
       end if
    else
       allocate(this%output(2, size(input,2)))
    end if

    do s = 1, size(input, 2)

       ! Step 1: Evaluate kernel MLP on every edge
       ptr1 => gno_kernel_eval( &
            input(2,s), &               ! edge features [d, num_edges]
            this%params(1), &            ! packed kernel params
            this%graph(s)%adj_ia, &
            this%graph(s)%adj_ja, &
            this%coord_dim, this%kernel_hidden, F_in, F_out &
       )

       ! Step 2: Aggregate neighbour messages using per-edge kernels
       ptr2 => gno_aggregate( &
            input(1,s), &               ! features [F_in, num_v]
            ptr1, &                      ! edge kernels [F*F_in, num_edges]
            this%graph(s)%adj_ia, &
            this%graph(s)%adj_ja, &
            F_in, F_out &
       )

       ! Step 3: Linear bypass — W @ features
       ptr3 => matmul(this%params(2), input(1,s))

       ! Step 4: Combine aggregation with bypass
       ptr4 => ptr2 + ptr3

       ! Step 5: Add bias (if used)
       if(this%use_bias)then
          ptr4 => add_bias( &
               ptr4, this%params(3), dim=1, dim_act_on_shape=.true. &
          )
       end if

       ! Step 6: Apply activation
       ptr4 => this%activation%apply(ptr4)

       ! Store output
       call this%output(1,s)%zero_grad()
       call this%output(1,s)%assign_and_deallocate_source(ptr4)
       this%output(1,s)%is_temporary = .false.

       if(this%output(2,s)%allocated) call this%output(2,s)%deallocate()
       call this%output(2,s)%allocate(source=input(2,s)%val)
       call this%output(2,s)%zero_grad()
       call this%output(2,s)%set_requires_grad(.false.)
       this%output(2,s)%is_temporary = .false.
    end do

  end subroutine update_message_gno
!###############################################################################


!###############################################################################
  subroutine update_readout_gno(this)
    !! No graph-level readout needed — GNO produces node-level output
    implicit none

    ! Arguments
    class(graph_nop_layer_type), intent(inout), target :: this
    !! Layer instance retained for interface compatibility
  end subroutine update_readout_gno
!###############################################################################

end module athena__graph_nop_layer