beqforge 0.3.2__tar.gz → 0.3.3__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 (235) hide show
  1. {beqforge-0.3.2 → beqforge-0.3.3}/PKG-INFO +1 -1
  2. {beqforge-0.3.2 → beqforge-0.3.3}/TODO.md +1 -1
  3. {beqforge-0.3.2 → beqforge-0.3.3}/beq_common/__init__.py +1 -1
  4. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/impact.py +114 -27
  5. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/impact.toml +23 -4
  6. {beqforge-0.3.2 → beqforge-0.3.3}/plans/bass-ceiling.md +6 -1
  7. {beqforge-0.3.2 → beqforge-0.3.3}/plans/redesign-scope.md +51 -0
  8. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_impact.py +93 -1
  9. {beqforge-0.3.2 → beqforge-0.3.3}/.dockerignore +0 -0
  10. {beqforge-0.3.2 → beqforge-0.3.3}/.gitattributes +0 -0
  11. {beqforge-0.3.2 → beqforge-0.3.3}/.github/dependabot.yml +0 -0
  12. {beqforge-0.3.2 → beqforge-0.3.3}/.github/workflows/build-designer-image.yml +0 -0
  13. {beqforge-0.3.2 → beqforge-0.3.3}/.github/workflows/build-device-check.yml +0 -0
  14. {beqforge-0.3.2 → beqforge-0.3.3}/.github/workflows/build-executable.yml +0 -0
  15. {beqforge-0.3.2 → beqforge-0.3.3}/.github/workflows/dependabot-auto-merge.yaml +0 -0
  16. {beqforge-0.3.2 → beqforge-0.3.3}/.github/workflows/tests.yml +0 -0
  17. {beqforge-0.3.2 → beqforge-0.3.3}/.gitignore +0 -0
  18. {beqforge-0.3.2 → beqforge-0.3.3}/.idea/.gitignore +0 -0
  19. {beqforge-0.3.2 → beqforge-0.3.3}/.idea/beqforge.iml +0 -0
  20. {beqforge-0.3.2 → beqforge-0.3.3}/.idea/editor.xml +0 -0
  21. {beqforge-0.3.2 → beqforge-0.3.3}/.idea/jsonSchemas.xml +0 -0
  22. {beqforge-0.3.2 → beqforge-0.3.3}/.idea/misc.xml +0 -0
  23. {beqforge-0.3.2 → beqforge-0.3.3}/.idea/modules.xml +0 -0
  24. {beqforge-0.3.2 → beqforge-0.3.3}/.idea/vcs.xml +0 -0
  25. {beqforge-0.3.2 → beqforge-0.3.3}/AGENTS.md +0 -0
  26. {beqforge-0.3.2 → beqforge-0.3.3}/CLAUDE.md +0 -0
  27. {beqforge-0.3.2 → beqforge-0.3.3}/LICENCE.md +0 -0
  28. {beqforge-0.3.2 → beqforge-0.3.3}/README.md +0 -0
  29. {beqforge-0.3.2 → beqforge-0.3.3}/beq_common/AGENTS.md +0 -0
  30. {beqforge-0.3.2 → beqforge-0.3.3}/beq_common/biquad.py +0 -0
  31. {beqforge-0.3.2 → beqforge-0.3.3}/beq_common/provenance.py +0 -0
  32. {beqforge-0.3.2 → beqforge-0.3.3}/beq_common/publication.py +0 -0
  33. {beqforge-0.3.2 → beqforge-0.3.3}/beq_common/types.py +0 -0
  34. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/AGENTS.md +0 -0
  35. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/__init__.py +0 -0
  36. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/accept.py +0 -0
  37. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/adjustments.py +0 -0
  38. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/beqd.py +0 -0
  39. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/biquad.py +0 -0
  40. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/cache.py +0 -0
  41. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/charts.py +0 -0
  42. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/cli.py +0 -0
  43. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/dc_offset.py +0 -0
  44. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/design.py +0 -0
  45. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/designer.py +0 -0
  46. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/diagnose.py +0 -0
  47. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/e10.py +0 -0
  48. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/explain.py +0 -0
  49. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/extraction.py +0 -0
  50. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/filters.py +0 -0
  51. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/fit_kernel.py +0 -0
  52. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/harness.py +0 -0
  53. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/identify.py +0 -0
  54. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/material.py +0 -0
  55. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/pipeline.py +0 -0
  56. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/record.py +0 -0
  57. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/reference.py +0 -0
  58. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/rolloff.py +0 -0
  59. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge/verify.py +0 -0
  60. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge-device-check.spec +0 -0
  61. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge.spec +0 -0
  62. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/AGENTS.md +0 -0
  63. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/__init__.py +0 -0
  64. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/analyse.py +0 -0
  65. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/audio.py +0 -0
  66. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/catalogue.py +0 -0
  67. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/cli.py +0 -0
  68. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/coefficients.py +0 -0
  69. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/engines.py +0 -0
  70. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/evidence.py +0 -0
  71. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/manifest.py +0 -0
  72. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/measurement.py +0 -0
  73. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/profiles.py +0 -0
  74. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/qualification.py +0 -0
  75. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/report.py +0 -0
  76. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/transactions.py +0 -0
  77. {beqforge-0.3.2 → beqforge-0.3.3}/beqforge_device_check/verify.py +0 -0
  78. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/AGENTS.md +0 -0
  79. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/__init__.py +0 -0
  80. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/cache.py +0 -0
  81. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/cli.py +0 -0
  82. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/core.py +0 -0
  83. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/data/seed-manifest.json +0 -0
  84. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/data/seed.json.gz +0 -0
  85. {beqforge-0.3.2 → beqforge-0.3.3}/beqoptimiser/parameters.py +0 -0
  86. {beqforge-0.3.2 → beqforge-0.3.3}/docs/device-check-examples/minidsp-2x4hd-usb-loopback.json +0 -0
  87. {beqforge-0.3.2 → beqforge-0.3.3}/docs/device-check-examples/simulation.json +0 -0
  88. {beqforge-0.3.2 → beqforge-0.3.3}/docs/device-check.md +0 -0
  89. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser-report/README.md +0 -0
  90. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser-report/assets/aggregate-48000.png +0 -0
  91. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser-report/assets/aggregate-96000.png +0 -0
  92. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser-report/assets/example-1.png +0 -0
  93. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser-report/assets/example-2.png +0 -0
  94. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser-report/entry-results.json.gz +0 -0
  95. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser-report/statistics.json +0 -0
  96. {beqforge-0.3.2 → beqforge-0.3.3}/docs/optimiser.md +0 -0
  97. {beqforge-0.3.2 → beqforge-0.3.3}/evidence_validation.json +0 -0
  98. {beqforge-0.3.2 → beqforge-0.3.3}/negative_corpus.json +0 -0
  99. {beqforge-0.3.2 → beqforge-0.3.3}/packaging/designer/Dockerfile +0 -0
  100. {beqforge-0.3.2 → beqforge-0.3.3}/packaging/designer/beqforge-designer.cmd +0 -0
  101. {beqforge-0.3.2 → beqforge-0.3.3}/packaging/designer/smoke.py +0 -0
  102. {beqforge-0.3.2 → beqforge-0.3.3}/packaging/device-check/NOTICE.txt +0 -0
  103. {beqforge-0.3.2 → beqforge-0.3.3}/plans/E10-validation-protocol.md +0 -0
  104. {beqforge-0.3.2 → beqforge-0.3.3}/plans/F2-catalogue-filter-optimisation.md +0 -0
  105. {beqforge-0.3.2 → beqforge-0.3.3}/plans/F2-device-precision-validation.md +0 -0
  106. {beqforge-0.3.2 → beqforge-0.3.3}/plans/F2-package-consolidation.md +0 -0
  107. {beqforge-0.3.2 → beqforge-0.3.3}/plans/R2a-server-stage-cache.md +0 -0
  108. {beqforge-0.3.2 → beqforge-0.3.3}/plans/contract-1.5-adjustments.md +0 -0
  109. {beqforge-0.3.2 → beqforge-0.3.3}/plans/default-record-and-log-locations.md +0 -0
  110. {beqforge-0.3.2 → beqforge-0.3.3}/plans/design-version.md +0 -0
  111. {beqforge-0.3.2 → beqforge-0.3.3}/plans/done-baseline-evidence.md +0 -0
  112. {beqforge-0.3.2 → beqforge-0.3.3}/plans/done-design-and-pipeline.md +0 -0
  113. {beqforge-0.3.2 → beqforge-0.3.3}/plans/e10-titles-v1.json +0 -0
  114. {beqforge-0.3.2 → beqforge-0.3.3}/plans/fit-kernel.md +0 -0
  115. {beqforge-0.3.2 → beqforge-0.3.3}/plans/fit-weighting.md +0 -0
  116. {beqforge-0.3.2 → beqforge-0.3.3}/plans/library-review-improvements.md +0 -0
  117. {beqforge-0.3.2 → beqforge-0.3.3}/plans/library-v5-review.md +0 -0
  118. {beqforge-0.3.2 → beqforge-0.3.3}/plans/parameter-space-optimisation.md +0 -0
  119. {beqforge-0.3.2 → beqforge-0.3.3}/plans/playback-contract.md +0 -0
  120. {beqforge-0.3.2 → beqforge-0.3.3}/plans/research-design-decisions.md +0 -0
  121. {beqforge-0.3.2 → beqforge-0.3.3}/plans/test-portability-and-runtime.md +0 -0
  122. {beqforge-0.3.2 → beqforge-0.3.3}/plans/texture-yardstick-sub-band.md +0 -0
  123. {beqforge-0.3.2 → beqforge-0.3.3}/plans/texture-yardstick-sub-band.patch +0 -0
  124. {beqforge-0.3.2 → beqforge-0.3.3}/pyproject.toml +0 -0
  125. {beqforge-0.3.2 → beqforge-0.3.3}/tests/__init__.py +0 -0
  126. {beqforge-0.3.2 → beqforge-0.3.3}/tests/conftest.py +0 -0
  127. {beqforge-0.3.2 → beqforge-0.3.3}/tests/fixtures/design_golden.json +0 -0
  128. {beqforge-0.3.2 → beqforge-0.3.3}/tests/fixtures/optimiser_seed_entry.json +0 -0
  129. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_cli.py +0 -0
  130. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_accept.py +0 -0
  131. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_adjustments.py +0 -0
  132. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_cache.py +0 -0
  133. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_cache_stores.py +0 -0
  134. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_channel_evidence.py +0 -0
  135. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_charts.py +0 -0
  136. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_corpus.py +0 -0
  137. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_dc_offset.py +0 -0
  138. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_design.py +0 -0
  139. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_designer.py +0 -0
  140. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_designer_server.py +0 -0
  141. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_device_rate.py +0 -0
  142. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_diagnose.py +0 -0
  143. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_evidence.py +0 -0
  144. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_evidence_excess.py +0 -0
  145. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_exclusions.py +0 -0
  146. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_explain.py +0 -0
  147. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_extraction.py +0 -0
  148. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_filters.py +0 -0
  149. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_frozen_cache.py +0 -0
  150. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_golden.py +0 -0
  151. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_guard.py +0 -0
  152. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_harness.py +0 -0
  153. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_headroom.py +0 -0
  154. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_hum.py +0 -0
  155. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_identify.py +0 -0
  156. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_jacobian.py +0 -0
  157. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_parallel.py +0 -0
  158. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_pipeline.py +0 -0
  159. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_playback.py +0 -0
  160. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_playback_headroom.py +0 -0
  161. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_publication.py +0 -0
  162. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_record.py +0 -0
  163. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_record_fingerprint.py +0 -0
  164. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_reference.py +0 -0
  165. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_references.py +0 -0
  166. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_ripple_band.py +0 -0
  167. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_rolloff.py +0 -0
  168. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_strategy_config.py +0 -0
  169. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_target_fit.py +0 -0
  170. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_timings.py +0 -0
  171. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_design_verify.py +0 -0
  172. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_designer_image.py +0 -0
  173. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_designer_server_startup.py +0 -0
  174. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_analyse.py +0 -0
  175. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_catalogue.py +0 -0
  176. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_catalogue_predict.py +0 -0
  177. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_cli.py +0 -0
  178. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_engines.py +0 -0
  179. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_evidence.py +0 -0
  180. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_examples.py +0 -0
  181. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_manifest.py +0 -0
  182. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_measurement.py +0 -0
  183. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_qualification.py +0 -0
  184. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_transactions.py +0 -0
  185. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_device_check_verify.py +0 -0
  186. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_e10.py +0 -0
  187. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_extract_layout.py +0 -0
  188. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_fit_kernel.py +0 -0
  189. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_optimiser.py +0 -0
  190. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_optimiser_cache.py +0 -0
  191. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_optimiser_parameters.py +0 -0
  192. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_optimiser_report.py +0 -0
  193. {beqforge-0.3.2 → beqforge-0.3.3}/tests/test_package_boundaries.py +0 -0
  194. {beqforge-0.3.2 → beqforge-0.3.3}/tools/__init__.py +0 -0
  195. {beqforge-0.3.2 → beqforge-0.3.3}/tools/build_optimiser_seed.py +0 -0
  196. {beqforge-0.3.2 → beqforge-0.3.3}/tools/design_beq.py +0 -0
  197. {beqforge-0.3.2 → beqforge-0.3.3}/tools/designer_server.py +0 -0
  198. {beqforge-0.3.2 → beqforge-0.3.3}/tools/e10_cases.py +0 -0
  199. {beqforge-0.3.2 → beqforge-0.3.3}/tools/e10_run.py +0 -0
  200. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/a1_sweep.py +0 -0
  201. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/compare_records.py +0 -0
  202. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/compare_verdicts.py +0 -0
  203. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/inject_variants.py +0 -0
  204. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p14_compare.py +0 -0
  205. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p14_jacobian.py +0 -0
  206. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p14_run_pipeline.py +0 -0
  207. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p14_surrogate.py +0 -0
  208. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p18_compare_greedy.py +0 -0
  209. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p18_compare_seeded.py +0 -0
  210. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p18_compare_structure.py +0 -0
  211. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p18_greedy.py +0 -0
  212. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p18_seeded.py +0 -0
  213. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p18_structure.py +0 -0
  214. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/p20_smooth_drift.py +0 -0
  215. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/parameter_benchmark.py +0 -0
  216. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/probe.py +0 -0
  217. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/refit_probe.py +0 -0
  218. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/request_timing.py +0 -0
  219. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/score_injected.py +0 -0
  220. {beqforge-0.3.2 → beqforge-0.3.3}/tools/experiments/t8_floor_rules.py +0 -0
  221. {beqforge-0.3.2 → beqforge-0.3.3}/tools/extract.py +0 -0
  222. {beqforge-0.3.2 → beqforge-0.3.3}/tools/impact.py +0 -0
  223. {beqforge-0.3.2 → beqforge-0.3.3}/tools/ledger_template.html +0 -0
  224. {beqforge-0.3.2 → beqforge-0.3.3}/tools/negative_corpus.py +0 -0
  225. {beqforge-0.3.2 → beqforge-0.3.3}/tools/optimiser_catalogue_report.py +0 -0
  226. {beqforge-0.3.2 → beqforge-0.3.3}/tools/prepare_device_check_helper.py +0 -0
  227. {beqforge-0.3.2 → beqforge-0.3.3}/tools/render_ledger.py +0 -0
  228. {beqforge-0.3.2 → beqforge-0.3.3}/tools/replay.py +0 -0
  229. {beqforge-0.3.2 → beqforge-0.3.3}/tools/seed_optimiser_cache.py +0 -0
  230. {beqforge-0.3.2 → beqforge-0.3.3}/tools/smoke_test_device_check.py +0 -0
  231. {beqforge-0.3.2 → beqforge-0.3.3}/tools/smoke_test_exe.py +0 -0
  232. {beqforge-0.3.2 → beqforge-0.3.3}/tools/smoke_test_install.py +0 -0
  233. {beqforge-0.3.2 → beqforge-0.3.3}/tools/summarise.py +0 -0
  234. {beqforge-0.3.2 → beqforge-0.3.3}/tools/validate_evidence.py +0 -0
  235. {beqforge-0.3.2 → beqforge-0.3.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beqforge
3
- Version: 0.3.2
3
+ Version: 0.3.3
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
@@ -56,7 +56,7 @@ ID alone (AGENTS.md says where each lives), and the stale contract versions are
56
56
  | 31 | Review the version 5 library redesign | open: handoff received 23 October 2026; group 1 released in 0.2.10 (design version 6) | Outcome and evidence: [the v5 review](plans/library-v5-review.md). beqdesigner's v5 rerun (job `ef4265fa`, 1,083 titles, about 1,000 designed and 69 declined; 18 titles v4 designed now decline, about half wrongly) is triaged into four groups in `/media/beq/triage/handoff-v5.md` (queue ids, v5 records and earlier runs per title). 1 (built): O2 refused a plateau starting just under 10 Hz on the knee of a real rolloff (A Quiet Place: Day One, Elysium, Pan); it now keeps such a plateau when it reaches 20 Hz and the shortfall beneath exceeds the passband's ripple, and all three design. Probe, protocol and corpus unchanged. 2: a whole candidate rejected for missing one check by a fraction: cliff by under 3 dB/oct (Heartburn, Back to the Future, Guardians of the Galaxy, The Commitments), tilt (Guardians, The Exorcist, Alien Resurrection, Philadelphia), level (Ray), F9 residual just below the content edge (Drive, Amy). The library has no negatives, so a tolerance it suggests still goes through the protocol and E10, never the catalogue. 3: every candidate corrected only down to above the content edge (Return to Seoul, Spirited Away, The Jungle Book, Bugonia). 4: known, rows 5/6 (steep filter; `judge_from_content_edge` is a server flag, not on the wire), F5 (Eastern Promises, Fame, Home Alone) and row 26. Earlier scope of this row: 0.2.9 reports design version 5, so beqdesigner's out-of-date filter flags every title designed before it. Review the redesign's declines and changed designs from their run records, as the first review was (row 29): confirm the fixes' titles moved as measured, rerun the open cases (row 29's tilt titles, the near-texture watch cases Black Bag, Bugonia and Incredible Hulk), and collect real-title evidence for rows 26 and 29's research. It can show wrong declines but not false acceptances (the library has no known negatives), so a retune it suggests still goes through E10. Do row 28 first if both machines will design. Records are kept by default from 0.2.10 (row 27, done). |
57
57
  | 32 | Fit optimum selection | part built (design version 7, not released); strategy choice still unstable | Found by row 28: reproducible is not stable. Changing the fit's input by at most 0.005 dB changed 24 winners on 54 titles and variants; Black Bag LR4 35's recovery fell from a 13.1 dB to a 3.4 dB peak. **Built (25 October 2026):** the fitter returns every fit within its 0.5 dB residual bar of its answer, and the pipeline keeps the first the judge passes, fewest sections first; an alternative must lift by more than the goal tolerance to replace the answer. Perturbing the fit input by 0.004 dB now flips 14 winners where HEAD flipped 19, 33 verdicts against 36, and no title swaps between designed and declined (HEAD 1). Black Bag LR4 35 goes to a 0.4 dB shortfall and holds; 13 of row 35's 14 new declines design again and none of its 17 gains is lost. Protocol unchanged, corpus gate passes. **Left:** most remaining flips are which strategy wins (flatten against a counterfactual on the steep variants, up to 38 dB apart below the content edge). The choice among strategies is untouched, and that is the next research step. See [the outcome](plans/library-review-improvements.md#row-32-choosing-among-near-equal-fits-25-october-2026-design_version-7) and [the finding](plans/library-review-improvements.md#built-a-reproducible-fit-input-22-october-2026-design_version-6). |
58
58
  | 33 | Noise lift beyond the evidence (found on a pumping floor) | open: the evidence bound was measured and not adopted; next, validate the floor detection | E10's first runs lifted `nonstationary_floor` 27-33 dB, but that recipe swung about 37 dB inside every event: event-locked content, matching real stopband leakage on contrast and coherence, so not a failure under the content principle ([v5](plans/E10-validation-protocol.md#the-pumping-floor-v5-28-october-2026) reports it as `event_locked_floor` and counts realistic 2-6 dB `tracking_floor` and `breathing_floor`). On seeds 1-12 under [v6](plans/E10-validation-protocol.md#the-skirt-and-the-cases-it-leaves-v6-28-october-2026) (noise lift counted beyond half an octave under the truth band), 4 of 72 realistic-floor rows lift noise. The two large ones are the judge passing the candidate that fits worst: on `breathing_floor/2` four three-section fits within 1.1-2.1 dB of target fail the cliff check, and a single shelf 6.4 dB off target and 5.5 dB over the evidence ceiling passes and is selected; on `breathing_floor/5` (gentle) a single section 9.0 dB over the ceiling at an unsupported bin is selected over a passing three-section fit 1.9 dB over. Nothing refuses a design for exceeding its evidence: the over-lift bar is measured from the goal, and `evidence_excess` is reported only. **Bounding lift by the evidence, not adopted (29 October 2026):** the fitter bound at the licence plus 1.5 dB (A), a judge check on any excess over 1.5 dB (B), and that check limited to excesses at least half an octave wide (C) were each built on branch `experiment-evidence-bound`, uncommitted. C cleared E10's counted noise lifts, but applied to the v8 library's stored `evidence_excess` it refuses the accepted design for 96 of 1,033 titles, and 14 of those would decline. Inspection showed that most of these are a smooth fit against a ragged licence on content that tracks the programme (Any Given Sunday, To Die For). The large lifts it catches below a diagnosed floor (Wait Until Dark, White Heat) sit inside a licence that allows 20-25 dB there, and C catches them only by an edge excess. **Lift below the diagnosed floor:** 247 of the 304 designed titles with measured bins under `noise_floor_hz` lift more than 3 dB there, and 151 more than 10 dB. Almost all are flatten, holding its lift flat, among them Moana (37 dB under 8.4 Hz) and White Heat (29 dB under 40 Hz). These are not counts of noise lift. `noise_floor_hz` is the mix tracking boundary, the first band whose envelope fails to track the passband, and failing that is not proof of noise. The contrast licence often disagrees with it (Wait Until Dark: 20 dB licensed below it). **On E10's truth** ([evidence](plans/E10-validation-protocol.md#is-the-tracking-boundary-a-noise-detector-29-october-2026)), the boundary sits 0.24-0.42 octave (median) below the true noise boundary on every floor family, up to 1.12 octave, and never above it: it calls the noise just above it content. It also misses half of `finite_stopband`'s exposed cases, and calls LFE-only content noise at 15-27 Hz on 3 of 12 `partial_channels`. **On the library**, an independent test calibrated on E10 (does the band's level scale with programme loudness, and is it steady) finds content under the boundary on 151 of 300 floored titles, among them Moana, Creed III and Juror #2, the largest lifts below it. Only 10 look like steady noise. The other 139, mostly older, neither scale nor stay steady, which these statistics cannot resolve. So the boundary is not fit to gate a rule, and its content-as-noise error also holds restorations flat that the content supports. Measured within 240 s stretches, only 27 of those 139 become steady noise; 85 stay bursty within minutes without scaling with the plateau, which is either content independent of the plateau or transient noise (splices, handling), and E10 has no transient noise to calibrate against. **Next:** listen to Wait Until Dark, Marnie and GoodFellas (bursty, not scaling) against Mary Poppins and The Exorcist (steady noise). If bursty and non-scaling proves to be noise, add a transient-noise family to E10. Then improve the floor detection itself, before deciding how a restoration should behave below a floor shown to be noise: hold flat, cap at the local target as the counterfactuals do, or return to 0 dB. [Evidence](plans/E10-validation-protocol.md#bounding-lift-by-the-evidence-three-experiments-none-adopted-29-october-2026). Related: rows 32, 36, E4/E9. |
59
- | 34 | Redesign scope | built and released in 0.2.12 (contract 1.6's `POST /impact`); beqdesigner's step 2 live check passed, step 4 verification next | The pipeline is a catalogue of 37 named features (`beqforge/impact.py`), each owning its code and reading from a run record how a design relied on it. Each answer-changing design version declares in `beqforge/impact.toml` which features it changed and how much that moved; `tools/impact.py check` refuses changed code no feature owns and undeclared answer-reaching changes (regression step 5). `POST /impact` scores up to 500 records (15 s over the share), and records from before 0.2.11 are identified by the release that wrote them, so the current library can be scored. Version 7's fit-choice degree was measured on the library on one machine: 18 of 34 post-fit declines moved, 7 of 15 designs with a failing candidate, 2 of 15 with all passing, 0 of 8 analysis declines. Against the library: 34 likely, 1,015 possible, 236 unaffected, 70 unknown (pre-design-version builds). It doesn't meet the plan's bar for this change (likely holds 18 of the sample's 27 movers): a change that re-rolls the fit can't be narrowed by outcome class. The ranking still holds: the top 30% hold about two thirds of the expected movers. Records now carry each verdict's margins and each fit `_choose_fit` had, so a threshold change is scored exactly (`will_move` or `unaffected`), and `unknown` (`no_figure`) only for older records or a losing candidate that would start passing; this changes no answer (rejudge probe 11 of 11 at `--tol 0`, refit probe 0 of 54 moved). Responses carry each change's `basis` and `unknown_reason` (agreed with beqdesigner). `impact.MOVED`'s cascade line is now 20/f dB, capped at 2 dB at 10 Hz and below, not a flat 1 dB. Version 7's designed classes were recounted on beqdesigner's 105-title sample: 16 of 35 with a failing candidate, 22 of 60 all passing. **Left:** beqdesigner's step 2 live check and step 4 verification sample (105 titles), and the check's from-the-results direction. See [the outcome](plans/redesign-scope.md#outcome). |
59
+ | 34 | Redesign scope | built and released in 0.2.12 (contract 1.6's `POST /impact`); beqdesigner's step 2 live check passed, step 4 verification next | The pipeline is a catalogue of 37 named features (`beqforge/impact.py`), each owning its code and reading from a run record how a design relied on it. Each answer-changing design version declares in `beqforge/impact.toml` which features it changed and how much that moved; `tools/impact.py check` refuses changed code no feature owns and undeclared answer-reaching changes (regression step 5). `POST /impact` scores up to 500 records (15 s over the share), and records from before 0.2.11 are identified by the release that wrote them, so the current library can be scored. Version 7's fit-choice degree was measured on the library on one machine: 18 of 34 post-fit declines moved, 7 of 15 designs with a failing candidate, 2 of 15 with all passing, 0 of 8 analysis declines. Against the library: 34 likely, 1,015 possible, 236 unaffected, 70 unknown (pre-design-version builds). It doesn't meet the plan's bar for this change (likely holds 18 of the sample's 27 movers): a change that re-rolls the fit can't be narrowed by outcome class. The ranking still holds: the top 30% hold about two thirds of the expected movers. Records now carry each verdict's margins and each fit `_choose_fit` had, so a threshold change is scored exactly (`will_move` or `unaffected`), and `unknown` (`no_figure`) only for older records or a losing candidate that would start passing; this changes no answer (rejudge probe 11 of 11 at `--tol 0`, refit probe 0 of 54 moved). Responses carry each change's `basis` and `unknown_reason` (agreed with beqdesigner). `impact.MOVED`'s cascade line is now 20/f dB, capped at 2 dB at 10 Hz and below, not a flat 1 dB. Version 7's designed classes were recounted on beqdesigner's 105-title sample: 16 of 35 with a failing candidate, 22 of 60 all passing. **Version 9's scores (1 November 2026):** the ceiling's basis came from a sample chosen for lifting above 60 Hz, and its two touches were combined as independent although they were one measurement, so every one of the v8 library's 1,033 designs scored `likely`. Each touch can now be confined above a frequency (`above_hz`, `basis_below`). A record is scored by whether its band, target or cascade reaches above 60 Hz, and the largest share among a change's touches stands for the change. That gives 365 `likely` and 684 `possible` (0.29, row 32's re-roll); expected movers fall from 999 to 532. No answer changes (probe 11 of 11 at `--tol 0`). See [the outcome](plans/redesign-scope.md#scoring-a-change-by-what-it-reaches-1-november-2026). **Left:** beqdesigner's step 2 live check and step 4 verification sample (105 titles), and the check's from-the-results direction. See [the outcome](plans/redesign-scope.md#outcome). |
60
60
  | 35 | Review the version 6 library redesign | open: handoff received 24 October 2026; the 500 and record overwriting fixed in 0.2.11; the 14 new declines traced to row 28, 13 of them designing again on row 32 (version 7, unreleased) | beqdesigner's v6 rerun (job `e6a70f7e`, 1,287 titles): 1,016 designed and 270 declined. A Quiet Place: Day One, Elysium and Pan now design (row 31's O2 fix). 17 titles declined on v5 now design, and 14 designed on v5 now decline. Handoff: `/media/beq/triage/handoff-v6.md`; ledger `/media/beq/triage/ledger.jsonl`, stage `E`. **Fixed:** A Quiet Place Part II got a 500 every time, because `TargetFit.within_tolerance` came back as 1.0000000000000002. Zero-weight bins that miss the tolerance change how numpy pairs up a sum, so the in-tolerance sum could round above the full one. It is now met / (met + missed), which can't exceed 1; this code predates row 28. The figure is reporting only, so no design version: the title's filters are unchanged (low shelf 18.25 Hz +24.6 dB, peak 28.38 Hz −3.5 dB), and its response is now valid. Probe 11 of 11 unchanged at `--tol 0`; suite passes. **The 14 new declines (analysed 24 October 2026):** all 14 were rerun on one machine, at v0.2.9, at `f6dca94` (row 28 alone) and at HEAD, from requests identical to the library's (same digest). All 14 design on 0.2.9 and decline at `f6dca94`; HEAD agrees with `f6dca94`. So row 28 alone is the cause, and processor variance accounts for none of them. In every case the target is identical (0.000 dB apart) and the fit lands on a different cascade: row 28's 0.01 dB quantisation of the fit's input re-rolls the fitter's choice among near-equal optima (row 32). Except for TINA, the new fits are within 1 dB of the old by the fitter's own measure, worst-case error (Kung Fu Panda 4 0.4-0.9 dB worse, You've Got Mail 0.9 dB better), yet a judge check that depends on the error's shape flips: cliff (Platoon, The Manchurian Candidate, You've Got Mail, How to Marry a Millionaire), tilt (On Golden Pond, Christine, Twilight's counterfactual), ripple (Arrival, Kung Fu Panda 4), turnover (Magnolia, Zoolander), extent (Twilight's flatten) or slot (Rocky, Souleymane's Story). In TINA's case the fit itself is worse: 0.2.9's three-section fits drifted 2.1-3.0 dB on publication rounding, against a 3.0 limit, with their lowest sections about 4 quantisation steps from the device's DC limit. The new fits most likely drifted just past it, and the exposure rule fell back to one shelf fitting 4-6 dB off. No threshold should move on this evidence: the declines are as arbitrary as the 0.2.9 acceptances were, and the fix is row 32's, choosing among near-equal fits by what the judge checks rather than by which optimum the arithmetic lands on. **Slot check:** Rocky's and Souleymane's Story's new fits carry sections under 1 dB that the pruner keeps, because dropping them costs 1.02 and 1.12 dB of worst-case error against its 1 dB allowance. Their 0.2.9 two-section fits reached the same error without them. `designer-interface.md` §4 allows a rejection only for a reason internal to the evidence, which a redundant section is not, so the stated reason is wrong whatever the outcome. The outcome itself is doubtful too: both designs are 0.2-2.6 dB sections at 71-353 Hz, mostly above an 80 Hz crossover, not low-end restoration. The library's flip lists are not ground truth for row 34 by themselves (see [the proposal](plans/redesign-scope.md#validation-before-relying-on-it)): its v5 records were overwritten by v6's, because a record was named by its request's audio digest. **Fixed (0.2.11):** a record is now named by digests of the audio and of the rest of the request (coverage, bass management, adjustments, settings) and by the design version, and holds `design_version` and `design_settings` itself. A response's `run_record` (1.6, agreed in principle with beqdesigner) names it relative to the shared root. No answer changes. |
61
61
  | 36 | Is a design worth publishing at all? | open | The judge checks that a design does what its target asks, never whether the target is worth asking for. Only the target stage refuses a correction within the goal tolerance (1.5 dB), so anything that reaches the judge by another route can pass as a trivial design: row 32's search over near-equal fits accepted a single 0.98 dB shelf on the band-exclusion test until `pipeline._lifts_beyond_goal` gated alternatives. Rocky's and Souleymane's Story's v6 designs (row 35: 0.2-2.6 dB sections at 71-353 Hz, mostly above the crossover) are the real-title form of it. Decide whether the judge should refuse a design whose low-end lift stays within the goal tolerance, and with what reason under `designer-interface.md` §4. A decision change: protocol, corpus and the library's small designs. |
62
62
  | 37 | A frequency-dependent fit tolerance | variant C works; held back behind row 36, not in 0.3.0 | The fit scores the worst absolute dB error the same at every frequency, so near-equal fits are interchangeable to it wherever their error sits. Version 8's re-roll (row 23) moved 28 Years Later by 1.13 dB at 33 Hz and Obsession by 1.09 dB at 29 Hz. `impact.MOVED` now uses 20/f dB, capped at 2 dB at 10 Hz. Weighting the fit's bars as well as its search failed: the fitter either spent sections on fractions of a dB above 50 Hz (A), or the near-equal window, 2 dB wide at 10 Hz, let single shelves up to 3.6 dB off win on section count (B). **C**, weighting only the search (f/40 Hz, 0.25 to 1), works: over the 31 winners, median worst error at 30-40 Hz falls from 1.11 to 0.63 dB and at 5-10 Hz rises from 1.05 to 1.65. No real winner changes, protocol and corpus gate are unchanged, and corpus recovery goes from 1.37 to 0.85 dB RMS. It is held back because the band-exclusion test now designs a 1 dB nothing where version 8 declined, which is row 36's hole. **Next:** C together with a row 36 rule (refuse the fitter's answer, not only its alternatives, when its lift stays within the goal tolerance), validated as one decision change. Code on branch `experiment-fit-weighting-search`. See [the evidence](plans/fit-weighting.md). |
@@ -1,6 +1,6 @@
1
1
  """Shared filter primitives; no workflow package dependencies."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.3"
4
4
 
5
5
  from .types import BiquadSpec as BiquadSpec
6
6
  from .types import BiquadType as BiquadType
@@ -689,9 +689,16 @@ class Touch:
689
689
  old: float | None = None
690
690
  new: float | None = None
691
691
  note: str = ""
692
-
693
- def share(self, kind: str) -> float:
694
- moved, measured = self.basis[kind]
692
+ above_hz: float | None = None
693
+ """For a behaviour or an all whose effect is confined above a frequency: a record that
694
+ reaches above it (`reaches_above`) is scored by `basis`, any other by `basis_below`."""
695
+ above_lift_db: float = 0.0
696
+ """How far a recorded cascade may lift above `above_hz` without reaching it."""
697
+ basis_below: dict[str, tuple[int, int]] | None = None
698
+ """As `basis`, measured on the records that reach nothing above `above_hz`."""
699
+
700
+ def share(self, kind: str, below: bool = False) -> float:
701
+ moved, measured = (self.basis_below if below else self.basis)[kind]
695
702
  return moved / measured if measured else 0.0
696
703
 
697
704
 
@@ -705,13 +712,20 @@ class Change:
705
712
 
706
713
  def basis(self) -> list[dict]:
707
714
  """The measured counts behind its scores, for `changes` (§7.1), one entry per outcome
708
- class in `OUTCOMES` order: the largest share declared for it, since that is what
709
- scores a record of it."""
715
+ class in `OUTCOMES` order: the largest share declared for it, the most that can score a
716
+ record of it, and of equal shares the larger measurement."""
710
717
  found: dict[str, tuple[int, int]] = {}
711
718
  for touch in self.touches:
712
- for kind, (moved, measured) in (touch.basis or {}).items():
719
+ declared = [
720
+ *(touch.basis or {}).items(),
721
+ *(touch.basis_below or {}).items(),
722
+ ]
723
+ for kind, (moved, measured) in declared:
713
724
  held = found.get(kind)
714
- if held is None or moved * held[1] > held[0] * measured:
725
+ if held is None or (
726
+ (moved / measured if measured else 0.0, measured)
727
+ > (held[0] / held[1] if held[1] else 0.0, held[1])
728
+ ):
715
729
  found[kind] = (moved, measured)
716
730
  return [
717
731
  {"class": kind, "measured": found[kind][1], "moved": found[kind][0]}
@@ -764,19 +778,30 @@ def parse(text: str) -> ImpactData:
764
778
  if t["feature"] not in _BY_ID:
765
779
  raise ValueError(f"change {c['id']}: unknown feature {t['feature']!r}")
766
780
  basis = t.get("basis")
781
+ below = t.get("basis_below")
782
+ if ("above_hz" in t) != (below is not None):
783
+ raise ValueError(
784
+ f"change {c['id']}: {t['feature']} needs above_hz and basis_below together"
785
+ )
786
+ if below is not None and kind not in ("behaviour", "all"):
787
+ raise ValueError(
788
+ f"change {c['id']}: only a behaviour or an all is scored above a frequency"
789
+ )
767
790
  if kind in ("behaviour", "all"):
768
- if not basis or set(basis) != set(OUTCOMES):
769
- raise ValueError(
770
- f"change {c['id']}: {t['feature']} needs a basis of [moved, measured] "
771
- f"for each of {OUTCOMES}"
772
- )
773
- if any(
774
- len(v) != 2 or not 0 <= int(v[0]) <= int(v[1])
775
- for v in basis.values()
776
- ):
777
- raise ValueError(
778
- f"change {c['id']}: a basis is [moved, measured], moved <= measured"
779
- )
791
+ for declared in (basis, below) if below is not None else (basis,):
792
+ if not declared or set(declared) != set(OUTCOMES):
793
+ raise ValueError(
794
+ f"change {c['id']}: {t['feature']} needs a basis of "
795
+ f"[moved, measured] for each of {OUTCOMES}"
796
+ )
797
+ if any(
798
+ len(v) != 2 or not 0 <= int(v[0]) <= int(v[1])
799
+ for v in declared.values()
800
+ ):
801
+ raise ValueError(
802
+ f"change {c['id']}: a basis is [moved, measured], "
803
+ "moved <= measured"
804
+ )
780
805
  if kind == "threshold":
781
806
  if "old" not in t or "new" not in t:
782
807
  raise ValueError(f"change {c['id']}: a threshold needs old and new")
@@ -795,6 +820,11 @@ def parse(text: str) -> ImpactData:
795
820
  t.get("old"),
796
821
  t.get("new"),
797
822
  t.get("note", ""),
823
+ float(t["above_hz"]) if "above_hz" in t else None,
824
+ float(t.get("above_lift_db", 0.0)),
825
+ {k: (int(v[0]), int(v[1])) for k, v in below.items()}
826
+ if below
827
+ else None,
798
828
  )
799
829
  )
800
830
  changes.append(
@@ -951,7 +981,7 @@ MOVED_TOLERANCE_DB = np.minimum(2.0, 20.0 / MOVED_GRID)
951
981
  boost is inaudible, the same dB near the top of the band is a change of balance."""
952
982
 
953
983
 
954
- def _response_db(filters: list) -> np.ndarray:
984
+ def _response_db(filters: list, grid: np.ndarray = MOVED_GRID) -> np.ndarray:
955
985
  from beqforge import BiquadSpec
956
986
  from beqforge.filters import biquad_sos, magnitude_db
957
987
  from beqforge.pipeline import PUBLISH_FS
@@ -962,7 +992,6 @@ def _response_db(filters: list) -> np.ndarray:
962
992
  else BiquadSpec(*f)
963
993
  for f in filters
964
994
  ]
965
- grid = MOVED_GRID
966
995
  if not specs:
967
996
  return np.zeros_like(grid)
968
997
  return magnitude_db(biquad_sos(specs, PUBLISH_FS), grid, PUBLISH_FS)
@@ -988,6 +1017,44 @@ def moved(before: dict, after: dict) -> tuple[bool, str]:
988
1017
  # -- scoring
989
1018
 
990
1019
 
1020
+ def reaches_above(record: dict, hz: float, lift_db: float) -> tuple[bool | None, str]:
1021
+ """Whether a record's design reached above `hz`, and how: a candidate's judged band ending
1022
+ above it, its priced target asking for any lift above it, or its cascade lifting more than
1023
+ `lift_db` above it (to the top of the design grid). None when a fitted candidate holds
1024
+ neither its band nor its target, so the record can't say."""
1025
+ from beqforge import DESIGN_GRID
1026
+
1027
+ above = DESIGN_GRID > hz
1028
+ for c in record.get("candidates") or []:
1029
+ label = c.get("label", "?")
1030
+ top = ((c.get("correction") or {}).get("band_hz") or (None, None))[1]
1031
+ target = c.get("target_db")
1032
+ if top is None and target is None:
1033
+ return (
1034
+ None,
1035
+ f"{label}'s record holds neither its judged band nor its target",
1036
+ )
1037
+ if top is not None and top > hz:
1038
+ return True, f"{label}'s judged band ends at {top:.0f} Hz"
1039
+ if target is not None and len(target) == len(DESIGN_GRID):
1040
+ asked = np.asarray(target, dtype=float)[above]
1041
+ if asked.max() > 0.0:
1042
+ at = DESIGN_GRID[above][int(np.argmax(asked))]
1043
+ return (
1044
+ True,
1045
+ f"{label}'s target asks {asked.max():.1f} dB at {at:.0f} Hz",
1046
+ )
1047
+ if c.get("filters"):
1048
+ lift = _response_db(c["filters"], DESIGN_GRID[above])
1049
+ if lift.max() > lift_db:
1050
+ at = DESIGN_GRID[above][int(np.argmax(lift))]
1051
+ return (
1052
+ True,
1053
+ f"{label}'s cascade lifts {lift.max():.1f} dB at {at:.0f} Hz",
1054
+ )
1055
+ return False, f"no candidate reaches above {hz:g} Hz"
1056
+
1057
+
991
1058
  @dataclass(frozen=True, slots=True)
992
1059
  class Reason:
993
1060
  change: str | None
@@ -1099,8 +1166,10 @@ def assess(record: dict, current: tuple[int, str], data: ImpactData) -> Assessme
1099
1166
  settings digest) is to differ from the one it holds (§7.1, "Impact").
1100
1167
 
1101
1168
  The score is an estimated probability of moving: each touch's measured share for the
1102
- record's outcome class, 1.0 for a threshold that flips a deciding verdict, and touches
1103
- combined as independent, 1 - prod(1 - p)."""
1169
+ record's outcome class (or, for one confined above a frequency, of the records that reach
1170
+ above it or don't), 1.0 for a threshold that flips a deciding verdict; the largest share
1171
+ among a change's touches stands for the change, and changes combine as independent,
1172
+ 1 - prod(1 - p)."""
1104
1173
  identity = record_identity(record, data)
1105
1174
  if isinstance(identity, tuple):
1106
1175
  return _unknown(*identity)
@@ -1137,6 +1206,9 @@ def assess(record: dict, current: tuple[int, str], data: ImpactData) -> Assessme
1137
1206
  stays = 1.0
1138
1207
  certain = reaches_all = False
1139
1208
  for change in data.after(version, now):
1209
+ # A basis is measured over the change as a whole, by its regression check, so the
1210
+ # touches of one change are not independent chances: the largest stands for it.
1211
+ largest = 0.0
1140
1212
  for touch in change.touches:
1141
1213
  if touch.kind == "unchanged":
1142
1214
  continue
@@ -1158,15 +1230,30 @@ def assess(record: dict, current: tuple[int, str], data: ImpactData) -> Assessme
1158
1230
  share, certain = 1.0, True
1159
1231
  detail = f"{why} ({touch.old} -> {touch.new})"
1160
1232
  else:
1161
- share = touch.share(relied.kind)
1162
- moved_, measured = touch.basis[relied.kind]
1233
+ below, where = False, ""
1234
+ if touch.above_hz is not None:
1235
+ reached, why = reaches_above(
1236
+ record, touch.above_hz, touch.above_lift_db
1237
+ )
1238
+ if reached is None:
1239
+ return _unknown(
1240
+ "no_figure", why, version, change.id, touch.feature
1241
+ )
1242
+ below, where = not reached, f"; {why}"
1243
+ share = touch.share(relied.kind, below)
1244
+ moved_, measured = (touch.basis_below if below else touch.basis)[
1245
+ relied.kind
1246
+ ]
1163
1247
  if touch.kind == "all":
1164
1248
  reaches_all = True
1165
1249
  elif share <= 0.0:
1166
1250
  continue
1167
- detail = f"{relied.detail}; {moved_} of {measured} such designs moved"
1168
- stays *= 1.0 - share
1251
+ detail = (
1252
+ f"{relied.detail}{where}; {moved_} of {measured} such designs moved"
1253
+ )
1254
+ largest = max(largest, share)
1169
1255
  reasons.append(Reason(change.id, touch.feature, detail))
1256
+ stays *= 1.0 - largest
1170
1257
  score = 1.0 - stays
1171
1258
  if certain:
1172
1259
  tier: Tier = "will_move"
@@ -18,7 +18,13 @@
18
18
  # is unaffected, and one that predates margins is unknown;
19
19
  # - "all": reaches every design that relies on the feature (at least `likely`), with a basis
20
20
  # as for a behaviour;
21
+ # - either of those confined above a frequency adds `above_hz`, `above_lift_db` and a
22
+ # `basis_below`: a record whose candidates reach above it (`impact.reaches_above`) is
23
+ # scored by `basis`, any other by `basis_below`;
21
24
  # - "unchanged": the code changed but the answers it computes did not (say how that was shown).
25
+ #
26
+ # A basis is measured over its change as a whole, so the largest share among a change's touches
27
+ # scores a record; changes combine as independent chances of moving.
22
28
 
23
29
  # The oldest design version this file can score a record from: every change after it is below.
24
30
  since = 6
@@ -130,15 +136,28 @@ kind = "behaviour"
130
136
  # not separable): v8 library titles designed at version 8 and 9 on one machine, from the
131
137
  # library's audio with default settings. The designed classes are 60 titles lifting 3 dB above
132
138
  # 60 Hz at version 8 (random, plus those named in review) and 20 random others; the declined
133
- # classes a random sample of each. The 3 declined_after_fit titles that now design are fit
134
- # re-rolls inside bands that end at 36-43 Hz, where the ceiling does not bind (row 32).
135
- basis = { no_fit = [0, 10], declined_after_fit = [3, 12], designed_with_failures = [11, 15], designed_all_passed = [54, 65] }
139
+ # classes a random sample of each. That sample is chosen for what the ceiling reaches, so it is
140
+ # split by it: `basis` counts the records `impact.reaches_above` says reach above 60 Hz (a
141
+ # judged band ending above it, any target lift above it, or a cascade lifting more than the
142
+ # level tolerance above it), `basis_below` the rest. Of the v8 library's 1,033 designs, 365
143
+ # reach above it. Below, the moves are fit re-rolls (row 32): the fitter's new bound above
144
+ # 60 Hz moves which near-equal fit it lands on, including the 3 declined_after_fit titles that
145
+ # now design, whose bands end at 36-43 Hz. Re-rolls don't depend on which side of the ceiling a
146
+ # record falls, so declined_after_fit is pooled across both (0 of 3 above, 3 of 9 below), and
147
+ # below it the two designed classes are pooled (0 of 3 and 4 of 11).
148
+ above_hz = 60.0
149
+ above_lift_db = 3.0
150
+ basis = { no_fit = [0, 0], declined_after_fit = [3, 12], designed_with_failures = [11, 12], designed_all_passed = [50, 54] }
151
+ basis_below = { no_fit = [0, 10], declined_after_fit = [3, 12], designed_with_failures = [4, 14], designed_all_passed = [4, 14] }
136
152
 
137
153
  [[change.touches]]
138
154
  feature = "judge.overlift"
139
155
  kind = "behaviour"
140
156
  # The same measurement as targets.shared.
141
- basis = { no_fit = [0, 10], declined_after_fit = [3, 12], designed_with_failures = [11, 15], designed_all_passed = [54, 65] }
157
+ above_hz = 60.0
158
+ above_lift_db = 3.0
159
+ basis = { no_fit = [0, 0], declined_after_fit = [3, 12], designed_with_failures = [11, 12], designed_all_passed = [50, 54] }
160
+ basis_below = { no_fit = [0, 10], declined_after_fit = [3, 12], designed_with_failures = [4, 14], designed_all_passed = [4, 14] }
142
161
 
143
162
  [[change.touches]]
144
163
  feature = "judge.assess"
@@ -136,7 +136,12 @@ Adopted as design version 9.
136
136
  | designed_with_failures | 11 / 15 |
137
137
  | designed_all_passed | 54 / 65 |
138
138
 
139
- `judge.assess` is declared unchanged: only its wording changed.
139
+ `judge.assess` is declared unchanged: only its wording changed. Because the sample was chosen
140
+ for what the ceiling reaches, these counts scored the whole library `likely`. Since 1 November
141
+ 2026 they are split by whether a record reaches above 60 Hz: 61 of 66 above, 4 of 14 designs
142
+ and 3 of 9 declines below. On the v8 library that is 365 `likely` and 684 `possible`, where it
143
+ was 1,033 `likely`
144
+ ([redesign scope](redesign-scope.md#scoring-a-change-by-what-it-reaches-1-november-2026)).
140
145
  - **Golden answers:** regenerated.
141
146
  - **Tests:** the two that expected the old over-lift wording now also accept the ceiling's.
142
147
  New tests cover the bar above the ceiling, the target taper and the judged top.
@@ -249,3 +249,54 @@ Both users agreed the following, and beqforge built it before 0.2.12:
249
249
  The list is `/media/beq/triage/impact-step4-sample.json`. The person picks the cut-off for the
250
250
  rest of the library from the measured rates, and the counts are folded back into
251
251
  `impact.toml`, which changes scores but no design.
252
+
253
+ ### Scoring a change by what it reaches, 1 November 2026
254
+
255
+ Version 9 (the bass ceiling, TODO 39) scored all 1,033 designed records in the v8 library
256
+ `likely`, with 999 expected to move. Two separate faults produced that figure, and neither was
257
+ in the ceiling itself.
258
+
259
+ * **The basis came from a chosen sample.** The paired sample was 60 titles picked because they
260
+ lift 3 dB above 60 Hz, plus 20 controls. Its 54 of 65 was then applied to every design in the
261
+ class. In the library, 12.4% of designs lift that far.
262
+ * **One measurement was counted twice.** `targets.shared` and `judge.overlift` were measured
263
+ together and declared the same basis, but `assess` combined them as independent: 0.83 became
264
+ 0.97. A basis measures its change as a whole, as `Change.basis` already assumed. Now the
265
+ largest share among a change's touches stands for the change, and only changes combine as
266
+ independent.
267
+
268
+ **Built.** A `behaviour` or `all` touch can now be confined above a frequency (`above_hz`,
269
+ `above_lift_db`, `basis_below`). `impact.reaches_above` reads each candidate in the record. A
270
+ record reaches above the frequency when any candidate's judged band ends above it, its priced
271
+ target asks for any lift there, or its cascade lifts more than `above_lift_db` there, up to the
272
+ top of the design grid. That record is scored by `basis`, and any other by `basis_below`. A
273
+ fitted candidate that holds neither its band nor its target is `unknown` (`no_figure`). These
274
+ are the ceiling's three mechanisms: the target taper, the judged band's capped top, and the
275
+ over-lift bar above the ceiling, which the fitter is given too. The ceiling's own sample
276
+ splits cleanly on them:
277
+
278
+ | Outcome class | Reaches above 60 Hz | Doesn't |
279
+ | --- | --- | --- |
280
+ | no_fit | 0 / 0 | 0 / 10 |
281
+ | declined_after_fit | 0 / 3 | 3 / 9 |
282
+ | designed_with_failures | 11 / 12 | 0 / 3 |
283
+ | designed_all_passed | 50 / 54 | 4 / 11 |
284
+
285
+ The moves below the ceiling are fit re-rolls (row 32): Predator, Run Lola Run, Scarface and
286
+ Terminator 2 among the controls, and the 3 declines that now design. Re-rolls don't depend on
287
+ which side a record falls, so the declared basis pools them. `declined_after_fit` is 3 of 12
288
+ on both sides. Below the ceiling, the two designed classes share 4 of 14. With 3 cases,
289
+ `designed_with_failures` alone would have read 0 and called those records unaffected.
290
+
291
+ **On the v8 library** (1,069 records at version 8, scored against version 9):
292
+
293
+ | | likely | possible | unaffected | expected movers |
294
+ | --- | --- | --- | --- | --- |
295
+ | before | 1,033 | 16 | 20 | 999 |
296
+ | after | 365 | 684 | 20 | 532 |
297
+
298
+ The 365 `likely` records are the designs that reach above 60 Hz, at 0.92-0.93. The rest score
299
+ 0.29, or 0.25 for a decline. Most of what remains is row 32's re-roll, which only stabilising
300
+ the fit choice will narrow. This changes scores but no design: the probe is unchanged at
301
+ `--tol 0` (11 of 11). The response's schema is unchanged. Each `changes` item's `basis` is
302
+ still the largest share declared per class, and of equal shares the larger measurement.
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  import numpy as np
9
9
  import pytest
10
10
 
11
- from beqforge import impact
11
+ from beqforge import DESIGN_GRID, impact
12
12
  from beqforge.designer import (
13
13
  DESIGN_VERSION,
14
14
  DesignRequest,
@@ -124,6 +124,29 @@ def test_the_shipped_impact_data_loads() -> None:
124
124
  "records no margin",
125
125
  ),
126
126
  ('feature = "judge.cliff"\nkind = "threshold"\nold = 2.0', "old and new"),
127
+ (
128
+ (
129
+ 'feature = "fit.choice"\nkind = "behaviour"\nabove_hz = 60.0\nbasis = { '
130
+ "no_fit = [0, 1], declined_after_fit = [0, 1], designed_with_failures = "
131
+ "[0, 1], designed_all_passed = [0, 1] }"
132
+ ),
133
+ "above_hz and basis_below together",
134
+ ),
135
+ (
136
+ (
137
+ 'feature = "judge.cliff"\nkind = "threshold"\nold = 2.0\nnew = 2.5\n'
138
+ "above_hz = 60.0\nbasis_below = { no_fit = [0, 1] }"
139
+ ),
140
+ "only a behaviour or an all",
141
+ ),
142
+ (
143
+ (
144
+ 'feature = "fit.choice"\nkind = "behaviour"\nabove_hz = 60.0\nbasis = { '
145
+ "no_fit = [0, 1], declined_after_fit = [0, 1], designed_with_failures = "
146
+ "[0, 1], designed_all_passed = [0, 1] }\nbasis_below = { no_fit = [0, 1] }"
147
+ ),
148
+ "needs a basis",
149
+ ),
127
150
  ],
128
151
  )
129
152
  def test_a_bad_declaration_is_refused(touch: str, match: str) -> None:
@@ -254,6 +277,75 @@ def test_changes_combine_as_independent_chances_of_moving() -> None:
254
277
  assert [r.change for r in found.reasons] == ["six", "seven"]
255
278
 
256
279
 
280
+ def test_one_changes_touches_are_one_measurement_not_independent_chances() -> None:
281
+ """Two features measured together by one regression check move together: the larger
282
+ share stands for the change, and each still gives its reason."""
283
+ data = _data(
284
+ _behaviour("targets.shared", designed_all_passed=(1, 2)),
285
+ _behaviour("judge.overlift", designed_all_passed=(1, 4)),
286
+ )
287
+ found = impact.assess(
288
+ _record([_candidate("flatten", True)], "flatten"), (7, SETTINGS), data
289
+ )
290
+ assert (found.tier, found.score) == ("likely", 0.5)
291
+ assert [r.feature for r in found.reasons] == ["targets.shared", "judge.overlift"]
292
+
293
+
294
+ def _reaching(
295
+ band_top: float | None = 40.0,
296
+ target: list[float] | None = None,
297
+ filters: tuple[dict, ...] = (),
298
+ ) -> dict:
299
+ candidate = _candidate("flatten", True) | {"filters": list(filters)}
300
+ if band_top is not None:
301
+ candidate["correction"] = {"band_hz": [5.0, band_top]}
302
+ if target is not None:
303
+ candidate["target_db"] = target
304
+ return _record([candidate], "flatten")
305
+
306
+
307
+ def _asking(hz: float, db: float) -> list[float]:
308
+ return np.where(DESIGN_GRID <= hz, db, 0.0).tolist()
309
+
310
+
311
+ def test_what_reaches_above_a_frequency_is_read_from_the_record() -> None:
312
+ def reach(record: dict) -> bool | None:
313
+ return impact.reaches_above(record, 60.0, 3.0)[0]
314
+
315
+ assert reach(_reaching(band_top=190.0)) is True
316
+ assert reach(_reaching(target=_asking(80.0, 0.5))) is True
317
+ assert reach(_reaching(target=_asking(55.0, 12.0))) is False
318
+ # a shelf's skirt lifts 10 dB at 30 Hz but under 3 dB above 60 Hz; at 60 Hz it lifts more
319
+ assert reach(_reaching(filters=(SHELF,))) is False
320
+ assert reach(_reaching(filters=(SHELF | {"freq_hz": 60.0},))) is True
321
+ assert reach(_reaching(band_top=None)) is None
322
+
323
+
324
+ def _confined(**counts: tuple[int, int]) -> impact.Touch:
325
+ return dataclasses.replace(
326
+ _behaviour("targets.shared", designed_all_passed=(9, 10)),
327
+ above_hz=60.0,
328
+ above_lift_db=3.0,
329
+ basis_below={k: counts.get(k, (0, 1)) for k in impact.OUTCOMES},
330
+ )
331
+
332
+
333
+ def test_a_change_confined_above_a_frequency_scores_by_which_side_a_record_falls() -> (
334
+ None
335
+ ):
336
+ """A sample chosen for what a change reaches would otherwise score the whole library by
337
+ the designs it was chosen for."""
338
+ data = _data(_confined(designed_all_passed=(1, 4)))
339
+ above = impact.assess(_reaching(band_top=190.0), (7, SETTINGS), data)
340
+ assert (above.tier, above.score) == ("likely", 0.9)
341
+ assert "judged band ends at 190 Hz; 9 of 10" in above.reasons[0].detail
342
+ below = impact.assess(_reaching(), (7, SETTINGS), data)
343
+ assert (below.tier, below.score) == ("possible", 0.25)
344
+ assert "no candidate reaches above 60 Hz; 1 of 4" in below.reasons[0].detail
345
+ unread = impact.assess(_reaching(band_top=None), (7, SETTINGS), data)
346
+ assert (unread.tier, unread.unknown_reason) == ("unknown", "no_figure")
347
+
348
+
257
349
  def test_a_design_declined_before_the_fit_does_not_rely_on_it() -> None:
258
350
  data = _data(_behaviour("fit.choice", **dict.fromkeys(impact.OUTCOMES, (1, 1))))
259
351
  found = impact.assess(_record(), (7, SETTINGS), data)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes