feat(color): derive role favorites from the whole roster so they stay tellable apart #300

Merged
coilysiren merged 1 commit from aos/claude/favorite-color-spread into main 2026-08-17 18:16:23 +00:00
Member

Closes the colour half of the personality work. #296 fixed the prose, #299 fixed the roster, and neither could touch this, because the cause was the derivation.

Why a per-role function could not work

Favorite took one role's three personality colours and returned their OKLab centroid. It never sees the other roles, so it cannot avoid colliding with them, and the mean is the worst possible estimator for this: qa and ai both meld meticulous and skeptical, whose colours sit 0.045 apart, and two means over nearly the same components land nearly the same place regardless of the third. The shipped palette's closest pair was 0.0347, tighter than any two personalities in the catalogue.

Two stages

Stage one weights by distinctiveness. Each component is discounted by how many groups share it, so whatever makes a role that role leads its colour. This is the roster's own semantics doing the work rather than an arbitrary nudge. Alone it takes the closest pair from 0.0347 to 0.0808.

Stage two spreads globally. Every colour walks away from its nearest neighbour to grow the smallest pairwise distance, bounded by a drift cap so none leaves its own blend, then clamps back into the legible band and sRGB gamut. Fixed iteration order, no randomness, so one roster always derives one palette.

  • closest pair - 0.0347 to 0.1312, a 3.8x improvement
  • mean - 0.1458 to 0.2004

Stage one is what makes stage two honest. Without it the spread step would push colours apart in arbitrary directions. With it, the direction each colour moves is already the direction of its own differentiator.

Palette

  • engineer #b39258 to #a88840
  • director #cb7471 to #d26c74
  • qa #62a5d9 to #56aeed
  • ops #229ca7 to #2d8ea4
  • design #ac8fd7 to #ca7fef
  • exec #b39e50 to #b1ba65
  • creator #da9395 to #eea5a1
  • ai #60abc8 to #0dc8b0

The floor replaces a check the spread step broke

Roster loading asserted that no two roles share a favourite colour. The spread step makes that unreachable: two roles melding identical personalities no longer land on one colour, they land ~0.06 apart. Equality can no longer catch the case the check existed for, so it is now a floor of 0.08 on the closest pair. TestFavoritesCannotRescueIdenticalGroups pins the ~0.06 figure the floor depends on.

Note the effective bound is a shade over twice the drift cap, not exactly it, because band and gamut repair runs after the projection. My first test asserted the tight bound and failed at 0.0613, which is how I found it.

Eight call sites became one

overlay, resolver, palette, nativeui, roster (twice), person, and person/snapshot each derived the role favourite independently from the same inputs. That duplication is why the collision was invisible from any one of them, and it would have let a roster-wide derivation disagree with itself per surface. They now read Role.FavoriteColor, resolved once at load by Person.ResolveFavoriteColors.

SnapshotRole carried its own FavoriteColor field which, once Role gained one, shadowed the embedded field on unmarshal and silently emptied it. Dropped.

Snapshot via pre-commit

Because the derivation is global, editing one role can move every colour. internal/palette/role-palette.txt is the committed record, regenerated by a repo-local pre-commit hook, so a roster edit arrives as a reviewable palette diff rather than silent drift across statuslines, identity cards, and bundles.

The hook is repo-local rather than an agentic-os catalogue validator because it needs this repo's own colour math, which cannot live upstream. It regenerates rather than only checking, so pre-commit's own modified-files detection forces the diff into the commit under review. --check exists for CI.

The artifact is a .txt under internal/palette/ rather than a docs page because docs/ is exactly at its 40-page cap and every page is exactly at its 120-line cap. The explanation that would have gone in docs/personality.md is in the artifact's own header instead, where it is also the thing a reviewer is already looking at.

Verification

go build ./..., go vet ./..., go test ./..., gofmt -l, and pre-commit run --all-files all pass. New tests cover determinism across repeated runs, legibility, drift bounds, the identical-groups case, single-group equivalence with the plain mean, empty input, and snapshot freshness and drift detection. I verified the hook fails on a hand-edited snapshot and auto-repairs it.

One thing for you

color.Favorite keeps the plain-mean behaviour and now has no callers outside the colour package's own tests, where it is the baseline the new derivation is measured against. Worth keeping for that, but easy to unexport if you would rather.

Closes the colour half of the personality work. #296 fixed the prose, #299 fixed the roster, and neither could touch this, because the cause was the derivation. ## Why a per-role function could not work `Favorite` took one role's three personality colours and returned their OKLab centroid. It never sees the other roles, so it cannot avoid colliding with them, and the mean is the worst possible estimator for this: qa and ai both meld meticulous and skeptical, whose colours sit 0.045 apart, and two means over nearly the same components land nearly the same place regardless of the third. The shipped palette's closest pair was **0.0347**, tighter than any two personalities in the catalogue. ## Two stages **Stage one weights by distinctiveness.** Each component is discounted by how many groups share it, so whatever makes a role that role leads its colour. This is the roster's own semantics doing the work rather than an arbitrary nudge. Alone it takes the closest pair from 0.0347 to **0.0808**. **Stage two spreads globally.** Every colour walks away from its nearest neighbour to grow the smallest pairwise distance, bounded by a drift cap so none leaves its own blend, then clamps back into the legible band and sRGB gamut. Fixed iteration order, no randomness, so one roster always derives one palette. * closest pair - 0.0347 to **0.1312**, a 3.8x improvement * mean - 0.1458 to **0.2004** Stage one is what makes stage two honest. Without it the spread step would push colours apart in arbitrary directions. With it, the direction each colour moves is already the direction of its own differentiator. ## Palette * engineer `#b39258` to `#a88840` * director `#cb7471` to `#d26c74` * qa `#62a5d9` to `#56aeed` * ops `#229ca7` to `#2d8ea4` * design `#ac8fd7` to `#ca7fef` * exec `#b39e50` to `#b1ba65` * creator `#da9395` to `#eea5a1` * ai `#60abc8` to `#0dc8b0` ## The floor replaces a check the spread step broke Roster loading asserted that no two roles share a favourite colour. The spread step makes that unreachable: two roles melding identical personalities no longer land on one colour, they land ~0.06 apart. Equality can no longer catch the case the check existed for, so it is now a floor of 0.08 on the closest pair. `TestFavoritesCannotRescueIdenticalGroups` pins the ~0.06 figure the floor depends on. Note the effective bound is a shade over twice the drift cap, not exactly it, because band and gamut repair runs after the projection. My first test asserted the tight bound and failed at 0.0613, which is how I found it. ## Eight call sites became one `overlay`, `resolver`, `palette`, `nativeui`, `roster` (twice), `person`, and `person/snapshot` each derived the role favourite independently from the same inputs. That duplication is why the collision was invisible from any one of them, and it would have let a roster-wide derivation disagree with itself per surface. They now read `Role.FavoriteColor`, resolved once at load by `Person.ResolveFavoriteColors`. `SnapshotRole` carried its own `FavoriteColor` field which, once `Role` gained one, shadowed the embedded field on unmarshal and silently emptied it. Dropped. ## Snapshot via pre-commit Because the derivation is global, editing one role can move every colour. `internal/palette/role-palette.txt` is the committed record, regenerated by a repo-local pre-commit hook, so a roster edit arrives as a reviewable palette diff rather than silent drift across statuslines, identity cards, and bundles. The hook is repo-local rather than an agentic-os catalogue validator because it needs this repo's own colour math, which cannot live upstream. It regenerates rather than only checking, so pre-commit's own modified-files detection forces the diff into the commit under review. `--check` exists for CI. The artifact is a `.txt` under `internal/palette/` rather than a docs page because `docs/` is exactly at its 40-page cap and every page is exactly at its 120-line cap. The explanation that would have gone in `docs/personality.md` is in the artifact's own header instead, where it is also the thing a reviewer is already looking at. ## Verification `go build ./...`, `go vet ./...`, `go test ./...`, `gofmt -l`, and `pre-commit run --all-files` all pass. New tests cover determinism across repeated runs, legibility, drift bounds, the identical-groups case, single-group equivalence with the plain mean, empty input, and snapshot freshness and drift detection. I verified the hook fails on a hand-edited snapshot and auto-repairs it. ## One thing for you `color.Favorite` keeps the plain-mean behaviour and now has no callers outside the colour package's own tests, where it is the baseline the new derivation is measured against. Worth keeping for that, but easy to unexport if you would rather.
feat(color): derive role favorites from the whole roster so they stay tellable apart
All checks were successful
ci / test (pull_request) Successful in 51s
105c04bb36
Favorite took one role's three personality colors and returned their OKLab
centroid. A per-role pure function cannot see the other roles, so it cannot
avoid colliding with them, and averaging is the worst case: qa and ai both meld
meticulous and skeptical, whose colors sit 0.045 apart, and two means over
nearly the same components land nearly the same place whatever the third
component is. The eight shipped role colors were 0.0347 apart at their closest,
tighter than any two personalities in the catalog.

Add color.Favorites, which derives every group together in two stages.

Stage one weights each component by how few groups share it, so whatever
distinguishes a role leads its color. That is the roster's own semantics doing
the work rather than an arbitrary nudge, and it alone takes the closest pair
from 0.0347 to 0.0808.

Stage two pushes every color away from its nearest neighbour to grow the
smallest pairwise distance, bounded by a drift cap so no color leaves its own
blend, then clamps back into the legible band and sRGB gamut. Fixed iteration
order and no randomness, so one roster always derives one palette.

  closest pair   0.0347 -> 0.1312   (3.8x)
  mean           0.1458 -> 0.2004

Roster loading now asserts a floor of 0.08 on the closest pair. That replaces
the duplicate-favorite-color check, which the spread step made unreachable:
two roles melding identical personalities no longer land on one color, they
land 0.06 apart, so a floor catches what equality no longer can.

Eight call sites each derived the role favorite independently, which is why the
collision was invisible from any one of them. They now read Role.FavoriteColor,
resolved once at load by Person.ResolveFavoriteColors.

Because the derivation is global, editing one role can move every color. The
result is committed to internal/palette/role-palette.txt and regenerated by a
repo-local pre-commit hook, so a roster edit lands as a reviewable palette diff
instead of silent drift across every rendered surface. The hook is repo-local
rather than an agentic-os catalog validator because it needs this repo's own
color math.

SnapshotRole carried its own FavoriteColor field, which after this change
shadowed the embedded Role field on unmarshal and silently emptied it. Dropped.

color.Favorite keeps the plain-mean behavior and now has no callers outside the
color package's own tests, where it is the baseline the new derivation is
measured against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agent-compose!300
No description provided.