Pack builder: Fix all and updates say why nothing went out while the web search card waits; page follow-up lines are not request words; Only rows narrow only the tag groups they rule on; answers are not request gaps; code-wired story beats skip the lookup lint; left-out rules read plainly and get Fix with AI; a rule overwriting a pick becomes an append; model labels read "For Illustrious"; roomier plan head. Chat: same-name edits refused with the shown item named; search by category for organizing; empty edit answers carry a note; prompt syntax coloured inside cards; every chat format named in the prompt; pointer captions avoid each other; one shared working line. Search: what an allowed search found shows as a card in the Pack builder and the Draft with AI panels; Use what fits sends all results for the model to keep what fits, ticking sends only those, None of these sends nothing.
225 lines
9.2 KiB
Python
225 lines
9.2 KiB
Python
"""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
|