@@ -205,6 +205,7 @@ def generate_shadow_learning_overlay_drafts(
205205 "read_enum_bindings" : read_enum_bindings ,
206206 "learned_capabilities" : list (learned_summary ["generated" ]),
207207 "skipped_duplicates" : list (learned_summary ["skipped" ]),
208+ "skipped_controls" : list (learned_summary ["rejected" ]),
208209 # Read sensors materialized from unique value/enum correlations.
209210 "learned_read_sensors" : list (learned_read ["generated" ]),
210211 "skipped_read_sensors" : list (learned_read ["skipped" ]),
@@ -930,6 +931,7 @@ def _build_learned_capabilities(
930931 generated : list [dict [str , Any ]] = []
931932 generated_summary : list [dict [str , Any ]] = []
932933 skipped_summary : list [dict [str , Any ]] = []
934+ rejected_summary : list [dict [str , Any ]] = []
933935
934936 next_order = 9000
935937 for group in grouped :
@@ -976,6 +978,37 @@ def _build_learned_capabilities(
976978 )
977979 continue
978980
981+ write_contract = _observed_modbus_write_contract (group ) if register >= 0 else None
982+ if register >= 0 and write_contract is None :
983+ rejected_summary .append (
984+ {
985+ "field_id" : field_id ,
986+ "field_name" : field_name ,
987+ "register" : register ,
988+ "reason" : "unproven_modbus_write_contract" ,
989+ }
990+ )
991+ continue
992+
993+ classification = _classify_learned_control (group )
994+ if write_contract is not None and (
995+ write_contract [1 ] != 1
996+ or classification ["word_count" ] != write_contract [1 ]
997+ ):
998+ # A multi-register cloud command must never become a truncated
999+ # single-register switch/action merely because its labels look familiar.
1000+ # Numeric multiword captures also need a proven encoding before
1001+ # they can be represented by the runtime capability codec.
1002+ rejected_summary .append (
1003+ {
1004+ "field_id" : field_id ,
1005+ "field_name" : field_name ,
1006+ "register" : register ,
1007+ "reason" : "unsupported_modbus_write_shape" ,
1008+ }
1009+ )
1010+ continue
1011+
9791012 base_key = (
9801013 _capability_key (field_id = field_id , field_name = field_name , register = register )
9811014 if register >= 0
@@ -985,7 +1018,6 @@ def _build_learned_capabilities(
9851018 existing_keys .add (capability_key )
9861019
9871020 read_key = _g_ascii_read_key_for_group (group )
988- classification = _classify_learned_control (group )
9891021 classification , command_map = _normalize_g_ascii_feature_flag_capability (
9901022 group = group ,
9911023 classification = classification ,
@@ -1024,6 +1056,8 @@ def _build_learned_capabilities(
10241056 "order" : next_order ,
10251057 "learned_provenance" : provenance ,
10261058 }
1059+ if write_contract is not None :
1060+ capability ["write_function" ] = write_contract [0 ]
10271061 next_order += 1
10281062
10291063 minimum = classification .get ("minimum" )
@@ -1058,6 +1092,7 @@ def _build_learned_capabilities(
10581092 "field_id" : field_id ,
10591093 "field_name" : field_name ,
10601094 "register" : register ,
1095+ "write_function" : capability .get ("write_function" ),
10611096 "command" : command ,
10621097 "command_map" : capability .get ("command_map" , {}),
10631098 "read_key" : read_key ,
@@ -1068,7 +1103,40 @@ def _build_learned_capabilities(
10681103 }
10691104 )
10701105
1071- return generated , {"generated" : generated_summary , "skipped" : skipped_summary }
1106+ return generated , {
1107+ "generated" : generated_summary ,
1108+ "skipped" : skipped_summary ,
1109+ "rejected" : rejected_summary ,
1110+ }
1111+
1112+
1113+ def _observed_modbus_write_contract (group : dict [str , Any ]) -> tuple [int , int ] | None :
1114+ """Require one observed, representable function/width; never guess FC16.
1115+
1116+ Cloud metadata describes a setting, not the wire operation. Every correlated
1117+ sample must agree before the generated control is allowed to replay it.
1118+ Older/incomplete or conflicting evidence stays in the support artifacts.
1119+ """
1120+
1121+ contract : tuple [int , int ] | None = None
1122+ for sample in group .get ("samples" ) or []:
1123+ observation = sample ["observation" ]
1124+ function = observation .get ("function_code" )
1125+ values = observation .get ("values" )
1126+ if (
1127+ type (function ) is not int
1128+ or function not in (6 , 16 )
1129+ or type (values ) is not list
1130+ or len (values ) not in (1 , 2 )
1131+ or any (type (value ) is not int or not 0 <= value <= 0xFFFF for value in values )
1132+ or (function == 6 and len (values ) != 1 )
1133+ ):
1134+ return None
1135+ observed = (function , len (values ))
1136+ if contract is not None and observed != contract :
1137+ return None
1138+ contract = observed
1139+ return contract
10721140
10731141
10741142def _group_matched_records (correlation : dict [str , Any ]) -> list [dict [str , Any ]]:
@@ -1114,12 +1182,10 @@ def _group_matched_records(correlation: dict[str, Any]) -> list[dict[str, Any]]:
11141182 "requested_at" : str (item .get ("requested_at" ) or "" ).strip (),
11151183 "observation" : {
11161184 "register" : register ,
1117- "function_code" : _to_int (observation .get ("function_code" )) or 16 ,
1118- "values" : [
1119- int (value )
1120- for value in list (observation .get ("values" ) or [])
1121- if _to_int (value ) is not None
1122- ],
1185+ # Preserve malformed/missing fields for fail-closed contract
1186+ # validation rather than defaulting or dropping bad words.
1187+ "function_code" : observation .get ("function_code" ),
1188+ "values" : observation .get ("values" ),
11231189 "devcode" : _to_int (observation .get ("devcode" )),
11241190 "devaddr" : _to_int (observation .get ("devaddr" )),
11251191 "unit" : _to_int (observation .get ("unit" )),
0 commit comments