Predicate Lab

A focused testing strip for a single predicate. The caller supplies a predicate(input) => boolean, an ordered inputs array, and an optional expected array of booleans. The component runs the predicate against every input, catches any thrown errors per row, and renders a side-by-side strip with the actual verdict next to the expected verdict — so the monotonic flip in feasibility tests, the boundary in a binary-search-on-answer-space drill, or a regression in a unit suite becomes a single glance.

Pure layout / playback primitive — it does not score, gate, or grade. Controlled (activeIndex + onActiveIndexChange) and uncontrolled (defaultActiveIndex) on the Radix pattern; built-in prev / next chrome can be hidden when the parent drives focus from outside.

7 inputs tested; 7 of 7 match expected.
Can ship 10 packages in 5 days at capacity…
#1input:cap = 10
Greedy packing — find the smallest cap where feasibility flips to true.
1 / 7
Customize
Highlight
1

Installation

npx shadcn@latest add https://craftbits.dev/r/predicate-lab.json

Usage

import { PredicateLab } from "@craft-bits/core";
 
function isEven(n: number) {
  return n % 2 === 0;
}
 
<PredicateLab
  predicate={isEven}
  inputs={[1, 2, 3, 4, 5]}
  expected={[false, true, false, true, false]}
/>

Controlled — the parent owns the active row and can drive it from a prediction gate or autoplay timer:

const [activeIndex, setActiveIndex] = useState(0);
 
<PredicateLab
  predicate={isEven}
  inputs={[1, 2, 3, 4, 5]}
  expected={[false, true, false, true, false]}
  activeIndex={activeIndex}
  onActiveIndexChange={setActiveIndex}
/>

Subscribe to the full results array — useful for the caller to gate "continue" on full coverage, score the run, or persist a diff:

<PredicateLab
  predicate={isEven}
  inputs={[1, 2, 3, 4, 5]}
  expected={[false, true, false, true, false]}
  onResults={(results) => {
    const allMatch = results.every((r) => r.matches);
    setCanContinue(allMatch);
  }}
/>

Generic over the input type — pass a formatInput to control how each input renders:

type Case = { name: string; arr: number[]; target: number };
 
<PredicateLab
  predicate={(c) => c.arr.includes(c.target)}
  inputs={CASES}
  expected={CASES.map((c) => c.target % 2 === 0)}
  formatInput={(c) => c.name + ": includes " + c.target + "?"}
/>

Understanding the component

  1. Pure predicate replay. Every render runs the predicate once per row inside a useMemo keyed on (predicate, inputs, expected). The same triple always produces the same verdict strip — no internal scoring, no internal timing, no internal sound.
  2. Caught predicate errors. A predicate that throws on a row's input is caught and surfaced as an error verdict on that row — the strip never crashes, and the error message is visible in the active-row banner.
  3. Expected vs actual. Each row renders the actual verdict (T / F / err) next to the expected verdict in a small two-cell badge — matches share the tone colour, mismatches and errors flip to the error tone so the regression reads at a glance.
  4. Controlled and uncontrolled activeIndex. Pair activeIndex with onActiveIndexChange for Radix-style control, or pass defaultActiveIndex and let the component own its focus. Built-in prev / next chrome can be hidden when the parent drives focus from outside.
  5. Active-row banner. The currently focused row gets a separate accent-tinted card above the strip showing the row index, the formatted input, an optional description, and the predicate's thrown error message if any. Keying the banner on activeIndex cross-fades softly between rows.
  6. Row anatomy. Every row is a real <button> carrying data-row-index, data-status (match / mismatch / error / unverified), and data-active — assistive tooling and consumer styles hook into these without depending on classnames.
  7. onResults subscription. The component fires onResults once per change in the computed results array — callers gate "continue" on full coverage, score the run, or persist a diff without re-running the predicate themselves.
  8. Hit targets. Every row and the prev / next buttons all clear the 44 by 44 px WCAG 2.5.8 floor.
  9. Reduced motion. Row enter, active-row cross-fade, and prev / next press scale all collapse to instant under prefers-reduced-motion: reduce. The strip still advances; only the motion drops.

Props

PropTypeDefaultDescription
predicate(input: T) => booleanrequiredPredicate to test. Thrown errors are caught and rendered as an error verdict.
inputsreadonly T[]requiredOrdered inputs to test the predicate against.
expectedreadonly boolean[]Expected verdict per input. Missing indices render as unverified (neutral).
formatInput(input: T, index: number) => ReactNodeString(input)Custom renderer for each input label.
titleReactNodeOptional title rendered above the strip.
descriptionReactNodeOptional caption rendered inside the active-row banner.
activeIndexnumberControlled active row. Pair with onActiveIndexChange.
defaultActiveIndexnumber0Uncontrolled initial active row.
onActiveIndexChange(next: number) => voidFires when the active row changes.
onResults(results: readonly PredicateLabResult<T>[]) => voidFires with the full results array whenever the predicate, inputs, or expected change.
tone"default" | "accent" | "success" | "warning" | "error""accent"Semantic tone for the active row, match dots, and focus ring.
prevLabelstring"Prev"Label for the previous-row button.
nextLabelstring"Next"Label for the next-row button.
hideControlsbooleanfalseHide the built-in prev / next chrome.
transitionTransitionSPRINGS.smoothOverride transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the root via cn().

Accessibility

  • A visually-hidden aria-live="polite" region narrates the row count, match tally, and any predicate errors as the inputs change.
  • The root carries aria-roledescription="predicate test lab" plus data-state, data-tone, and data-status attributes so assistive tooling and consumer styles can hook into the lab's overall verdict.
  • Each row is a real <button> with an explicit aria-label summarising actual, expected, and match status, plus aria-current="true" on the active row.
  • Prev / next buttons render at 44 by 44 px minimum, disable themselves at the ends, and respond to keyboard Enter and Space. Arrow-left / arrow-right keys on the root advance the active row.
  • Verdict glyphs (T / F / err) are paired with tone tinting, an inset border, and the explicit aria-label text — verdict is never communicated by colour alone.
  • Motion respects prefers-reduced-motion: reduce — row enter, active-row cross-fade, and prev / next press scale collapse to instant. The strip still advances; only the motion drops.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/decision/PredicateLab.tsx). The source was a 2900-line four-act game (Probe, Discover, Build, Search) that drove a shipping-capacity binary-search puzzle through manual probing, a number-line discovery of the F-F-F-T-T-T monotonic pattern, a drag-and-drop predicate assembly with distractor tiles, and a spatial binary search with prediction gates — all wired to a reducer with twenty-plus phases, per-step audio cues, a hardcoded greedy-packing simulator, and a built-in weighted scoring formula. The library extract drops the game, the reducer, the scoring, the audio, the simulator, the morph transitions, and the tile drag-and-drop. What remains is the testing spine: a predicate, a range of inputs, an optional expected array, and a side-by-side actual / expected strip. Consumers compose any acts, narration, prediction gates, or sound on top.