import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types.ts"; import { catalogOf, evaluateRuleChain } from "../rules/evaluate.ts"; import type { RuleChainResult } from "../rules/types.ts"; import type { EvaluationContext, HardField, LadderResult, QualityFacts, QualityTier, SlotEvaluation, SlotSpec, SlotTierRule, UnknownPolicy, Violation, } from "./types.ts"; export const DEFAULT_SOURCE_TIERS: Readonly> = { REMUX: 3, "BluRay-Disc": 3, BluRay: 3, "WEB-DL": 2, WEBRip: 1, HDTV: 1, HDRip: 1, DVDRip: 0, DVD: 0, }; export const RESOLUTION_RANK: Readonly> = { "4320p": 7, "2160p": 6, "1440p": 5, "1080p": 4, "1080i": 3, "720p": 2, "576p": 1, "480p": 0, }; export function qualityTier(f: QualityFacts, spec?: SlotSpec): QualityTier { const tiers = { ...DEFAULT_SOURCE_TIERS, ...spec?.upgrade?.sourceTiers }; return { source: f.source ? tiers[f.source] : -1, resolution: f.resolution ? RESOLUTION_RANK[f.resolution] : -1, }; } function policyFor(spec: SlotSpec, field: HardField): UnknownPolicy { const p = spec.unknownPolicy; if (p === undefined) return "hold"; if (typeof p === "string") return p; return p[field] ?? "hold"; } const lc = (s: string) => s.toLowerCase(); function subtitleSatisfied(have: string[], need: string): boolean { return have.some((h) => h === need || (need === "zh" && h.startsWith("zh"))); } /** 硬条件检查:区分"缺字段"(missing)和"字段不满足"(violations) */ export function checkHardConditions( spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}, ): { violations: Violation[]; missing: HardField[]; accepted: HardField[] } { const h = spec.hard; const violations: Violation[] = []; const missing: HardField[] = []; const accepted: HardField[] = []; const unknown = (f: HardField) => { const p = policyFor(spec, f); if (p === "hold") missing.push(f); else if (p === "reject") violations.push({ field: f, code: "unknown_rejected_by_policy" }); else accepted.push(f); }; if (h.resolutions?.length) { if (!r.resolution) unknown("resolution"); else if (!h.resolutions.includes(r.resolution)) violations.push({ field: "resolution", code: "not_allowed", expected: h.resolutions, actual: r.resolution }); } if (h.sources?.length || h.requireEncode) { if (!r.source) unknown("source"); else { if (h.sources?.length && !h.sources.includes(r.source)) violations.push({ field: "source", code: "not_allowed", expected: h.sources, actual: r.source }); if (h.requireEncode && !r.isEncode) violations.push({ field: "source", code: "not_encode", expected: "BluRay encode", actual: r.source }); } } if (h.maxSizeBytes !== undefined || h.minSizeBytes !== undefined) { if (r.sizeBytes === undefined) unknown("size"); else { if (h.maxSizeBytes !== undefined && r.sizeBytes > h.maxSizeBytes) violations.push({ field: "size", code: "too_large", expected: h.maxSizeBytes, actual: r.sizeBytes }); if (h.minSizeBytes !== undefined && r.sizeBytes < h.minSizeBytes) violations.push({ field: "size", code: "too_small", expected: h.minSizeBytes, actual: r.sizeBytes }); } } if (h.requiredSubtitles?.length) { if (r.subtitles === undefined) unknown("subtitles"); else { const lack = h.requiredSubtitles.filter((s) => !subtitleSatisfied(r.subtitles!, s)); if (lack.length > 0) violations.push({ field: "subtitles", code: "missing_language", expected: lack, actual: r.subtitles }); } } if (h.cuts?.length) { const cut = r.cut ?? "default"; if (!h.cuts.includes(cut)) violations.push({ field: "cut", code: "not_allowed", expected: h.cuts, actual: cut }); } if (h.excludeGroups?.length && r.releaseGroup) { const g = lc(r.releaseGroup.name); if (h.excludeGroups.some((x) => g.split("@").includes(lc(x)))) violations.push({ field: "group", code: "excluded", actual: r.releaseGroup.name }); } if (h.contentTypes?.length) { if (r.contentType === "unknown" && !h.contentTypes.includes("unknown")) unknown("contentType"); else if (!h.contentTypes.includes(r.contentType)) violations.push({ field: "contentType", code: "not_allowed", expected: h.contentTypes, actual: r.contentType }); } if (h.excludeSources?.length) { if (!r.source) unknown("source"); else if (h.excludeSources.includes(r.source)) violations.push({ field: "source", code: "excluded", expected: h.excludeSources, actual: r.source }); } if (h.hdr) { const have = r.hdr ?? []; const { anyOf, noneOf, requireSdr } = h.hdr; if (anyOf?.length && !anyOf.some((x) => have.includes(x))) violations.push({ field: "hdr", code: "missing_required", expected: anyOf, actual: have }); const hit = (noneOf ?? []).filter((x) => have.includes(x)); if (hit.length) violations.push({ field: "hdr", code: "excluded", expected: noneOf, actual: hit }); if (requireSdr && have.length) violations.push({ field: "hdr", code: "not_sdr", actual: have }); } if (h.codecs?.length || h.excludeCodecs?.length) { if (!r.videoCodec) unknown("codec"); else { if (h.codecs?.length && !h.codecs.includes(r.videoCodec)) violations.push({ field: "codec", code: "not_allowed", expected: h.codecs, actual: r.videoCodec }); if (h.excludeCodecs?.includes(r.videoCodec)) violations.push({ field: "codec", code: "excluded", expected: h.excludeCodecs, actual: r.videoCodec }); } } if (h.minUnitSizeBytes !== undefined || h.maxUnitSizeBytes !== undefined) { const n = unitCountOf(r, ctx); if (r.sizeBytes === undefined || n === undefined) unknown("size"); else { const per = Math.round(r.sizeBytes / n); if (h.maxUnitSizeBytes !== undefined && per > h.maxUnitSizeBytes) violations.push({ field: "size", code: "unit_too_large", expected: h.maxUnitSizeBytes, actual: per }); if (h.minUnitSizeBytes !== undefined && per < h.minUnitSizeBytes) violations.push({ field: "size", code: "unit_too_small", expected: h.minUnitSizeBytes, actual: per }); } } if (h.include?.length || h.exclude?.length) { const text = matchText(r, ctx); const bad = [...(h.include ?? []), ...(h.exclude ?? [])].filter((p) => validateKeywordPattern(p) !== null); if (bad.length) violations.push({ field: "keywords", code: "invalid_pattern", actual: bad }); else { if (h.include?.length && !h.include.some((p) => keywordRe(p).test(text))) violations.push({ field: "keywords", code: "include_missed", expected: h.include }); const hits = (h.exclude ?? []).filter((p) => keywordRe(p).test(text)); if (hits.length) violations.push({ field: "keywords", code: "exclude_hit", actual: hits }); } } if (h.sites?.length || h.excludeSites?.length) { if (!ctx.siteId) unknown("site"); else { if (h.sites?.length && !h.sites.includes(ctx.siteId)) violations.push({ field: "site", code: "not_in_scope", expected: h.sites, actual: ctx.siteId }); if (h.excludeSites?.includes(ctx.siteId)) violations.push({ field: "site", code: "excluded", actual: ctx.siteId }); } } if (h.minSeeders !== undefined) { if (ctx.seeders === undefined) unknown("seeders"); else if (ctx.seeders < h.minSeeders) violations.push({ field: "seeders", code: "too_few", expected: h.minSeeders, actual: ctx.seeders }); } if (h.freeOnly) { if (ctx.downloadVolumeFactor === undefined) unknown("promotion"); else if (ctx.downloadVolumeFactor > 0) violations.push({ field: "promotion", code: "not_free", actual: ctx.downloadVolumeFactor }); } return { violations, missing, accepted }; } /** 候选覆盖的单元数:上下文给定 > 解析出的集范围 > 标题里的"全 N 集" */ export function unitCountOf(r: Pick, ctx: EvaluationContext = {}) { if (ctx.unitCount !== undefined && ctx.unitCount > 0) return ctx.unitCount; if (r.episodes) return Math.max(1, r.episodes.end - r.episodes.start + 1); if (r.totalEpisodes && r.totalEpisodes > 0) return r.totalEpisodes; return undefined; } const MAX_PATTERN = 200; /** * 关键词正则白名单校验:长度上限、禁止反向引用/后行断言/嵌套量词(防灾难性回溯),必须能编译。 * 返回 null 表示合法,否则为原因。 */ export function validateKeywordPattern(p: string): string | null { if (typeof p !== "string" || p.length === 0) return "empty"; if (p.length > MAX_PATTERN) return "too_long"; if (/\\[1-9]|\\k(); function keywordRe(p: string): RegExp { let re = reCache.get(p); if (!re) { re = new RegExp(p, "i"); if (reCache.size > 500) reCache.clear(); reCache.set(p, re); } return re; } function matchText(r: Pick, ctx: EvaluationContext): string { return [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join("\n"); } const GiB = 1024 ** 3; const fmtBytes = (v: unknown) => typeof v === "number" ? (v >= GiB ? `${(v / GiB).toFixed(2)} GiB` : `${Math.round(v / 1024 ** 2)} MiB`) : String(v); const fmtList = (v: unknown) => (Array.isArray(v) ? `[${v.join(", ")}]` : String(v ?? "未知")); /** 违规 → 可读说明(对齐 MP trace 的可读性,但字段结构化保留在 violations) */ export function describeViolation(v: Violation): string { const e = v.expected; const a = v.actual; switch (`${v.field}.${v.code}`) { case "resolution.not_allowed": return `分辨率 ${a} 不在允许列表 ${fmtList(e)}`; case "source.not_allowed": return `来源 ${a} 不在允许列表 ${fmtList(e)}`; case "source.excluded": return `来源 ${a} 被排除`; case "source.not_encode": return `要求蓝光压制,实际 ${a}`; case "size.too_large": return `体积 ${fmtBytes(a)} 超过上限 ${fmtBytes(e)}`; case "size.too_small": return `体积 ${fmtBytes(a)} 低于下限 ${fmtBytes(e)}`; case "size.unit_too_large": return `每集体积 ${fmtBytes(a)} 超过上限 ${fmtBytes(e)}`; case "size.unit_too_small": return `每集体积 ${fmtBytes(a)} 低于下限 ${fmtBytes(e)}`; case "subtitles.missing_language": return `缺少字幕 ${fmtList(e)}(实际 ${fmtList(a)})`; case "cut.not_allowed": return `版本 ${a} 不在允许列表 ${fmtList(e)}`; case "group.excluded": return `发布组 ${a} 被排除`; case "contentType.not_allowed": return `内容类型 ${a} 不在允许列表 ${fmtList(e)}`; case "hdr.missing_required": return `HDR 需含 ${fmtList(e)} 之一(实际 ${fmtList(a)})`; case "hdr.excluded": return `HDR ${fmtList(a)} 被排除`; case "hdr.not_sdr": return `要求 SDR,实际带 ${fmtList(a)}`; case "codec.not_allowed": return `编码 ${a} 不在允许列表 ${fmtList(e)}`; case "codec.excluded": return `编码 ${a} 被排除`; case "keywords.include_missed": return `不包含任何关键词 ${fmtList(e)}`; case "keywords.exclude_hit": return `命中排除关键词 ${fmtList(a)}`; case "keywords.invalid_pattern": return `关键词正则不合法 ${fmtList(a)}`; case "site.not_in_scope": return "站点不在范围内"; case "site.excluded": return "站点被排除"; case "seeders.too_few": return `做种数 ${a} 小于 ${e}`; case "promotion.not_free": return `非免费(下载系数 ${a})`; case "tier.no_tier_matched": return "不命中优先级阶梯的任何一层"; case "rule.hard_exclude": return `命中规则组「${e}」的硬排除 ${fmtList(a)}`; case "rule.no_layer_matched": return `不命中规则组「${e}」的任何一层`; case "upgrade.upgrade_disabled": return "已有版本且未开启升级"; case "upgrade.at_ceiling": return "当前版本已达顶档,不再升级"; case "upgrade.same_tier": return "与当前版本同档,不反复更换"; case "upgrade.lower_tier": return "低于当前版本"; case "upgrade.below_min_improvement": return "提升幅度不足最小改进阈值"; case "upgrade.encode_not_qualified": return `压制组 ${a ?? "未知"} 不在合格名单`; case "upgrade.not_higher_priority": return `阶梯 pri 未严格提升(当前 ${JSON.stringify(e)},候选 ${JSON.stringify(a)})`; case "upgrade.not_full_pack": return "全集洗版只接受整季包"; case "upgrade.no_improvable_units": return "候选所含单元都没有可提升的"; default: return e === undefined ? `${v.field}: ${v.code}` : `${v.field}: ${v.code}(期望 ${JSON.stringify(e)},实际 ${JSON.stringify(a ?? null)})`; } } /** 阶梯层匹配:返回未满足的条件说明(空 = 命中) */ function tierFailures(rule: SlotTierRule, r: ParsedRelease, ctx: EvaluationContext): string[] { const out: string[] = []; if (rule.resolutions?.length && !(r.resolution && rule.resolutions.includes(r.resolution))) out.push(`分辨率 ${r.resolution ?? "未知"} 不在 ${fmtList(rule.resolutions)}`); if (rule.sources?.length && !(r.source && rule.sources.includes(r.source))) out.push(`来源 ${r.source ?? "未知"} 不在 ${fmtList(rule.sources)}`); if (rule.excludeSources?.length && (!r.source || rule.excludeSources.includes(r.source))) out.push(`来源 ${r.source ?? "未知"} 被本层排除`); if (rule.requireEncode && !r.isEncode) out.push("不是蓝光压制"); if (rule.hdr?.length && !rule.hdr.some((x) => (r.hdr ?? []).includes(x))) out.push(`HDR ${fmtList(r.hdr ?? [])} 不含 ${fmtList(rule.hdr)}`); if (rule.codecs?.length && !(r.videoCodec && rule.codecs.includes(r.videoCodec))) out.push(`编码 ${r.videoCodec ?? "未知"} 不在 ${fmtList(rule.codecs)}`); if (rule.groups?.length) { const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; if (!rule.groups.some((g) => groups.includes(lc(g)))) out.push(`发布组 ${r.releaseGroup?.name ?? "未知"} 不在名单`); } if (rule.include?.length || rule.exclude?.length) { const text = matchText(r, ctx); const ok = (p: string) => validateKeywordPattern(p) === null; if (rule.include?.length && !rule.include.some((p) => ok(p) && keywordRe(p).test(text))) out.push(`不含关键词 ${fmtList(rule.include)}`); const hit = (rule.exclude ?? []).filter((p) => ok(p) && keywordRe(p).test(text)); if (hit.length) out.push(`命中排除词 ${fmtList(hit)}`); } return out; } /** * 优先级阶梯(对应 MP 规则组 `A > B > C`):逐层匹配,第 k 层(0 起)命中 → pri = 100 - k; * 未配置阶梯 → pri = 100、level = null(不参与排序差异);配置了但都不命中 → pri = 0。 */ export function priorityLadder(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}): LadderResult { const tiers = spec.soft?.tiers ?? []; if (tiers.length === 0) return { pri: 100, level: null, levelName: null, trace: [] }; const trace: string[] = []; for (let k = 0; k < tiers.length; k++) { const t = tiers[k]!; const name = t.name ?? `第${k + 1}层`; const fail = tierFailures(t, r, ctx); if (fail.length === 0) { trace.push(`${name}:命中,pri=${100 - k}`); return { pri: 100 - k, level: k, levelName: name, trace }; } trace.push(`${name}:${fail.join(";")}`); } return { pri: 0, level: null, levelName: null, trace }; } /** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */ export function currentPri(spec: SlotSpec, current: QualityFacts): number { if (typeof current.pri === "number") return current.pri; const pseudo = { raw: "", hdr: [], audio: [], isEncode: false, ...current } as unknown as ParsedRelease; return effectiveLadder(spec, pseudo, {}, ruleChainOf(spec, pseudo, {})).pri; } /** 版本目标引用的规则组链求值;没有规则组时为 null */ export function ruleChainOf(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}): RuleChainResult | null { if (!spec.rules?.groups.length) return null; return evaluateRuleChain(spec.rules.groups, r, ctx, catalogOf(spec.rules.atoms)); } /** * 实际生效的阶梯:版本目标自身 soft.tiers 视为排在最前的匿名组,规则组链里有带层的组时 pri 取最后一个带层的组。 */ export function effectiveLadder( spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext, chain: RuleChainResult | null, ): LadderResult { const own = priorityLadder(spec, r, ctx); if (!chain) return own; if (chain.pri === null || chain.decidingGroup === null) return { ...own, trace: [...own.trace, ...chain.trace] }; const g = chain.groups[chain.decidingGroup]!; return { pri: chain.pri, level: g.level, levelName: g.level === null ? null : `${g.name}·第${g.level + 1}层`, trace: [...own.trace, ...chain.trace], }; } function chainViolations(chain: RuleChainResult | null, spec: SlotSpec): Violation[] { if (!chain) return []; const out: Violation[] = []; chain.groups.forEach((g, i) => { if (g.excludedBy.length) out.push({ field: "rule", code: "hard_exclude", expected: g.name, actual: g.excludedBy }); const required = spec.rules!.groups[i]!.ladderRequired !== false; if (g.pri !== null && g.level === null && required) out.push({ field: "rule", code: "no_layer_matched", expected: g.name }); }); return out; } function prefIndexScore(pref: readonly T[] | undefined, have: readonly T[]): number { if (!pref?.length) return 0; for (let i = 0; i < pref.length; i++) if (have.includes(pref[i]!)) return pref.length - i; return 0; } /** 软偏好排序键(越大越好,按字典序比较):HDR、发布组、来源偏好、编码、体积区间 */ export function softRank(spec: SlotSpec, r: ParsedRelease): number[] { const s = spec.soft ?? {}; const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; const size = s.preferredSizeBytes; let sizeScore = 0; if (size && r.sizeBytes !== undefined) { const okMin = size.min === undefined || r.sizeBytes >= size.min; const okMax = size.max === undefined || r.sizeBytes <= size.max; sizeScore = okMin && okMax ? 1 : -1; } return [ prefIndexScore(s.hdr, r.hdr), prefIndexScore(s.groups?.map(lc), groups), prefIndexScore(s.sources, r.source ? [r.source] : []), prefIndexScore(s.codecs, r.videoCodec ? [r.videoCodec] : []), sizeScore, ]; } export function compareRank(a: readonly number[], b: readonly number[]): number { for (let i = 0; i < Math.max(a.length, b.length); i++) { const d = (a[i] ?? 0) - (b[i] ?? 0); if (d !== 0) return d; } return 0; } /** * 候选对某个 Slot 的确定性评估。 * - 已知不满足硬条件 → reject(列出全部违规) * - 硬条件字段缺失且策略为 hold → unknown_field(区分缺字段与不满足) * - 配置了阶梯且 tiersRequired、一层都不命中 → reject(tier.no_tier_matched) * - 无当前资产 → accept(附软偏好排序键、阶梯 pri) * - 有当前资产 → 仅在达到最小改进阈值时 upgrade,否则 reject(同等级不反复更换); * priority 口径下要求 pri 严格提升(对照 MP 洗版 pri_order > current_priority) * 所有结果带 trace(可读说明)。 */ export function evaluateCandidate( spec: SlotSpec, r: ParsedRelease, current?: QualityFacts | null, ctx: EvaluationContext = {}, ): SlotEvaluation { const hard = checkHardConditions(spec, r, ctx); const { missing, accepted } = hard; const chain = ruleChainOf(spec, r, ctx); const withRules = chain ? { rules: chain } : {}; const violations = [...hard.violations, ...chainViolations(chain, spec)]; const ladder = effectiveLadder(spec, r, ctx, chain); if (violations.length > 0) return { decision: "reject", violations, trace: [...violations.map(describeViolation), ...ladder.trace], ladder, ...withRules, }; if (missing.length > 0) return { decision: "unknown_field", missing, trace: missing.map((f) => `字段未知,需要补充:${f}`) }; if (spec.soft?.tiers?.length && spec.soft.tiersRequired && ladder.level === null) { const v: Violation = { field: "tier", code: "no_tier_matched" }; return { decision: "reject", violations: [v], trace: [describeViolation(v), ...ladder.trace], ladder, ...withRules, }; } const tier = qualityTier(r, spec); const rank = softRank(spec, r); const reasons = accepted.map((f) => `unknown_${f}_accepted_by_policy`); const tiered = !!spec.soft?.tiers?.length || (chain?.pri ?? null) !== null; const ruleReasons = (chain?.groups ?? []).flatMap((g) => g.groupId ? [`rule_group:${g.groupId}${g.revision !== undefined ? `@${g.revision}` : ""}`] : [], ); const priReasons = [ ...(tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : []), ...ruleReasons, ]; if (!current) return { decision: "accept", tier, softRank: rank, reasons: [...reasons, "hard_conditions_met", ...priReasons], pri: ladder.pri, ladder, trace: ["硬条件全部满足", ...ladder.trace], ...withRules, }; const reject = (v: Violation): SlotEvaluation => ({ decision: "reject", violations: [v], trace: [describeViolation(v), ...ladder.trace], ladder, ...withRules, }); const up = spec.upgrade; if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" }); if (up.mode === "full_pack" && (r.episodes || r.airDate)) return reject({ field: "upgrade", code: "not_full_pack" }); const basis = up.basis ?? (tiered ? "priority" : "tier"); if (basis === "priority") { const fromPri = currentPri(spec, current); const target = up.targetPri ?? 100; if (fromPri >= target) return reject({ field: "upgrade", code: "at_ceiling", expected: target, actual: fromPri }); if (ladder.pri <= fromPri) return reject({ field: "upgrade", code: "not_higher_priority", expected: fromPri, actual: ladder.pri }); const from = qualityTier(current, spec); return { decision: "upgrade", tier, fromTier: from, softRank: rank, reasons: [...reasons, "priority_improved", ...priReasons], pri: ladder.pri, fromPri, ladder, trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace], ...withRules, }; } if (!current.source) return { decision: "unknown_field", missing: ["current_asset"], trace: ["当前资产交付特征未知"] }; const from = qualityTier(current, spec); if (up.ceilingTier !== undefined && from.source >= up.ceilingTier) return reject({ field: "upgrade", code: "at_ceiling", expected: up.ceilingTier, actual: from.source }); const minSteps = up.minTierSteps ?? 1; const dSource = tier.source - from.source; const dRes = tier.resolution - from.resolution; const bySource = dSource >= minSteps && dRes >= 0; const byRes = !!up.allowResolutionUpgrade && dRes > 0 && dSource >= 0; if (!bySource && !byRes) { const code = dSource === 0 && dRes === 0 ? "same_tier" : dSource < 0 || dRes < 0 ? "lower_tier" : "below_min_improvement"; return reject({ field: "upgrade", code, expected: { minTierSteps: minSteps, from }, actual: tier }); } if (r.isEncode && up.encodeGroupAllowlist?.length) { const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g)))) return reject({ field: "upgrade", code: "encode_not_qualified", expected: up.encodeGroupAllowlist, actual: r.releaseGroup?.name, }); } return { decision: "upgrade", tier, fromTier: from, softRank: rank, reasons: [...reasons, bySource ? "source_tier_improved" : "resolution_improved", ...priReasons], pri: ladder.pri, ladder, trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace], ...withRules, }; } /** * 分集洗版:候选所含单元里哪些值得替换(对照 MP get_best_version_interested_episodes)。 * unitCurrent 缺某单元 = 该单元还没有版本(视为可填补)。返回可提升单元与逐单元结论。 */ export function improvableUnits( spec: SlotSpec, r: ParsedRelease, units: readonly string[], unitCurrent: Readonly>, ctx: EvaluationContext = {}, ): { units: string[]; perUnit: Record } { const perUnit: Record = {}; const out: string[] = []; for (const u of units) { const ev = evaluateCandidate(spec, r, unitCurrent[u] ?? null, ctx); perUnit[u] = ev.decision; if (ev.decision === "accept" || ev.decision === "upgrade") out.push(u); } return { units: out, perUnit }; } export interface RankedEvaluation { candidate: T; evaluation: SlotEvaluation; } /** 对一批候选评估并排序:可用(accept/upgrade)在前,按阶梯 pri、质量等级再按软偏好;其余保持原顺序 */ export function rankForSlot( spec: SlotSpec, candidates: readonly T[], current?: QualityFacts | null, ctxOf?: (c: T) => EvaluationContext, ): RankedEvaluation[] { const evals = candidates.map((c, i) => ({ candidate: c, evaluation: evaluateCandidate(spec, c, current, ctxOf?.(c) ?? {}), i, })); const usable = (e: SlotEvaluation) => e.decision === "accept" || e.decision === "upgrade"; return evals .sort((a, b) => { const ua = usable(a.evaluation); const ub = usable(b.evaluation); if (ua !== ub) return ua ? -1 : 1; if (ua && ub) { const ea = a.evaluation as Extract; const eb = b.evaluation as Extract; return ( (eb.pri ?? 100) - (ea.pri ?? 100) || eb.tier.source - ea.tier.source || eb.tier.resolution - ea.tier.resolution || compareRank(eb.softRank, ea.softRank) || a.i - b.i ); } return a.i - b.i; }) .map(({ candidate, evaluation }) => ({ candidate, evaluation })); }