@@ -689,21 +689,44 @@ def _load_metadata_files(
689689 ('group_name' , 'character' , 'drives per-group dispatch inside ccpp_physics_* (each suite_cap emits a select case on this name)' ),
690690 ('horizontal_loop_begin' , 'integer' , 'lower horizontal slice bound at scheme call sites' ),
691691 ('horizontal_loop_end' , 'integer' , 'upper horizontal slice bound at scheme call sites' ),
692- ('thread_number' , 'integer' , 'current thread number (pass 1 if single-threaded)' ),
693- ('number_of_threads' , 'integer' , 'total thread count (pass 1 if single-threaded)' ),
694692 ('number_of_physics_threads' ,'integer' , 'physics-internal thread budget (pass 1 if unused)' ),
695693 ('ccpp_error_code' , 'integer' , 'CCPP error flag' ),
696694 ('ccpp_error_message' , 'character' , 'CCPP error message' ),
697695]
698-
699- # Optional control variables that must be declared as a *pair*. Hosts that
700- # need a multi-instance API declare both ``instance_number`` (the index) and
701- # ``number_of_instances`` (the bound). Hosts that don't may omit both; the
702- # generator will emit a single-instance API and dimension all per-instance
703- # arrays to length 1. Declaring exactly one is an error.
696+ # NOTE: the threading index/count (``thread_number`` / ``number_of_threads``)
697+ # is NOT required — it is a paired-optional control pair, fully symmetric with
698+ # (``instance_number`` / ``number_of_instances``); see
699+ # ``_PAIRED_OPTIONAL_CTRL_VARS`` below. ``number_of_physics_threads`` is a
700+ # separate, unpaired scheme-facing scalar that stays unconditionally required.
701+
702+ # Paired-optional control variables. Each entry is an (index, count) pair:
703+ # the host declares BOTH members (in ``type=control``) or NEITHER; declaring
704+ # exactly one is a hard error. Declaring a pair opts the host into that
705+ # multi-<X> API — the index flows as a per-call control dummy and the count
706+ # gives the bound. When a pair is absent the public API drops both args and
707+ # the framework uses literal ``1`` wherever the index would appear. A host
708+ # variable may be dimensioned by the count standard name only when its pair is
709+ # declared (otherwise the resolver's scalar-index collapse raises — it needs
710+ # the index variable in scope).
711+ #
712+ # The two pairs are fully symmetric (decision 2026-06-09):
713+ # * (instance_number, number_of_instances) — multi-instance API. The
714+ # framework reads ``number_of_instances`` at register/init to size its
715+ # own per-instance state (``ccpp_suite_data(:)``, ``ccpp_group_state(:)``).
716+ # * (thread_number, number_of_threads) — multi-threading API.
717+ # ``thread_number`` indexes host-owned per-thread containers;
718+ # ``number_of_threads`` is carried as a control dummy (the framework owns
719+ # no per-thread state yet, so its value is not consumed — kept for symmetry
720+ # with ``number_of_instances`` and future per-thread sizing).
721+ # (A chunk/block index is intentionally NOT a control pair: capgen-ng's
722+ # slice-based design passes the current chunk as a horizontal range via
723+ # horizontal_loop_begin/end, so no scheme ever indexes by chunk inside a call.)
724+ # Each entry: (index std_name, count std_name, index description, count description).
704725_PAIRED_OPTIONAL_CTRL_VARS = [
705- ('instance_number' , 'integer' , 'current model instance index' ),
706- ('number_of_instances' , 'integer' , 'total number of model instances' ),
726+ ('instance_number' , 'number_of_instances' ,
727+ 'current model instance index' , 'total number of model instances' ),
728+ ('thread_number' , 'number_of_threads' ,
729+ 'current thread index' , 'total thread count' ),
707730]
708731
709732
@@ -775,39 +798,58 @@ def _check_control_var(std_name, expected_type, description, required: bool) ->
775798 for std_name , expected_type , description in _REQUIRED_CTRL_VARS :
776799 _check_control_var (std_name , expected_type , description , required = True )
777800
778- # Paired optional: both ``instance_number`` (the per-call index) and
779- # ``number_of_instances`` (the bound, used at register time to size
780- # the per-instance state arrays) live in ``type=control``. Symmetric
781- # with the (thread_number, number_of_threads) pair. Either both
782- # declared or neither.
783- _check_control_var (
784- 'instance_number' , 'integer' ,
785- 'current model instance index' , required = False ,
786- )
787- _check_control_var (
788- 'number_of_instances' , 'integer' ,
789- 'total number of model instances' , required = False ,
790- )
801+ # Paired-optional control pairs (see _PAIRED_OPTIONAL_CTRL_VARS): for each
802+ # (index, count) pair the host declares both members in a type=control
803+ # table or neither. Declaring exactly one is an error. Both pairs —
804+ # (instance_number, number_of_instances) and (thread_number,
805+ # number_of_threads) — are validated identically.
806+ for idx_name , cnt_name , idx_desc , cnt_desc in _PAIRED_OPTIONAL_CTRL_VARS :
807+ _check_control_var (idx_name , 'integer' , idx_desc , required = False )
808+ _check_control_var (cnt_name , 'integer' , cnt_desc , required = False )
809+
810+ idx_present = host_dict .get (idx_name ) is not None
811+ cnt_present = host_dict .get (cnt_name ) is not None
812+ if idx_present ^ cnt_present :
813+ present , missing = (
814+ (idx_name , cnt_name ) if idx_present else (cnt_name , idx_name )
815+ )
816+ errors .append (
817+ "Host '{}' declares '{}' in a type=control table but is "
818+ "missing its paired variable '{}' (which must also be in a "
819+ "type=control table).\n "
820+ " '{}' and '{}' are a paired-optional control pair: declare "
821+ "both members to opt into that API, or neither." .format (
822+ host_name , present , missing , idx_name , cnt_name ,
823+ )
824+ )
791825
792- inst_present = host_dict .get ('instance_number' ) is not None
793- ninst_present = host_dict .get ('number_of_instances' ) is not None
794- if inst_present ^ ninst_present :
795- present , missing = (
796- ('instance_number' , 'number_of_instances' )
797- if inst_present
798- else ('number_of_instances' , 'instance_number' )
799- )
800- errors .append (
801- "Host '{}' declares '{}' (in a type=control table) but is "
802- "missing the paired variable '{}' (which must also be in a "
803- "type=control table).\n "
804- " Declare both for a multi-instance API, or neither for a "
805- "single-instance API." .format (host_name , present , missing )
806- )
826+ # Control-table allowlist: a type=control table may declare ONLY the
827+ # framework's known control variables — the unconditionally required set
828+ # plus the members of the paired-optional pairs. Anything else in a
829+ # type=control table is a hard error. Host-specific quantities that
830+ # schemes consume belong in a type=host table; the subcycle loop variables
831+ # (ccpp_loop_counter / ccpp_loop_extent) are generator-owned locals the
832+ # host never declares.
833+ allowed_control = {name for name , _type , _desc in _REQUIRED_CTRL_VARS }
834+ for idx_name , cnt_name , _idesc , _cdesc in _PAIRED_OPTIONAL_CTRL_VARS :
835+ allowed_control .add (idx_name )
836+ allowed_control .add (cnt_name )
837+ for std_name , entry in sorted (host_dict .items ()):
838+ if entry .is_control and std_name not in allowed_control :
839+ errors .append (
840+ "Variable '{}' is declared in a type=control table for host "
841+ "'{}' but is not a recognized framework control variable.\n "
842+ " A type=control table may declare only: {}.\n "
843+ " If '{}' is a host quantity that schemes consume, declare it "
844+ "in a type=host table instead." .format (
845+ std_name , host_name , ', ' .join (sorted (allowed_control )),
846+ std_name ,
847+ )
848+ )
807849
808850 if errors :
809851 raise CCPPError (
810- "Host '{}' is missing required control variables :\n \n {}" .format (
852+ "Host '{}' has invalid control-variable metadata :\n \n {}" .format (
811853 host_name ,
812854 '\n \n ' .join ("ERROR: " + e for e in errors ),
813855 )
0 commit comments