#!/bin/bash # ============================================================================================== # ================================= Decision Engine ============================================ # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Behavior-driven scoring kernel for media automation. Sourced by consumer # scripts — not run directly. Evaluates candidates, applies weighted scoring, # temporal decay, and deduplication, then returns a verdict. # # Does NOT download media, search indexers, or manage applications. Has no # side effects — no file writes, no API calls, no deletions. # # Currently paired with: Arrs_Stack/playback_aware_lidarr_discovery.sh # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # A library, not a program. Consumers source it and drive the pipeline themselves: # # 1. score_candidate() — sum the four weighted component scores # 2. apply_temporal_decay() — reduce by one unit per 30 days of age, floored at 0 # 3. is_duplicate_candidate() — check the consumer's own history file # 4. make_decision() — ACCEPT or REJECT against the consumer's threshold # # Every input is supplied by the caller and every output is returned to it. The engine holds # no state between calls, reads no config, and never acts on its own verdict. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Domain-Agnostic Core # The engine itself has no knowledge of music vs TV vs movies. Consumers # define thresholds, weights, and strictness profiles — the engine just # scores and decides. This prevents cross-domain bias pollution: music # strictness cannot contaminate TV intake logic. # # Lidarr consumers → highly selective, quality-first discovery # Sonarr consumers → balanced, family-aware episodic intake # Radarr consumers → broader flexibility with intelligent filtering # # Temporal Decay # Old behavioral signals lose influence over time (one decay unit per 30 # days). Prevents permanent genre lock-in, historical bias accumulation, # and dead-user score dominance. # # Consumer Owns the Decision # The engine returns ACCEPT/REJECT/SCORE. What happens next is entirely # the consumer's concern — the engine never acts on its own verdict. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # No Side Effects — Structural, Not Incidental # Writes no files, makes no API calls, deletes nothing, starts nothing. This is the # safeguard: a scoring mistake here can only ever produce a wrong number, never a wrong # action. Keep it that way — the moment this library acquires a side effect, every consumer # inherits it silently. # # No Root, No Lock, No detect_hosts — Deliberate # Correct for a sourced library and should not be "fixed" to match the executable scripts. # There is no state for a lock to protect, no privileged operation to gate, and no # host-specific config to alias. It runs entirely inside the caller's process. # # Caller Owns the Verdict # ACCEPT/REJECT is a return value, not an instruction. Nothing here can cause a candidate # to be added, removed, or downloaded — the consumer decides what a verdict means. # # Literal Duplicate Matching # is_duplicate_candidate() matches with grep -Fx, treating the candidate as data rather # than a pattern. Artist and title strings routinely contain regex metacharacters, and a # false positive here silently discards a genuinely new candidate. # # Decay Floors at Zero # apply_temporal_decay() clamps at 0, so an old signal can never become a negative score # that drags an otherwise-passing candidate below threshold. # # Integer Arithmetic Throughout # All scoring is integer. No floating point means no locale-dependent decimal parsing and # no rounding drift between hosts. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # None, by design. The engine reads no conf file and no environment variable. # # Thresholds, weights and strictness profiles live with the consumer — see # LIDARR_DISCOVERY_* / SONARR_DISCOVERY_* / RADARR_DISCOVERY_* in master.conf. That is what # keeps the core domain-agnostic: music strictness cannot leak into TV intake, because the # engine never learns which domain it is scoring for. # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # None — this file is sourced, never executed: # # source "$SCRIPT_DIR/../Kernel/decision_engine.sh" # # It has no argument parsing, no --dry-run and no --status, because it takes no action that # a dry run could suppress. # # ============================================================================================== # FUNCTIONS # ============================================================================================== # # score_candidate user_score popularity_score recency_score quality_score # Returns TOTAL_SCORE (integer sum). Consumer defines actual weight values. # # evaluate_threshold score minimum # Returns 0 (pass) or 1 (fail). Used as: if evaluate_threshold ... # # apply_temporal_decay score age_days # Returns adjusted score. Subtracts (age_days / 30), floor at 0. # # is_duplicate_candidate candidate history_file # Returns 0 (duplicate found in history_file) or 1 (not found). # # make_decision score threshold # Returns "ACCEPT" or "REJECT". Calls evaluate_threshold internally. # # ============================================================================================== # ── score_candidate ─────────────────────────────────────────────────────────── score_candidate() { local user_score="${1:-0}" local popularity_score="${2:-0}" local recency_score="${3:-0}" local quality_score="${4:-0}" local TOTAL_SCORE TOTAL_SCORE=$(( \ user_score + \ popularity_score + \ recency_score + \ quality_score \ )) echo "$TOTAL_SCORE" } # ── evaluate_threshold ─────────────────────────────────────────────────────── evaluate_threshold() { local score="$1" local minimum="$2" if (( score >= minimum )); then return 0 fi return 1 } # ── apply_temporal_decay ───────────────────────────────────────────────────── apply_temporal_decay() { local score="$1" local age_days="$2" local decay=$(( age_days / 30 )) local adjusted=$(( score - decay )) (( adjusted < 0 )) && adjusted=0 echo "$adjusted" } # ── is_duplicate_candidate ─────────────────────────────────────────────────── is_duplicate_candidate() { local candidate="$1" local history_file="$2" # -F -x, not "^$" anchors: the candidate is data, not a pattern. Interpolating it into a # regex makes every metacharacter in an artist or title active — "R.E.M." matches "RxExMy", # and an unbalanced bracket makes grep error out entirely. Either way the caller reads the # result as "already seen" and silently skips something genuinely new. -F disables regex, # -x anchors the whole line, which is exactly what the anchors were reaching for. grep -qiFx -- "$candidate" "$history_file" 2>/dev/null } # ── make_decision ───────────────────────────────────────────────────────────── make_decision() { local score="$1" local threshold="$2" if evaluate_threshold "$score" "$threshold"; then echo "ACCEPT" else echo "REJECT" fi }