"""Pure constraint re-weight math (SP3). No engine/ctx imports — testable bare. One multiply operator at three levels: across an option's matching matrix cells (multi-tag), across source picks (multi-pick), and — by the caller — across constraints. `exclude` (factor 0) is the absorbing element. **One exception, added 2026-08 (tag axes).** Tags belonging to a source group the wildcard declared `accepts` are ALTERNATIVES the option offers, not statements that are all true at once. Those fold with `max`, so one sibling's `exclude` cannot speak for the rest. Everything else still multiplies, and the axis path is unreachable unless the caller passes `axis_kinds` — so a payload that predates the feature folds byte-identically. **`only` (2026-09, linked picks).** A rule that turns its row into an allow-list: when the source fires, every target WITHOUT a rule of its own in that row is excluded. On an exception it gates by literal value — any target value with no exception for that source value drops out. In a matrix row it gates by tag — an option carrying no tag that has a rule in the row drops out. The `only` rule itself weighs 1.0 (like `allow`), and a target that has any other rule in the row keeps that rule, so `only` shuts out the unmentioned and leaves the mentioned alone. In a matrix row an `allow` cell does NOT count as a mention: the editors show `allow` as the empty "neutral" state, so counting it would let an invisible cell through. An `allow` exception is a visible row in the exceptions list, so it does count. Inside a target `accepts` axis the allow-list is per axis: a member with no rule offers nothing only when the row rules on some member of that axis; an axis the row never mentions stays neutral (the row is about other tags), so a target that is itself the source of another agreement keeps rolling that axis. A payload with no `only` rule never reaches either gate, so it folds byte-identically. """ from __future__ import annotations from typing import Any class _ExcludeSentinel: """Absorbing zero: the option drops out of the pool entirely. A distinct class rather than a bare ``object()`` so a type checker can narrow ``float | _ExcludeSentinel`` after an isinstance test. With the bare sentinel every ``factor *= r`` was unprovable, because ``is EXCLUDE`` does not narrow an ``object``. """ __slots__ = () def __repr__(self) -> str: # pragma: no cover - debug aid only return "EXCLUDE" EXCLUDE = _ExcludeSentinel() # sentinel: option drops out of the pool _Factor = float | _ExcludeSentinel def _apply_rule(rule: dict[str, Any]) -> _Factor: mode = rule.get("mode") if mode == "exclude": return EXCLUDE if mode in ("boost", "reduce"): try: return max(0.0, float(rule.get("factor", 1.0))) except (TypeError, ValueError): return 1.0 return 1.0 # allow / only / unknown -> no weight change def _row_has_only(row: dict[str, Any]) -> bool: return any(isinstance(r, dict) and r.get("mode") == "only" for r in row.values()) def _cell_factor( matrix: dict[str, Any], source_tag: str, opt_tags: list[str], target_axes: dict[str, list[str]] | None = None, ) -> _Factor: """Fold one source tag against every tag on the option. Classify target tags fold with PRODUCT — an option sitting on several rows of the same source tag is genuinely several separate statements about it. Tags belonging to a target ``accepts`` axis (``target_axes``: group → members) fold with MAX instead: the option OFFERS those as ALTERNATIVES, so it stays viable when the source allows ANY of them (the mirror of the source-side accepts fold — the target's own roll is then restricted to the allowed members by the caller). A no-rule target tag is neutral (1.0). EXCLUDE is absorbing for a classify tag and for a fully-excluded axis; the ``target_axes`` path is unreachable unless the caller passes it, so a legacy call folds byte-identically. """ row = matrix.get(source_tag) if not isinstance(row, dict): return 1.0 # `only` makes the row an allow-list: an option carrying no tag that the # row mentions is shut out. Inside an accepts axis the row MENTIONS (it # rules on at least one member) an unmentioned member then offers nothing # (0), rather than the usual neutral 1.0; an axis the row never mentions # is not an allow-list and stays neutral, so a target that is also the # SOURCE of another agreement (its own accepts group) is not shut out by # a row about its classify tags. A neutral (`allow`) cell is not a # mention — see the module docstring. has_only = _row_has_only(row) if has_only: row = { t: r for t, r in row.items() if isinstance(r, dict) and r.get("mode") != "allow" } if not any(t in row for t in opt_tags): return EXCLUDE target_axes = target_axes or {} unruled_in = { ax: 0.0 if has_only and any(m in row for m in (members or [])) else 1.0 for ax, members in target_axes.items() } axis_of = {t: ax for ax, members in target_axes.items() for t in (members or [])} factor = 1.0 axis_members: dict[str, list[str]] = {} for t in opt_tags: ax = axis_of.get(t) if ax is None: rule = row.get(t) if isinstance(rule, dict): r = _apply_rule(rule) if isinstance(r, _ExcludeSentinel): return EXCLUDE factor *= r else: axis_members.setdefault(ax, []).append(t) for ax, members in axis_members.items(): best = 0.0 unruled = unruled_in.get(ax, 1.0) for t in members: rule = row.get(t) r = _apply_rule(rule) if isinstance(rule, dict) else unruled best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r) # Every alternative this axis offered was excluded → the option drops. if best <= 0.0: return EXCLUDE factor *= best return factor def combine_constraint_factor( source_picks: list[dict[str, Any]] | None, option: dict[str, Any], matrix: dict[str, Any] | None, exceptions: list[Any] | None, axis_kinds: dict[str, str] | None = None, target_axes: dict[str, list[str]] | None = None, ) -> _Factor: matrix = matrix or {} axis_kinds = axis_kinds or {} target_axes = target_axes or {} exc_by_pair: dict[tuple[str, str], dict] = {} for e in (exceptions or []): if not isinstance(e, dict): continue s = e.get("source_value") if s is None: s = e.get("source") t = e.get("target_value") if t is None: t = e.get("target") if isinstance(s, str) and isinstance(t, str): exc_by_pair[(s, t)] = e # Source values whose exceptions include an `only`: for those, a target # value with no exception of its own is excluded. Read off the de-duplicated # pair map so it agrees with the rule that actually applies to each pair. only_sources = { s for (s, _t), e in exc_by_pair.items() if e.get("mode") == "only" } opt_value = str(option.get("value", "")) opt_tags = option.get("tags") or [] factor = 1.0 for pick in (source_picks or []): p_value = str(pick.get("value", "")) p_tags = pick.get("tags") or [] exc = exc_by_pair.get((p_value, opt_value)) if exc is not None: r = _apply_rule(exc) if isinstance(r, _ExcludeSentinel): return EXCLUDE factor *= r continue if p_value in only_sources: return EXCLUDE # Partition this pick's tags: those inside an `accepts` axis fold with # max, the rest keep multiplying. A pick only carries `axes` when the # source wildcard declared the group `accepts`, and `axis_kinds` gates # it a second time, so a legacy pick takes the flat path untouched. pick_axes = pick.get("axes") or {} accepts: dict[str, list[str]] = {} claimed: set[str] = set() for axis, members in pick_axes.items(): if axis_kinds.get(axis) != "accepts": continue in_axis = [t for t in p_tags if t in (members or [])] if in_axis: accepts[axis] = in_axis claimed.update(in_axis) for s in p_tags: if s in claimed: continue r = _cell_factor(matrix, s, opt_tags, target_axes) if isinstance(r, _ExcludeSentinel): return EXCLUDE factor *= r for members in accepts.values(): best = 0.0 for s in members: r = _cell_factor(matrix, s, opt_tags, target_axes) # One member's EXCLUDE is that member declining, not the axis # declining — it contributes 0 to the max and the siblings # still get their say. best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r) # Every alternative the option offered was excluded, so the option # really does drop out. if best <= 0.0: return EXCLUDE factor *= best return factor