Files
Dumitru Picalau 5056722e1a fix(ai): Claude walkthrough fixes, chat polish and a search results card (#82)
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.
2026-10-21 22:05:41 +02:00

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