Skip to content

Commit a7609a9

Browse files
juaristi22claude
andcommitted
Fix #835's claimant guard for child-only benefit units (licensed-build blocker)
The first licensed spine-m build failed at uc_reporter_redraw: 'Every benefit unit must contain an adult claimant candidate.' A 16-19 qualifying young person heading their own benefit unit is its only member, so the engine's UC child flag leaves no ~uc_child candidate — the licensed frame carries 238 all-under-18 benefit units, and PR CI can never reach this path (secrets-free, no licensed builds). The fallback now takes the unit's eldest member as its de-facto head, and the fail-closed check moves to the landing, where a POSITIVE draw on a child claimant refuses — the award screen keeps child-only units out of the drawn domain, so a positive landing there is exactly the corruption the old total guard feared. Regression tests for both halves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e0b2305 commit a7609a9

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

packages/microcosm-build/src/microcosm/build/uk_runtime/uc_reporter_redraw.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ def redraw_spi_reported_uc(
276276
spi=spi,
277277
claimant_rows=claimant_rows,
278278
draws=draws,
279+
uc_child=uc_child,
279280
)
280281
reported_after = _benefit_unit_reporter_amounts(person, benunit)
281282
if np.any(reported_after[spi & ~screen] != 0.0):
@@ -373,7 +374,13 @@ def _claimant_rows(person: pd.DataFrame, *, uc_child: np.ndarray) -> np.ndarray:
373374
if not len(candidates):
374375
candidates = rows[adult[rows]]
375376
if not len(candidates):
376-
raise ValueError("Every benefit unit must contain an adult claimant candidate.")
377+
# A 16-19 qualifying young person heading their own benefit unit
378+
# is its only member: the engine's UC child flag covers them, so
379+
# no ~uc_child candidate exists. The unit's eldest member is its
380+
# de-facto head; the fail-closed check moves to the landing,
381+
# where a POSITIVE draw on a child claimant refuses (the award
382+
# screen keeps such units out of the drawn domain).
383+
candidates = rows
377384
order = np.lexsort((person_id[candidates], -age[candidates]))
378385
claimant[candidates[order[0]]] = True
379386
return claimant
@@ -510,6 +517,7 @@ def _land_spi_draws(
510517
spi: np.ndarray,
511518
claimant_rows: np.ndarray,
512519
draws: np.ndarray,
520+
uc_child: np.ndarray,
513521
) -> None:
514522
spi_ids = set(benunit.loc[spi, "benunit_id"])
515523
spi_people = person["person_benunit_id"].isin(spi_ids).to_numpy(dtype=bool)
@@ -519,9 +527,18 @@ def _land_spi_draws(
519527
draw_by_benunit
520528
)
521529
claimant_spi = claimant_rows & spi_people
522-
person.loc[claimant_spi, UC_REPORTER_REDRAW_OUTPUT] = claimant_draws.loc[
523-
claimant_spi[claimant_rows]
524-
].to_numpy(dtype=float)
530+
landed = claimant_draws.loc[claimant_spi[claimant_rows]].to_numpy(dtype=float)
531+
child_claimants = uc_child[claimant_spi]
532+
if bool((child_claimants & (landed > 0.0)).any()):
533+
# The fail-closed half of the child-only-benunit fallback above: the
534+
# award screen keeps qualifying-young-person-only units out of the
535+
# drawn domain, so a positive amount landing on a child claimant is
536+
# the corruption the old total guard feared — refused exactly here.
537+
raise ValueError(
538+
"A positive UC reporter draw landed on a child claimant; "
539+
"the award screen must exclude child-only benefit units."
540+
)
541+
person.loc[claimant_spi, UC_REPORTER_REDRAW_OUTPUT] = landed
525542

526543

527544
def _reporter_transition_receipt(

packages/microcosm-build/tests/test_uk_uc_reporter_redraw.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,71 @@ def test_transform_records_checkpoint_metadata() -> None:
418418
evidence = transform.checkpoint_metadata()["evidence"]
419419
assert evidence["stage"] == "uc_reporter_redraw"
420420
assert evidence["seed"] == UC_REPORTER_REDRAW_SEED
421+
422+
423+
def test_child_only_benunit_gets_its_eldest_member_as_claimant() -> None:
424+
"""A qualifying-young-person-only benefit unit must not crash the stage.
425+
426+
A 16-19 QYP heading their own unit is its only member, so no ~uc_child
427+
candidate exists; the unit's eldest member is its de-facto head. The
428+
licensed spine carries hundreds of such units, so the old total guard
429+
failed every full build.
430+
"""
431+
432+
from microcosm.build.uk_runtime.uc_reporter_redraw import _claimant_rows
433+
434+
person = pd.DataFrame(
435+
{
436+
"person_id": [1, 2, 3],
437+
"person_benunit_id": [10, 10, 20],
438+
"age": [40.0, 12.0, 17.0],
439+
}
440+
)
441+
uc_child = np.array([False, True, True])
442+
443+
claimant = _claimant_rows(person, uc_child=uc_child)
444+
445+
assert claimant.tolist() == [True, False, True]
446+
447+
448+
def test_positive_draw_on_a_child_claimant_refuses_at_the_landing() -> None:
449+
"""The fail-closed half of the child-only fallback lives at the landing."""
450+
451+
from microcosm.build.uk_runtime.uc_reporter_redraw import (
452+
_claimant_rows,
453+
_land_spi_draws,
454+
)
455+
456+
person = pd.DataFrame(
457+
{
458+
"person_id": [1, 2],
459+
"person_benunit_id": [10, 20],
460+
"age": [40.0, 17.0],
461+
UC_REPORTER_REDRAW_OUTPUT: [0.0, 0.0],
462+
}
463+
)
464+
benunit = pd.DataFrame({"benunit_id": [10, 20]})
465+
uc_child = np.array([False, True])
466+
claimant = _claimant_rows(person, uc_child=uc_child)
467+
spi = np.array([True, True])
468+
469+
with pytest.raises(ValueError, match="child claimant"):
470+
_land_spi_draws(
471+
person,
472+
benunit,
473+
spi=spi,
474+
claimant_rows=claimant,
475+
draws=np.array([500.0, 500.0]),
476+
uc_child=uc_child,
477+
)
478+
479+
zero_for_child = np.array([500.0, 0.0])
480+
_land_spi_draws(
481+
person,
482+
benunit,
483+
spi=spi,
484+
claimant_rows=claimant,
485+
draws=zero_for_child,
486+
uc_child=uc_child,
487+
)
488+
assert person[UC_REPORTER_REDRAW_OUTPUT].tolist() == [500.0, 0.0]

0 commit comments

Comments
 (0)