beqforge 0.2.4__tar.gz → 0.2.5__tar.gz

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 (206) hide show
  1. {beqforge-0.2.4 → beqforge-0.2.5}/.github/workflows/build-designer-image.yml +6 -3
  2. {beqforge-0.2.4 → beqforge-0.2.5}/.github/workflows/build-executable.yml +12 -0
  3. {beqforge-0.2.4 → beqforge-0.2.5}/AGENTS.md +2 -1
  4. {beqforge-0.2.4 → beqforge-0.2.5}/PKG-INFO +8 -4
  5. {beqforge-0.2.4 → beqforge-0.2.5}/README.md +7 -3
  6. {beqforge-0.2.4 → beqforge-0.2.5}/TODO.md +1 -1
  7. {beqforge-0.2.4 → beqforge-0.2.5}/beq_common/__init__.py +1 -1
  8. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/AGENTS.md +1 -1
  9. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/diagnose.py +98 -8
  10. {beqforge-0.2.4 → beqforge-0.2.5}/beqoptimiser/AGENTS.md +5 -1
  11. {beqforge-0.2.4 → beqforge-0.2.5}/beqoptimiser/__init__.py +15 -0
  12. {beqforge-0.2.4 → beqforge-0.2.5}/beqoptimiser/cache.py +182 -4
  13. beqforge-0.2.5/beqoptimiser/parameters.py +500 -0
  14. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser.md +50 -0
  15. {beqforge-0.2.4 → beqforge-0.2.5}/plans/done-design-and-pipeline.md +41 -2
  16. {beqforge-0.2.4 → beqforge-0.2.5}/plans/parameter-space-optimisation.md +52 -1
  17. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_diagnose.py +49 -1
  18. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_designer_server_startup.py +15 -4
  19. beqforge-0.2.5/tests/test_optimiser_parameters.py +200 -0
  20. {beqforge-0.2.4 → beqforge-0.2.5}/tools/designer_server.py +12 -7
  21. beqforge-0.2.5/tools/experiments/parameter_benchmark.py +152 -0
  22. {beqforge-0.2.4 → beqforge-0.2.5}/.dockerignore +0 -0
  23. {beqforge-0.2.4 → beqforge-0.2.5}/.gitattributes +0 -0
  24. {beqforge-0.2.4 → beqforge-0.2.5}/.github/dependabot.yml +0 -0
  25. {beqforge-0.2.4 → beqforge-0.2.5}/.github/workflows/build-device-check.yml +0 -0
  26. {beqforge-0.2.4 → beqforge-0.2.5}/.github/workflows/dependabot-auto-merge.yaml +0 -0
  27. {beqforge-0.2.4 → beqforge-0.2.5}/.github/workflows/tests.yml +0 -0
  28. {beqforge-0.2.4 → beqforge-0.2.5}/.gitignore +0 -0
  29. {beqforge-0.2.4 → beqforge-0.2.5}/.idea/.gitignore +0 -0
  30. {beqforge-0.2.4 → beqforge-0.2.5}/.idea/beqforge.iml +0 -0
  31. {beqforge-0.2.4 → beqforge-0.2.5}/.idea/editor.xml +0 -0
  32. {beqforge-0.2.4 → beqforge-0.2.5}/.idea/jsonSchemas.xml +0 -0
  33. {beqforge-0.2.4 → beqforge-0.2.5}/.idea/misc.xml +0 -0
  34. {beqforge-0.2.4 → beqforge-0.2.5}/.idea/modules.xml +0 -0
  35. {beqforge-0.2.4 → beqforge-0.2.5}/.idea/vcs.xml +0 -0
  36. {beqforge-0.2.4 → beqforge-0.2.5}/CLAUDE.md +0 -0
  37. {beqforge-0.2.4 → beqforge-0.2.5}/LICENCE.md +0 -0
  38. {beqforge-0.2.4 → beqforge-0.2.5}/beq_common/AGENTS.md +0 -0
  39. {beqforge-0.2.4 → beqforge-0.2.5}/beq_common/biquad.py +0 -0
  40. {beqforge-0.2.4 → beqforge-0.2.5}/beq_common/provenance.py +0 -0
  41. {beqforge-0.2.4 → beqforge-0.2.5}/beq_common/publication.py +0 -0
  42. {beqforge-0.2.4 → beqforge-0.2.5}/beq_common/types.py +0 -0
  43. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/__init__.py +0 -0
  44. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/accept.py +0 -0
  45. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/beqd.py +0 -0
  46. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/biquad.py +0 -0
  47. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/cache.py +0 -0
  48. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/charts.py +0 -0
  49. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/cli.py +0 -0
  50. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/design.py +0 -0
  51. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/designer.py +0 -0
  52. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/explain.py +0 -0
  53. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/extraction.py +0 -0
  54. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/filters.py +0 -0
  55. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/harness.py +0 -0
  56. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/identify.py +0 -0
  57. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/material.py +0 -0
  58. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/pipeline.py +0 -0
  59. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/record.py +0 -0
  60. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/reference.py +0 -0
  61. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/rolloff.py +0 -0
  62. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge/verify.py +0 -0
  63. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge-device-check.spec +0 -0
  64. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge.spec +0 -0
  65. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/AGENTS.md +0 -0
  66. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/__init__.py +0 -0
  67. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/analyse.py +0 -0
  68. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/audio.py +0 -0
  69. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/catalogue.py +0 -0
  70. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/cli.py +0 -0
  71. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/coefficients.py +0 -0
  72. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/engines.py +0 -0
  73. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/evidence.py +0 -0
  74. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/manifest.py +0 -0
  75. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/measurement.py +0 -0
  76. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/profiles.py +0 -0
  77. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/qualification.py +0 -0
  78. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/report.py +0 -0
  79. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/transactions.py +0 -0
  80. {beqforge-0.2.4 → beqforge-0.2.5}/beqforge_device_check/verify.py +0 -0
  81. {beqforge-0.2.4 → beqforge-0.2.5}/beqoptimiser/cli.py +0 -0
  82. {beqforge-0.2.4 → beqforge-0.2.5}/beqoptimiser/core.py +0 -0
  83. {beqforge-0.2.4 → beqforge-0.2.5}/beqoptimiser/data/seed-manifest.json +0 -0
  84. {beqforge-0.2.4 → beqforge-0.2.5}/beqoptimiser/data/seed.json.gz +0 -0
  85. {beqforge-0.2.4 → beqforge-0.2.5}/docs/device-check-examples/minidsp-2x4hd-usb-loopback.json +0 -0
  86. {beqforge-0.2.4 → beqforge-0.2.5}/docs/device-check-examples/simulation.json +0 -0
  87. {beqforge-0.2.4 → beqforge-0.2.5}/docs/device-check.md +0 -0
  88. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser-report/README.md +0 -0
  89. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser-report/assets/aggregate-48000.png +0 -0
  90. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser-report/assets/aggregate-96000.png +0 -0
  91. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser-report/assets/example-1.png +0 -0
  92. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser-report/assets/example-2.png +0 -0
  93. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser-report/entry-results.json.gz +0 -0
  94. {beqforge-0.2.4 → beqforge-0.2.5}/docs/optimiser-report/statistics.json +0 -0
  95. {beqforge-0.2.4 → beqforge-0.2.5}/evidence_validation.json +0 -0
  96. {beqforge-0.2.4 → beqforge-0.2.5}/negative_corpus.json +0 -0
  97. {beqforge-0.2.4 → beqforge-0.2.5}/packaging/designer/Dockerfile +0 -0
  98. {beqforge-0.2.4 → beqforge-0.2.5}/packaging/designer/beqforge-designer.cmd +0 -0
  99. {beqforge-0.2.4 → beqforge-0.2.5}/packaging/designer/smoke.py +0 -0
  100. {beqforge-0.2.4 → beqforge-0.2.5}/packaging/device-check/NOTICE.txt +0 -0
  101. {beqforge-0.2.4 → beqforge-0.2.5}/plans/F2-catalogue-filter-optimisation.md +0 -0
  102. {beqforge-0.2.4 → beqforge-0.2.5}/plans/F2-device-precision-validation.md +0 -0
  103. {beqforge-0.2.4 → beqforge-0.2.5}/plans/F2-package-consolidation.md +0 -0
  104. {beqforge-0.2.4 → beqforge-0.2.5}/plans/R2a-server-stage-cache.md +0 -0
  105. {beqforge-0.2.4 → beqforge-0.2.5}/plans/done-baseline-evidence.md +0 -0
  106. {beqforge-0.2.4 → beqforge-0.2.5}/plans/research-design-decisions.md +0 -0
  107. {beqforge-0.2.4 → beqforge-0.2.5}/plans/test-portability-and-runtime.md +0 -0
  108. {beqforge-0.2.4 → beqforge-0.2.5}/plans/texture-yardstick-sub-band.md +0 -0
  109. {beqforge-0.2.4 → beqforge-0.2.5}/plans/texture-yardstick-sub-band.patch +0 -0
  110. {beqforge-0.2.4 → beqforge-0.2.5}/pyproject.toml +0 -0
  111. {beqforge-0.2.4 → beqforge-0.2.5}/tests/__init__.py +0 -0
  112. {beqforge-0.2.4 → beqforge-0.2.5}/tests/conftest.py +0 -0
  113. {beqforge-0.2.4 → beqforge-0.2.5}/tests/fixtures/optimiser_seed_entry.json +0 -0
  114. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_cli.py +0 -0
  115. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_accept.py +0 -0
  116. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_cache.py +0 -0
  117. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_cache_stores.py +0 -0
  118. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_channel_evidence.py +0 -0
  119. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_charts.py +0 -0
  120. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_corpus.py +0 -0
  121. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_design.py +0 -0
  122. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_designer.py +0 -0
  123. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_designer_server.py +0 -0
  124. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_device_rate.py +0 -0
  125. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_evidence.py +0 -0
  126. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_evidence_excess.py +0 -0
  127. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_exclusions.py +0 -0
  128. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_explain.py +0 -0
  129. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_extraction.py +0 -0
  130. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_filters.py +0 -0
  131. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_frozen_cache.py +0 -0
  132. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_guard.py +0 -0
  133. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_harness.py +0 -0
  134. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_headroom.py +0 -0
  135. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_hum.py +0 -0
  136. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_identify.py +0 -0
  137. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_jacobian.py +0 -0
  138. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_parallel.py +0 -0
  139. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_pipeline.py +0 -0
  140. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_playback.py +0 -0
  141. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_playback_headroom.py +0 -0
  142. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_publication.py +0 -0
  143. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_record.py +0 -0
  144. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_record_fingerprint.py +0 -0
  145. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_reference.py +0 -0
  146. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_references.py +0 -0
  147. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_ripple_band.py +0 -0
  148. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_rolloff.py +0 -0
  149. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_strategy_config.py +0 -0
  150. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_timings.py +0 -0
  151. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_design_verify.py +0 -0
  152. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_designer_image.py +0 -0
  153. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_analyse.py +0 -0
  154. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_catalogue.py +0 -0
  155. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_catalogue_predict.py +0 -0
  156. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_cli.py +0 -0
  157. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_engines.py +0 -0
  158. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_evidence.py +0 -0
  159. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_examples.py +0 -0
  160. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_manifest.py +0 -0
  161. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_measurement.py +0 -0
  162. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_qualification.py +0 -0
  163. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_transactions.py +0 -0
  164. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_device_check_verify.py +0 -0
  165. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_extract_layout.py +0 -0
  166. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_optimiser.py +0 -0
  167. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_optimiser_cache.py +0 -0
  168. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_optimiser_report.py +0 -0
  169. {beqforge-0.2.4 → beqforge-0.2.5}/tests/test_package_boundaries.py +0 -0
  170. {beqforge-0.2.4 → beqforge-0.2.5}/tools/__init__.py +0 -0
  171. {beqforge-0.2.4 → beqforge-0.2.5}/tools/build_optimiser_seed.py +0 -0
  172. {beqforge-0.2.4 → beqforge-0.2.5}/tools/design_beq.py +0 -0
  173. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/a1_sweep.py +0 -0
  174. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/compare_records.py +0 -0
  175. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/compare_verdicts.py +0 -0
  176. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/inject_variants.py +0 -0
  177. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p14_compare.py +0 -0
  178. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p14_jacobian.py +0 -0
  179. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p14_run_pipeline.py +0 -0
  180. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p14_surrogate.py +0 -0
  181. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p18_compare_greedy.py +0 -0
  182. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p18_compare_seeded.py +0 -0
  183. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p18_compare_structure.py +0 -0
  184. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p18_greedy.py +0 -0
  185. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p18_seeded.py +0 -0
  186. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p18_structure.py +0 -0
  187. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/p20_smooth_drift.py +0 -0
  188. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/probe.py +0 -0
  189. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/refit_probe.py +0 -0
  190. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/request_timing.py +0 -0
  191. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/score_injected.py +0 -0
  192. {beqforge-0.2.4 → beqforge-0.2.5}/tools/experiments/t8_floor_rules.py +0 -0
  193. {beqforge-0.2.4 → beqforge-0.2.5}/tools/extract.py +0 -0
  194. {beqforge-0.2.4 → beqforge-0.2.5}/tools/ledger_template.html +0 -0
  195. {beqforge-0.2.4 → beqforge-0.2.5}/tools/negative_corpus.py +0 -0
  196. {beqforge-0.2.4 → beqforge-0.2.5}/tools/optimiser_catalogue_report.py +0 -0
  197. {beqforge-0.2.4 → beqforge-0.2.5}/tools/prepare_device_check_helper.py +0 -0
  198. {beqforge-0.2.4 → beqforge-0.2.5}/tools/render_ledger.py +0 -0
  199. {beqforge-0.2.4 → beqforge-0.2.5}/tools/replay.py +0 -0
  200. {beqforge-0.2.4 → beqforge-0.2.5}/tools/seed_optimiser_cache.py +0 -0
  201. {beqforge-0.2.4 → beqforge-0.2.5}/tools/smoke_test_device_check.py +0 -0
  202. {beqforge-0.2.4 → beqforge-0.2.5}/tools/smoke_test_exe.py +0 -0
  203. {beqforge-0.2.4 → beqforge-0.2.5}/tools/smoke_test_install.py +0 -0
  204. {beqforge-0.2.4 → beqforge-0.2.5}/tools/summarise.py +0 -0
  205. {beqforge-0.2.4 → beqforge-0.2.5}/tools/validate_evidence.py +0 -0
  206. {beqforge-0.2.4 → beqforge-0.2.5}/uv.lock +0 -0
@@ -1,15 +1,16 @@
1
1
  name: Designer image
2
2
 
3
3
  # The designer as a container, beside a beqdesigner pipeline service (README, "Running the
4
- # designer in a container"). Every change to what goes into it is built and smoke-tested;
5
- # a vX.Y.Z tag also publishes linux/amd64 and linux/arm64 to GHCR as ghcr.io/<owner>/beqforge-designer.
4
+ # designer in a container"). Every change to what goes into it is built and smoke-tested.
5
+ # A release publishes linux/amd64 and linux/arm64 to GHCR as ghcr.io/<owner>/beqforge-designer:
6
+ # build-executable.yml calls this for a vX.Y.Z tag once the release tests have passed, as it
7
+ # does for the executables and PyPI, so an image never ships ahead of its tests.
6
8
  # A manual run with `publish` publishes the image of an existing release from a later commit,
7
9
  # only if the designer's runtime code is identical to that release's tag (as when the image
8
10
  # recipe arrived after the release).
9
11
  on:
10
12
  push:
11
13
  branches: [main]
12
- tags: ['v*']
13
14
  paths:
14
15
  - 'packaging/designer/**'
15
16
  - '.dockerignore'
@@ -30,6 +31,8 @@ on:
30
31
  description: Publish the image as beq_common.__version__ (its vX.Y.Z tag must exist, with identical designer code)
31
32
  type: boolean
32
33
  default: false
34
+ # the release: build-executable.yml, from a tag, after its test gate
35
+ workflow_call:
33
36
 
34
37
  permissions:
35
38
  contents: read
@@ -234,6 +234,18 @@ jobs:
234
234
  env:
235
235
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
236
236
 
237
+ image:
238
+ name: Publish designer image
239
+ needs: [validated_tests, package]
240
+ # see create_release for why the needed results are checked explicitly
241
+ if: >-
242
+ !cancelled() && startsWith(github.ref, 'refs/tags/') &&
243
+ needs.validated_tests.result == 'success' && needs.package.result == 'success'
244
+ uses: ./.github/workflows/build-designer-image.yml
245
+ permissions:
246
+ contents: read
247
+ packages: write
248
+
237
249
  publish:
238
250
  name: Publish Python package to PyPI
239
251
  needs: [validated_tests, package, build, create_release]
@@ -60,7 +60,8 @@ or fixed ports), with coverage (`--cov`, configured in `pyproject.toml`, `multip
60
60
  children included) uploaded to Codecov by each test job using the `CODECOV_TOKEN` secret;
61
61
  release runs inherit it. Feature branch pushes test on Linux only and a newer push cancels the branch's
62
62
  previous run; `main`, tags and manual runs test Linux, macOS and Windows and are never cancelled. `.github/workflows/build-executable.yml` builds release executables and distributions;
63
- tag pushes publish to PyPI after validation and successful executable builds. Tags must match
63
+ tag pushes publish to PyPI after validation and successful executable builds, and the designer
64
+ image to GHCR after validation. Tags must match
64
65
  the shared version exactly (`vX.Y.Z`). Manual runs build artifacts without publishing.
65
66
  PyPI uses the `pypi` environment and Trusted Publishing; setup details are in the README.
66
67
  Device-check preview executable packaging has its own workflow, not a separate Python release;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beqforge
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Audio-derived BEQ design, published filter optimisation and DSP validation
5
5
  License-File: LICENCE.md
6
6
  Requires-Python: <3.15,>=3.13
@@ -190,8 +190,9 @@ measures cold and warm requests.
190
190
  **Several designs at once.** `--workers N` (1-8, default 1) runs N designs at once, each in
191
191
  its own worker process; further requests queue. `GET /health` reports it as `parallel`, so a
192
192
  caller can send each server as many designs as it takes rather than be configured with that
193
- number. Each design's fit pool gets an equal share of the machine's cores (`BEQ_FIT_WORKERS`,
194
- if set, stays each design's own). The share changes how long a fit takes, never its answer.
193
+ number. Concurrent designs share the machine's logical processors, SMT threads included, each
194
+ at most the width one design uses alone (`BEQ_FIT_WORKERS`, if set, stays each design's own).
195
+ The share changes how long a fit takes, never its answer.
195
196
 
196
197
  Every `serve-designer` configuration option can also be set through an environment variable.
197
198
  Explicit CLI options take precedence; for repeatable options, CLI values replace the entire
@@ -259,8 +260,11 @@ pure `design(request) -> response` adapter, independently testable without a soc
259
260
 
260
261
  `ghcr.io/3ll3d00d/beqforge-designer:<version>` runs `beqforge serve-designer` for a beqdesigner
261
262
  pipeline service beside it (beqdesigner's `docker/compose.example.yaml` runs both). It is
262
- published for linux/amd64 and linux/arm64 by `.github/workflows/build-designer-image.yml` on
263
+ published for linux/amd64 and linux/arm64 by `.github/workflows/build-designer-image.yml` for
263
264
  each `vX.Y.Z` tag, from the same commit as that release; `latest` follows the newest release.
265
+ The release workflow (`build-executable.yml`) calls it only once the release tests have passed,
266
+ as it does for the executables and PyPI; pushes to `main` and pull requests build and
267
+ smoke-test the image without publishing it.
264
268
  A manual run of that workflow with `publish` set publishes the current version's image from a
265
269
  later commit, and refuses unless the designer's code is identical to that version's tag (0.2.0's
266
270
  image was published this way, the recipe having arrived after the release).
@@ -164,8 +164,9 @@ measures cold and warm requests.
164
164
  **Several designs at once.** `--workers N` (1-8, default 1) runs N designs at once, each in
165
165
  its own worker process; further requests queue. `GET /health` reports it as `parallel`, so a
166
166
  caller can send each server as many designs as it takes rather than be configured with that
167
- number. Each design's fit pool gets an equal share of the machine's cores (`BEQ_FIT_WORKERS`,
168
- if set, stays each design's own). The share changes how long a fit takes, never its answer.
167
+ number. Concurrent designs share the machine's logical processors, SMT threads included, each
168
+ at most the width one design uses alone (`BEQ_FIT_WORKERS`, if set, stays each design's own).
169
+ The share changes how long a fit takes, never its answer.
169
170
 
170
171
  Every `serve-designer` configuration option can also be set through an environment variable.
171
172
  Explicit CLI options take precedence; for repeatable options, CLI values replace the entire
@@ -233,8 +234,11 @@ pure `design(request) -> response` adapter, independently testable without a soc
233
234
 
234
235
  `ghcr.io/3ll3d00d/beqforge-designer:<version>` runs `beqforge serve-designer` for a beqdesigner
235
236
  pipeline service beside it (beqdesigner's `docker/compose.example.yaml` runs both). It is
236
- published for linux/amd64 and linux/arm64 by `.github/workflows/build-designer-image.yml` on
237
+ published for linux/amd64 and linux/arm64 by `.github/workflows/build-designer-image.yml` for
237
238
  each `vX.Y.Z` tag, from the same commit as that release; `latest` follows the newest release.
239
+ The release workflow (`build-executable.yml`) calls it only once the release tests have passed,
240
+ as it does for the executables and PyPI; pushes to `main` and pull requests build and
241
+ smoke-test the image without publishing it.
238
242
  A manual run of that workflow with `publish` set publishes the current version's image from a
239
243
  later commit, and refuses unless the designer's code is identical to that version's tag (0.2.0's
240
244
  image was published this way, the recipe having arrived after the release).
@@ -43,7 +43,7 @@ are retained so records and code comments remain traceable. Completed items are
43
43
  | 24 | Chart peak bias | parked | Deliberate catalogue-compatible per-bin maximum; it affects presentation only. Reopen if a reader needs an alternative percentile view; keep it distinct from the 95th-percentile evidence envelope. |
44
44
  | 25 | Documentation citations | open | Remove or repoint orphan references and stale descriptions of shipped behaviour. Low filter impact, but prevents future changes being based on obsolete assumptions. |
45
45
  | 26 | Texture yardstick retune (library review) | parked | The cubic (row 2) keeps the 200 Hz band only because the sub-band cap admitted one synthetic negative, `broadband/5`: chance unevenness in noise bursts, at a 2.6 dB boost. With 45 gated cases a single case moves the gate's upper bound from 0.079 to 0.118. Carrie is still declined (0.85x about a cubic, 1.33x with the cap). Reopen when the beqdesigner library review (~1,290 titles) is complete. Then retune against its declines: the band edge, the trend degree, and whether the gate weights a false acceptance by its boost. The library holds no known negative, so it can expose wrong declines but not measure false acceptances. Any retune still needs the corpus or a fresh E10 set for that, ideally with negatives built from real programme texture (row 5). See [the outcome](plans/texture-yardstick-sub-band.md#outcome-17-october-2026). |
46
- | 27 | Parameter-space optimisation for several profiles | open | beqdesigner's main window will let a filter designer optimise a filter for chosen device profiles before publishing, in freq/Q/gain terms (a BEQ is published and loaded as parameters, so no catalogue schema change), minimising the worst error across all chosen profiles at once. The search belongs in `beqoptimiser`, beside `optimise()`; beqdesigner builds the interface. See [the request](plans/parameter-space-optimisation.md). |
46
+ | 27 | Parameter-space optimisation for several profiles | implemented; release pending | `beqoptimiser.optimise_parameters` built and benchmarked (18 October 2026): within margin on 21/25 and 10/25 of the worst 48/96 kHz float32 entries against coefficient search's 20/25 and 16/25; see [the outcome](plans/parameter-space-optimisation.md#outcome-18-october-2026). Done once released in a version beqdesigner can pin. Originally: beqdesigner's main window will let a filter designer optimise a filter for chosen device profiles before publishing, in freq/Q/gain terms (a BEQ is published and loaded as parameters, so no catalogue schema change), minimising the worst error across all chosen profiles at once. The search belongs in `beqoptimiser`, beside `optimise()`; beqdesigner builds the interface. See [the request](plans/parameter-space-optimisation.md). |
47
47
 
48
48
  ## Review evidence
49
49
 
@@ -1,6 +1,6 @@
1
1
  """Shared filter primitives; no workflow package dependencies."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.5"
4
4
 
5
5
  from .types import BiquadSpec as BiquadSpec
6
6
  from .types import BiquadType as BiquadType
@@ -43,7 +43,7 @@ still holds: no other API, no other service, and the server is a thin transport
43
43
  | `beqforge/explain.py` | The plain-language account a designer response leads with — `found` (reference plateau, which channels carry it and which are silent, down to where there is real bass content, judged band, goal), `correction` (the cascade, then frequency by frequency what was wanted, what the content supports and before → after), `clipping` (the sub-feed peak and how far to turn the sub down, on the request's or the assumed bass management) and `alternatives` (why each other candidate lost). Reads the `Report`; measures and decides nothing. |
44
44
  | `beqforge/designer.py` | The `design(request) -> response` adapter between beqdesigner's `designer-interface.md` v1.1 and `beqforge.pipeline.run` — request/response dataclasses (1.1: every candidate the judge failed goes back in `rejected` with its failures, review only, beside the answer or the decline), wire-format (de)serialisation and the field-by-field mapping onto `Report`/`Candidate`/`Verdict`. Also `validate_response`, an independent re-implementation of beqdesigner's own §3-§5 rules (that repo isn't importable — PyQt6), called by the server before a response ever goes on the wire. Pure and independently testable; no socket. |
45
45
  | `tools/design_beq.py` | **The entry point.** One command, a filter and its reasoning. `--charts DIR` for the pictures; writes a `.run.json.gz` record beside the material unless `--no-record`. |
46
- | `tools/designer_server.py` | `beqforge serve-designer` — the HTTP transport (`designer-interface.md` §7.1) around `beqforge/designer.py`. Server-wide flags for device realisation/strategies/exclusions; everything per-title comes from the request. A `ThreadingHTTPServer`, so `/health` answers during a design, with every design run in a long-lived worker process started with "spawn" (`_DesignWorker`): the fitter forks worker processes (`beqforge.filters.PARALLEL_FITS`) when a fit escalates past one section count, and forking a multi-threaded process risks a deadlock, so the process that forks must stay single-threaded — the worker's log records come back over a `SimpleQueue`, not a `Queue`, for that reason. A worker designs one title at a time; `--workers N` (1-8) runs N of them, each request taking the next idle one, and `/health` reports N as `parallel` so a caller can size its sends from the designer. With N > 1 each worker's fit pool is `filters.FIT_WORKERS // N` (`fit_share`) unless `BEQ_FIT_WORKERS` is set: pool size changes timing, never an answer. A worker that dies answers 500 and is replaced; the others carry on. SIGTERM shuts down like Ctrl-C, stopping the worker's process group, fit pool included. Every response names its build (`beqforge_revision`); `--record-dir DIR` also writes each request's run record, named by a digest of the request audio, so a review-queue entry can be replayed. `--cache-dir DIR` keeps the stage cache there (`cache.DirStore`, one file per entry, atomic): a repeat request for a title skips the analysis — 110 s cold, 50 s warm on a 2-hour 8-channel title. Each request logs a timing line (read, parse, decode, design, respond, queued); `tools/experiments/request_timing.py` reports them. `--shared-root DIR` accepts audio by reference (contract 1.2, beqdesigner's `design/designer-by-reference.md` §3): an array names a WAV relative to DIR plus the SHA-256 of its decoded column, and `beqforge/reference.py` resolves, decodes and checks it; one that cannot be honoured is a 422 naming the array, never a decline. `GET /health` reports `contract_version`, `shared_root` and `parallel`. |
46
+ | `tools/designer_server.py` | `beqforge serve-designer` — the HTTP transport (`designer-interface.md` §7.1) around `beqforge/designer.py`. Server-wide flags for device realisation/strategies/exclusions; everything per-title comes from the request. A `ThreadingHTTPServer`, so `/health` answers during a design, with every design run in a long-lived worker process started with "spawn" (`_DesignWorker`): the fitter forks worker processes (`beqforge.filters.PARALLEL_FITS`) when a fit escalates past one section count, and forking a multi-threaded process risks a deadlock, so the process that forks must stay single-threaded — the worker's log records come back over a `SimpleQueue`, not a `Queue`, for that reason. A worker designs one title at a time; `--workers N` (1-8) runs N of them, each request taking the next idle one, and `/health` reports N as `parallel` so a caller can size its sends from the designer. With N > 1 each worker's fit pool is the logical processors less one, divided by N and capped at `filters.FIT_WORKERS` (`fit_share`), unless `BEQ_FIT_WORKERS` is set: concurrent designs are what can use SMT threads, and pool size changes timing, never an answer. A worker that dies answers 500 and is replaced; the others carry on. SIGTERM shuts down like Ctrl-C, stopping the worker's process group, fit pool included. Every response names its build (`beqforge_revision`); `--record-dir DIR` also writes each request's run record, named by a digest of the request audio, so a review-queue entry can be replayed. `--cache-dir DIR` keeps the stage cache there (`cache.DirStore`, one file per entry, atomic): a repeat request for a title skips the analysis — 110 s cold, 50 s warm on a 2-hour 8-channel title. Each request logs a timing line (read, parse, decode, design, respond, queued); `tools/experiments/request_timing.py` reports them. `--shared-root DIR` accepts audio by reference (contract 1.2, beqdesigner's `design/designer-by-reference.md` §3): an array names a WAV relative to DIR plus the SHA-256 of its decoded column, and `beqforge/reference.py` resolves, decodes and checks it; one that cannot be honoured is a 422 naming the array, never a decline. `GET /health` reports `contract_version`, `shared_root` and `parallel`. |
47
47
  | `tools/replay.py` | Redraw charts and export to beqdesigner from a record — no rerun, no extraction. Refuses on a stale record unless `--force`. |
48
48
  | `tools/extract.py` | ffmpeg → 1 kHz per-channel `.npz`. Requires an explicit supported channel layout; preserves layout provenance. Needs no beqdesigner. |
49
49
  | `tools/summarise.py` | Sanity-check an extraction before using it. |
@@ -8,6 +8,7 @@ unknown source spectrum.
8
8
 
9
9
  import logging
10
10
  import math
11
+ from concurrent.futures import ProcessPoolExecutor
11
12
  from dataclasses import dataclass, field, replace
12
13
  from typing import TYPE_CHECKING
13
14
 
@@ -633,7 +634,7 @@ def diagnose(
633
634
  # and reported the band top as where tracking stopped (A Few Good Men: 150.5-200 Hz found by
634
635
  # one, none by the other).
635
636
  _, mix_plateau = plateau_reference(mix_spectrum, spectrum_freqs, params)
636
- from beqforge.extraction import ExtractionParams, extract
637
+ from beqforge.extraction import ExtractionParams
637
638
 
638
639
  extraction_params = extraction_params or ExtractionParams()
639
640
  extraction_params = replace(
@@ -645,12 +646,18 @@ def diagnose(
645
646
  ),
646
647
  )
647
648
 
649
+ plateaus = {
650
+ name: plateau_reference(
651
+ spectra[name][band], freqs, params, one_sided_fallback=False
652
+ )
653
+ for name in material.channels
654
+ }
655
+ evidence = _channel_evidence(material, params, extraction_params, plateaus, freqs)
656
+
648
657
  channels: dict[str, ChannelDiagnosis] = {}
649
658
  for name in material.channels:
650
659
  response = spectra[name][band]
651
- level, plateau = plateau_reference(
652
- response, freqs, params, one_sided_fallback=False
653
- )
660
+ level, plateau = plateaus[name]
654
661
  response = np.where(
655
662
  unexcluded(freqs, params.exclude_bands_hz), response, np.nan
656
663
  )
@@ -666,10 +673,19 @@ def diagnose(
666
673
  passband_share = (
667
674
  float(share[in_share_band].mean()) if in_share_band.any() else 0.0
668
675
  )
669
- _, channel_spread, _, _, tracking = _temporal_evidence(
670
- material.channels[name], material.fs, params, plateau, freqs
671
- )
672
- envelope = extract(material.channels[name], material.fs, extraction_params)
676
+ if name in evidence:
677
+ (_, channel_spread, _, _, tracking), envelope, records = evidence[name]
678
+ for record in records:
679
+ logging.getLogger(record.name).handle(record)
680
+ else:
681
+ (_, channel_spread, _, _, tracking), envelope = _one_channel(
682
+ material.channels[name],
683
+ material.fs,
684
+ params,
685
+ plateau,
686
+ freqs,
687
+ extraction_params,
688
+ )
673
689
  channels[name] = ChannelDiagnosis(
674
690
  name=name,
675
691
  response_db=response,
@@ -707,6 +723,80 @@ def diagnose(
707
723
  )
708
724
 
709
725
 
726
+ def _one_channel(samples, fs, params, plateau, freqs, extraction_params):
727
+ """A channel's temporal evidence and envelopes: most of a cold analysis's time, and
728
+ independent of every other channel's."""
729
+ from beqforge.extraction import extract
730
+
731
+ return (
732
+ _temporal_evidence(samples, fs, params, plateau, freqs),
733
+ extract(samples, fs, extraction_params),
734
+ )
735
+
736
+
737
+ class _Collect(logging.Handler):
738
+ def __init__(self):
739
+ super().__init__()
740
+ self.records: list[logging.LogRecord] = []
741
+
742
+ def emit(self, record: logging.LogRecord) -> None:
743
+ # made picklable: the message rendered, any traceback as text
744
+ record.msg, record.args = record.getMessage(), None
745
+ if record.exc_info:
746
+ record.exc_text = logging.Formatter().formatException(record.exc_info)
747
+ record.exc_info = None
748
+ self.records.append(record)
749
+
750
+
751
+ def _one_channel_logged(level, *args):
752
+ """`_one_channel` in a pool process, its log records returned rather than emitted, so the
753
+ parent logs them in channel order on every platform: a spawned process has none of the
754
+ parent's handlers, and a forked one would print them out of order."""
755
+ root = logging.getLogger()
756
+ handlers, previous = root.handlers[:], root.level
757
+ collect = _Collect()
758
+ root.handlers[:] = [collect]
759
+ root.setLevel(level)
760
+ try:
761
+ return (*_one_channel(*args), collect.records)
762
+ finally:
763
+ root.handlers[:] = handlers
764
+ root.setLevel(previous)
765
+
766
+
767
+ def _channel_evidence(material, params, extraction_params, plateaus, freqs):
768
+ """`_one_channel` for every channel, with its log records, computed in parallel: a
769
+ process per channel up to the fitter's pool size, when the fitter may run in parallel
770
+ (`filters.PARALLEL_FITS`, `filters.FIT_WORKERS`). Empty otherwise, and `diagnose` works
771
+ through the channels in turn as it always did. Each channel's work is seeded and
772
+ self-contained, so the answer is the serial one; only the time differs."""
773
+ from beqforge import filters
774
+
775
+ names = list(material.channels)
776
+ tasks = [
777
+ (
778
+ material.channels[n],
779
+ material.fs,
780
+ params,
781
+ plateaus[n][1],
782
+ freqs,
783
+ extraction_params,
784
+ )
785
+ for n in names
786
+ ]
787
+ workers = min(len(names), filters.FIT_WORKERS) if filters.PARALLEL_FITS else 1
788
+ if workers < 2:
789
+ return {}
790
+ level = logging.getLogger().getEffectiveLevel()
791
+ with ProcessPoolExecutor(max_workers=workers) as pool:
792
+ results = list(
793
+ pool.map(
794
+ _one_channel_logged, [level] * len(tasks), *zip(*tasks, strict=True)
795
+ )
796
+ )
797
+ return dict(zip(names, results, strict=True))
798
+
799
+
710
800
  def _temporal_evidence(
711
801
  subject: np.ndarray,
712
802
  fs: float,
@@ -41,7 +41,8 @@ strings with additive feedback; internal SOS uses subtractive feedback.
41
41
  | File | Responsibility |
42
42
  | --- | --- |
43
43
  | `core.py` | Precision models, deterministic bounded search and independent validation |
44
- | `cache.py` | Public cached optimisation, immutable numerical results and seed lookup |
44
+ | `parameters.py` | Parameter (freq/Q/gain) search for several (rate, precision) profiles at once, on the publication grid |
45
+ | `cache.py` | Public cached optimisation (coefficients and parameters), immutable numerical results and seed lookup |
45
46
  | `cli.py` | Catalogue preparation, current-entry metadata, variant export and CLI |
46
47
  | `__init__.py` | Public library exports, including the cached `optimise` wrapper |
47
48
  | `data/seed.json.gz`, `data/seed-manifest.json` | Bundled numerical results and provenance |
@@ -53,6 +54,9 @@ dependency versions and execution architecture.
53
54
  Custom precision implementations without a complete explicit `cache_identity()` bypass caching.
54
55
  Corrupt entries are misses; unwritable storage must not prevent calculation or seed reuse.
55
56
  Use atomic writes and retain checksums, request identity and publication-policy validation.
57
+ Parameter results are keyed by that identity plus `parameters.py`'s own hash and
58
+ `PARAMETER_VERSION`, kept out of `implementation_identity()` so they never invalidate the seed;
59
+ put parameter-search code in `parameters.py`, never `core.py`, for the same reason.
56
60
 
57
61
  Bundled seeds are reusable only in their recorded numerical environment. Changes to numerical
58
62
  code or settings invalidate them; do not relabel old results as compatible. Bump
@@ -8,6 +8,9 @@ from .cache import (
8
8
  from .cache import (
9
9
  optimise as optimise,
10
10
  )
11
+ from .cache import (
12
+ optimise_parameters as optimise_parameters,
13
+ )
11
14
  from .core import (
12
15
  FixedPoint as FixedPoint,
13
16
  )
@@ -32,3 +35,15 @@ from .core import (
32
35
  from .core import (
33
36
  stable as stable,
34
37
  )
38
+ from .parameters import (
39
+ Fixed as Fixed,
40
+ )
41
+ from .parameters import (
42
+ ParameterResult as ParameterResult,
43
+ )
44
+ from .parameters import (
45
+ ParameterSettings as ParameterSettings,
46
+ )
47
+ from .parameters import (
48
+ ProfileError as ProfileError,
49
+ )
@@ -17,16 +17,25 @@ from pathlib import Path
17
17
  import numpy as np
18
18
  import scipy
19
19
 
20
- from . import core
20
+ from . import core, parameters
21
21
  from .core import (
22
22
  NUMERICAL_VERSION,
23
+ PUBLISHED_OUTCOMES,
23
24
  FixedPoint,
24
25
  Float32,
25
26
  Precision,
26
27
  Result,
28
+ Section,
27
29
  Settings,
28
30
  stable,
29
31
  )
32
+ from .parameters import (
33
+ PARAMETER_VERSION,
34
+ Fixed,
35
+ ParameterResult,
36
+ ParameterSettings,
37
+ ProfileError,
38
+ )
30
39
 
31
40
  LOGGER = logging.getLogger(__name__)
32
41
  CACHE_SCHEMA = 1
@@ -263,10 +272,13 @@ class ResultCache:
263
272
  self.directory = Path(directory)
264
273
  self.use_seed = use_seed
265
274
 
266
- def get(self, request) -> Result | None:
275
+ def get(self, request, restore=None):
276
+ """The cached result for a request, or None; `restore` checks an envelope against
277
+ its request (`_restore` for coefficient results, the default)."""
278
+ restore = restore or _restore
267
279
  key = request[0]
268
280
  try:
269
- result = _restore(
281
+ result = restore(
270
282
  json.loads((self.directory / f"{key}.json").read_text()), request
271
283
  )
272
284
  LOGGER.debug(f"Optimiser disk cache hit: {key}")
@@ -277,7 +289,7 @@ class ResultCache:
277
289
  try:
278
290
  envelope = _bundled_entries().get(key)
279
291
  if envelope is not None:
280
- result = _restore(envelope, request)
292
+ result = restore(envelope, request)
281
293
  LOGGER.debug(f"Optimiser bundled cache hit: {key}")
282
294
  return result
283
295
  except (ValueError, KeyError, TypeError, OverflowError):
@@ -373,3 +385,169 @@ def optimise(
373
385
  _restore(envelope, request)
374
386
  store._write(envelope)
375
387
  return result
388
+
389
+
390
+ PARAMETER_OUTCOMES = (
391
+ "within_margin",
392
+ "replacement",
393
+ "improvement",
394
+ "no_replacement",
395
+ "unresolved",
396
+ )
397
+
398
+
399
+ def _section_identity(section) -> dict:
400
+ return {"kind": type(section).__name__, **asdict(section)}
401
+
402
+
403
+ def _parameter_request(sections, *, profiles, settings=None):
404
+ """(key, sections, profiles, settings), or None when a request cannot be cached: an
405
+ invalid one (left for `optimise_parameters` to refuse) or a precision without a complete
406
+ identity. Keyed by the coefficient optimiser's implementation identity plus this module's
407
+ own source and `PARAMETER_VERSION`, so the coefficient seed is unaffected."""
408
+ cfg = settings or ParameterSettings()
409
+ cascade = tuple(sections)
410
+ chosen = list(profiles)
411
+ if (
412
+ not cascade
413
+ or not chosen
414
+ or not all(isinstance(s, (Section, Fixed)) for s in cascade)
415
+ ):
416
+ return None
417
+ identities = []
418
+ for rate, precision in chosen:
419
+ identity = _precision_identity(precision)
420
+ if identity is None or rate not in parameters.RATES:
421
+ return None
422
+ identities.append({"rate": rate, "name": precision.name, "identity": identity})
423
+ try:
424
+ implementation = implementation_identity() | {
425
+ "parameters": hashlib.sha256(
426
+ files("beqoptimiser").joinpath("parameters.py").read_bytes()
427
+ ).hexdigest(),
428
+ "parameter_version": PARAMETER_VERSION,
429
+ }
430
+ payload = {
431
+ "sections": [_section_identity(s) for s in cascade],
432
+ "profiles": identities,
433
+ "settings": asdict(cfg),
434
+ }
435
+ key = _digest({"implementation": implementation, "request": payload})
436
+ except (OSError, TypeError, ValueError):
437
+ return None
438
+ return key, cascade, chosen, cfg
439
+
440
+
441
+ def _parameter_envelope(key: str, result: ParameterResult) -> dict:
442
+ body = _encode(
443
+ asdict(result)
444
+ | {
445
+ "sections": None
446
+ if result.sections is None
447
+ else [_section_identity(s) for s in result.sections]
448
+ }
449
+ )
450
+ return {
451
+ "schema": CACHE_SCHEMA,
452
+ "kind": "parameters",
453
+ "key": key,
454
+ "result": body,
455
+ "checksum": _digest(body),
456
+ }
457
+
458
+
459
+ def _decode_section(value: dict):
460
+ kind = value.pop("kind")
461
+ if kind == "Section":
462
+ return Section(**value)
463
+ if kind == "Fixed":
464
+ return Fixed(tuple((int(r), tuple(row)) for r, row in value["coefficients"]))
465
+ raise ValueError(f"unknown section kind {kind!r}")
466
+
467
+
468
+ def _restore_parameters(envelope: dict, request) -> ParameterResult:
469
+ key, cascade, chosen, cfg = request
470
+ if (
471
+ envelope["schema"] != CACHE_SCHEMA
472
+ or envelope.get("kind") != "parameters"
473
+ or envelope["key"] != key
474
+ or envelope["checksum"] != _digest(envelope["result"])
475
+ ):
476
+ raise ValueError("invalid cache envelope")
477
+ body = _decode(envelope["result"])
478
+ body["band_hz"] = tuple(body["band_hz"])
479
+ body["profiles"] = tuple(ProfileError(**p) for p in body["profiles"])
480
+ if body["sections"] is not None:
481
+ body["sections"] = tuple(_decode_section(dict(s)) for s in body["sections"])
482
+ result = ParameterResult(**body)
483
+ expected = [(rate, p.name) for rate, p in chosen]
484
+ if (
485
+ result.version != PARAMETER_VERSION
486
+ or result.outcome not in PARAMETER_OUTCOMES
487
+ or result.margin_db != cfg.margin_db
488
+ or result.band_hz != cfg.band_hz
489
+ or [(p.rate, p.precision) for p in result.profiles]
490
+ != list(dict.fromkeys(expected))
491
+ or np.isnan(result.original_error_db)
492
+ or result.original_error_db < 0
493
+ or result.evaluations < 0
494
+ ):
495
+ raise ValueError("cache result does not match request")
496
+ if result.outcome in PUBLISHED_OUTCOMES:
497
+ if (
498
+ result.sections is None
499
+ or len(result.sections) != len(cascade)
500
+ or result.candidate_error_db is None
501
+ or not np.isfinite(result.candidate_error_db)
502
+ or result.original_error_db <= cfg.margin_db
503
+ ):
504
+ raise ValueError("cached replacement violates publication policy")
505
+ for old, new in zip(cascade, result.sections, strict=True):
506
+ if type(old) is not type(new) or (isinstance(old, Fixed) and old != new):
507
+ raise ValueError("cached replacement changes the cascade's structure")
508
+ if isinstance(new, Section) and (
509
+ new.type != old.type
510
+ or round(new.freq, 2) != new.freq
511
+ or round(new.q, 4) != new.q
512
+ or round(new.gain, 2) != new.gain
513
+ ):
514
+ raise ValueError("cached replacement is off the publication grid")
515
+ if result.outcome == "replacement" and (
516
+ result.candidate_error_db > cfg.margin_db
517
+ ):
518
+ raise ValueError("cached replacement violates publication policy")
519
+ if (
520
+ result.outcome == "improvement"
521
+ and result.candidate_error_db
522
+ >= result.original_error_db - cfg.numerical_tolerance_db
523
+ ):
524
+ raise ValueError("cached improvement violates publication policy")
525
+ elif result.sections is not None:
526
+ raise ValueError("unexpected cached replacement")
527
+ return result
528
+
529
+
530
+ def optimise_parameters(
531
+ sections,
532
+ *,
533
+ profiles,
534
+ settings: ParameterSettings | None = None,
535
+ cache: ResultCache | bool | None = None,
536
+ ) -> ParameterResult:
537
+ """`parameters.optimise_parameters` with automatic cache reuse; use cache=False for a
538
+ fresh calculation."""
539
+ kwargs = {"profiles": profiles, "settings": settings}
540
+ if cache is False or os.environ.get("BEQOPTIMISER_CACHE") == "0":
541
+ return parameters.optimise_parameters(sections, **kwargs)
542
+ request = _parameter_request(sections, **kwargs)
543
+ if request is None:
544
+ return parameters.optimise_parameters(sections, **kwargs)
545
+ store = cache if isinstance(cache, ResultCache) else ResultCache()
546
+ result = store.get(request, _restore_parameters)
547
+ if result is not None:
548
+ return result
549
+ result = parameters.optimise_parameters(sections, **kwargs)
550
+ envelope = _parameter_envelope(request[0], result)
551
+ _restore_parameters(envelope, request)
552
+ store._write(envelope)
553
+ return result