Repository navigation
Never-play rules, beyond a dislike #22
Closed
robert-dean
started this conversation in
Ideas
Replies: 1 comment
|
Shipped in #420: |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Written: 2026-08-19, from asking how a station is told to refuse a genre.
State of the tree: nothing here is built. The operator can forbid a THING (
tracks.rating,albums.rating,artists.ratingat-1, collapsed byCandidatesRepository.effectiveRatingandenforced by
rejectDisliked). There is no way to forbid a KIND of thing, so "never play countrywestern" is expressible only by disliking every artist by hand.
This file supersedes station-intelligence §5, which is the original
design and is still worth reading for the two rules it states. What is here is that design plus what
the tree looks like now: §5 was written before
PickResolverexisted as the single chokepoint, andbefore there was a
RotationCandidateto hang a predicate off.What a rule is, and what it is not
A predicate: never play this genre, this tag, this mood, anything on this playlist, anything by
this artist. Two rules from §5 are load-bearing and neither is negotiable.
Enforcement is inherited, never added. Rules evaluate at the chokepoints a dislike already passes
through. A second filter bolted onto a pick path is how the two kinds drift until one is enforced in
three places and the other in two.
A rule is absolute, like a dislike. No never-starve exception, including for a listener request.
A rule that quietly relaxes under pressure is worse than no rule, because nobody can reproduce it.
The corollary, which §5 states as a console note and is really a design constraint: offer no
one-click undo on a row that a RULE excluded. One rule can cover hundreds of rows, and the edit
belongs where the rule is rather than where a symptom of it showed up.
What is already in the tree
The chokepoint exists, so §5's first rule is already paid for.
pick.resolver.ts is documented as the one
step every pick from every source passes through, and every generator's comments defer to it by name.
It has exactly one caller,
plan.records.ts:120. A rule engine hung offPickResolver.judgecostsone call site.
The genre data is the enrichment arrays, not the promoted column.
track_enrichment.data -> 'genres'andartist_enrichment.data -> 'genres', both jsonb, one row perprovider.
taggedWith()is already thecorrelated union over both levels, guarded by
jsonb_typeofbecause plugins write it. Both levelscount for the reason that file gives: a tag on the artist is how a style reaches a record nobody
tagged individually, which on this library is most of them.
tracks.genreis a promotedgenres[0](enrichment.service.ts, inpromote). Per §6 the array isthe data and the scalar is a convenience, so the matcher reads the arrays and never the scalar.
The scalar stays exactly as it is, for display and for
searchPlayable.RotationCandidatecarries nothing a predicate could read. It is{ songKey, artistKey, rating? }.This is the one seam §5 does not mention, and it is the real work:
PickResolveralready fetchesratingsFor(trackIds)so a dislike is true for picks that never touched the catalog, and a genre ruleneeds the identical shape beside it.
Decisions
Exclude only. No
requiredirection. A positive "only these genres" can starve the station, whichfights the rule that the deterministic floor cannot fail. Positive format steering already has a home:
station_lineup.briefand the set generators.No weight column, ever. §5's own "The bubble, which the rules above cannot fix" says the fix for a
stale draw is a bias and never a filter, and warns that a filter with preferences would fight the
rules for authority over what may air. A rule is a veto; a lean is a different mechanism and belongs
in the draw. Independent confirmation from a station that built both: where soft music steering and
hard exclusion live on one object, the difference has to be re-expressed as a per-object
strictflag, which is the same split arriving late and costing a field.
No loose SQL exclusion in
sample, and this reverses the obvious instinct. Over-fetching looselyand filtering precisely afterwards is safe only in the POSITIVE direction, where the precise pass can
still discard. In the exclude direction a loose
ilike '%rap%'dropsTrapin SQL and the precisematcher never sees it to keep it: a silent over-block. Filter in TypeScript. If the pool ever starves,
the safe optimization is exact-normalised equality in SQL, which is provably a subset of what the
matcher excludes.
One tag query, two callers.
CandidatesRepository.tagsFor(trackIds)is the only place theenrichment union is written.
sample()calls it on its own drawn ids soCatalogSetGeneratorcanfilter early, and
PickResolver.judgecalls it besideratingsForfor picks it never drew. Both thenrun the same
applyRules, so the early filter and the guaranteed filter cannot disagree. Neither isdeletable because the other exists, which is the rule already written for the dislike.
Fields:
genreandtagto start. Both read the same arrays, so both cost one matcher and no newdependency.
genreuses §6's refinement rule;tagis exact-normalised, because a free-text tagnamespace is noisy and containment there catches things nobody asked for.
Scopes: season, daypart, lineup mode, schedule slot. §5 names the first and the third. The daypart
is what track-lyrics needs ("nothing explicit before nine") and §5 does not
list it;
readClockinclock.bands.tsalready returns a zoned hour and minute. The slot becomesreal once the daypart schedule lands. An empty scope list means "every one", so an unscoped rule needs
no null branch.
Genre matching is one-directional, and the direction is the whole of it
From §6, and it is the part that is expensive to get wrong later. A tag may REFINE a target and may
never broaden it, and containment must be word-boundary aligned:
PunkPunk RockR&BContemporary R&BPop PunkPopRapTrapNormalisation throws the separators away, so the boundary information has to be carried separately:
record, per normalised character, whether it opens or closes a word in the ORIGINAL string, and
require the target's occurrence to align to both. The failure the boundary rule prevents is measured
elsewhere and is not hypothetical: unbounded substrings pull
Trapinto aRapfilter, and a filterthat merely CONTAINS the target lets a strict request fill with anything tagged plain
Pop.The phases
genre.match.ts, pure, with the table above as its test. No consumers.block.rules.ts, pure:compileRules(normalise once per mutation),inSeason(month/daykeys,
from <= toa closed interval andfrom > towrapping the year end),ruleActive,ruleMatches,blockedBynaming the rule that matched. The evaluation context is computed onceper judge call and never per candidate. Export
readClockfromclock.bands.tsrather thanwriting a third
Intl.formatToParts; there are already two. No consumers.deadair.block_rules, plus contract, repository, service and module.station_keyfrom thefirst migration, per multi-station. Seeds nothing: an empty table is a
coherent state. Unread.
tagsFor,RotationCandidate.tags, andsample()attaching them, plusscripts/block.rules.smoke.tsfollowingrating.smoke.tsandadvisory.smoke.ts, because theinteresting half is SQL. Unjudged.
PickResolver.applyRulesgains the compiled rules, applied FIRST besiderejectDislikedand for the same reason: absolute, cheapest, and not disable-able by a lineup, soit must not live on
ResolvedRuleswhereNO_RULESwould zero it. This is where it startsworking.
LibrarySearchTool: over-fetch roughly three timesMAX_RESULTS, filter, slice back.The over-fetch is required rather than tidy, since a model shown too few rows pads its answer with
repeats and
CatalogSetGeneratorquietly fills the hour./rulesconsole page, mirroring/personas.Out of scope, and why
moodandplaylistfields. Moods want station-moment's table, whichdoes not exist; playlist membership wants member sets resolved and cached before matching can stay
synchronous.
Era rules. §6's era half has to land first: an album's
yearis the year of THAT release, so acompilation reports its reissue date and a naive
tracks.yearrule puts every track on it in thewrong decade. Whatever precedence the SQL uses and whatever precedence the JS uses have to be the
same precedence, or a rule and a badge will disagree about one record.
Lyric-derived fields (subject, season, explicitness, language). track-lyrics
names this table as their first consumer, and says why that is the honest first consumer rather than
mood steering: a predicate is checkable and a mood is a matter of taste. One trap recorded there: a
lyric-derived explicitness judgement is a weaker second answer and must be its own field rather than
written over
track_sources.advisory, or the station starts disagreeing with its provider about onerecord with no way to tell which claim it holds.
The freshness bias, which is §5's own bubble section and is deliberately a different mechanism.
Related
and era rules this depends on.
moodfield would need.station_keyis on the first migration.Moved from
docs/todo/never-play-rules.md. The file as it last stood: https://github.com/robert-dean/deadair/blob/71e431d45ce7b35a189c7d226832ec4a6247e9dc/docs/todo/never-play-rules.mdAll reactions