feat(domain): rule groups — MP rule-string parser/serializer, builtin keywords, chain evaluation wired into slot

- rules/: RuleExpr tree, '>' layers with ! & | and parens, position-aware errors
  (unknown atom with suggestions, unsupported music atoms, parens, empty layer)
- builtin keyword table aligned with MP BUILTIN_RULE_SET; field-based judgement
  where parsed fields exist, MP regex otherwise; custom atoms (MP 7 fields)
- chain evaluation: hard excludes, ladder pri 100-k, groups AND-ed, pri from last
  laddered group, readable per-atom trace
- slot: SlotSpec.rules, rule.hard_exclude / rule.no_layer_matched violations,
  pri basis switches to priority when a rule ladder is present
- differential: 66 rule chains x 32 torrents vs MP python path (parse 64/64,
  pri 2090/2112, 22 registered mp_bug)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 14:50:43 +08:00
1 parent 22856d0447
commit e994a7bcfa
13 files changed
+23919 -10

No files matched your search

+79 -8
View File
@@ -1,4 +1,6 @@
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,
@@ -288,6 +290,10 @@ export function describeViolation(v: Violation): string {
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":
@@ -366,8 +372,47 @@ export function priorityLadder(spec: SlotSpec, r: ParsedRelease, ctx: Evaluation
/** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */
export function currentPri(spec: SlotSpec, current: QualityFacts): number {
if (typeof current.pri === "number") return current.pri;
const pseudo = { raw: "", hdr: [], isEncode: false, ...current } as unknown as ParsedRelease;
return priorityLadder(spec, pseudo).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<T>(pref: readonly T[] | undefined, have: readonly T[]): number {
@@ -420,21 +465,43 @@ export function evaluateCandidate(
current?: QualityFacts | null,
ctx: EvaluationContext = {},
): SlotEvaluation {
const { violations, missing, accepted } = checkHardConditions(spec, r, ctx);
const ladder = priorityLadder(spec, r, ctx);
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 };
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 };
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;
const priReasons = tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : [];
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",
@@ -444,6 +511,7 @@ export function evaluateCandidate(
pri: ladder.pri,
ladder,
trace: ["硬条件全部满足", ...ladder.trace],
...withRules,
};
const reject = (v: Violation): SlotEvaluation => ({
@@ -451,6 +519,7 @@ export function evaluateCandidate(
violations: [v],
trace: [describeViolation(v), ...ladder.trace],
ladder,
...withRules,
});
const up = spec.upgrade;
if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" });
@@ -473,6 +542,7 @@ export function evaluateCandidate(
fromPri,
ladder,
trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace],
...withRules,
};
}
if (!current.source)
@@ -509,6 +579,7 @@ export function evaluateCandidate(
pri: ladder.pri,
ladder,
trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace],
...withRules,
};
}