Skip to content

Design a sensory screen before it runs: blocked, carry-over balanced serving plans - #481

Open
kgdunn wants to merge 5 commits into
mainfrom
claude/sensory-screening-doe-design-rqiwmy
Open

Design a sensory screen before it runs: blocked, carry-over balanced serving plans#481
kgdunn wants to merge 5 commits into
mainfrom
claude/sensory-screening-doe-design-rqiwmy

Conversation

@kgdunn

@kgdunn kgdunn commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

process_improve.sensory currently analyses panel data that already exists (validate, panel check, relate, compare_products). This PR adds the step before that: turning a long candidate list into a serving sheet a sensory lab can actually run.

New module src/process_improve/sensory/screening.py. A panel screen is not an ordinary designed experiment, and the module handles the three reasons why:

Session capacity. An assessor can only judge a handful of samples in one sitting before fatigue and carry-over dominate. Once the candidate list is longer than a session, every assessor sees only a subset, so the design is an incomplete block design with the assessor-session as the block. cyclic_block_design builds the blocks so replication is equal (or as near-equal as the numbers allow) and every pair of candidates meets inside a block about equally often - the pairwise concurrence, which decides how precisely two candidates can be compared with each other.

Carry-over and position. williams_design returns serving orders in which every treatment appears once per position and every ordered pair occurs equally often, so no candidate is systematically favoured by what was tasted before it. An even treatment count needs one square; an odd count needs a mirrored second one.

Sensitivity. detectable_difference converts a residual standard deviation and a panel size into the smallest difference the screen can resolve; required_panelists inverts it. Both Bonferroni-correct for the size of the comparison family, because testing 20 candidates against one control is 20 comparisons.

sensory_screening_plan assembles all three into one sheet, optionally anchoring a reference sample first in every block so session and assessor drift can be removed later. The sheet uses the descriptive_long column names, so filling in the scores feeds it straight into compare_products with panelist_id as the block - the design half and the analysis half now close the loop.

Design note: why two move types in the block construction

The greedy fill lands close to balance but not on it. The hill-climb that follows uses two moves and needs both:

  • a substitution (one treatment in a block replaced by one not in it) changes replication, so it is what levels replication out;
  • an exchange (two blocks trade one treatment each) leaves replication untouched, so it is what goes on improving concurrence after replication has settled.

With replication already level, every substitution makes it worse, so a substitution-only search stalls one step short. With the exchange added, the construction recovers an exact balanced incomplete block design when one exists - verified against the Fano plane (t=7, k=3, b=7, r=3, lambda=1).

Behaviour worth knowing

  • A panel too small to cover the candidate list is reported in warnings, not silently truncated. Quietly screening 18 of your 21 candidates is the failure mode worth being loud about.
  • plan_diagnostics says whether the blocks are an exact BIBD (balanced), plus replication and concurrence min/max/mean, control coverage and position balance, so the plan is contestable rather than taken on faith.
  • Everything is seeded and reproducible.

Also included

  • Agent tools sensory_screening_plan and sensory_detectable_difference (@tool_spec, registered in get_sensory_tool_specs).
  • A new "Before the panel: designing the screen" section in docs/user_guide/sensory_panel.rst.
  • Version 1.62.2 -> 1.63.0 (new module), CITATION.cff and CHANGELOG.md kept in sync.

Test plan

  • uv run pytest tests/test_sensory_screening.py --no-cov - 36 tests pass
  • Williams squares: ordered-pair balance verified for t = 2, 4, 6, 8 (each pair once) and t = 3, 5, 7 (each pair twice), plus the Latin-square position property
  • cyclic_block_design recovers the Fano plane; near-equal replication (max - min <= 1) for a (20, 6, 10) case with no exact BIBD; deterministic for a seed
  • Serving plan: capacity respected, no repeat within a block, control in every block at position 1, reproducible, shortfall warned, invalid inputs rejected
  • detectable_difference monotone in n and in the comparison count, linear in sd; required_panelists verified to be the smallest n that reaches the target (n-1 does not)
  • uv run ruff check and uv run mypy src/process_improve/sensory/screening.py clean

Note: uv.lock is deliberately not touched.


Generated by Claude Code

claude added 3 commits August 3, 2026 12:19
Everything in `process_improve.sensory` so far analyses panel data that
already exists. This adds the step before it: turning a long candidate
list into a serving sheet a sensory lab can run.

A panel screen is not an ordinary designed experiment, and the new module
handles the three reasons why:

- Session capacity. An assessor can only judge a handful of samples in one
  sitting, so once the candidate list is longer than a session the design is
  an incomplete block design with the assessor-session as the block.
  `cyclic_block_design` builds the blocks by a greedy fill followed by a
  hill-climb over two move types (substitution to level replication,
  exchange to level concurrence once replication has settled). Both moves
  are needed: with replication already level, every substitution makes it
  worse, so without the exchange the search stalls one step short of an
  exact balanced incomplete block design.
- Carry-over and position. `williams_design` returns serving orders in which
  every treatment appears once per position and every ordered pair occurs
  equally often; an odd treatment count needs the mirrored second square.
- Sensitivity. `detectable_difference` / `required_panelists` convert a
  residual SD and a panel size into the smallest resolvable difference and
  back, with a Bonferroni correction for the comparison family.

`sensory_screening_plan` assembles the three into one sheet, optionally
anchoring a reference first in every block. The sheet uses the
`descriptive_long` column names, so adding scores feeds it straight into
`compare_products` with `panelist_id` as the block. A panel too small to
cover the candidate list is reported in `warnings` rather than silently
dropping candidates.

36 tests, including the Fano plane as a known balanced incomplete block
design and the ordered-pair balance of the Williams squares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqdSBoXb1DpSobB76pMzFi
The "exactly one of n_per_product / difference" guard was written as a
single equality on two `is None` tests, which mypy cannot use to narrow
either field, so `difference: float | None` reached `required_panelists`.
Written as two positive tests instead, each branch narrows at its own call
site and the ambiguous case falls through to the same error response.

Also restores a Field() call that the formatter had collapsed; it is not
part of this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqdSBoXb1DpSobB76pMzFi
Sphinx builds with -W, so the one-character-short underline under
"Before the panel: designing the screen" failed the docs job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqdSBoXb1DpSobB76pMzFi
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.33887% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/process_improve/sensory/screening.py 98.07% 2 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

claude added 2 commits August 3, 2026 12:36
Two fixes for CI on this branch.

1. `test_registered_in_registry` asserted that dummies registered by two
   OTHER tests in the class were in the global registry. That only holds
   when all three run in the same process, and pytest-xdist distributes
   them across workers, so it was one scheduling change away from failing.
   Adding tools to the registry was that change. It now registers its own
   tool and asserts on that, so it is independently runnable.

2. Screening coverage 88% -> 99%. The uncovered lines were the guard
   clauses, and those are worth testing individually: the message is the
   deliverable, because a scientist who asks for an impossible design needs
   to know which number to change. Also covers the tool's ValueError path
   (an unreachable target difference comes back as ok=False rather than
   raising).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqdSBoXb1DpSobB76pMzFi
The Cochran & Cox and Naes et al. citations carried specific chapter
numbers I could not verify. A precise-looking wrong pointer is worse than
no pointer, so they are replaced with what each source actually
contributes - and the Naes entry now says plainly that the block
construction is NOT in it, which is the thing a reader would otherwise go
looking for there and not find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqdSBoXb1DpSobB76pMzFi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants