flextool 4.0.0__py3-none-any.whl

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.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1253 @@
1
+ """Layer 2 (semantic per-type scaling) — implementation.
2
+
3
+ Layer 2 mutates a polar-high :class:`Problem` *in place* before
4
+ ``Problem.solve(...)`` and returns a :class:`Layer2Plan` describing the
5
+ inverse transform. The plan is consumed by
6
+ :func:`unscale_solution` after the solver returns, so callers stay
7
+ oblivious to the scaling change.
8
+
9
+ Mathematics (forward semantics — see the bit-for-bit test for the
10
+ correctness anchor):
11
+
12
+ * ``col_factor[j]`` is a positive power-of-two. The variable
13
+ substitution that lands in the LP arrays passed to HiGHS is
14
+ ``x_scaled[j] = col_factor[j] * x[j]`` ( ⇔ ``x[j] = x_scaled[j] /
15
+ col_factor[j]``).
16
+ * ``row_factor[i]`` is a positive power-of-two. Each row of the
17
+ scaled LP is the original row **multiplied** by ``row_factor[i]``
18
+ (LHS coefficients, RHS, both).
19
+
20
+ The four LP-array transforms implemented below are then:
21
+
22
+ ================== ============================================
23
+ Quantity Scaled value
24
+ ================== ============================================
25
+ ``matrix[i, j]`` ``matrix[i, j] * row_factor[i] / col_factor[j]``
26
+ ``cost[j]`` ``cost[j] / col_factor[j]``
27
+ ``lb[j], ub[j]`` ``lb[j] * col_factor[j]``, ``ub[j] * col_factor[j]``
28
+ ``rhs[i]`` ``rhs[i] * row_factor[i]``
29
+ ================== ============================================
30
+
31
+ Unscale (post-solve):
32
+
33
+ ================== =============================================
34
+ Quantity Original value
35
+ ================== =============================================
36
+ ``x[j]`` ``x_scaled[j] / col_factor[j]``
37
+ ``col_dual[j]`` ``col_dual_scaled[j] * col_factor[j]``
38
+ ``row_dual[i]`` ``row_dual_scaled[i] * row_factor[i]``
39
+ ``obj`` unchanged
40
+ ================== =============================================
41
+
42
+ The objective is invariant: ``c x = (c/cf) * (cf*x) = c_scaled *
43
+ x_scaled``. We do not touch the objective offset, the model sense, or
44
+ the integrality flags.
45
+
46
+ Layer 2 deliberately **does not column-scale integer variables** —
47
+ shifting their bounds by a non-unit factor would break integrality of
48
+ the recovered solution. Their bounds, cost, and matrix entries flow
49
+ through unchanged.
50
+
51
+ MPS-export consideration
52
+ ------------------------
53
+
54
+ Because Layer 2 mutates the LP arrays *before* they are passed to HiGHS,
55
+ ``Highs.writeModel('out.mps')`` exports the **scaled** model. An
56
+ external solver consuming that MPS would therefore receive the
57
+ re-coordinatised LP, not the original; its results would live in scaled
58
+ coordinates and would need the inverse transform recorded in the
59
+ ``autoscale_<solve>.yaml`` audit (per-type exponents) to be returned to
60
+ user units.
61
+
62
+ For a workflow that writes MPS and passes it to a third-party solver,
63
+ the safe defaults are either:
64
+
65
+ 1. Run with ``--scaling=basic`` or ``--scaling=solver_only`` so the exported MPS reflects the
66
+ unscaled problem, or
67
+ 2. Read the per-type exponents from the autoscale YAML report and apply
68
+ the inverse transform to the external solver's solution.
69
+
70
+ Layer 3 (``user_*_scale``) is HiGHS-internal and is **not** captured in
71
+ ``writeModel`` output — that scaling is invisible to external solvers
72
+ regardless of FlexTool's autoscale setting.
73
+ """
74
+ from __future__ import annotations
75
+
76
+ import logging
77
+ import math
78
+ import os
79
+ import sys
80
+ import time
81
+ from dataclasses import dataclass, field
82
+ from typing import Any
83
+
84
+ import numpy as np
85
+ import polars as pl
86
+
87
+ from ._config import ScalingConfig
88
+ from ._layer2_types import (
89
+ VarFamily,
90
+ lookup_var,
91
+ resolve_cstr_rhs_type,
92
+ )
93
+ from ._quantity_types import QuantityType
94
+
95
+
96
+ _logger = logging.getLogger(__name__)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Capability gate for the bounded coefficient-walk (Phase D-5 step 3).
101
+ #
102
+ # ``bucket_coefficients`` prefers ``polar_high.autoscale._coef_walk`` to walk
103
+ # the block-COO ``(rid/col_id, coef)`` stream in bounded slices instead of
104
+ # materialising the full ``Var ⋈ P1 ⋈ P2 …`` product (see its docstring).
105
+ # The walk ships in polar-high>=2.4.0 (pinned in pyproject), so the import
106
+ # is guaranteed. The capability detect below is retained as a defensive
107
+ # no-op: if a user force-downgrades polar-high, ``bucket_coefficients``
108
+ # falls back to the materialising ``_collect_term_agg`` collect for every
109
+ # term (correct, just unbounded) rather than raising ``ImportError``.
110
+ try: # pragma: no cover - exercised by both-polar_high verification runs
111
+ from polar_high.autoscale._coef_walk import ( # noqa: F401
112
+ CoefWalkRecipe as _CoefWalkRecipe,
113
+ Log2HistogramReducer as _Log2HistogramReducer,
114
+ bounded_coefficient_walk as _bounded_coefficient_walk,
115
+ )
116
+
117
+ _HAVE_COEF_WALK = True
118
+ except ImportError:
119
+ _HAVE_COEF_WALK = False
120
+
121
+
122
+ # Clamp on the chosen per-type exponents. ±20 keeps the scale factors
123
+ # inside roughly [1e-6, 1e+6] which is a sane band for energy-system
124
+ # LPs; values past that risk losing precision when HiGHS' own
125
+ # equilibration multiplies on top.
126
+ _DEFAULT_CLAMP = 20
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # QuantityType ↔ int round-trip helpers.
131
+ #
132
+ # ``QuantityType`` is a *string*-valued Enum (see ``_quantity_types.py``),
133
+ # so we cannot use ``QuantityType(int_id)`` directly. Build a stable
134
+ # bijection once at import — keyed by the enum's insertion order — and
135
+ # reuse it everywhere polars needs an integer column for ``column_type``
136
+ # joins / group-bys. The mapping is process-local; it is regenerated
137
+ # every time this module is imported, which is fine because callers
138
+ # always reconstruct ``QuantityType`` *inside* this module.
139
+
140
+ _QTY_TO_ID: dict[QuantityType, int] = {q: i for i, q in enumerate(QuantityType)}
141
+ _ID_TO_QTY: dict[int, QuantityType] = {i: q for q, i in _QTY_TO_ID.items()}
142
+
143
+
144
+ def _qty_to_id(q: QuantityType | None) -> int | None:
145
+ """Stable Int32 id for a :class:`QuantityType`, or ``None``."""
146
+ if q is None:
147
+ return None
148
+ return _QTY_TO_ID[q]
149
+
150
+
151
+ def _id_to_qty(i: int) -> QuantityType:
152
+ return _ID_TO_QTY[int(i)]
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Layer2Plan:
157
+ """Forward + inverse Layer-2 transform.
158
+
159
+ ``col_factors`` and ``row_factors`` are length-``n_cols`` /
160
+ ``n_rows`` ``np.float64`` arrays. All entries are positive
161
+ powers of two (1.0 = identity); the bit-for-bit roundtrip relies
162
+ on this.
163
+
164
+ Attributes
165
+ ----------
166
+ col_factors:
167
+ Per-column forward multipliers.
168
+ row_factors:
169
+ Per-row forward multipliers.
170
+ type_exponents:
171
+ Power-of-two exponent picked for each :class:`QuantityType`
172
+ present in the LP (for reporting).
173
+ type_buckets_before:
174
+ Per-type (min, max) magnitude before scaling — fed to the
175
+ Layer-2 section of the autoscale YAML.
176
+ type_buckets_after:
177
+ Per-type (min, max) magnitude after scaling.
178
+ skipped_rows:
179
+ Constraint-row names whose family declares ``rhs_type=None``
180
+ (user-defined constraints / structural zero-RHS rows) and
181
+ therefore receive no per-row factor.
182
+ skipped_integer_cols:
183
+ Column ids of integer variables that received no column
184
+ scaling (their factor is 1.0 in ``col_factors``).
185
+ """
186
+
187
+ col_factors: np.ndarray
188
+ row_factors: np.ndarray
189
+ type_exponents: dict[QuantityType, int]
190
+ type_buckets_before: dict[QuantityType, tuple[float, float]]
191
+ type_buckets_after: dict[QuantityType, tuple[float, float]] = field(
192
+ default_factory=dict,
193
+ )
194
+ skipped_rows: list[str] = field(default_factory=list)
195
+ skipped_integer_cols: list[int] = field(default_factory=list)
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Bucketing
200
+
201
+
202
+ def _effective_matrix_type(
203
+ var_family: VarFamily,
204
+ row_type: QuantityType | None,
205
+ ) -> QuantityType:
206
+ """Return the bucket key for a matrix entry sitting in ``row_type``'s
207
+ row and feeding ``var_family``'s column.
208
+
209
+ Layer 2 buckets by the matrix entry's effective physical type — the
210
+ product of the column type and the row's multiplier param's type —
211
+ so ``v_flow``-times-``p_unitsize`` rows land in POWER even though
212
+ the column itself is DIMENSIONLESS.
213
+
214
+ Heuristic:
215
+
216
+ * If the column carries a ``multiplier_param``, the matrix entry's
217
+ type is *whichever of POWER / ENERGY the row demands*. We trust
218
+ the row's ``rhs_type`` for this — every flextool family
219
+ multiplies the dimensionless column by exactly the row's units.
220
+ * Otherwise the column's own type is the entry type.
221
+ * When ``row_type`` is ``None`` (skip-per-row family), we fall
222
+ back to the column's own type so the matrix entry still
223
+ contributes to its column-side bucket.
224
+ """
225
+ if var_family.multiplier_param is not None and row_type is not None:
226
+ # User constraints (rhs_type=None) hit the column-type branch
227
+ # below; for them we don't push a multiplied effective type
228
+ # because the multiplier param IS the user's coefficient.
229
+ return row_type
230
+ return var_family.column_type
231
+
232
+
233
+ def _per_var_column_type(name: str) -> QuantityType:
234
+ return lookup_var(name).column_type
235
+
236
+
237
+ # Per-type accumulator value: ``(log2_sum, count, abs_min, abs_max)``.
238
+ #
239
+ # Layer 2 only needs ``(log2_sum, count)`` to compute the geometric mean
240
+ # (see :func:`choose_scale_powers`). ``abs_min`` / ``abs_max`` are
241
+ # carried for the report-side ``type_buckets_before`` /
242
+ # ``type_buckets_after`` fields on :class:`Layer2Plan` — both come for
243
+ # free from the polars ``group_by`` so we propagate them rather than
244
+ # losing the information.
245
+ _AccVal = tuple[float, int, float, float]
246
+ _INIT_ACC: _AccVal = (0.0, 0, math.inf, 0.0)
247
+
248
+
249
+ def _build_col_id_classification(problem: Any) -> pl.DataFrame:
250
+ """Return a per-``col_id`` classification table.
251
+
252
+ Columns:
253
+
254
+ * ``col_id`` — Int64
255
+ * ``column_type_id`` — Int32 (see :data:`_QTY_TO_ID`)
256
+ * ``has_multiplier_param`` — Boolean
257
+
258
+ Built once per :func:`bucket_coefficients` call and joined into
259
+ every objective / constraint term's lazy plan. ``has_multiplier_param``
260
+ drives :func:`_effective_matrix_type`'s branch — when set *and* the
261
+ constraint family has a ``rhs_type``, the matrix entry is bucketed
262
+ against the row's type rather than the column's.
263
+ """
264
+ col_ids: list[int] = []
265
+ type_ids: list[int] = []
266
+ has_mp: list[bool] = []
267
+ for name, var in problem._vars.items():
268
+ try:
269
+ fam = lookup_var(name)
270
+ except KeyError as exc:
271
+ raise KeyError(
272
+ f"Layer 2: variable {name!r} not in VARIABLE_FAMILIES — "
273
+ "register it in _layer2_types.py before solving."
274
+ ) from exc
275
+ ct_id = _qty_to_id(fam.column_type)
276
+ mp = fam.multiplier_param is not None
277
+ ids = var.frame["col_id"].to_numpy().tolist()
278
+ col_ids.extend(int(c) for c in ids)
279
+ type_ids.extend([ct_id] * len(ids))
280
+ has_mp.extend([mp] * len(ids))
281
+ return pl.DataFrame(
282
+ {
283
+ "col_id": pl.Series(col_ids, dtype=pl.Int64),
284
+ "column_type_id": pl.Series(type_ids, dtype=pl.Int32),
285
+ "has_multiplier_param": pl.Series(has_mp, dtype=pl.Boolean),
286
+ }
287
+ )
288
+
289
+
290
+ def _collect_term_agg(
291
+ term_lazy: pl.LazyFrame,
292
+ *,
293
+ classification_lazy: pl.LazyFrame,
294
+ rhs_t_id: int | None,
295
+ ) -> pl.DataFrame | None:
296
+ """Aggregate one term to ``(eff_t, log_sum, n, log_min, log_max)``.
297
+
298
+ Returns ``None`` for terms with no finite nonzero coefficient (the
299
+ caller skips merging in that case).
300
+
301
+ Streaming engine: we try the ``streaming`` engine first. Polars'
302
+ streaming planner does not support every expression (``.log()`` and
303
+ ``.abs()`` historically had patches of unsupported ops); if the
304
+ streaming collect raises, we fall back to a per-term in-memory
305
+ collect. Even in the fallback the peak is bounded by *one* term's
306
+ materialised size — orders of magnitude smaller than the original
307
+ code's all-terms-at-once Python-list accumulation.
308
+ """
309
+ if rhs_t_id is None:
310
+ eff_t_expr = pl.col("column_type_id")
311
+ else:
312
+ eff_t_expr = (
313
+ pl.when(pl.col("has_multiplier_param"))
314
+ .then(pl.lit(rhs_t_id, dtype=pl.Int32))
315
+ .otherwise(pl.col("column_type_id"))
316
+ )
317
+ plan = (
318
+ term_lazy
319
+ .join(classification_lazy, on="col_id", how="inner")
320
+ .with_columns(eff_t=eff_t_expr)
321
+ .filter(pl.col("coef") != 0.0)
322
+ .with_columns(log_abs=pl.col("coef").abs().log(2.0))
323
+ .filter(pl.col("log_abs").is_finite())
324
+ .group_by("eff_t")
325
+ .agg(
326
+ pl.col("log_abs").sum().alias("log_sum"),
327
+ pl.len().alias("n"),
328
+ pl.col("log_abs").min().alias("log_min"),
329
+ pl.col("log_abs").max().alias("log_max"),
330
+ )
331
+ )
332
+ try:
333
+ agg = plan.collect(engine="streaming")
334
+ except Exception as exc: # noqa: BLE001 — polars may raise diverse types
335
+ _logger.debug(
336
+ "Layer 2: streaming engine refused term aggregate (%s); "
337
+ "falling back to in-memory collect for this term.",
338
+ exc,
339
+ )
340
+ agg = plan.collect()
341
+ if agg.height == 0:
342
+ return None
343
+ return agg
344
+
345
+
346
+ def _merge_into_accumulator(
347
+ acc: dict[QuantityType, _AccVal],
348
+ agg: pl.DataFrame,
349
+ ) -> None:
350
+ """Fold one term's aggregate into the per-type accumulator."""
351
+ for eff_t_id, log_sum, n, log_min, log_max in zip(
352
+ agg["eff_t"].to_list(),
353
+ agg["log_sum"].to_list(),
354
+ agg["n"].to_list(),
355
+ agg["log_min"].to_list(),
356
+ agg["log_max"].to_list(),
357
+ ):
358
+ if eff_t_id is None:
359
+ continue
360
+ t = _id_to_qty(int(eff_t_id))
361
+ ps, pn, pmin, pmax = acc.get(t, _INIT_ACC)
362
+ amin = float(2.0 ** float(log_min))
363
+ amax = float(2.0 ** float(log_max))
364
+ acc[t] = (
365
+ ps + float(log_sum),
366
+ pn + int(n),
367
+ min(pmin, amin),
368
+ max(pmax, amax),
369
+ )
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Bounded coefficient-walk wiring (Phase D-5 step 3).
374
+ #
375
+ # ``_collect_term_agg`` above materialises the merged ``Var ⋈ P1 ⋈ P2 …``
376
+ # (or RHS Param) chain per term to reduce it to a per-type
377
+ # ``(Σlog2|coef|, count, min, max)`` histogram. On the FlexTool DES LP the
378
+ # polars streaming planner cannot push the group-by into the deep product,
379
+ # so the product materialises — the residual ~46 GB autoscale peak.
380
+ #
381
+ # The walk below replaces that materialising collect with
382
+ # :func:`polar_high.autoscale._coef_walk.bounded_coefficient_walk` driven by
383
+ # :class:`Log2HistogramReducer`: each term's ``(rid/col_id, coef)`` stream is
384
+ # walked in bounded ``_WALK_BATCH_ROWS`` slices and folded into the same
385
+ # per-bucket ``(Σlog2, count, min, max)`` accumulator. ``scale=(None,0,None)``
386
+ # — bucketing uses RAW ``|coef|`` (no side-vector scaling). The reducer's
387
+ # ``classify`` reproduces the ``col_id → eff_t`` mapping the old
388
+ # ``_collect_term_agg`` joined: for the objective ``eff_t = column_type``; for
389
+ # a matrix family ``eff_t = rhs_type`` when the column carries a multiplier
390
+ # param (and the family has an rhs_type), else ``column_type``.
391
+ #
392
+ # Terms with no recoverable block-COO recipe (a fully-collapsed
393
+ # ``Sum(over=ALL)`` term clears ``var_source`` / ``sum_block_meta`` and ends
394
+ # up ``over is None`` / ``dims == ()``) cannot be rebuilt by the walk; they
395
+ # keep the existing ``_collect_term_agg`` collect as a backstop — bounded by
396
+ # the (tiny) per-type aggregate, the same envelope as before.
397
+
398
+ # 256k keeps each batch's block-COO product comfortably small while
399
+ # amortising per-batch overhead. The histogram's per-batch Σlog2 reassociates
400
+ # vs a single whole-collect sum, so a coefficient on a half-integer log2
401
+ # boundary may shift a chosen exponent by ±1 → a different (objective-
402
+ # invariant) scaling. Accepted per the step-3 correctness bar.
403
+ _WALK_BATCH_ROWS = 256_000
404
+
405
+
406
+ def _layer2_bucket_profiler() -> Any:
407
+ """Return an ``emit(family, term_idx, **extras)`` callable when
408
+ ``POLAR_HIGH_LAYER2_PROFILE=1`` and ``psutil`` is importable, else
409
+ ``None``.
410
+
411
+ Mirrors the ``[ranges-stream profile]`` instrument in
412
+ :mod:`polar_high.autoscale._ranges`: one ``[layer2-bucket profile]``
413
+ stderr line per walked term carrying ``family``, ``term_idx``,
414
+ ``over_height`` / ``n``, the post-walk wall clock, and an RSS sample —
415
+ so the final DES run can CONFIRM this site was the ~46 GB driver and is
416
+ now bounded.
417
+ """
418
+ if os.environ.get("POLAR_HIGH_LAYER2_PROFILE") != "1":
419
+ return None
420
+ try:
421
+ import psutil
422
+ except ImportError:
423
+ return None
424
+ proc = psutil.Process()
425
+ t0 = time.monotonic()
426
+
427
+ def _emit(family: str, term_idx: int, **extras: Any) -> None:
428
+ rss = proc.memory_info().rss / (1024 ** 3)
429
+ wall = time.monotonic() - t0
430
+ extras_str = "\t".join(f"{k}={v}" for k, v in extras.items())
431
+ print(
432
+ f"[layer2-bucket profile]\tfamily={family}\tterm_idx={term_idx}"
433
+ f"\trss_gb={rss:.2f}\twall_s={wall:.2f}"
434
+ + (f"\t{extras_str}" if extras_str else ""),
435
+ file=sys.stderr,
436
+ flush=True,
437
+ )
438
+
439
+ return _emit
440
+
441
+
442
+ def _build_col_id_eff_t(problem: Any) -> dict[int, tuple[QuantityType, bool]]:
443
+ """Per-``col_id`` ``(column_type, has_multiplier_param)`` lookup.
444
+
445
+ Drives the walk's ``classify`` closures — the Python-side analogue of
446
+ the ``col_id → (column_type_id, has_multiplier_param)`` classification
447
+ table :func:`_build_col_id_classification` joins into the lazy plan.
448
+ """
449
+ out: dict[int, tuple[QuantityType, bool]] = {}
450
+ for name, var in problem._vars.items():
451
+ fam = lookup_var(name) # KeyError already filtered by the caller.
452
+ ct = fam.column_type
453
+ mp = fam.multiplier_param is not None
454
+ for cid in var.frame["col_id"].to_numpy().tolist():
455
+ out[int(cid)] = (ct, mp)
456
+ return out
457
+
458
+
459
+ def _classify_matrix(
460
+ col_eff: dict[int, tuple[QuantityType, bool]],
461
+ rhs_t: QuantityType | None,
462
+ ):
463
+ """Return a ``col_id -> QuantityType | None`` classifier for a matrix
464
+ family with row type ``rhs_t``.
465
+
466
+ Reproduces :func:`_effective_matrix_type`'s branch row-for-row: when the
467
+ column carries a multiplier param AND the family has an ``rhs_type`` the
468
+ entry buckets against the row's type, else against the column's own type.
469
+ A ``col_id`` with no registered family classifies to ``None`` (the
470
+ reducer drops it — same as the old inner-join missing the row).
471
+ """
472
+
473
+ def classify(cid: int):
474
+ ent = col_eff.get(int(cid))
475
+ if ent is None:
476
+ return None
477
+ col_t, has_mp = ent
478
+ if has_mp and rhs_t is not None:
479
+ return rhs_t
480
+ return col_t
481
+
482
+ return classify
483
+
484
+
485
+ def _classify_cost(col_eff: dict[int, tuple[QuantityType, bool]]):
486
+ """Return a ``col_id -> column_type | None`` classifier for the
487
+ objective (cost) walk — ``eff_t == column_type`` (no rhs_type)."""
488
+
489
+ def classify(cid: int):
490
+ ent = col_eff.get(int(cid))
491
+ return None if ent is None else ent[0]
492
+
493
+ return classify
494
+
495
+
496
+ def _merge_hist_into_accumulator(
497
+ acc: dict[QuantityType, _AccVal],
498
+ hist: dict[QuantityType, tuple[float, int, float, float]],
499
+ ) -> None:
500
+ """Fold one walked term's :class:`Log2HistogramReducer` result into the
501
+ per-type accumulator.
502
+
503
+ The reducer keys directly by :class:`QuantityType` (the classify
504
+ closures return ``QuantityType`` values), and packs each bucket as
505
+ ``(Σlog2|coef|, count, abs_min, abs_max)`` — the SAME packing the
506
+ accumulator carries — so the fold is a direct combine.
507
+ """
508
+ for t, (slog, cnt, amin, amax) in hist.items():
509
+ if t is None or cnt == 0:
510
+ continue
511
+ ps, pn, pmin, pmax = acc.get(t, _INIT_ACC)
512
+ acc[t] = (
513
+ ps + float(slog),
514
+ pn + int(cnt),
515
+ min(pmin, float(amin)),
516
+ max(pmax, float(amax)),
517
+ )
518
+
519
+
520
+ def _obj_term_recipe(term: Any):
521
+ """Return a column-mode ``(recipe, spine)`` for an objective term, or
522
+ ``None`` if the term cannot route through the walk.
523
+
524
+ Routes when the term carries a Var seed the column-mode walk can rebuild:
525
+ a non-Sum term (``var_source`` set) or a pure-RELABEL Sum term
526
+ (``sum_block_meta`` set, ``reduce_dims ⊆ var.dims``, no map-effect Where)
527
+ — exactly the regime ``_ranges._obj_chain_bounded`` admits, where every
528
+ ``col_id`` group is single-element so the per-cell product equals the
529
+ reduced coef. A fully-collapsed ``Sum(over=ALL)`` term (``var_source``
530
+ and ``sum_block_meta`` both cleared) returns ``None`` → the caller keeps
531
+ the existing collect.
532
+ """
533
+ from polar_high.autoscale._coef_walk import CoefWalkRecipe
534
+
535
+ meta = getattr(term, "sum_block_meta", None)
536
+ if meta is not None:
537
+ var = meta.var_source
538
+ if var is None:
539
+ return None
540
+ if meta.where_map_frames is not None:
541
+ return None
542
+ if not set(meta.reduce_dims).issubset(set(var.dims)):
543
+ return None
544
+ recipe = CoefWalkRecipe.from_term(term)
545
+ return recipe, var.frame
546
+ var = getattr(term, "var_source", None)
547
+ if var is None:
548
+ return None
549
+ recipe = CoefWalkRecipe.from_term(term)
550
+ return recipe, var.frame
551
+
552
+
553
+ def bucket_coefficients(
554
+ problem: Any,
555
+ ) -> tuple[
556
+ dict[QuantityType, _AccVal],
557
+ dict[QuantityType, _AccVal],
558
+ dict[QuantityType, _AccVal],
559
+ dict[int, QuantityType],
560
+ ]:
561
+ """Walk ``problem._vars``, ``problem._cstrs``, ``problem._obj_terms``
562
+ and aggregate every nonzero finite coefficient magnitude by its
563
+ effective :class:`QuantityType`.
564
+
565
+ Returns four maps:
566
+
567
+ * ``matrix_acc[t]`` — constraint-matrix entries, packed as
568
+ ``(log2_sum, count, abs_min, abs_max)``.
569
+ * ``cost_acc[t]`` — objective coefficients (same packing).
570
+ * ``bound_acc[t]`` — finite variable bounds (same packing).
571
+ RHS magnitudes are not bucketed here — they are handled per-family
572
+ via the row_factor logic.
573
+ * ``col_id_to_type`` — column id → its column QuantityType. Used
574
+ by :func:`apply_layer2` to push per-column factors back into the
575
+ lazy term plans.
576
+
577
+ Implementation note (rewrite 2026-05-31, Phase D-5 step 3): per term
578
+ the per-type histogram is accumulated by walking the block-COO
579
+ ``(rid/col_id, coef)`` stream in bounded ``_WALK_BATCH_ROWS`` slices via
580
+ :func:`polar_high.autoscale._coef_walk.bounded_coefficient_walk` +
581
+ :class:`Log2HistogramReducer`, NEVER materialising the merged
582
+ ``Var ⋈ P1 ⋈ P2 …`` product. Peak RSS is bounded by one batch's product
583
+ (not the full chain) — this is the change that removes the residual DES
584
+ autoscale spike and lets the previously-skipped huge families be bucketed
585
+ at all. Terms with no recoverable recipe (fully-collapsed
586
+ ``Sum(over=ALL)`` ⇒ ``over is None`` / ``dims == ()``) keep the bounded
587
+ ``_collect_term_agg`` per-term collect as a backstop.
588
+
589
+ Capability gate: the bounded walk requires ``polar_high.autoscale.
590
+ _coef_walk`` (block-COO). When that module is absent (older polar_high,
591
+ detected once at import as ``_HAVE_COEF_WALK``) this falls back to the
592
+ pre-step-3 behaviour — the materialising ``_collect_term_agg`` collect for
593
+ every term, including the >1M per-family size skip — so the solve still
594
+ autoscales correctly (just unbounded) instead of raising ``ImportError``.
595
+ """
596
+ classification = _build_col_id_classification(problem)
597
+ classification_lazy = classification.lazy()
598
+
599
+ # ── col_id → column QuantityType (consumed by apply_layer2). Built
600
+ # before the capability branch so both paths share it.
601
+ col_id_to_type: dict[int, QuantityType] = {}
602
+ for name, var in problem._vars.items():
603
+ fam = lookup_var(name) # KeyError already filtered above.
604
+ for cid in var.frame["col_id"].to_numpy().tolist():
605
+ col_id_to_type[int(cid)] = fam.column_type
606
+
607
+ matrix_acc: dict[QuantityType, _AccVal] = {}
608
+ cost_acc: dict[QuantityType, _AccVal] = {}
609
+ bound_acc: dict[QuantityType, _AccVal] = {}
610
+
611
+ # ── Variable bounds: small (≤ 2 per var family); keep in Python
612
+ # but match the (sum_log2, count, min, max) accumulator shape. Bounds
613
+ # never went through the walk, so this is identical on both paths.
614
+ for name, var in problem._vars.items():
615
+ fam = lookup_var(name)
616
+ for b in (var.lower, var.upper):
617
+ if not math.isfinite(b) or b == 0.0:
618
+ continue
619
+ av = abs(float(b))
620
+ lv = math.log2(av)
621
+ if not math.isfinite(lv):
622
+ continue
623
+ ps, pn, pmin, pmax = bound_acc.get(fam.column_type, _INIT_ACC)
624
+ bound_acc[fam.column_type] = (
625
+ ps + lv, pn + 1, min(pmin, av), max(pmax, av),
626
+ )
627
+
628
+ if not _HAVE_COEF_WALK:
629
+ # ── Capability fallback: this polar_high has no block-COO
630
+ # ``_coef_walk`` (e.g. released ``main``). Reproduce the pre-step-3
631
+ # ``bucket_coefficients`` exactly — the materialising
632
+ # ``_collect_term_agg`` collect for every objective and matrix term,
633
+ # including the >1M per-family size skip (driven by the same
634
+ # ``POLAR_HIGH_RANGES_MAX_FAMILY_ROWS`` env var as Layer 1's skip).
635
+ _logger.debug(
636
+ "Layer 2: polar_high lacks _coef_walk; bucketing via the "
637
+ "pre-step-3 per-term collect (unbounded peak, but correct)."
638
+ )
639
+ for term in problem._obj_terms:
640
+ agg = _collect_term_agg(
641
+ term.lazy,
642
+ classification_lazy=classification_lazy,
643
+ rhs_t_id=None,
644
+ )
645
+ if agg is not None:
646
+ _merge_into_accumulator(cost_acc, agg)
647
+
648
+ try:
649
+ _max_family_rows = int(
650
+ os.environ.get("POLAR_HIGH_RANGES_MAX_FAMILY_ROWS", "1000000")
651
+ )
652
+ except (ValueError, TypeError):
653
+ _max_family_rows = 1_000_000
654
+
655
+ for cname, proto, over in problem._cstrs:
656
+ row_count = 0 if over is None else int(over.height)
657
+ if _max_family_rows > 0 and row_count > _max_family_rows:
658
+ continue
659
+ try:
660
+ rhs_t = resolve_cstr_rhs_type(cname)
661
+ except KeyError as exc:
662
+ raise KeyError(
663
+ f"Layer 2: constraint {cname!r} not in CONSTRAINT_FAMILIES "
664
+ "— register it in _layer2_types.py before solving."
665
+ ) from exc
666
+ rhs_t_id = _qty_to_id(rhs_t)
667
+ for term in proto.expr.terms:
668
+ agg = _collect_term_agg(
669
+ term.lazy,
670
+ classification_lazy=classification_lazy,
671
+ rhs_t_id=rhs_t_id,
672
+ )
673
+ if agg is not None:
674
+ _merge_into_accumulator(matrix_acc, agg)
675
+
676
+ return matrix_acc, cost_acc, bound_acc, col_id_to_type
677
+
678
+ # ── Bounded coefficient-walk path (Phase D-5 step 3). Reached only when
679
+ # this polar_high ships ``_coef_walk`` (gated above at import).
680
+ col_eff = _build_col_id_eff_t(problem)
681
+ dense_axes = getattr(problem, "_dense_axes", None)
682
+ profile = _layer2_bucket_profiler()
683
+
684
+ from polar_high.autoscale._coef_walk import (
685
+ Log2HistogramReducer,
686
+ bounded_coefficient_walk,
687
+ )
688
+
689
+ # ``scale=(None, 0, None)`` — bucketing uses RAW |coef| (no side-vector
690
+ # scaling); the reducer's ``_scaled_abs`` then returns ``|coef|`` verbatim.
691
+ _RAW_SCALE: tuple[Any, int, Any] = (None, 0, None)
692
+
693
+ # ── Objective: rhs_t is N/A → eff_t == column_type. Route each term
694
+ # through the bounded column-mode walk when it carries a Var seed the
695
+ # walk can rebuild; otherwise (fully-collapsed Sum) keep the collect.
696
+ cost_classify = _classify_cost(col_eff)
697
+ for ti, term in enumerate(problem._obj_terms):
698
+ if term.lazy is None:
699
+ continue
700
+ routed = _obj_term_recipe(term)
701
+ if routed is not None:
702
+ recipe, spine = routed
703
+ (hist,) = bounded_coefficient_walk(
704
+ spine,
705
+ recipe,
706
+ _RAW_SCALE,
707
+ [Log2HistogramReducer(_RAW_SCALE, cost_classify)],
708
+ batch_rows=_WALK_BATCH_ROWS,
709
+ dense_axes=dense_axes,
710
+ )
711
+ _merge_hist_into_accumulator(cost_acc, hist)
712
+ if profile is not None:
713
+ profile(
714
+ "<objective>", ti, n=int(spine.height), path="walk",
715
+ )
716
+ else:
717
+ agg = _collect_term_agg(
718
+ term.lazy,
719
+ classification_lazy=classification_lazy,
720
+ rhs_t_id=None,
721
+ )
722
+ if agg is not None:
723
+ _merge_into_accumulator(cost_acc, agg)
724
+ if profile is not None:
725
+ profile("<objective>", ti, path="collect")
726
+
727
+ # ── Matrix: per-family walk of the expression terms.
728
+ #
729
+ # NO blanket family-size skip (Phase D-5 step 3): the bounded
730
+ # coefficient walk below caps the per-term peak at one batch's product,
731
+ # so the old >1M skip — which silently dropped the biggest families
732
+ # (e.g. the DES LP's ``profile_flow_upper_limit``, 1.5M rows × multi-
733
+ # Param) from the scaling decision — is no longer needed. Every family's
734
+ # dim-bound LHS terms are now folded into the histogram. Terms the walk
735
+ # cannot rebuild (scalar / no-over / fully-collapsed Sum) take the bounded
736
+ # ``_collect_term_agg`` collect; those are small by construction (no deep
737
+ # product to materialise — the Sum already reduced it).
738
+ from polar_high.autoscale._coef_walk import CoefWalkRecipe
739
+
740
+ for cname, proto, over in problem._cstrs:
741
+ try:
742
+ rhs_t = resolve_cstr_rhs_type(cname)
743
+ except KeyError as exc:
744
+ raise KeyError(
745
+ f"Layer 2: constraint {cname!r} not in CONSTRAINT_FAMILIES "
746
+ "— register it in _layer2_types.py before solving."
747
+ ) from exc
748
+ rhs_t_id = _qty_to_id(rhs_t)
749
+ matrix_classify = _classify_matrix(col_eff, rhs_t)
750
+ for ti, term in enumerate(proto.expr.terms):
751
+ # Route a dim-bound LHS term (real ``over`` grid, open dims, a
752
+ # rebuildable Var/Sum recipe) through the bounded walk; anything
753
+ # else (scalar, no ``over``, fully-collapsed Sum with no recipe)
754
+ # keeps the bounded per-term collect backstop.
755
+ # Routability mirrors ``CoefWalkRecipe.from_term``'s exact
756
+ # precondition via ``is_buildable`` (meta present →
757
+ # ``meta.var_source is not None``; else ``term.var_source is not
758
+ # None``). The earlier SHALLOW ``var_source or sum_block_meta``
759
+ # check let a fully-collapsed ``Sum`` (meta present, but
760
+ # ``meta.var_source`` None) through, then ``from_term`` raised.
761
+ routable = (
762
+ over is not None
763
+ and bool(term.dims)
764
+ and CoefWalkRecipe.is_buildable(term)
765
+ )
766
+ if routable:
767
+ recipe = CoefWalkRecipe.from_term(term)
768
+ (hist,) = bounded_coefficient_walk(
769
+ over,
770
+ recipe,
771
+ _RAW_SCALE,
772
+ [Log2HistogramReducer(_RAW_SCALE, matrix_classify)],
773
+ batch_rows=_WALK_BATCH_ROWS,
774
+ dense_axes=dense_axes,
775
+ )
776
+ _merge_hist_into_accumulator(matrix_acc, hist)
777
+ if profile is not None:
778
+ profile(
779
+ cname, ti, over_height=int(over.height), path="walk",
780
+ )
781
+ else:
782
+ agg = _collect_term_agg(
783
+ term.lazy,
784
+ classification_lazy=classification_lazy,
785
+ rhs_t_id=rhs_t_id,
786
+ )
787
+ if agg is not None:
788
+ _merge_into_accumulator(matrix_acc, agg)
789
+ if profile is not None:
790
+ profile(
791
+ cname, ti,
792
+ over_height=(0 if over is None else int(over.height)),
793
+ path="collect",
794
+ )
795
+
796
+ return matrix_acc, cost_acc, bound_acc, col_id_to_type
797
+
798
+
799
+ # ---------------------------------------------------------------------------
800
+ # Small helpers
801
+
802
+
803
+ def _col_id_cache(problem: Any) -> dict[int, str]:
804
+ cache: dict[int, str] = {}
805
+ for name, var in problem._vars.items():
806
+ for cid in var.frame["col_id"].to_numpy().tolist():
807
+ cache[int(cid)] = name
808
+ return cache
809
+
810
+
811
+ # Memoised per-problem; rebuilt by ``_col_id_to_var_name``'s first call.
812
+ _COL_ID_CACHE_ATTR = "_autoscale_col_id_cache"
813
+
814
+
815
+ def _col_id_to_var_name(problem: Any, col_id: int) -> str:
816
+ cache = getattr(problem, _COL_ID_CACHE_ATTR, None)
817
+ if cache is None:
818
+ cache = _col_id_cache(problem)
819
+ try:
820
+ setattr(problem, _COL_ID_CACHE_ATTR, cache)
821
+ except Exception:
822
+ # If the Problem doesn't permit dynamic attrs, fall back
823
+ # to per-call recomputation. polar-high's Problem has a
824
+ # regular __dict__, so this branch should not fire.
825
+ pass
826
+ return cache[col_id]
827
+
828
+
829
+ def _bucket_range(values: list[float]) -> tuple[float, float]:
830
+ if not values:
831
+ return (math.nan, math.nan)
832
+ arr = np.asarray(values)
833
+ return (float(arr.min()), float(arr.max()))
834
+
835
+
836
+ # ---------------------------------------------------------------------------
837
+ # Power-of-two exponent picking
838
+
839
+
840
+ def choose_scale_powers(
841
+ matrix_acc: dict[QuantityType, _AccVal],
842
+ cost_acc: dict[QuantityType, _AccVal],
843
+ bound_acc: dict[QuantityType, _AccVal],
844
+ *,
845
+ clamp: int = _DEFAULT_CLAMP,
846
+ ) -> dict[QuantityType, int]:
847
+ """Pick a power-of-two exponent for each :class:`QuantityType` seen.
848
+
849
+ Per Bröchin et al. (2024) and the autoscaler handoff
850
+ (``specs/flextool-autoscaling-handoff.md``):
851
+
852
+ s_t = round( log2( 1 / geomean(|values|) ) )
853
+
854
+ where the geometric mean is computed via
855
+ ``log_mean = sum(log2|values|) / count`` pooled across the matrix,
856
+ cost, and bound accumulators of type ``t``. We clamp into
857
+ ``[-clamp, +clamp]`` to keep the factors inside double precision's
858
+ comfort zone.
859
+
860
+ Returns a dict of :class:`QuantityType` → int. Types with no
861
+ samples (``count == 0``) are absent (so callers can default their
862
+ factor to 1.0).
863
+
864
+ Mathematically equivalent to the pre-2026-05-27 implementation that
865
+ pooled raw magnitude lists, modulo at most ±1 on values that sit on
866
+ a half-integer ``round`` boundary (sum-then-divide vs.
867
+ ``np.log2(arr).mean()`` reorder floating-point ops).
868
+ """
869
+ pool: dict[QuantityType, tuple[float, int]] = {}
870
+ for src in (matrix_acc, cost_acc, bound_acc):
871
+ for t, val in src.items():
872
+ log_sum, count = val[0], val[1]
873
+ ps, pn = pool.get(t, (0.0, 0))
874
+ pool[t] = (ps + float(log_sum), pn + int(count))
875
+
876
+ chosen: dict[QuantityType, int] = {}
877
+ for t, (log_sum, count) in pool.items():
878
+ if count == 0:
879
+ continue
880
+ log_mean = log_sum / count
881
+ exp = int(round(-log_mean))
882
+ if exp > clamp:
883
+ exp = clamp
884
+ elif exp < -clamp:
885
+ exp = -clamp
886
+ chosen[t] = exp
887
+
888
+ return chosen
889
+
890
+
891
+ # ---------------------------------------------------------------------------
892
+ # Public API
893
+
894
+
895
+ def apply_layer2(
896
+ problem: Any,
897
+ config: ScalingConfig,
898
+ ) -> Layer2Plan:
899
+ """Apply Layer 2 to ``problem`` in place.
900
+
901
+ Mutates ``problem._vars`` (bound rescale on non-integer columns)
902
+ and writes the two side vectors
903
+ ``problem._layer2_col_factor`` / ``problem._layer2_row_factor``
904
+ that the polar-high consumers (``write_mps``, ``_build_lp_arrays``,
905
+ ``_solve_streaming``, ``WarmProblem._initial_build``,
906
+ ``LpView.from_problem``) multiply into the emitted LHS / cost /
907
+ RHS at consumption time. Also sets ``problem._layer2_locked =
908
+ True`` to prevent post-Layer-2 structural changes that would
909
+ invalidate the side-vector sizes.
910
+
911
+ Does NOT mutate ``problem._cstrs`` or ``problem._obj_terms`` —
912
+ the GLPK-style "scaling lives as metadata, coefficients are
913
+ immutable" property. Returns a :class:`Layer2Plan` carrying the
914
+ inverse transform for :func:`unscale_solution`.
915
+ """
916
+ matrix_acc, cost_acc, bound_acc, col_id_to_type = bucket_coefficients(problem)
917
+ exponents = choose_scale_powers(matrix_acc, cost_acc, bound_acc)
918
+
919
+ # Per-type bucket-range reporting (computed from the accumulators that
920
+ # the walk above produced). These feed the YAML / console report only;
921
+ # they are NOT needed to install the side vectors, so the
922
+ # exponents-only replay path (:func:`apply_layer2_with_exponents`)
923
+ # passes ``None`` and the plan carries empty buckets.
924
+ all_types = set(matrix_acc) | set(cost_acc) | set(bound_acc)
925
+ type_buckets_before: dict[QuantityType, tuple[float, float]] = {}
926
+ type_buckets_after: dict[QuantityType, tuple[float, float]] = {}
927
+ for t in all_types:
928
+ amin = math.inf
929
+ amax = 0.0
930
+ any_seen = False
931
+ for src in (matrix_acc, cost_acc, bound_acc):
932
+ if t in src:
933
+ _, count, mn, mx = src[t]
934
+ if count > 0:
935
+ amin = min(amin, mn)
936
+ amax = max(amax, mx)
937
+ any_seen = True
938
+ if not any_seen:
939
+ continue
940
+ type_buckets_before[t] = (float(amin), float(amax))
941
+ exp = exponents.get(t, 0)
942
+ f = float(2 ** exp)
943
+ type_buckets_after[t] = (float(amin * f), float(amax * f))
944
+
945
+ return apply_layer2_with_exponents(
946
+ problem,
947
+ exponents,
948
+ type_buckets_before=type_buckets_before,
949
+ type_buckets_after=type_buckets_after,
950
+ )
951
+
952
+
953
+ def apply_layer2_with_exponents(
954
+ problem: Any,
955
+ exponents: dict[QuantityType, int],
956
+ *,
957
+ type_buckets_before: dict[QuantityType, tuple[float, float]] | None = None,
958
+ type_buckets_after: dict[QuantityType, tuple[float, float]] | None = None,
959
+ ) -> Layer2Plan:
960
+ """Install Layer-2 side vectors on ``problem`` from KNOWN exponents.
961
+
962
+ This is the cheap second half of :func:`apply_layer2` — it walks
963
+ only ``problem._vars`` (O(#var families)) and ``problem._cstrs``
964
+ (O(#constraint families)) to map the per-type ``exponents`` onto
965
+ column / row factors. It performs NO coefficient traversal
966
+ (``bucket_coefficients`` / ``detect_ranges`` / ``_ranges_via_streaming``)
967
+ and therefore none of the multi-GB transient working set those walks
968
+ spike.
969
+
970
+ Used by the orchestrator's per-roll autoscale cache: the first solve
971
+ of a structural shape runs the full :func:`apply_layer2` (deriving
972
+ ``exponents``); every subsequent same-shape roll replays the decision
973
+ here against THIS roll's freshly-built ``Problem``, producing
974
+ byte-identical scaled coefficients (the side vectors depend only on
975
+ the per-type exponents and the column/row family layout, both of
976
+ which are invariant for a fixed structural fingerprint).
977
+
978
+ ``type_buckets_*`` are optional reporting fields; when omitted the
979
+ returned plan carries empty buckets (the per-roll YAML report's
980
+ Layer-2 section is then range-free, but the scaling applied to the LP
981
+ is identical).
982
+ """
983
+ n_cols = problem._next_col
984
+ col_factors = np.ones(n_cols, dtype=np.float64)
985
+
986
+ # ── Column factors -------------------------------------------------
987
+ integer_cols: list[int] = []
988
+ for name, var in problem._vars.items():
989
+ if var.integer:
990
+ ids = var.frame["col_id"].to_numpy()
991
+ integer_cols.extend(int(c) for c in ids.tolist())
992
+ continue
993
+ fam = lookup_var(name)
994
+ col_t = fam.column_type
995
+ exp = exponents.get(col_t)
996
+ if exp is None:
997
+ continue
998
+ f = float(2 ** exp)
999
+ ids = var.frame["col_id"].to_numpy()
1000
+ col_factors[ids] = f
1001
+
1002
+ # Variable bound mutation — multiply finite bounds by col_factor.
1003
+ # Skip integer columns (col_factors[j] == 1.0 there by construction).
1004
+ # This is the one place Layer 2 mutates state that's not behind the
1005
+ # side vectors; intentional because Var.lower/upper are scalar per
1006
+ # family and the cost is O(n_var_families), no peak-memory concern.
1007
+ for name, var in problem._vars.items():
1008
+ if var.integer:
1009
+ continue
1010
+ fam = lookup_var(name)
1011
+ f = float(2 ** exponents[fam.column_type]) if fam.column_type in exponents else 1.0
1012
+ if f == 1.0:
1013
+ continue
1014
+ if math.isfinite(var.lower):
1015
+ var.lower = float(var.lower) * f
1016
+ if math.isfinite(var.upper):
1017
+ var.upper = float(var.upper) * f
1018
+
1019
+ # ── Row factors ----------------------------------------------------
1020
+ # Walk ``_cstrs`` in the same order consumers do; ``row_factors_list``
1021
+ # is built 0-based per constraint row. The cost row is NOT in this
1022
+ # vector (objective gets column scaling only — GLPK convention).
1023
+ row_factors_list: list[float] = []
1024
+ skipped_rows: list[str] = []
1025
+
1026
+ for cname, proto, over in problem._cstrs:
1027
+ rhs_t = resolve_cstr_rhs_type(cname)
1028
+ if rhs_t is None:
1029
+ rf = 1.0
1030
+ else:
1031
+ exp = exponents.get(rhs_t)
1032
+ rf = float(2 ** exp) if exp is not None else 1.0
1033
+ row_count = 1 if over is None else int(over.height)
1034
+ if rhs_t is None and row_count > 0:
1035
+ skipped_rows.append(cname)
1036
+ row_factors_list.extend([rf] * row_count)
1037
+
1038
+ row_factors = np.asarray(row_factors_list, dtype=np.float64)
1039
+
1040
+ # ── Install side vectors on the Problem ---------------------------
1041
+ # Size assertion: col_factors must be exactly n_cols. row_factors
1042
+ # is indexed 0-based by constraint row in the order consumers walk
1043
+ # ``_cstrs`` (the cost row is not in this vector).
1044
+ #
1045
+ # IMPORTANT — convention asymmetry. The math (see module docstring):
1046
+ #
1047
+ # x_scaled[j] = col_factor[j] * x[j]
1048
+ # matrix_scaled[i,j] = matrix[i,j] * row_factor[i] / col_factor[j]
1049
+ # cost_scaled[j] = cost[j] / col_factor[j]
1050
+ # rhs_scaled[i] = rhs[i] * row_factor[i]
1051
+ #
1052
+ # Consumers (write_mps, _build_lp_arrays, _solve_streaming,
1053
+ # WarmProblem._initial_build, LpView.from_problem) multiply emitted
1054
+ # values by ``_layer2_row_factor[i]`` and ``_layer2_col_factor[j]``
1055
+ # *directly* — no inversion. For matrix and cost that means the
1056
+ # value we install in ``_layer2_col_factor`` is ``1 / col_factor``
1057
+ # (the inverse of the math ``cf``), so that ``vals * _cf[j]`` yields
1058
+ # the math-correct ``vals / cf[j]``. ``_layer2_row_factor`` is the
1059
+ # forward ``rf`` — applied unchanged to matrix LHS and RHS.
1060
+ #
1061
+ # ``Layer2Plan.col_factors`` keeps the FORWARD ``cf`` (used by
1062
+ # ``unscale_solution`` as ``cv / cf``); only the side vector on the
1063
+ # Problem is inverted. Since every ``cf`` is a power of two,
1064
+ # ``1/cf`` is exact in IEEE.
1065
+ assert col_factors.shape[0] == problem._next_col, (
1066
+ f"Layer 2: col_factors length {col_factors.shape[0]} != "
1067
+ f"problem._next_col {problem._next_col}"
1068
+ )
1069
+ problem._layer2_col_factor = 1.0 / col_factors
1070
+ problem._layer2_row_factor = row_factors
1071
+ # Lock AFTER writing both arrays — otherwise the writes themselves
1072
+ # could trip future guards if they touch any locked code path.
1073
+ problem._layer2_locked = True
1074
+ # Invalidate any cached canonical matrix on the Problem (polar-high
1075
+ # B1, commit a3dd35f). The side vectors above are baked into
1076
+ # ``_matrix.val`` at ``canonicalise()`` time, so an existing cached
1077
+ # matrix built BEFORE this call is stale. No current call path
1078
+ # triggers the stale-cache scenario (canonicalise is normally first
1079
+ # reached AFTER apply_layer2 via write_mps / _build_lp_arrays), but
1080
+ # B2's _build_lp_arrays migration would route every non-streaming
1081
+ # solve through canonicalise(), making a sequence like
1082
+ # ``write_mps → apply_layer2 → solve`` produce silently wrong
1083
+ # results without this flag.
1084
+ problem._canonical_dirty = True
1085
+
1086
+ plan = Layer2Plan(
1087
+ col_factors=col_factors,
1088
+ row_factors=row_factors,
1089
+ type_exponents=dict(exponents),
1090
+ type_buckets_before=dict(type_buckets_before or {}),
1091
+ type_buckets_after=dict(type_buckets_after or {}),
1092
+ skipped_rows=skipped_rows,
1093
+ skipped_integer_cols=integer_cols,
1094
+ )
1095
+ return plan
1096
+
1097
+
1098
+ # ---------------------------------------------------------------------------
1099
+ # Unscale
1100
+
1101
+
1102
+ def unscale_solution(sol: Any, plan: Layer2Plan) -> None:
1103
+ """In-place: undo the Layer-2 forward transform on ``sol``.
1104
+
1105
+ Mutates ``sol.col_value``, ``sol.col_dual``, ``sol.row_dual``. Also
1106
+ mirrors the unscaled values onto ``sol.highs`` (the live solver
1107
+ handle) so downstream writers that read ``h.getSolution().col_value``
1108
+ see physical-coordinate values rather than the scaled solver state.
1109
+ ``sol.obj`` is invariant under the (c→c/cf, x→cf·x) substitution
1110
+ and is left untouched.
1111
+
1112
+ Length checks ensure the plan matches the solution. Mismatch
1113
+ indicates the caller wired the plan to the wrong solve; we raise
1114
+ rather than silently corrupt the results.
1115
+ """
1116
+ cv = np.asarray(sol.col_value, dtype=np.float64)
1117
+ if cv.shape[0] != plan.col_factors.shape[0]:
1118
+ raise ValueError(
1119
+ f"Layer 2 unscale: col_value length {cv.shape[0]} != "
1120
+ f"col_factors length {plan.col_factors.shape[0]}"
1121
+ )
1122
+ new_col_value = cv / plan.col_factors
1123
+ sol.col_value = new_col_value
1124
+
1125
+ new_col_dual: "np.ndarray | None" = None
1126
+ cd = np.asarray(getattr(sol, "col_dual", None), dtype=np.float64) \
1127
+ if getattr(sol, "col_dual", None) is not None else None
1128
+ if cd is not None and cd.size > 0:
1129
+ if cd.shape[0] != plan.col_factors.shape[0]:
1130
+ raise ValueError(
1131
+ f"Layer 2 unscale: col_dual length {cd.shape[0]} != "
1132
+ f"col_factors length {plan.col_factors.shape[0]}"
1133
+ )
1134
+ new_col_dual = cd * plan.col_factors
1135
+ sol.col_dual = new_col_dual
1136
+
1137
+ new_row_dual: "np.ndarray | None" = None
1138
+ rd = np.asarray(getattr(sol, "row_dual", None), dtype=np.float64) \
1139
+ if getattr(sol, "row_dual", None) is not None else None
1140
+ if rd is not None and rd.size > 0:
1141
+ if rd.shape[0] != plan.row_factors.shape[0]:
1142
+ raise ValueError(
1143
+ f"Layer 2 unscale: row_dual length {rd.shape[0]} != "
1144
+ f"row_factors length {plan.row_factors.shape[0]}"
1145
+ )
1146
+ new_row_dual = rd * plan.row_factors
1147
+ sol.row_dual = new_row_dual
1148
+
1149
+ _push_unscaled_to_highs(
1150
+ sol,
1151
+ new_col_value=new_col_value,
1152
+ new_col_dual=new_col_dual,
1153
+ new_row_dual=new_row_dual,
1154
+ )
1155
+
1156
+
1157
+ def _push_unscaled_to_highs(
1158
+ sol: Any,
1159
+ *,
1160
+ new_col_value: "np.ndarray | None" = None,
1161
+ new_col_dual: "np.ndarray | None" = None,
1162
+ new_row_dual: "np.ndarray | None" = None,
1163
+ ) -> None:
1164
+ """Mirror the unscaled values onto ``sol.highs``.
1165
+
1166
+ Downstream output writers (``process_outputs.read_highs_solution``)
1167
+ consume ``h.getSolution().col_value`` directly off the solver handle;
1168
+ without this push they would see the scaled solver state and write
1169
+ physically-meaningless values. Two handle types appear in practice:
1170
+
1171
+ * The duck-typed ``_SolHighsShim`` (cold HiGHS subprocess path) —
1172
+ direct attribute assignment on its ``_SolutionView``.
1173
+ * A real :class:`highspy.Highs` (warm path; commercial-solver cold
1174
+ path that injected primal via ``setSolution``) — round-trip through
1175
+ a fresh ``HighsSolution`` pushed via ``setSolution``.
1176
+ """
1177
+ h = getattr(sol, "highs", None)
1178
+ if h is None:
1179
+ return
1180
+
1181
+ try:
1182
+ sv = h.getSolution()
1183
+ except Exception:
1184
+ sv = None
1185
+
1186
+ # Shim path: the ``_SolHighsShim._SolutionView`` is a tiny class
1187
+ # whose ``__slots__`` advertise ``col_value`` / ``col_dual`` /
1188
+ # ``row_dual``. Direct assignment makes ``h.getSolution()`` return
1189
+ # the unscaled arrays on the next call.
1190
+ sv_slots = getattr(sv, "__slots__", None) if sv is not None else None
1191
+ if sv_slots is not None and "col_value" in sv_slots:
1192
+ if new_col_value is not None:
1193
+ sv.col_value = new_col_value
1194
+ if new_col_dual is not None:
1195
+ sv.col_dual = new_col_dual
1196
+ if new_row_dual is not None:
1197
+ sv.row_dual = new_row_dual
1198
+ return
1199
+
1200
+ # Real highspy.Highs path: round-trip through HighsSolution +
1201
+ # setSolution. ``setSolution`` after ``run`` overwrites the solver's
1202
+ # stored solution — verified by the inline unit test in
1203
+ # ``tests/test_autoscale_unscale_highs_pushback.py``.
1204
+ #
1205
+ # BUG (fixed): ``setSolution`` AFTER ``run`` also resets HiGHS's cached
1206
+ # ``getObjectiveValue()`` to 0.0 (verified empirically against highspy
1207
+ # 1.14.0). ``write_v_obj`` reads that very value to derive
1208
+ # ``v_obj__{solve}.parquet`` / ``total_cost.val``, so without the
1209
+ # rescue path below the post-autoscale objective collapses to zero
1210
+ # whenever Layer 2 fires on a warm-path solve (this manifested as
1211
+ # the three ``test_commodity_ladder_rolling`` failures in v56). Stash
1212
+ # the pre-setSolution objective on the handle so the writer can
1213
+ # prefer it over the zeroed-out cache.
1214
+ try:
1215
+ import highspy
1216
+ except ImportError: # pragma: no cover — highspy is a hard dep
1217
+ return
1218
+ try:
1219
+ # Capture HiGHS's post-run objective BEFORE setSolution wipes it.
1220
+ # ``sol.obj`` may already be the unscaled-by-Layer-2 value
1221
+ # (Layer 2 cost-side substitution is c -> c / cf, x -> cf · x,
1222
+ # so c^T x is invariant; ``sol.obj`` is left untouched by
1223
+ # ``unscale_solution``). Read straight off the handle so the
1224
+ # rescue path captures whatever the live HiGHS just reported,
1225
+ # including HiGHS's own user_bound_scale unscale.
1226
+ pre_obj = float(h.getObjectiveValue())
1227
+ hs = highspy.HighsSolution()
1228
+ cv_push = new_col_value if new_col_value is not None else sol.col_value
1229
+ hs.col_value = np.asarray(cv_push, dtype=np.float64).tolist()
1230
+ hs.value_valid = True
1231
+ rd_push = new_row_dual if new_row_dual is not None else getattr(sol, "row_dual", None)
1232
+ if rd_push is not None and np.asarray(rd_push).size > 0:
1233
+ hs.row_dual = np.asarray(rd_push, dtype=np.float64).tolist()
1234
+ hs.dual_valid = True
1235
+ h.setSolution(hs)
1236
+ # Re-attach the captured objective so writers (see
1237
+ # ``process_outputs.read_highs_solution.write_v_obj``) can
1238
+ # bypass the zeroed ``getObjectiveValue()`` cache.
1239
+ try:
1240
+ h._flextool_unscaled_objective = pre_obj # type: ignore[attr-defined]
1241
+ except Exception: # pragma: no cover — handle may forbid setattr
1242
+ pass
1243
+ except Exception: # pragma: no cover — version-specific highspy quirk
1244
+ pass
1245
+
1246
+
1247
+ __all__ = [
1248
+ "Layer2Plan",
1249
+ "apply_layer2",
1250
+ "bucket_coefficients",
1251
+ "choose_scale_powers",
1252
+ "unscale_solution",
1253
+ ]