feat(scheduler-allocation): implement parjob's combinatorial plan generation

Add the real xce_alloc_plan_iter_t for parjob: for host-count k=1..maxTasks
(ascending, most-preferred-first), generate one balanced partition of
maxTasks into k buckets (floor/ceil split, remainder to the first rem
buckets), which is descending by construction. Pure function of maxTasks
alone -- never dereferences sched -- with a real pthread_testcancel()
cancellation point and stable post-exhaustion NULL (Req 2.1-2.6, 4.1).

my_alloc()/parjob_prepare_allocation() remain untouched stubs; wiring the
iterator into real allocation is task 4.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYdtrEWWZfKRRGED7raEYg
This commit is contained in:
Rongsong Shen 2026-09-04 20:04:44 +00:00
parent 9d64ba0d69
commit 6ca717fb86
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
4 changed files with 583 additions and 1 deletions

View file

@ -51,7 +51,7 @@
- _Depends: 3.1_
- [ ] 4. Core: parjob combinatorial allocation
- [ ] 4.1 Implement parjob's lazy combinatorial plan generation
- [x] 4.1 Implement parjob's lazy combinatorial plan generation
- Generate candidate plans as a pure function of a job's task count: ascending host-count order, one balanced partition per host-count, descending bucket order within each plan
- Ensure each call advances a real cancellation point and that plan generation never inspects live worker state (a pure function of the task count alone)
- Write a unit test proving one-plan-per-call generation, most-preferred-first ordering (including an uneven split, e.g. splitting 7 tasks across 3 hosts), and a stable exhaustion signal

View file

@ -8,6 +8,7 @@
#include "kvmgr.h"
#include "plugin.h"
#include "preason.h"
#include "sched/alloc_iter.h"
#include "scheduler.h"
#include "utils.h"
#include "xce/types.h"
@ -74,6 +75,160 @@ static int parjob_prepare_allocation(void* arg, xce_sched_ctx_t* ctx)
return 0;
}
/*
* parjob's combinatorial xce_alloc_plan_iter_t (task 4.1, Req 2 AC 1-6 /
* Req 4 AC 1). Unlike seqjob's fixed single-plan iterator
* (seqjob_allocator.c), this generates the full ascending host-count
* sequence k = 1..maxTasks, one balanced partition of maxTasks into k
* buckets per host-count, most-preferred (fewest hosts) first.
*
* Pure function of maxTasks alone (Req 4 AC 1's "1host x N, 2hosts x
* ceil(N/2), ..., Nhosts x 1" sequence, generalized to real partitions
* per requirements.md's "Data Structure Changes Summary"): `sched` is
* accepted (fixed by the shared xce_alloc_plan_iter_t.init() signature)
* but never dereferenced -- plan SHAPE and ORDER never inspect
* sched->core.workers or any other live worker/resource state. Only
* try_alloc_on_host()/host_iter.c (task 4.2's job) ever look at live
* worker state, to decide whether a generated plan succeeds.
*
* Partition rule for a given host-count k (requirements.md, "Data
* Structure Changes Summary", v1 default): the canonical BALANCED split
* -- (maxTasks mod k) buckets get ceil(maxTasks/k) slots, the remaining
* (k - maxTasks mod k) buckets get floor(maxTasks/k) slots. E.g.
* maxTasks=7, k=3 -> [3,2,2]. Filling the first `rem` buckets with
* (base+1) and the rest with `base` already yields descending
* (largest-first) bucket order within the plan by construction -- no
* separate sort step is needed, and this is the v1 default bucket order
* requirements.md specifies (a real correctness property here, not a
* fail-fast heuristic -- see the Composition Algorithm's worked example).
*
* `bucket_slots` is one heap buffer sized to the tightest true upper
* bound (maxTasks, since a plan can never need more buckets than
* maxTasks -- each bucket needs >=1 slot) and reused across next() calls
* within one iterator instance, refilled from index 0 up to the current
* plan's own nbuckets each call -- matching xce_alloc_plan_t's own
* documented "valid until the next next()/fini() call" contract.
*
* plan.ctx is left NULL here, exactly like seqjob_plan_iter_init()'s own
* convention (seqjob_allocator.c): init()'s fixed (iter, resreq, sched)
* signature has no ctx parameter to thread it through. Setting
* plan->ctx = ctx on every plan returned from next(), before
* constructing a host_iter from it, is the CALLER's responsibility
* (task 4.2's my_alloc(), not this iterator) -- see alloc_iter.h's
* xce_alloc_plan_t.ctx doc comment and tasks.md's task 2.1 implementation
* note.
*/
typedef struct {
xce_alloc_plan_t plan;
int* bucket_slots; /* heap buffer, size == max_tasks */
int max_tasks; /* N -- primary_resreq->maxTasks */
int next_k; /* next host-count to generate, 1-based */
} parjob_plan_iter_priv_t;
static int parjob_plan_iter_init(xce_alloc_plan_iter_t* iter,
xce_primary_resreq_t* resreq,
xce_scheduler_t* sched)
{
parjob_plan_iter_priv_t* priv;
int max_tasks;
/* Req 4 AC 1 / "pure function of the task count alone": sched is part
* of the shared init() signature but never inspected -- plan
* generation must not touch live worker/resource state. */
(void)sched;
if ((iter == NULL) || (resreq == NULL)) {
return -EINVAL;
}
max_tasks = resreq->maxTasks;
if (max_tasks < 1) {
return -EINVAL;
}
priv = (parjob_plan_iter_priv_t*)calloc(1, sizeof(*priv));
if (priv == NULL) {
return -ENOMEM;
}
priv->bucket_slots = (int*)calloc((size_t)max_tasks, sizeof(int));
if (priv->bucket_slots == NULL) {
free(priv);
return -ENOMEM;
}
priv->max_tasks = max_tasks;
priv->next_k = 1;
priv->plan.nbuckets = 0;
priv->plan.bucket_slots = priv->bucket_slots;
priv->plan.ctx = NULL;
iter->private = priv;
return 0;
}
static xce_alloc_plan_t* parjob_plan_iter_next(xce_alloc_plan_iter_t* iter)
{
parjob_plan_iter_priv_t* priv;
int k;
int base;
int rem;
int i;
/* Req 2 AC 6: a real cancellation point at least once per invocation
* -- combinatorial plan generation is fast, non-blocking pure
* computation with no cancellation point of its own. Called
* unconditionally, including once already-exhausted, matching
* host_iter.c's/seqjob_plan_iter_next()'s own convention. */
pthread_testcancel();
if ((iter == NULL) || (iter->private == NULL)) {
return NULL;
}
priv = (parjob_plan_iter_priv_t*)iter->private;
/* Req 2 AC 4: stable end-of-iteration signal, once host-count would
* exceed maxTasks, across every repeated call thereafter. */
if (priv->next_k > priv->max_tasks) {
return NULL;
}
k = priv->next_k;
priv->next_k++;
base = priv->max_tasks / k;
rem = priv->max_tasks % k;
for (i = 0; i < rem; i++) {
priv->bucket_slots[i] = base + 1;
}
for (i = rem; i < k; i++) {
priv->bucket_slots[i] = base;
}
priv->plan.nbuckets = k;
return &(priv->plan);
}
static void parjob_plan_iter_fini(xce_alloc_plan_iter_t* iter)
{
parjob_plan_iter_priv_t* priv;
if ((iter == NULL) || (iter->private == NULL)) {
return;
}
priv = (parjob_plan_iter_priv_t*)iter->private;
free(priv->bucket_slots);
free(priv);
iter->private = NULL;
}
static int my_alloc(xce_sched_plugin_t* plugin, xce_sched_ctx_t* ctx)
{
xce_scheduler_t* sched = (xce_scheduler_t*)plugin->sched;

View file

@ -1559,6 +1559,27 @@ sched_seqjob_rewrap_equivalence_test = executable(
dependencies: [scheduler_test_deps],
install: false)
# Task 4.1 (scheduler-allocation spec, Req 2 AC 1-6, Req 4 AC 1):
# parjob_allocator.c's REAL combinatorial xce_alloc_plan_iter_t
# implementation -- ascending host-count plan order, one balanced
# ceil/floor partition per host-count, descending bucket order within
# each plan, a stable exhaustion signal, and a real pthread_testcancel()
# cancellation point. White-box #include of
# ../src/plugins/parjob_allocator.c itself (the file under test) --
# simpler than sched_seqjob_allocator_test's own white-box stack since
# parjob_allocator.c's still-stubbed my_alloc()/parjob_prepare_allocation()
# bodies (untouched by this task -- task 4.2's job) make no calls into
# core.c/resource.c/schedobj.c/host_iter.c at all, so none of those need
# to be pulled in here. Only my_alloc()/prepare_allocation() themselves
# are never called by this file's tests -- they exercise
# parjob_plan_iter_init()/_next()/_fini() directly.
sched_parjob_combinatorial_test = executable(
'sched_parjob_combinatorial_test',
['test_parjob_combinatorial.c'],
include_directories:[incdir],
dependencies: [scheduler_test_deps],
install: false)
# Register as meson tests so `meson test --suite scheduler` runs them all.
# detect_leaks=0 matches the suite convention (fixtures are not torn down).
test('scheduler test', scheduler_test, suite: 'scheduler',
@ -1756,3 +1777,6 @@ test('scheduler seqjob allocator test',
test('scheduler seqjob rewrap equivalence test',
sched_seqjob_rewrap_equivalence_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])
test('scheduler parjob combinatorial test',
sched_parjob_combinatorial_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])

View file

@ -0,0 +1,403 @@
/*
* test_parjob_combinatorial.c - task 4.1 (scheduler-allocation spec):
* unit tests for parjob_allocator.c's REAL combinatorial
* xce_alloc_plan_iter_t implementation (Req 2 AC 1-6, Req 4 AC 1).
*
* Task 4.1's boundary is the plan-ITERATOR only -- parjob_allocator.c's
* my_alloc()/parjob_prepare_allocation() bodies are untouched stubs,
* still returning 0 unconditionally (task 4.2's job). So this file never
* calls my_alloc(); it drives parjob_plan_iter_init()/_next()/_fini()
* (static functions, hence the white-box #include below) directly,
* exactly the way a future my_alloc() will.
*
* White-box #include convention: same as test_seqjob_allocator.c
* (task 3.1), but simpler -- unlike seqjob_allocator.c's my_alloc(),
* parjob_allocator.c's current my_alloc()/parjob_prepare_allocation()
* stub bodies make no calls into resource.c/schedobj.c/core.c/host_iter.c
* at all (confirmed by reading the file: both just touch struct fields
* and return 0), so only ../src/plugins/parjob_allocator.c itself needs
* to be pulled in, plus a link-satisfying stub for plugin_register()/
* plugin_unregister() (referenced by register_parjob_allocator(), never
* itself invoked by any test below).
*
* Covers:
* - one-plan-per-call generation (Req 2 AC 2)
* - most-preferred-first ordering: ascending host-count k = 1..N
* (Req 2 AC 3, Req 4 AC 1)
* - the canonical balanced partition rule, including an uneven split
* (N=7 across k=3 hosts -> [3,2,2]) (Req 4 AC 1)
* - descending bucket order within one plan, by construction (Req 2
* AC 2's "not a uniform pair" + the Composition Algorithm's
* descending-order correctness property)
* - a stable end-of-iteration signal once k > N, stable across
* repeated post-exhaustion calls (Req 2 AC 4)
* - purity: plan shape/order is a pure function of maxTasks alone,
* never inspecting live worker/scheduler state (Req 4 AC 1's "never
* inspects live worker state" clause) -- proved by passing a NULL
* xce_scheduler_t* to init() and confirming plans still generate
* correctly
* - fini() releases the iterator's private state (Req 2 AC 5)
* - Req 2 AC 6: next() calls pthread_testcancel() (or an equivalent
* real cancellation point) -- proved behaviorally, same technique
* test_host_iter.c already established for host_iter_next().
*/
#include <errno.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <unity/unity.h>
#include "resreq.h"
#include "sched/alloc_iter.h"
#include "scheduler.h"
/* Pull in parjob_allocator.c itself (the file under test) -- the plan
* iterator functions (parjob_plan_iter_init/next/fini) are `static`, so
* this white-box #include is the only way to reach them directly,
* matching test_seqjob_allocator.c's own established convention. */
#include "../src/plugins/parjob_allocator.c"
/* ---------------------------------------------------------------------- */
/* Link-satisfying stubs */
/* ---------------------------------------------------------------------- */
/* plugin_register()/plugin_unregister() are referenced by
* register_parjob_allocator() (parjob_allocator.c), but no test below
* calls register_parjob_allocator() -- the plan iterator is exercised
* directly. Never actually invoked by anything this file's tests
* exercise; provided only so the TU links. */
int plugin_register(void* sched, void* plugin)
{
(void)sched;
(void)plugin;
return 0;
}
void plugin_unregister(void* arg) { (void)arg; }
/* ---------------------------------------------------------------------- */
/* Helpers */
/* ---------------------------------------------------------------------- */
static void bind_plan_iter(xce_alloc_plan_iter_t* iter)
{
memset(iter, 0, sizeof(*iter));
iter->init = parjob_plan_iter_init;
iter->next = parjob_plan_iter_next;
iter->fini = parjob_plan_iter_fini;
}
static void assert_plan_eq(xce_alloc_plan_t* plan, const int* expect,
int nexpect)
{
int i;
TEST_ASSERT_NOT_NULL(plan);
TEST_ASSERT_EQUAL_INT(nexpect, plan->nbuckets);
for (i = 0; i < nexpect; i++) {
TEST_ASSERT_EQUAL_INT(expect[i], plan->bucket_slots[i]);
}
}
/* ---------------------------------------------------------------------- */
/* Tests */
/* ---------------------------------------------------------------------- */
/*
* Req 2 AC 2/AC 3, Req 4 AC 1: for maxTasks = 7, successive next() calls
* return exactly one plan per call, in ascending host-count order
* (k = 1..7), each plan the canonical balanced ceil/floor partition with
* buckets in descending order. k=3 is the uneven-split worked example
* from requirements.md: [3,2,2], not [2,2,3]/[2,3,2]/[5,1,1].
*/
static void
test_plans_ordered_ascending_host_count_with_balanced_partitions(void)
{
xce_primary_resreq_t resreq;
xce_alloc_plan_iter_t iter_storage;
xce_alloc_plan_iter_t* iter = &iter_storage;
xce_alloc_plan_t* plan;
/* Expected balanced partition for maxTasks=7, per host-count k=1..7,
* descending bucket order within each -- worked by hand against
* requirements.md's "(N mod k) buckets get ceil(N/k), the rest get
* floor(N/k)" rule. */
static const int expect_k1[] = { 7 };
static const int expect_k2[] = { 4, 3 };
static const int expect_k3[] = { 3, 2, 2 }; /* the uneven-split example */
static const int expect_k4[] = { 2, 2, 2, 1 };
static const int expect_k5[] = { 2, 2, 1, 1, 1 };
static const int expect_k6[] = { 2, 1, 1, 1, 1, 1 };
static const int expect_k7[] = { 1, 1, 1, 1, 1, 1, 1 };
memset(&resreq, 0, sizeof(resreq));
resreq.minTasks = 1;
resreq.maxTasks = 7;
bind_plan_iter(iter);
TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL));
plan = iter->next(iter);
assert_plan_eq(plan, expect_k1, 1);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k2, 2);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k3, 3);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k4, 4);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k5, 5);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k6, 6);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k7, 7);
/* Req 2 AC 4: exhausted once k would exceed maxTasks (here k=8 > 7). */
TEST_ASSERT_NULL(iter->next(iter));
/* Stable across repeated post-exhaustion calls. */
TEST_ASSERT_NULL(iter->next(iter));
TEST_ASSERT_NULL(iter->next(iter));
iter->fini(iter);
TEST_ASSERT_NULL(iter->private);
}
/*
* A clean divisor case (maxTasks = 6): every partition splits evenly, so
* every bucket in every plan carries the same slot count -- descending
* order is trivially satisfied (all equal), and this exercises rem == 0
* for every k that evenly divides 6 (k=1,2,3,6) as well as k=4,5 where
* rem != 0.
*/
static void test_plans_for_evenly_divisible_task_count(void)
{
xce_primary_resreq_t resreq;
xce_alloc_plan_iter_t iter_storage;
xce_alloc_plan_iter_t* iter = &iter_storage;
xce_alloc_plan_t* plan;
static const int expect_k1[] = { 6 };
static const int expect_k2[] = { 3, 3 };
static const int expect_k3[] = { 2, 2, 2 };
static const int expect_k4[] = { 2, 2, 1, 1 };
static const int expect_k5[] = { 2, 1, 1, 1, 1 };
static const int expect_k6[] = { 1, 1, 1, 1, 1, 1 };
memset(&resreq, 0, sizeof(resreq));
resreq.minTasks = 1;
resreq.maxTasks = 6;
bind_plan_iter(iter);
TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL));
plan = iter->next(iter);
assert_plan_eq(plan, expect_k1, 1);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k2, 2);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k3, 3);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k4, 4);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k5, 5);
plan = iter->next(iter);
assert_plan_eq(plan, expect_k6, 6);
TEST_ASSERT_NULL(iter->next(iter));
TEST_ASSERT_NULL(iter->next(iter));
iter->fini(iter);
}
/*
* maxTasks = 1: exactly one plan (a single host, single slot), then
* stable exhaustion. Degenerate but required boundary case -- parjob is
* only ever reached for maxTasks > 1 in production (sched_make_primary_
* alloc_()'s dispatch), but the iterator itself makes no such assumption.
*/
static void test_single_task_yields_single_plan(void)
{
xce_primary_resreq_t resreq;
xce_alloc_plan_iter_t iter_storage;
xce_alloc_plan_iter_t* iter = &iter_storage;
xce_alloc_plan_t* plan;
static const int expect_k1[] = { 1 };
memset(&resreq, 0, sizeof(resreq));
resreq.minTasks = 1;
resreq.maxTasks = 1;
bind_plan_iter(iter);
TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL));
plan = iter->next(iter);
assert_plan_eq(plan, expect_k1, 1);
TEST_ASSERT_NULL(iter->next(iter));
iter->fini(iter);
}
/*
* Req 4 AC 1 ("never inspects live worker state" / "pure function of the
* task count alone"): the SAME maxTasks value must yield IDENTICAL plan
* sequences regardless of what (if anything) `sched` points to --
* including a NULL scheduler, and a garbage-but-non-NULL one whose
* fields are never touched. Since parjob_plan_iter_init()/_next() never
* dereference `sched` at all, a non-NULL "poisoned" scheduler pointer
* that would crash on any real field access is a stronger proof of
* purity than merely passing NULL.
*/
static void test_plan_generation_ignores_scheduler_state(void)
{
xce_primary_resreq_t resreq;
xce_alloc_plan_iter_t iter_a_storage;
xce_alloc_plan_iter_t* iter_a = &iter_a_storage;
xce_alloc_plan_iter_t iter_b_storage;
xce_alloc_plan_iter_t* iter_b = &iter_b_storage;
xce_scheduler_t* poisoned_sched = (xce_scheduler_t*)0x1;
int k;
memset(&resreq, 0, sizeof(resreq));
resreq.minTasks = 1;
resreq.maxTasks = 7;
bind_plan_iter(iter_a);
TEST_ASSERT_EQUAL_INT(0, iter_a->init(iter_a, &resreq, NULL));
bind_plan_iter(iter_b);
/* A deliberately invalid, non-dereferenceable pointer -- init()/
* next() must never read through it, or this test would crash
* instead of merely failing an assertion. */
TEST_ASSERT_EQUAL_INT(0, iter_b->init(iter_b, &resreq, poisoned_sched));
for (k = 1; k <= 7; k++) {
xce_alloc_plan_t* plan_a = iter_a->next(iter_a);
xce_alloc_plan_t* plan_b = iter_b->next(iter_b);
int i;
TEST_ASSERT_NOT_NULL(plan_a);
TEST_ASSERT_NOT_NULL(plan_b);
TEST_ASSERT_EQUAL_INT(plan_a->nbuckets, plan_b->nbuckets);
for (i = 0; i < plan_a->nbuckets; i++) {
TEST_ASSERT_EQUAL_INT(plan_a->bucket_slots[i],
plan_b->bucket_slots[i]);
}
}
TEST_ASSERT_NULL(iter_a->next(iter_a));
TEST_ASSERT_NULL(iter_b->next(iter_b));
iter_a->fini(iter_a);
iter_b->fini(iter_b);
}
/*
* init() rejects invalid input rather than crashing: a NULL iter, a NULL
* resreq, and a non-positive maxTasks.
*/
static void test_init_rejects_invalid_input(void)
{
xce_primary_resreq_t resreq;
xce_alloc_plan_iter_t iter_storage;
xce_alloc_plan_iter_t* iter = &iter_storage;
memset(&resreq, 0, sizeof(resreq));
resreq.maxTasks = 3;
bind_plan_iter(iter);
TEST_ASSERT_TRUE(iter->init(NULL, &resreq, NULL) != 0);
TEST_ASSERT_TRUE(iter->init(iter, NULL, NULL) != 0);
resreq.maxTasks = 0;
TEST_ASSERT_TRUE(iter->init(iter, &resreq, NULL) != 0);
}
/*
* Req 2 AC 6: next() calls pthread_testcancel() (or an equivalent real
* cancellation point) at least once per invocation. Proved behaviorally,
* same technique as test_host_iter.c's test_next_has_a_real_cancellation_
* point(): a worker thread parked in a tight loop calling next()
* repeatedly (spinning past exhaustion, which still executes the
* cancellation point on every call) is reliably torn down by
* pthread_cancel() from the main thread. If parjob_plan_iter_next() had
* no real cancellation point, this tight loop would never yield to the
* pending cancellation and pthread_join() below would hang (timing out
* the whole test binary) rather than return.
*/
typedef struct {
xce_alloc_plan_iter_t* plan_iter;
} cancel_thread_arg_t;
static void* cancel_spin_thread(void* argp)
{
cancel_thread_arg_t* arg = (cancel_thread_arg_t*)argp;
for (;;) {
(void)arg->plan_iter->next(arg->plan_iter);
}
return NULL; /* unreachable */
}
static void test_next_has_a_real_cancellation_point(void)
{
xce_primary_resreq_t resreq;
xce_alloc_plan_iter_t iter_storage;
xce_alloc_plan_iter_t* iter = &iter_storage;
cancel_thread_arg_t arg;
pthread_t tid;
int rc;
memset(&resreq, 0, sizeof(resreq));
resreq.minTasks = 1;
resreq.maxTasks = 4;
bind_plan_iter(iter);
TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL));
arg.plan_iter = iter;
rc = pthread_create(&tid, NULL, cancel_spin_thread, &arg);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Let the spin thread actually get running before cancelling it. */
usleep(20000);
rc = pthread_cancel(tid);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = pthread_join(tid, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Safe post-cancellation cleanup: pthread_testcancel() only ever
* fires between complete logical steps (function entry), never
* mid-mutation, so the iterator's private state is always
* well-formed here regardless of exactly where cancellation
* landed. */
iter->fini(iter);
}
void setUp(void) { return; }
void tearDown(void) { return; }
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_plans_ordered_ascending_host_count_with_balanced_partitions);
RUN_TEST(test_plans_for_evenly_divisible_task_count);
RUN_TEST(test_single_task_yields_single_plan);
RUN_TEST(test_plan_generation_ignores_scheduler_state);
RUN_TEST(test_init_rejects_invalid_input);
RUN_TEST(test_next_has_a_real_cancellation_point);
return UNITY_END();
}