diff --git a/Kernel/decision_engine.sh b/Kernel/decision_engine.sh index eebd511..e3de79b 100755 --- a/Kernel/decision_engine.sh +++ b/Kernel/decision_engine.sh @@ -15,6 +15,20 @@ # 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 # ============================================================================================== # @@ -38,6 +52,60 @@ # 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 # ============================================================================================== # @@ -111,7 +179,12 @@ is_duplicate_candidate() { local candidate="$1" local history_file="$2" - grep -qi "^${candidate}$" "$history_file" 2>/dev/null + # -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 ─────────────────────────────────────────────────────────────