Repository navigation
🔥 FIX - a linear group reports the level its members were set from, not their raw brightness 🎚️ (#10) - #65
Conversation
…ot their raw brightness 🎚️ (#10) A `LightGroupLinear` re-derived its own state by averaging its lit members' raw levels. Each member goes through its own range, so the group drifted off the level it was set to. Bedroom Lights at 50 puts top/main/bed at 90/65/25. With bed turned off at the wall, the group re-derived to (90 + 65) / 2 = 77. Now each lit member's level is inverted to a group level before `re_derive` averages it, as it averaged the raw levels before. The aggregation is unchanged. The inversion scans the forward map (`map_brightness`) over 1..=100, so it doesn't duplicate it and doesn't depend on its shape. A member's candidates are the group levels that light it at its level, or nearest to it when none does (clamped to the range). Rounding and clamping usually give several candidates: - If some level is a candidate of every lit member, each inverts to the one of those nearest the group's set level (the level it last took from a write, or found every member at). That level becomes the set level. - Otherwise each inverts to its own candidate nearest the set level. Ties go to the lower level. Off members, and members on at 0, are left out as before. A write at any level G now reads back G. Re-deriving from members that haven't moved is idempotent. Bed dimmed to 10 at 50 re-derives to (50 + 50 + 20) / 3 = 40, where the raw average gave 55. Tests: round trips over 0..=100 for the shipped bedroom_lights.toml and for identity, steep, flat and falling ranges. Also the representative a group seeded after a restart reads, a V-shaped (non-monotonic) fan-out, and a manager end-to-end test (50 stays 50; the wall dim gives 40). Restoring the raw push fails 9 tests. The dummy's kitchen light (bed, 0-50) starts on at 75, past its range, so Bedroom Lights now seeds at 100 instead of 75. Two controller tests set it to 75 first. One API test's re-derived level goes from 65 to 59: (50 + 50 + 79) / 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReviewHead reviewed: Verdict: FIX-FIRST. Only the tests need work. The inversion and the Findings1. Medium: the Mutant: skip removed in
That's expected: re-deriving a linear group from members at its own fan-out is now exact, so for that type the skip is only a saving. But Fix, checked here: point ) -> LightGroup {
let curves = serde_json::json!({
e: { "breakpoints": [[0, 80], [100, 100]] },
f: { "breakpoints": [[0, 0], [100, 50]] },
});
let config = VirtualDeviceConfig {
device_id: device_id.to_string(),
device_type: VirtualDeviceType::LightGroup,
name: device_id.to_string(),
description: None,
enabled: true,
config: serde_json::json!({ "lights": [e, f], "brightness_curves": curves }),
};
LightGroup::new(config, store.clone()).expect("lossy group")(Its doc at 2. Low: rule 1 moving Mutant: 3. Low: design note, no change needed. A stale Rule 2 leaves
4. Nit: the dummy now starts at 100. This is correct under the model.
On a real hub, a member turned up past its max counts as 100: set 50, then 5. Nit: performance is fine. A 20-member group costs 78 µs per re-derive in release (464 µs in debug), about 90% of it the inversion. That's 20 × 100 6. Info: not a regression. A scene, or an outer group, that writes this group as one of its devices goes through Changed expectations, recomputed
OracleThe oracle is independent and not circular. It uses only My mutants (
|
| mutant | result |
|---|---|
ties go to the higher level (Reverse(level) in nearest) |
red: V-shape unit test only. A linear range can't tie, because its candidate sets are contiguous, so the tie rule is only reachable in that synthetic test. Fine. |
rule 1 ignores set_level, picks the highest common level |
red: round trip |
rule 1 ignores set_level, picks the lowest common level |
red: 9 tests (in-file, end to end, both gesture tests) |
rule 2 ignores set_level, picks each member's highest candidate |
red: 1 (the axum 59 test) |
rule 1 doesn't move set_level |
survives (finding 2) |
no accounts_for skip in track_input |
red: 1 at head, 9 at base (finding 1) |
What I ran, and what was fine
- Detached worktree at
1e1fcdf,nix develop,cargo test -p v1bectl_virtual -p v1bectl_api: all green. - A throwaway probe test (not pushed) for the scenarios above, the mutants above, the same skip mutant at base
4d40cbb, and thelossy_groupre-point with and without the skip. - Fine:
- Every write that goes through the group itself updates
set_level: the API and button actions both reachwrite_virtual→commit_write→take_state(manager.rs:960). - A failed partial write keeps the old
set_level, and tracking then re-derives sensibly. For example, set 50, then a write of 80 fails aftertopreaches 96: the group reads 59 (by hand:top96 → {78..82} → 78, so (78 + 50 + 50) / 3). resolve_writealways yields a level > 0, so the> 0filter intake_statenever drops a write's level.- Re-deriving is idempotent, and the round trip holds for 0..=100 on all six groups.
Levels::ALLand the bit operations are correct, andinvertingis never empty.- The seed and resync paths behave as documented.
- Every write that goes through the group itself updates
This was an independent review by a separate agent (Opus).
|
Switched to draft for the review's FIX-FIRST, which is a test-coverage finding only; the inversion logic checks out. With the manager's |
…found level is pinned 🛡️ (#10) #65 review, finding 1: re-deriving a linear group from members at its own fan-out is exact now, so the tests that guarded the skip of a group's own echoes (`accounts_for`) lost their teeth. With the skip removed, 9 tests went red at 4d40cbb but only 1 at 1e1fcdf, and none of those was in v1bectl_virtual. The skip still matters for a curved `LightGroup`, which re-derives from its members' own levels (set to 50, it reads 57 or 60). - manager.rs: `lossy_group` (`k` in the lag catch-up test) is now a curved `LightGroup` over the same 80-100 and 0-50 shapes. Two new guards run over it: its own and stale echoes, and a member colour the hub normalised. - axum_server.rs: `home_with(kind)` registers either Bedroom Lights or a curved group that lights the same members exactly the same way. The four guard tests run over both: the write echoes, off then on, back to back level changes, and the hub-normalised colour. With the skip removed, 8 tests go red now, 3 of them in v1bectl_virtual. #65 review, finding 2: rule 1 moving the set level wasn't pinned. Bedroom Lights started on members where 50 put them reads 50. With `bed` then dimmed to 11 it reads 40, and 41 if the line is deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix roundNew head Finding 1 (Medium): the echo-skip guards have teeth againThe skip still matters for a curved
Mutant: remove the skip (
At
Three tests that went red at the base don't come back: Finding 2 (Low): rule 1 moving
|
Refs #10
Implements the
light_group_linear.rsTODO, "Inverse map member brightness to group brightness".The bug
LightGroupLinear::calculate_group_statepushed each lit member's raw brightness intore_derive, so the group averaged member levels as if they were group levels. Every member goes through its own[min, max]range, so after any re-derive the group drifted off the level it had been set to.Worked example, using the shipped
virtual_devices/bedroom_lights.toml(top80-100,main40-90,bed0-50):bedturned off at the wallbeddimmed to 10 at the wall(The
accounts_forguard from #22 already hid the last row for pure echoes. Any real member change exposed it.)Note: #10 describes breakpoint curves, but the linear group maps each member through a two-point
[min, max]range (map_brightness). The breakpoint curves (BrightnessCurve) belong to the other group type,LightGroup. That type has the same raw push, but it's outside this change (see follow-ups).The inversion rule
The fix changes only what goes into
re_derive. The aggregation itself (average of the lit members, off when none are lit, level kept) is unchanged.mhas as candidates the group levels 1..=100 whose fan-out lights it at exactlym. If no level does, the candidates are the levels that light it nearest tom. So a member dimmed below its range, or turned up past it, clamps to the bottom or top of that range. The candidates come from scanning the existing forward map (map_brightness), so the forward logic isn't duplicated and the rounding and clamping match exactly. The candidate set is au128bitset.topmoves 1 per 5 group levels), the clamp at 100 whenmax > 100, ormin == max. The choice among them is deterministic:DEFAULT_GROUP_LEVEL.min > max), or is flat, and the loader has no validation to rely on. The rule doesn't assume monotonicity anyway, because it scans the fan-out. A unit test covers a V-shaped fan-out with candidates on both sides of the dip, plus the tie rule.map_brightness(newrejects a member without a range anyway).Properties:
Tests
light_group_linear.rs(in-file):a_group_set_to_a_level_reads_it_back_from_its_members: property round trip for G in 0..=100 (plan_write, then members into the store, then take_state, then re-derive; the group reports G). Runs for the shippedbedroom_lights.toml, read from the file, and for hand-made identity (0-100), steep (0-250, flat from 40), flat (70-70) and falling (90-40) ranges, plus all four in one group.a_group_started_on_its_members_reads_the_highest_level_that_puts_them_there: the documented representative for the same groups. A brute-force oracle checks the level, and the group must account for every lit member.a_member_dimmed_at_the_wall_counts_at_the_level_that_puts_it_there: exactly 40, re-deriving again stays 40, andbedback at 25 gives 50.a_member_turned_off_leaves_the_group_at_its_level: 50, not 77.a_group_inverts_towards_the_level_it_found_its_members_at(fix round): pins rule 1 movingset_level. The group starts on members where 50 put them and reads 50. Withbedthen dimmed to 11 it reads 40. Without the update it reads 41, becausetopinverts to 52, nearest the stale 100.a_member_level_inverts_to_the_group_levels_that_light_it_there: exact candidate sets fortop: 90 → {48..52}, 81 → {3..7}, 30 → {1,2}, 100 → {98,99,100}.a_fan_out_that_falls_and_rises_inverts_to_the_nearest_candidate: non-monotonic fan-out, ties, and both rules ofinvert.tests/linear_group_levels.rs(new, end to end throughVirtualDeviceManagerand the bus, shipped config):bedturned off at the wall: the manager re-derives the group, it reports 50, and nothing is echoed.bedback on leaves it at 50.beddimmed to 10: the group re-derives to exactly 40, echoed once. Back to 25 gives 50.bed, 0-50) starts on at 75, which is past its range. Bedroom Lights therefore now seeds at 100 (the level that gets it nearest) instead of 75. The two controller-gesture tests (tests/dummy_scenario.rsand theaxum_server.rstest) now assert the seed of 100 and set the group to 75 first, soinc 10still shows 85.axum_server.rsoutside_member_change_after_a_group_write_re_derives_the_group: 65 becomes 59, i.e. (50 + 50 + 79) / 3.accounts_forskip only saves work, and the tests that guarded it over Bedroom Lights lost their teeth. The skip still matters for a curvedLightGroup, which re-derives from raw levels (set to 50, it reads 57 or 60). The guards therefore run over a curved group again:manager.rs:lossy_group(kininput_tracking_catches_the_groups_up_after_falling_behind) is now a curvedLightGroupwith the same 80-100 and 0-50 shapes. It has two new guards:a_curved_group_is_not_re_derived_from_its_own_echoes(own echoes, and two back-to-back writes' stale echoes) anda_member_colour_the_hub_normalised_does_not_re_derive_a_curved_group.axum_server.rs:home_with(kind)registers either Bedroom Lights or a curved group that lights the same members exactly the same way (MEMBERS_AT_50/MEMBERS_AT_80).virtual_group_write_echoes_group_and_members,group_off_then_on_restores_members,back_to_back_level_changes_end_at_the_last_levelandhub_normalised_member_colour_does_not_re_derive_the_groupeach run over both.Mutants
dummy_scenario, 2 API teststake_statestops tracking the set levellight_group_linear.rs:218)a_group_inverts_towards_the_level_it_found_its_members_at(41 ≠ 40). It survived before the fix round.invertunit testaccounts_forskip intrack_input(manager.rs:638),-p v1bectl_virtual -p v1bectl_api4d40cbb: 9 red (2 inv1bectl_virtual);1e1fcdf: 1 red (0 inv1bectl_virtual); now: 8 red (3 inv1bectl_virtual: the lag catch-up test and both new manager guards; the 4 axum guards;api_round_trip's curved group)At the base,
a_virtual_group_write_comes_back_over_the_socket,press_button_runs_the_controller…andshipped_button_controller_follows_reported_gestures…also went red without the skip. That was only because Bedroom Lights re-derived lossily. They now re-derive exactly, and each has a curved-group guard covering the same path.Verification
Re-run on the fix-round head
3562f78:cargo fmt --all --check: 0cargo clippy --workspace --exclude v1bectl_web --all-targets -- -D warnings: 0 with clippy 0.1.96 (nix develop) and 0.1.98 (nixpkgs)cargo test --workspace --exclude v1bectl_web: 0.-p v1bectl_virtuallooped 5×: all green.RUSTDOCFLAGS='-D warnings' cargo doc --workspace --exclude v1bectl_web --no-deps: 0Cargo.lockunchangedFollow-ups (not in this PR)
Tracked on #66:
LightGroup::calculate_group_statestill averages raw member levels overBrightnessCurve.Levels::invertingandinverttake any forward map, so reusing them there is mechanical.virtual_device.rs(theaccounts_fordocs),manager.rs:641(thetrack_inputcomment), anddocs/VIRTUAL_DEVICES.md.bedroom_lights.toml's range comment ("x < min => 0 and x > max = 100") doesn't describe whatmap_brightnessdoes.(The weakened echo-skip guards listed here before are fixed in this PR, in the fix round above.)
🤖 Generated with Claude Code