beqforge 0.2.1__tar.gz → 0.2.2__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 (197) hide show
  1. {beqforge-0.2.1 → beqforge-0.2.2}/.github/workflows/build-device-check.yml +1 -1
  2. {beqforge-0.2.1 → beqforge-0.2.2}/.github/workflows/build-executable.yml +5 -3
  3. {beqforge-0.2.1 → beqforge-0.2.2}/.github/workflows/tests.yml +25 -5
  4. {beqforge-0.2.1 → beqforge-0.2.2}/AGENTS.md +3 -1
  5. {beqforge-0.2.1 → beqforge-0.2.2}/PKG-INFO +4 -3
  6. {beqforge-0.2.1 → beqforge-0.2.2}/README.md +3 -2
  7. {beqforge-0.2.1 → beqforge-0.2.2}/beq_common/__init__.py +1 -1
  8. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/AGENTS.md +1 -1
  9. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/diagnose.py +10 -4
  10. {beqforge-0.2.1 → beqforge-0.2.2}/plans/done-design-and-pipeline.md +70 -0
  11. {beqforge-0.2.1 → beqforge-0.2.2}/pyproject.toml +18 -1
  12. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_channel_evidence.py +3 -1
  13. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_designer_server.py +81 -48
  14. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_diagnose.py +30 -0
  15. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_designer_image.py +8 -11
  16. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_designer_server_startup.py +17 -18
  17. {beqforge-0.2.1 → beqforge-0.2.2}/tools/designer_server.py +268 -59
  18. {beqforge-0.2.1 → beqforge-0.2.2}/uv.lock +136 -76
  19. {beqforge-0.2.1 → beqforge-0.2.2}/.dockerignore +0 -0
  20. {beqforge-0.2.1 → beqforge-0.2.2}/.github/dependabot.yml +0 -0
  21. {beqforge-0.2.1 → beqforge-0.2.2}/.github/workflows/build-designer-image.yml +0 -0
  22. {beqforge-0.2.1 → beqforge-0.2.2}/.github/workflows/dependabot-auto-merge.yaml +0 -0
  23. {beqforge-0.2.1 → beqforge-0.2.2}/.gitignore +0 -0
  24. {beqforge-0.2.1 → beqforge-0.2.2}/.idea/.gitignore +0 -0
  25. {beqforge-0.2.1 → beqforge-0.2.2}/.idea/beqforge.iml +0 -0
  26. {beqforge-0.2.1 → beqforge-0.2.2}/.idea/editor.xml +0 -0
  27. {beqforge-0.2.1 → beqforge-0.2.2}/.idea/jsonSchemas.xml +0 -0
  28. {beqforge-0.2.1 → beqforge-0.2.2}/.idea/misc.xml +0 -0
  29. {beqforge-0.2.1 → beqforge-0.2.2}/.idea/modules.xml +0 -0
  30. {beqforge-0.2.1 → beqforge-0.2.2}/.idea/vcs.xml +0 -0
  31. {beqforge-0.2.1 → beqforge-0.2.2}/CLAUDE.md +0 -0
  32. {beqforge-0.2.1 → beqforge-0.2.2}/LICENCE.md +0 -0
  33. {beqforge-0.2.1 → beqforge-0.2.2}/TODO.md +0 -0
  34. {beqforge-0.2.1 → beqforge-0.2.2}/beq_common/AGENTS.md +0 -0
  35. {beqforge-0.2.1 → beqforge-0.2.2}/beq_common/biquad.py +0 -0
  36. {beqforge-0.2.1 → beqforge-0.2.2}/beq_common/provenance.py +0 -0
  37. {beqforge-0.2.1 → beqforge-0.2.2}/beq_common/publication.py +0 -0
  38. {beqforge-0.2.1 → beqforge-0.2.2}/beq_common/types.py +0 -0
  39. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/__init__.py +0 -0
  40. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/accept.py +0 -0
  41. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/beqd.py +0 -0
  42. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/biquad.py +0 -0
  43. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/cache.py +0 -0
  44. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/charts.py +0 -0
  45. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/cli.py +0 -0
  46. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/design.py +0 -0
  47. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/designer.py +0 -0
  48. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/explain.py +0 -0
  49. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/extraction.py +0 -0
  50. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/filters.py +0 -0
  51. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/harness.py +0 -0
  52. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/identify.py +0 -0
  53. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/material.py +0 -0
  54. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/pipeline.py +0 -0
  55. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/record.py +0 -0
  56. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/reference.py +0 -0
  57. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/rolloff.py +0 -0
  58. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge/verify.py +0 -0
  59. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge-device-check.spec +0 -0
  60. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge.spec +0 -0
  61. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/AGENTS.md +0 -0
  62. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/__init__.py +0 -0
  63. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/analyse.py +0 -0
  64. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/audio.py +0 -0
  65. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/catalogue.py +0 -0
  66. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/cli.py +0 -0
  67. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/coefficients.py +0 -0
  68. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/engines.py +0 -0
  69. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/evidence.py +0 -0
  70. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/manifest.py +0 -0
  71. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/measurement.py +0 -0
  72. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/profiles.py +0 -0
  73. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/qualification.py +0 -0
  74. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/report.py +0 -0
  75. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/transactions.py +0 -0
  76. {beqforge-0.2.1 → beqforge-0.2.2}/beqforge_device_check/verify.py +0 -0
  77. {beqforge-0.2.1 → beqforge-0.2.2}/beqoptimiser/AGENTS.md +0 -0
  78. {beqforge-0.2.1 → beqforge-0.2.2}/beqoptimiser/__init__.py +0 -0
  79. {beqforge-0.2.1 → beqforge-0.2.2}/beqoptimiser/cache.py +0 -0
  80. {beqforge-0.2.1 → beqforge-0.2.2}/beqoptimiser/cli.py +0 -0
  81. {beqforge-0.2.1 → beqforge-0.2.2}/beqoptimiser/core.py +0 -0
  82. {beqforge-0.2.1 → beqforge-0.2.2}/beqoptimiser/data/seed-manifest.json +0 -0
  83. {beqforge-0.2.1 → beqforge-0.2.2}/beqoptimiser/data/seed.json.gz +0 -0
  84. {beqforge-0.2.1 → beqforge-0.2.2}/docs/device-check-examples/minidsp-2x4hd-usb-loopback.json +0 -0
  85. {beqforge-0.2.1 → beqforge-0.2.2}/docs/device-check-examples/simulation.json +0 -0
  86. {beqforge-0.2.1 → beqforge-0.2.2}/docs/device-check.md +0 -0
  87. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser-report/README.md +0 -0
  88. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser-report/assets/aggregate-48000.png +0 -0
  89. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser-report/assets/aggregate-96000.png +0 -0
  90. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser-report/assets/example-1.png +0 -0
  91. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser-report/assets/example-2.png +0 -0
  92. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser-report/entry-results.json.gz +0 -0
  93. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser-report/statistics.json +0 -0
  94. {beqforge-0.2.1 → beqforge-0.2.2}/docs/optimiser.md +0 -0
  95. {beqforge-0.2.1 → beqforge-0.2.2}/evidence_validation.json +0 -0
  96. {beqforge-0.2.1 → beqforge-0.2.2}/negative_corpus.json +0 -0
  97. {beqforge-0.2.1 → beqforge-0.2.2}/packaging/designer/Dockerfile +0 -0
  98. {beqforge-0.2.1 → beqforge-0.2.2}/packaging/designer/smoke.py +0 -0
  99. {beqforge-0.2.1 → beqforge-0.2.2}/packaging/device-check/NOTICE.txt +0 -0
  100. {beqforge-0.2.1 → beqforge-0.2.2}/plans/F2-catalogue-filter-optimisation.md +0 -0
  101. {beqforge-0.2.1 → beqforge-0.2.2}/plans/F2-device-precision-validation.md +0 -0
  102. {beqforge-0.2.1 → beqforge-0.2.2}/plans/F2-package-consolidation.md +0 -0
  103. {beqforge-0.2.1 → beqforge-0.2.2}/plans/R2a-server-stage-cache.md +0 -0
  104. {beqforge-0.2.1 → beqforge-0.2.2}/plans/done-baseline-evidence.md +0 -0
  105. {beqforge-0.2.1 → beqforge-0.2.2}/plans/research-design-decisions.md +0 -0
  106. {beqforge-0.2.1 → beqforge-0.2.2}/plans/test-portability-and-runtime.md +0 -0
  107. {beqforge-0.2.1 → beqforge-0.2.2}/tests/__init__.py +0 -0
  108. {beqforge-0.2.1 → beqforge-0.2.2}/tests/conftest.py +0 -0
  109. {beqforge-0.2.1 → beqforge-0.2.2}/tests/fixtures/optimiser_seed_entry.json +0 -0
  110. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_cli.py +0 -0
  111. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_accept.py +0 -0
  112. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_cache.py +0 -0
  113. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_cache_stores.py +0 -0
  114. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_charts.py +0 -0
  115. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_corpus.py +0 -0
  116. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_design.py +0 -0
  117. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_designer.py +0 -0
  118. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_device_rate.py +0 -0
  119. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_evidence.py +0 -0
  120. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_evidence_excess.py +0 -0
  121. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_exclusions.py +0 -0
  122. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_explain.py +0 -0
  123. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_extraction.py +0 -0
  124. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_filters.py +0 -0
  125. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_frozen_cache.py +0 -0
  126. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_guard.py +0 -0
  127. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_harness.py +0 -0
  128. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_headroom.py +0 -0
  129. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_hum.py +0 -0
  130. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_identify.py +0 -0
  131. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_jacobian.py +0 -0
  132. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_parallel.py +0 -0
  133. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_pipeline.py +0 -0
  134. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_playback.py +0 -0
  135. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_playback_headroom.py +0 -0
  136. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_publication.py +0 -0
  137. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_record.py +0 -0
  138. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_record_fingerprint.py +0 -0
  139. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_reference.py +0 -0
  140. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_references.py +0 -0
  141. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_rolloff.py +0 -0
  142. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_strategy_config.py +0 -0
  143. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_timings.py +0 -0
  144. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_design_verify.py +0 -0
  145. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_analyse.py +0 -0
  146. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_catalogue.py +0 -0
  147. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_catalogue_predict.py +0 -0
  148. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_cli.py +0 -0
  149. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_engines.py +0 -0
  150. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_evidence.py +0 -0
  151. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_examples.py +0 -0
  152. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_manifest.py +0 -0
  153. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_measurement.py +0 -0
  154. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_qualification.py +0 -0
  155. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_transactions.py +0 -0
  156. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_device_check_verify.py +0 -0
  157. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_extract_layout.py +0 -0
  158. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_optimiser.py +0 -0
  159. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_optimiser_cache.py +0 -0
  160. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_optimiser_report.py +0 -0
  161. {beqforge-0.2.1 → beqforge-0.2.2}/tests/test_package_boundaries.py +0 -0
  162. {beqforge-0.2.1 → beqforge-0.2.2}/tools/__init__.py +0 -0
  163. {beqforge-0.2.1 → beqforge-0.2.2}/tools/build_optimiser_seed.py +0 -0
  164. {beqforge-0.2.1 → beqforge-0.2.2}/tools/design_beq.py +0 -0
  165. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/a1_sweep.py +0 -0
  166. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/compare_records.py +0 -0
  167. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/compare_verdicts.py +0 -0
  168. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/inject_variants.py +0 -0
  169. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p14_compare.py +0 -0
  170. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p14_jacobian.py +0 -0
  171. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p14_run_pipeline.py +0 -0
  172. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p14_surrogate.py +0 -0
  173. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p18_compare_greedy.py +0 -0
  174. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p18_compare_seeded.py +0 -0
  175. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p18_compare_structure.py +0 -0
  176. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p18_greedy.py +0 -0
  177. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p18_seeded.py +0 -0
  178. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p18_structure.py +0 -0
  179. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/p20_smooth_drift.py +0 -0
  180. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/probe.py +0 -0
  181. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/refit_probe.py +0 -0
  182. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/request_timing.py +0 -0
  183. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/score_injected.py +0 -0
  184. {beqforge-0.2.1 → beqforge-0.2.2}/tools/experiments/t8_floor_rules.py +0 -0
  185. {beqforge-0.2.1 → beqforge-0.2.2}/tools/extract.py +0 -0
  186. {beqforge-0.2.1 → beqforge-0.2.2}/tools/ledger_template.html +0 -0
  187. {beqforge-0.2.1 → beqforge-0.2.2}/tools/negative_corpus.py +0 -0
  188. {beqforge-0.2.1 → beqforge-0.2.2}/tools/optimiser_catalogue_report.py +0 -0
  189. {beqforge-0.2.1 → beqforge-0.2.2}/tools/prepare_device_check_helper.py +0 -0
  190. {beqforge-0.2.1 → beqforge-0.2.2}/tools/render_ledger.py +0 -0
  191. {beqforge-0.2.1 → beqforge-0.2.2}/tools/replay.py +0 -0
  192. {beqforge-0.2.1 → beqforge-0.2.2}/tools/seed_optimiser_cache.py +0 -0
  193. {beqforge-0.2.1 → beqforge-0.2.2}/tools/smoke_test_device_check.py +0 -0
  194. {beqforge-0.2.1 → beqforge-0.2.2}/tools/smoke_test_exe.py +0 -0
  195. {beqforge-0.2.1 → beqforge-0.2.2}/tools/smoke_test_install.py +0 -0
  196. {beqforge-0.2.1 → beqforge-0.2.2}/tools/summarise.py +0 -0
  197. {beqforge-0.2.1 → beqforge-0.2.2}/tools/validate_evidence.py +0 -0
@@ -61,7 +61,7 @@ jobs:
61
61
  executable: beqforge-device-check
62
62
  steps:
63
63
  - uses: actions/checkout@v7
64
- - uses: astral-sh/setup-uv@v10.2.0
64
+ - uses: astral-sh/setup-uv@v10.3.0
65
65
  with:
66
66
  enable-cache: true
67
67
  - name: Install Linux audio and USB libraries
@@ -25,7 +25,7 @@ jobs:
25
25
  steps:
26
26
  - name: Find successful tests for the tagged commit
27
27
  id: check
28
- uses: actions/github-script@v8
28
+ uses: actions/github-script@v9
29
29
  with:
30
30
  script: |
31
31
  core.setOutput('reusable', 'false');
@@ -72,6 +72,8 @@ jobs:
72
72
  needs: previous_tests
73
73
  if: needs.previous_tests.outputs.reusable != 'true'
74
74
  uses: ./.github/workflows/tests.yml
75
+ # CODECOV_TOKEN, for the coverage upload
76
+ secrets: inherit
75
77
  permissions:
76
78
  contents: read
77
79
 
@@ -102,7 +104,7 @@ jobs:
102
104
  contents: read
103
105
  steps:
104
106
  - uses: actions/checkout@v7
105
- - uses: astral-sh/setup-uv@v10.2.0
107
+ - uses: astral-sh/setup-uv@v10.3.0
106
108
  with:
107
109
  enable-cache: true
108
110
  - name: Build wheel and source distribution
@@ -191,7 +193,7 @@ jobs:
191
193
  - uses: actions/checkout@v7
192
194
 
193
195
  - name: Install uv
194
- uses: astral-sh/setup-uv@v10.2.0
196
+ uses: astral-sh/setup-uv@v10.3.0
195
197
  with:
196
198
  enable-cache: true
197
199
 
@@ -67,7 +67,7 @@ jobs:
67
67
  run: choco install ffmpeg -y
68
68
 
69
69
  - name: Install uv
70
- uses: astral-sh/setup-uv@v10.2.0
70
+ uses: astral-sh/setup-uv@v10.3.0
71
71
  with:
72
72
  enable-cache: true
73
73
 
@@ -76,7 +76,17 @@ jobs:
76
76
 
77
77
  # the device-check tests run in their own job below, in parallel, with the extra installed
78
78
  - name: Run tests
79
- run: uv run --extra designer pytest -n auto --durations=25 --ignore-glob='tests/test_device_check_*.py'
79
+ run: uv run --extra designer pytest -n auto --durations=25 --ignore-glob='tests/test_device_check_*.py' --cov --cov-report=xml
80
+
81
+ - name: Upload coverage
82
+ if: ${{ !cancelled() }}
83
+ uses: codecov/codecov-action@v7.1.1
84
+ with:
85
+ token: ${{ secrets.CODECOV_TOKEN }}
86
+ files: coverage.xml
87
+ disable_search: true
88
+ flags: designer,${{ runner.os }}
89
+ name: designer-${{ runner.os }}
80
90
 
81
91
  device-check:
82
92
  name: Device check (${{ matrix.os }})
@@ -90,7 +100,7 @@ jobs:
90
100
  - uses: actions/checkout@v7
91
101
 
92
102
  - name: Install uv
93
- uses: astral-sh/setup-uv@v10.2.0
103
+ uses: astral-sh/setup-uv@v10.3.0
94
104
  with:
95
105
  enable-cache: true
96
106
 
@@ -100,7 +110,17 @@ jobs:
100
110
 
101
111
  - name: Run device-check tests
102
112
  shell: bash
103
- run: uv run --extra device-check pytest -n auto --durations=25 tests/test_device_check_*.py
113
+ run: uv run --extra device-check pytest -n auto --durations=25 tests/test_device_check_*.py --cov --cov-report=xml
114
+
115
+ - name: Upload coverage
116
+ if: ${{ !cancelled() }}
117
+ uses: codecov/codecov-action@v7.1.1
118
+ with:
119
+ token: ${{ secrets.CODECOV_TOKEN }}
120
+ files: coverage.xml
121
+ disable_search: true
122
+ flags: device-check,${{ runner.os }}
123
+ name: device-check-${{ runner.os }}
104
124
 
105
125
  package-profiles:
106
126
  name: Install profile (${{ matrix.profile }})
@@ -111,7 +131,7 @@ jobs:
111
131
  profile: [base, designer, optimiser, device-check, all]
112
132
  steps:
113
133
  - uses: actions/checkout@v7
114
- - uses: astral-sh/setup-uv@v10.2.0
134
+ - uses: astral-sh/setup-uv@v10.3.0
115
135
  with:
116
136
  enable-cache: true
117
137
  - name: Build the single distribution
@@ -56,7 +56,9 @@ importing them; use an isolated worktree for simultaneous experiments.
56
56
  The root build configuration includes every workflow and the optimiser's bundled seed in one
57
57
  wheel and source distribution. `.github/workflows/tests.yml` checks isolated dependency
58
58
  profiles and runs the suite in parallel (`pytest -n auto`, so tests must not share mutable state
59
- or fixed ports). Feature branch pushes test on Linux only and a newer push cancels the branch's
59
+ or fixed ports), with coverage (`--cov`, configured in `pyproject.toml`, `multiprocessing`
60
+ children included) uploaded to Codecov by each test job using the `CODECOV_TOKEN` secret;
61
+ release runs inherit it. Feature branch pushes test on Linux only and a newer push cancels the branch's
60
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;
61
63
  tag pushes publish to PyPI after validation and successful executable builds. Tags must match
62
64
  the shared version exactly (`vX.Y.Z`). Manual runs build artifacts without publishing.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beqforge
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -270,8 +270,9 @@ The image's defaults, all overridable with the environment variables above:
270
270
 
271
271
  It runs as uid/gid 1000 (`--build-arg PUID=… PGID=…` to change), which must be able to write
272
272
  the shared root and the cache: the server checks both with a write at startup. Its
273
- `HEALTHCHECK` asks `/health`. The server answers one request at a time (its fitter forks), so a
274
- caller sending several at once queues them; size the caller's timeout for that.
273
+ `HEALTHCHECK` asks `/health`, which answers while a design runs: designs run in a separate
274
+ worker process, one at a time, so a caller sending several at once queues them; size the
275
+ caller's timeout for that. `docker stop` stops a design in progress.
275
276
 
276
277
  Build and check it locally from the repository root:
277
278
 
@@ -244,8 +244,9 @@ The image's defaults, all overridable with the environment variables above:
244
244
 
245
245
  It runs as uid/gid 1000 (`--build-arg PUID=… PGID=…` to change), which must be able to write
246
246
  the shared root and the cache: the server checks both with a write at startup. Its
247
- `HEALTHCHECK` asks `/health`. The server answers one request at a time (its fitter forks), so a
248
- caller sending several at once queues them; size the caller's timeout for that.
247
+ `HEALTHCHECK` asks `/health`, which answers while a design runs: designs run in a separate
248
+ worker process, one at a time, so a caller sending several at once queues them; size the
249
+ caller's timeout for that. `docker stop` stops a design in progress.
249
250
 
250
251
  Build and check it locally from the repository root:
251
252
 
@@ -1,6 +1,6 @@
1
1
  """Shared filter primitives; no workflow package dependencies."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
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. Single-threaded (`http.server.HTTPServer`, not `ThreadingHTTPServer`) on purpose: 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. Costs nothing real — the contract is one synchronous POST per `design()` call. 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); `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` and `shared_root`. |
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 one 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. One worker keeps designs serial (the contract is one synchronous POST per `design()` call); a worker that dies answers 500 and is replaced. 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` and `shared_root`. |
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. |
@@ -618,15 +618,21 @@ def diagnose(
618
618
  constrain channel restoration and the band over which a filter is judged.
619
619
  """
620
620
  params = params or DiagnoseParams()
621
- freqs, mix_db = mean_spectrum(material.mono_mix, material.fs)
622
- band = (freqs >= params.band_hz[0]) & (freqs <= params.band_hz[1])
623
- freqs, mix_db = freqs[band], mix_db[band]
621
+ spectrum_freqs, mix_spectrum = mean_spectrum(material.mono_mix, material.fs)
622
+ band = (spectrum_freqs >= params.band_hz[0]) & (spectrum_freqs <= params.band_hz[1])
623
+ freqs, mix_db = spectrum_freqs[band], mix_spectrum[band]
624
624
  spectra = {
625
625
  name: mean_spectrum(samples, material.fs)[1]
626
626
  for name, samples in material.channels.items()
627
627
  }
628
628
  shares, share_errors = coherent_shares(material)
629
- _, mix_plateau = plateau_reference(mix_db, freqs, params)
629
+ # The mix plateau is found on the whole spectrum, exactly as `pipeline` finds it, so tracking
630
+ # is measured from the plateau the run judges against. The reference grid ends exactly at the
631
+ # band top: cut off at the last bin below it, a plateau reaching that edge loses its top point
632
+ # and can vanish. Diagnosis then lost the plateau `pipeline` had, measured no tracking at all
633
+ # and reported the band top as where tracking stopped (A Few Good Men: 150.5-200 Hz found by
634
+ # one, none by the other).
635
+ _, mix_plateau = plateau_reference(mix_spectrum, spectrum_freqs, params)
630
636
  from beqforge.extraction import ExtractionParams, extract
631
637
 
632
638
  extraction_params = extraction_params or ExtractionParams()
@@ -748,6 +748,76 @@ beside the answer or the decline (probe unchanged; no decision touched).
748
748
  caller's; `beqforge_revision` is already in every response for it.
749
749
 
750
750
 
751
+ ## The designer server answers `/health` during a design (17 October 2026)
752
+
753
+ A deployed image went unhealthy while it designed correctly. `serve-designer` was a plain
754
+ `HTTPServer` running `design()` on its only thread, so a `GET /health` waited behind a 50-110 s
755
+ design; the image's `HEALTHCHECK` gives up after 3 s and three failures mark the container
756
+ unhealthy. It had been single-threaded on purpose: the fitter forks its pool, and forking a
757
+ multi-threaded process risks a deadlock.
758
+
759
+ The server is now a `ThreadingHTTPServer`, and every design runs in one long-lived worker
760
+ process started with "spawn" (`_DesignWorker` in `tools/designer_server.py`). The worker's
761
+ only thread runs the design, so the fitter forks from a single-threaded process as before. Its
762
+ log records come back over a `SimpleQueue` (a `Queue` would start a feeder thread in it) and
763
+ go through the parent's loggers, and the parent waits for a request's records before
764
+ answering. One worker keeps designs serial. A worker that dies answers 500 and is replaced.
765
+ SIGTERM now shuts down as Ctrl-C does (as a container's PID 1 the server used to ignore it),
766
+ and closing stops the worker's process group, fit pool included, rather than wait for a
767
+ design in progress. The timing line gains `queued`. Nothing under `beqforge/` changed, so the
768
+ decision regression probe does not apply.
769
+
770
+ * **Checks.** New tests: `/health` answers within 3 s while a real design runs, and a killed
771
+ worker gives a 500 and then a working replacement. The existing server, image and startup
772
+ tests now build `DesignServer` with `DesignSettings` (51 passed). Against the installed
773
+ `beqforge serve-designer`: `/health` took at most 16 ms during a design, and SIGTERM in the
774
+ middle of one exited in 0.3 s with no worker or fit-pool process left. Before the
775
+ process-group fix, the fit pool was orphaned and kept computing. The image built and
776
+ `packaging/designer/smoke.py` passed, inline and by reference. Full designer suite with
777
+ coverage: 741 passed, 1 skipped. The fork-from-a-threaded-process warnings in it come from
778
+ xdist's own worker threads and appear without these changes.
779
+ * **Coverage.** CI now measures coverage (`pytest-cov`; `[tool.coverage.run]` in
780
+ `pyproject.toml`, with `concurrency = multiprocessing` so the design worker and fit pools
781
+ count on every platform; `patch = fork` is unsupported on Windows). Both test jobs upload
782
+ `coverage.xml` to Codecov with `CODECOV_TOKEN`, and the release workflow passes its secrets
783
+ to the tests it calls.
784
+
785
+
786
+ ## The mix plateau diagnosis tracks from is the pipeline's (17 October 2026)
787
+
788
+ `diagnose` found the mix plateau on the spectrum cut off at the analysis band's top, while
789
+ `pipeline` finds it on the whole spectrum. `plateau_reference`'s grid ends exactly at the band
790
+ top with `right=nan`, so the cut-off copy always lost the top grid point, and a plateau reaching
791
+ that edge could vanish. Diagnosis then took `_temporal_evidence`'s early return (no strata, the
792
+ tracking floor set to the band top), and the pipeline's plateau blocker read that as "nothing
793
+ below it tracks the programme (tracking stops at 200.0 Hz)". The mix plateau is now found on
794
+ the whole spectrum, as the pipeline finds it. Channel plateaus are unchanged.
795
+
796
+ * **Found in the full-library run (beqdesigner).** A Few Good Men (5.1) declined
797
+ `no_usable_plateau`: the pipeline's plateau was 150.5-200 Hz, diagnosis had none, and its
798
+ record showed no strata and a tracking floor of exactly 200.0. `band_tracking` against the
799
+ pipeline's plateau passes every octave band to ~13 Hz (0.53-0.62). Patched, it accepts two
800
+ low shelves (16.8 Hz +5.8 dB, 26.8 Hz +6.5 dB, confidence 0.91). Before the Devil Knows
801
+ You're Dead and Blink Twice show the same record signature.
802
+ * **First version: every plateau on the whole spectrum, rejected.** Handing channels the whole
803
+ spectrum too moved channel plateaus on eight baseline titles (an extra finite top point moves
804
+ the 90th-percentile threshold), changed counterfactual targets on four, and in real runs
805
+ against HEAD flipped counterfactual verdicts on Alto Knights and Obsession. As with the
806
+ one-sided fallback above, the channel calls were left alone.
807
+ * **Validation of the mix-only version**, against 7a1702f. Probe: 28 Years Later's tracking
808
+ floor 20.50 -> 20.30 Hz (judged band and targets by up to 0.17 dB); Dossier 137's filter
809
+ floor 59.3 -> 79.8 Hz and Send Help's 20.0 -> 20.8 Hz, nothing else; the other eight titles
810
+ unchanged. 28 Years Later's real run against a HEAD run: the winner changes `parametric` ->
811
+ `flatten`, both passing either way; the accepted corrections agree within 0.6 dB in every band
812
+ from 5 to 80 Hz (+5.8/+6.0/+6.6/+3.3/+0.6 -> +5.9/+6.6/+6.4/+3.1/+0.7 dB), the same answer.
813
+ Protocol, seeds 101 and 947: identical false acceptances, abstentions, selections and recovery.
814
+ Corpus, seeds 1-9: gated 0/45, gate pass 0.079; positives 7/9 (median recovery 1.27 dB RMS);
815
+ `natural_droop` 4/9 against the committed file's 5/9 (an older revision; reported, not gated).
816
+ Full suite: 804 passed, 2 skipped. New test in `tests/test_design_diagnose.py`: a mix
817
+ high-passed at 144-146 Hz, whose only plateau reaches the band top, is tracked from that
818
+ plateau (fails on the old code: no strata, floor at 200 Hz).
819
+
820
+
751
821
  ## Levels beside an omission, and mains-hum notches as texture (11 October 2026)
752
822
 
753
823
  Three titles from a beqdesigner run against `/media/beq` were declined in ways that should not
@@ -30,12 +30,29 @@ all = [
30
30
  ]
31
31
 
32
32
  [dependency-groups]
33
- dev = ["pytest>=9.0.2", "pytest-xdist>=3.8", "ruff>=0.14.10", "scipy-stubs>=1.17.1,<1.19.0"]
33
+ dev = [
34
+ "pytest>=9.0.2",
35
+ "pytest-cov>=7.1.0",
36
+ "pytest-xdist>=3.8",
37
+ "ruff>=0.14.10",
38
+ "scipy-stubs>=1.17.1,<1.19.0",
39
+ ]
34
40
 
35
41
  [tool.pytest.ini_options]
36
42
  pythonpath = ["."]
37
43
  testpaths = ["tests"]
38
44
 
45
+ [tool.coverage.run]
46
+ # the repository root, so every report path is the file's path in the repository
47
+ source = ["."]
48
+ omit = ["tests/*", ".venv/*", "build/*", "packaging/*"]
49
+ # the designer server's design worker and the fitter's pool are `multiprocessing` processes,
50
+ # measured too (and on every platform, unlike `patch = ["fork"]`); every process writes its
51
+ # own data file and they are combined at the end of the run
52
+ concurrency = ["multiprocessing", "thread"]
53
+ patch = ["subprocess"]
54
+ relative_files = true
55
+
39
56
  [build-system]
40
57
  requires = ["hatchling"]
41
58
  build-backend = "hatchling.build"
@@ -52,7 +52,9 @@ def test_mix_floor_is_measured_on_mix_not_walled_lfe():
52
52
  material = material_from({"L": source, "LFE": high_passed(source, 25, 10)})
53
53
  result = diagnose(material)
54
54
  params = DiagnoseParams()
55
- _, plateau = plateau_reference(result.mix_db, result.freqs, params)
55
+ # found on the whole mix spectrum, as diagnosis and the pipeline both find it
56
+ freqs, mix_db = mean_spectrum(material.mono_mix, 1000)
57
+ _, plateau = plateau_reference(mix_db, freqs, params)
56
58
  axis, strata = stratified_response(material.mono_mix, 1000, params, plateau)
57
59
  from beqforge.diagnose import smooth_unexcluded
58
60
 
@@ -6,7 +6,8 @@ Everything else about the designer binding is tested in isolation in
6
6
  `test_design_designer.py`, on purpose (see that file's docstring) — this file exists because
7
7
  none of those tests ever go through an actual HTTP request, so a bug in `_Handler` itself (a
8
8
  wrong status code, a header the client can't parse, `do_POST` never reaching `design()`) would
9
- be invisible to them.
9
+ be invisible to them. Every server here runs its designs in its own spawned worker process,
10
+ as production does.
10
11
 
11
12
  The accepted-candidate case costs a real, bounded fit — `strategies=("flatten",)` measured
12
13
  directly at ~4-8s (AGENTS.md's "40-80s a title" is for a full multi-strategy run against real,
@@ -35,17 +36,15 @@ from beqforge.designer import (
35
36
  from beqforge.harness import apply_high_pass
36
37
  from beqforge.material import LFE_GAIN
37
38
  from beqforge.pipeline import PipelineParams
38
- from tools.designer_server import DESIGN_PATH, HTTPServer, _Handler
39
+ from tools.designer_server import DESIGN_PATH, DesignServer, DesignSettings
39
40
 
40
41
 
41
42
  @pytest.fixture(scope="module")
42
43
  def server():
43
- """`HTTPServer`, same as production (see its fork-safety comment) — driven from a
44
- background thread here only because the test itself needs the main thread free to make
45
- requests; the real deployment calls `serve_forever()` from the process's only thread.
46
- """
47
- _Handler.params = PipelineParams(strategies=("flatten",))
48
- httpd = HTTPServer(("127.0.0.1", 0), _Handler)
44
+ """`DesignServer`, same as production, its designs in a spawned worker process."""
45
+ httpd = DesignServer(
46
+ ("127.0.0.1", 0), DesignSettings(PipelineParams(strategies=("flatten",)))
47
+ )
49
48
  thread = threading.Thread(target=httpd.serve_forever, daemon=True)
50
49
  thread.start()
51
50
  try:
@@ -53,6 +52,7 @@ def server():
53
52
  finally:
54
53
  httpd.shutdown()
55
54
  thread.join(timeout=5)
55
+ httpd.server_close()
56
56
 
57
57
 
58
58
  DESIGN_TIMEOUT_S = 900.0
@@ -223,15 +223,61 @@ def test_accepts_a_real_injected_rolloff_over_http(server) -> None:
223
223
  validate_response(_response_from_json(body)) # must not raise
224
224
 
225
225
 
226
- def _serve(monkeypatch, params: PipelineParams, cache):
227
- """A server of its own, with its own settings — `_Handler`'s are class attributes, so
228
- these are restored when the test ends and the module's shared server is unaffected."""
226
+ def test_health_answers_while_a_design_runs(server) -> None:
227
+ """The designs run in the worker process, so a health check never waits behind one."""
228
+ answered = []
229
+ designing = threading.Thread(
230
+ target=lambda: answered.append(
231
+ _request(
232
+ server,
233
+ "POST",
234
+ DESIGN_PATH,
235
+ body=_known_filter_request(),
236
+ timeout=DESIGN_TIMEOUT_S,
237
+ )
238
+ )
239
+ )
240
+ designing.start()
241
+ try:
242
+ status, _ = _request(server, "GET", "/health", timeout=3.0)
243
+ assert status == 200
244
+ assert designing.is_alive()
245
+ finally:
246
+ designing.join(timeout=DESIGN_TIMEOUT_S)
247
+ assert answered[0][0] == 200
248
+
249
+
250
+ def test_a_dead_worker_is_a_500_and_the_next_request_gets_a_new_one() -> None:
251
+ httpd, thread = _serve(PipelineParams(strategies=("flatten",)), None)
252
+ try:
253
+ for process in list(httpd.worker._pool._processes.values()):
254
+ process.kill()
255
+ process.join(timeout=10)
256
+ status, body = _request(
257
+ httpd.server_address, "POST", DESIGN_PATH, _no_evidence_request()
258
+ )
259
+ assert status == 500
260
+ assert "worker died" in body["error"]
261
+ status, body = _request(
262
+ httpd.server_address, "POST", DESIGN_PATH, _no_evidence_request()
263
+ )
264
+ assert status == 200
265
+ assert body["decline_reason"] == "channel_evidence_unavailable"
266
+ finally:
267
+ _stop(httpd, thread)
268
+
269
+
270
+ def _serve(params: PipelineParams, cache, shared_root=None):
271
+ """A server of its own, with its own settings and its own worker process."""
229
272
  # These tests exercise transport and cache identity/reuse, not section-budget
230
273
  # escalation. Still run the real pipeline with a bounded numerical search;
231
274
  # test_accepts_a_real_injected_rolloff_over_http retains the full budget.
232
- monkeypatch.setattr(_Handler, "params", replace(params, max_sections=1))
233
- monkeypatch.setattr(_Handler, "cache", cache)
234
- httpd = HTTPServer(("127.0.0.1", 0), _Handler)
275
+ httpd = DesignServer(
276
+ ("127.0.0.1", 0),
277
+ DesignSettings(
278
+ replace(params, max_sections=1), cache=cache, shared_root=shared_root
279
+ ),
280
+ )
235
281
  thread = threading.Thread(target=httpd.serve_forever, daemon=True)
236
282
  thread.start()
237
283
  return httpd, thread
@@ -255,14 +301,12 @@ BOTH = ("flatten", "parametric")
255
301
 
256
302
 
257
303
  def test_a_repeat_request_reuses_every_stage_and_answers_the_same(
258
- monkeypatch, tmp_path, caplog
304
+ tmp_path, caplog
259
305
  ) -> None:
260
306
  """R2a: `--cache-dir` — the second request for a title skips its analysis."""
261
307
  from beqforge.cache import DirStore
262
308
 
263
- httpd, thread = _serve(
264
- monkeypatch, PipelineParams(strategies=BOTH), DirStore(tmp_path)
265
- )
309
+ httpd, thread = _serve(PipelineParams(strategies=BOTH), DirStore(tmp_path))
266
310
  try:
267
311
  request = _known_filter_request()
268
312
  first = _request(
@@ -280,16 +324,12 @@ def test_a_repeat_request_reuses_every_stage_and_answers_the_same(
280
324
  assert list((tmp_path / "analysis").iterdir())
281
325
 
282
326
 
283
- def test_another_server_setting_reuses_the_analysis_only(
284
- monkeypatch, tmp_path, caplog
285
- ) -> None:
327
+ def test_another_server_setting_reuses_the_analysis_only(tmp_path, caplog) -> None:
286
328
  from beqforge.accept import AcceptParams
287
329
  from beqforge.cache import DirStore
288
330
 
289
331
  request = _known_filter_request()
290
- httpd, thread = _serve(
291
- monkeypatch, PipelineParams(strategies=BOTH), DirStore(tmp_path)
292
- )
332
+ httpd, thread = _serve(PipelineParams(strategies=BOTH), DirStore(tmp_path))
293
333
  try:
294
334
  _request(httpd.server_address, "POST", DESIGN_PATH, request, DESIGN_TIMEOUT_S)
295
335
  finally:
@@ -297,7 +337,7 @@ def test_another_server_setting_reuses_the_analysis_only(
297
337
  tilted = PipelineParams(
298
338
  strategies=BOTH, accept=AcceptParams(target_tilt_db_per_octave=1.0)
299
339
  )
300
- httpd, thread = _serve(monkeypatch, tilted, DirStore(tmp_path))
340
+ httpd, thread = _serve(tilted, DirStore(tmp_path))
301
341
  caplog.clear()
302
342
  try:
303
343
  with caplog.at_level(logging.INFO, logger="beqforge.cache"):
@@ -311,15 +351,11 @@ def test_another_server_setting_reuses_the_analysis_only(
311
351
  assert len(list((tmp_path / "parametric").iterdir())) == 2
312
352
 
313
353
 
314
- def test_bass_management_alone_reuses_the_analysis(
315
- monkeypatch, tmp_path, caplog
316
- ) -> None:
354
+ def test_bass_management_alone_reuses_the_analysis(tmp_path, caplog) -> None:
317
355
  from beqforge.cache import DirStore
318
356
 
319
357
  request = _known_filter_request()
320
- httpd, thread = _serve(
321
- monkeypatch, PipelineParams(strategies=("flatten",)), DirStore(tmp_path)
322
- )
358
+ httpd, thread = _serve(PipelineParams(strategies=("flatten",)), DirStore(tmp_path))
323
359
  try:
324
360
  _request(httpd.server_address, "POST", DESIGN_PATH, request, DESIGN_TIMEOUT_S)
325
361
  managed = {
@@ -343,8 +379,8 @@ def test_bass_management_alone_reuses_the_analysis(
343
379
  assert "analysis" in _reused(caplog)
344
380
 
345
381
 
346
- def test_without_a_cache_dir_nothing_is_written(monkeypatch, tmp_path) -> None:
347
- httpd, thread = _serve(monkeypatch, PipelineParams(strategies=("flatten",)), None)
382
+ def test_without_a_cache_dir_nothing_is_written(tmp_path) -> None:
383
+ httpd, thread = _serve(PipelineParams(strategies=("flatten",)), None)
348
384
  try:
349
385
  status, _ = _request(
350
386
  httpd.server_address, "POST", DESIGN_PATH, _no_evidence_request()
@@ -393,9 +429,8 @@ def _by_reference(request: dict, root) -> dict:
393
429
  }
394
430
 
395
431
 
396
- def test_health_says_whether_references_are_accepted(monkeypatch, tmp_path) -> None:
397
- httpd, thread = _serve(monkeypatch, PipelineParams(strategies=("flatten",)), None)
398
- monkeypatch.setattr(_Handler, "shared_root", tmp_path)
432
+ def test_health_says_whether_references_are_accepted(tmp_path) -> None:
433
+ httpd, thread = _serve(PipelineParams(strategies=("flatten",)), None, tmp_path)
399
434
  try:
400
435
  status, body = _request(httpd.server_address, "GET", "/health")
401
436
  finally:
@@ -404,14 +439,13 @@ def test_health_says_whether_references_are_accepted(monkeypatch, tmp_path) -> N
404
439
  assert body == {"status": "ok", "contract_version": "1.2", "shared_root": True}
405
440
 
406
441
 
407
- def test_a_request_by_reference_answers_as_the_same_request_inline(
408
- monkeypatch, tmp_path
409
- ) -> None:
442
+ def test_a_request_by_reference_answers_as_the_same_request_inline(tmp_path) -> None:
410
443
  """R2b over a real socket: same audio by path, same response."""
411
444
  inline = {**_known_filter_request(), "contract_version": "1.2"}
412
445
  by_reference = _by_reference(inline, tmp_path)
413
- httpd, thread = _serve(monkeypatch, PipelineParams(strategies=("flatten",)), None)
414
- monkeypatch.setattr(_Handler, "shared_root", tmp_path.resolve())
446
+ httpd, thread = _serve(
447
+ PipelineParams(strategies=("flatten",)), None, tmp_path.resolve()
448
+ )
415
449
  try:
416
450
  first = _request(
417
451
  httpd.server_address, "POST", DESIGN_PATH, inline, DESIGN_TIMEOUT_S
@@ -425,11 +459,9 @@ def test_a_request_by_reference_answers_as_the_same_request_inline(
425
459
  assert first[1] == second[1]
426
460
 
427
461
 
428
- def test_a_reference_without_a_shared_root_is_422_naming_the_array(
429
- monkeypatch, tmp_path
430
- ) -> None:
462
+ def test_a_reference_without_a_shared_root_is_422_naming_the_array(tmp_path) -> None:
431
463
  by_reference = _by_reference(_known_filter_request(duration_s=20.0), tmp_path)
432
- httpd, thread = _serve(monkeypatch, PipelineParams(strategies=("flatten",)), None)
464
+ httpd, thread = _serve(PipelineParams(strategies=("flatten",)), None)
433
465
  try:
434
466
  status, body = _request(httpd.server_address, "POST", DESIGN_PATH, by_reference)
435
467
  finally:
@@ -439,7 +471,7 @@ def test_a_reference_without_a_shared_root_is_422_naming_the_array(
439
471
  assert "no shared root" in body["reason"]
440
472
 
441
473
 
442
- def test_an_escaping_path_is_422_and_both_forms_is_400(monkeypatch, tmp_path) -> None:
474
+ def test_an_escaping_path_is_422_and_both_forms_is_400(tmp_path) -> None:
443
475
  root = tmp_path / "root"
444
476
  root.mkdir()
445
477
  by_reference = _by_reference(_known_filter_request(duration_s=20.0), root)
@@ -456,8 +488,9 @@ def test_an_escaping_path_is_422_and_both_forms_is_400(monkeypatch, tmp_path) ->
456
488
  **by_reference,
457
489
  "mono_mix": {**by_reference["mono_mix"], "data_base64": "AAAA"},
458
490
  }
459
- httpd, thread = _serve(monkeypatch, PipelineParams(strategies=("flatten",)), None)
460
- monkeypatch.setattr(_Handler, "shared_root", root.resolve())
491
+ httpd, thread = _serve(
492
+ PipelineParams(strategies=("flatten",)), None, root.resolve()
493
+ )
461
494
  try:
462
495
  escaped = _request(httpd.server_address, "POST", DESIGN_PATH, escaping)
463
496
  doubled = _request(httpd.server_address, "POST", DESIGN_PATH, both)
@@ -9,7 +9,9 @@ from beqforge.diagnose import (
9
9
  band_slope,
10
10
  band_tracking,
11
11
  diagnose,
12
+ mean_spectrum,
12
13
  mix_shares,
14
+ plateau_reference,
13
15
  steepest_slope,
14
16
  stratified_response,
15
17
  )
@@ -192,6 +194,34 @@ def test_the_plateau_reference_follows_the_channel_not_a_constant() -> None:
192
194
  )
193
195
 
194
196
 
197
+ @pytest.mark.parametrize("corner_hz", [144.0, 145.0, 146.0])
198
+ def test_a_plateau_reaching_the_band_top_is_the_one_the_pipeline_finds(
199
+ corner_hz,
200
+ ) -> None:
201
+ """Diagnosis measures tracking from the mix plateau the pipeline judges against.
202
+
203
+ A plateau that runs into the band's top edge was lost when the spectrum was cut off at the
204
+ last bin below it: diagnosis then measured no strata or tracking and reported the band top as
205
+ where tracking stopped, which the pipeline read as "nothing below it tracks" (A Few Good Men,
206
+ 150.5-200 Hz).
207
+ """
208
+ rng = np.random.default_rng(0)
209
+ mix = signal.sosfilt(
210
+ signal.butter(8, corner_hz, btype="high", fs=FS, output="sos"),
211
+ rng.standard_normal(int(FS * DURATION_S)),
212
+ )
213
+ mix = mix + 1e-3 * rng.standard_normal(mix.size)
214
+ freqs, spectrum = mean_spectrum(mix, FS)
215
+ level, plateau = plateau_reference(spectrum, freqs, PARAMS)
216
+ assert np.isfinite(level) and plateau[1] == PARAMS.band_hz[1]
217
+
218
+ result = diagnose(Material("edge", FS, mix, {}, "complete_programme"), PARAMS)
219
+
220
+ assert result.stratified
221
+ # nothing below the high-pass tracks, so tracking stops at the plateau's lower edge
222
+ assert result.noise_floor_hz == pytest.approx(plateau[0])
223
+
224
+
195
225
  def test_the_plateau_reference_clears_the_knee() -> None:
196
226
  """The reference must sit above the corner, or attenuation is measured against itself."""
197
227
  material = material_from(