Files
sortof/docs/specs/2026-05-01-build-context-dep-add.md

175 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Spec C — Build context + dep Add + auto-disambiguation rules
> **Lineage:** sits on top of Spec A (multi-branch picker) and Spec B+F (collection expansion / live drain). Adds context-aware default selection. Does **not** modify the picker contract — Spec A §8 ownership still holds.
## §1 Overview
Three loosely-related improvements that share the same core: the system has more context than it has been using. The user already tells us their PZ build via the `pzBuild` localStorage toggle. The user already gives us a list of mod_ids (via the wsids in their input). The system should consult both before deciding which branches of a multi-branch wsid land in `MODS_LINE` by default.
**Goal:** smarter pre-ticked boxes in the multi-branch picker. **Non-goal:** any form of "magic" sort that emits a branch the user didn't see.
## §2 Build context (`pzBuild`)
The frontend stores `sortof.pzBuild` in localStorage with values `"B41" | "B42"`, default `"B41"`. It already drives `MODS_LINE` rendering (B42 prefixes mod_ids with `\`).
This spec extends `pzBuild` to:
- Travel with `/api/sort` POST body as `pz_build: "B41" | "B42"`. The backend defaults to `"B42"` when missing or invalid.
- Inform Rule A (§4.3) of auto-disambiguation.
`pz_build` is **not** sent on `/api/resort` — the resort flow uses an explicit mod_id list and never re-evaluates rules.
## §3 Dep Add (already shipped)
Documenting the existing behavior so it's part of the locked design.
When `mlos_sort` reports a missing requirement (mod A requires mod B, B is not in the user's enabled set), `build_warnings` enriches each `tag: "missing"` warning with one of:
- `actions: [{type: "add-wsid", wsid, modId, label}]` — when `mod_parsed` has a row with `mod_id == B` (using `DISTINCT ON (mod_id) ORDER BY parsed_at_time_updated DESC`)
- `actions: [{type: "search-workshop", modId, url, label}]` — when no cache hit; URL is `https://steamcommunity.com/workshop/browse/?appid=108600&searchtext=<modId>`
The frontend renders `[add modId]` as a filled blue chip; clicking appends the wsid to the input textarea AND auto-resorts (no separate sort click needed). The search variant is `[↗ find modId]` and opens Steam's search in a new tab.
## §4 Auto-disambiguation rules
### §4.1 Design principle (locked)
These rules **adjust which boxes are pre-ticked in the picker**. They never bypass the picker and never silently emit a branch the user didn't see. Spec A §8 ownership holds — the picker is the source of truth.
The rules are applied at `/api/sort` time, before `MODS_LINE` is composed. The response's `MOD_DB` always contains every cached branch (so the picker can offer them); `SORTED_ORDER` and `MODS_LINE` reflect only the auto-selected set. The frontend reads the picker default from `SORTED_ORDER` membership.
### §4.2 Order of evaluation per wsid
```
A → C → B
```
The first rule that single-ticks a branch wins; subsequent rules emit warnings only. Exception: A and C are orthogonal (build × addon) and both may tick the same wsid simultaneously — see §4.7.
If a wsid is **coordinated** (any branch references a sibling via `requirements` / `loadAfter` / `loadBefore`) or **radio** (any branch lists a sibling in `incompatibleMods`), it is **exempt from rules A/B/C**. Coordinated → all branches stay; radio → first only.
### §4.3 Rule A — build-aware default *(highest ROI)*
A branch is **B41-flavored** if `mod_id`:
- ends with `B41` (case-insensitive), or
- contains `_legacy_` followed by version digits (e.g., `_legacy_42_12`, `_legacy_41_*`)
A branch is **B42-flavored** if `mod_id`:
- ends with `B42` or `_b42` (case-insensitive), or
- contains `_b42_` (e.g., `vac_mod_b42_utils`)
A branch is **un-flavored** otherwise.
Apply: if exactly one branch is flavored to match the active `pz_build` AND no other branch shares that flavor, pre-tick that one. Un-flavored branches are treated as "the build the author considers default" — currently always B42.
If no branch matches the active build (e.g., user is on B41 but every branch is B42-flavored), fall through to Rule C / B and emit a `build-mismatch` warning: `"no <build> variant for <name> (<wsid>); using author default"`.
Rule A also fires when the wsid has **exactly two branches** and one is `B41`-flavored, one is unflavored — the unflavored is treated as B42 default. So a B42 user gets the unflavored, a B41 user gets the B41-flavored.
### §4.4 Rule B — prefix-base tiebreaker
When the auto-pick path needs to pick *one* branch (Rule A didn't single-tick, Rule C didn't fire), use the **shortest mod_id that is a strict prefix of every other branch's mod_id** instead of alphabetical-first.
**Strict prefix definition:** `A` is a strict prefix of `B` iff:
1. `A``B`, AND
2. `B.startswith(A)`, AND
3. The character at position `len(A)` in `B` is a non-lowercase-letter — separator (`_` `-` ` `), digit (`0`-`9`), or uppercase letter (`A`-`Z`).
Boundary regex: `^<A>([_\- ]|[A-Z]|[0-9]|$)`.
**Examples that qualify:**
- `ArmoredVests``ArmoredVestsPatch` (boundary: `P`)
- `ToadTraits``ToadTraitsDisablePrepared` (boundary: `D`)
- `LitSortOGSN``LitSortOGSN_chocolate` (boundary: `_`)
- `WaterDispenser``WaterDispenser2` (boundary: `2`)
**Examples that do NOT qualify:**
- `Foo` vs `Foobar` (boundary: `b`, lowercase continuation)
- `Lit` vs `LitSort` (boundary: `S`, capital — actually qualifies; this case becomes prefix-base, deliberate)
If multiple branches are mutual prefixes (impossible by definition) or if no branch is a prefix-of-all-others, fall back to alphabetical-first by `mod_id`.
Rule B still emits the `auto-picked-branch` warning (Spec A §4) and renders the click-to-expand picker buttons — the only behavior change is the choice of which branch wins the auto-pick.
### §4.5 Rule C — input cross-reference *(solves Jeeve's Patches)*
For each ambiguous branch whose mod_id matches the pattern `<base>_<TOKEN>` (i.e., contains an underscore, with a base prefix shared with at least one sibling branch):
1. Extract `<TOKEN>` (the part after the last `_`).
2. Look up `<TOKEN>` against the resolved mod_id set from the user's input — case-insensitive substring match against any cached `mod_parsed.mod_id` whose wsid is in the user's input.
3. Match requires `<TOKEN>` length ≥ 3 (avoid `_a`, `_x` false positives).
**Hit:** pre-tick that branch alongside the base branch.
**No hits on any suffix-tokened branch:** tick the base branch only and emit `unmatched-addons` warning listing the unticked branches by name.
The "base branch" inside a Rule-C wsid is determined by Rule B (prefix-base tiebreaker), with alphabetical fallback.
**Worked example: Jeeve's Patches** (wsid 3684025083, branches `JeevesPatches`, `JeevesPatches_AZ`, `JeevesPatches_DAMN`, `JeevesPatches_GGS`, `JeevesPatches_ISA`, `JeevesPatches_PlayerStatus`, `JeevesPatches_Spongie`, `JeevesPatches_Tanker`, `JeevesPatches_Towing`, `JeevesPatches_Vanilla`, `JeevesPatches_ZRE`):
- Base = `JeevesPatches` (Rule B: prefix-of-others).
- Tokens: `AZ`, `DAMN`, `GGS`, `ISA`, `PlayerStatus`, `Spongie`, `Tanker`, `Towing`, `Vanilla`, `ZRE`.
- User submits `AuthenticZ`-related wsids → some cached mod_id contains `AZ` (case-insensitive substring) → tick `JeevesPatches_AZ`.
- User submits `JeevesIntegration` → no token hits → tick `JeevesPatches` only, emit `unmatched-addons` warning.
### §4.6 Rules D + G — hint text only
When the picker renders an ambiguous wsid's branches, attach a per-branch `hint` field surfaced in the picker row. **No state mutation.** Hints are:
| Suffix pattern | Hint text |
|---|---|
| `_Lite`, `_Light` | "lighter alternate variant — pick one" |
| `_HD`, `_DetailsHD` | "high-resolution variant" |
| `_NoCE`, `_NoVanilla`, `_FarmDisable`, `_Disable*` | "opt-out variant" |
| `_USDM`, `_Imports`, `_Exotics`, `_RealNames` | "alternate variant — pick one" |
| `_v\d+(_\d+)*`, `_legacy_*` | "legacy build — usually not what you want" |
| `_AZ`, `_DAMN`, `_GGS`, etc. (Rule C suffixes that didn't match input) | "addon for `<TOKEN>` — only if you have it" |
Pattern matching is case-insensitive with anchored end-of-string for short tags. Multiple hints on one branch concatenate with " · ".
### §4.7 Edge cases
**A and C both fire and disagree** (build hint says B42, input cross-ref says addon `_AZ`): both tick. Build × addon are orthogonal axes; they don't compete for the same slot.
**A says no match (build-mismatch), C also fires:** C still fires; the build-mismatch warning surfaces alongside C's selections. Rule A's "fall back to author default" doesn't override C.
**Coordinated wsid where one branch happens to be B42-flavored and another B41-flavored:** the wsid is exempt from A/B/C (coordinated detection runs first). All branches stay regardless of build mismatch. This is the right answer because coordinated branches by definition need each other.
**Stored selections** in localStorage from previous sessions: the existing `runResort(branchSelections)` flow fires *after* the initial sort response is rendered. User's stored selections override the rules. Rules only determine the initial render's pre-ticked state for never-touched wsids.
**`pz_build` missing or invalid in request:** backend treats as `"B42"`. Forwards-compatible if frontend on a stale build doesn't send it.
## §5 Implementation notes
**Backend** (`adapters.py` + `app.py`):
- `SortRequest` gains `pz_build: Optional[str]`.
- `_autopick_ambiguous(mods)` is renamed `_apply_branch_rules(mods, *, pz_build, input_modids)` and replaces the alphabetical-first picker with the rule pipeline above.
- Returns `(drop_ids, warnings, hints)``hints` is `Dict[mod_id -> str]` consumed by `build_response` and attached to MOD_DB entries as `hint?: string`.
- `sort_endpoint` computes `input_modids = set(by_id.keys())` (the cached mod_ids) and passes alongside `pz_build`.
**Frontend** (`sortof-app.jsx`):
- `onSort` POST body adds `pz_build: pzBuild`.
- `defaultSelectionForBranches(branches, activeSet)` accepts an `activeSet: Set<modId>` (the union of `D.SORTED_ORDER` mod_ids). Returns `branches.filter(b => activeSet.has(b.modId)).map(b => b.modId)`, falling back to `[branches[0].modId]` if none match.
- All call sites of `defaultSelectionForBranches` pass `new Set(D.SORTED_ORDER || [])`.
- `BranchPicker` renders `branch.hint` when present, as a small italic line under the mod_id.
The frontend doesn't reimplement the rules — it simply reflects the backend's chosen `SORTED_ORDER`. Same data path keeps the picker as source of truth and makes the rules introspectable from a curl response.
## §6 Acceptance criteria
- [ ] `POST /api/sort` accepts `pz_build` and defaults to `"B42"` when omitted.
- [ ] B42 user submits a wsid with branches `Foo` (un-flavored) and `FooB41`: `MODS_LINE` includes `Foo` only. B41 user gets `FooB41`.
- [ ] User submits Jeeve's Patches wsid alone: `MODS_LINE` is `JeevesPatches`. WARNINGS includes `unmatched-addons` listing the 10 untouched branches.
- [ ] User submits Jeeve's Patches + a wsid whose mod_id is `AuthenticZ_Current`: `MODS_LINE` includes `JeevesPatches` and `JeevesPatches_AZ`.
- [ ] Wsid `1962761540` (`ArmoredVests`, `ArmoredVestsPatch`): auto-pick selects `ArmoredVests` (prefix-base), not alphabetical-first (which is the same here — pick a wsid where they differ to verify).
- [ ] Coordinated wsid `2791656602` (fhqMotoriusZone): all 5 branches stay in `MODS_LINE` regardless of `pz_build`.
- [ ] Picker UI shows hint text per branch for D/G-class suffixes.
- [ ] `WORKSHOP_ITEMS_LINE` matches `wsids[]` regardless of which branches got ticked (Spec A §8 unchanged).
## §7 Test recipes
1. **Build A — B42 default.** `pz_build=B42` + wsid with branches `[Foo, FooB41]`. Expect `MODS_LINE = "Foo"`, no `build-mismatch` warning.
2. **Build A — B41 + only-B42-variants → mismatch warning.** `pz_build=B41` + wsid where every branch is B42-flavored. Expect `build-mismatch` warning, fall through to Rule B.
3. **Rule C — Jeeve's alone.** Submit only Jeeve's Patches wsid. Expect MODS_LINE = `JeevesPatches`, `unmatched-addons` warning lists the 10 others.
4. **Rule C — Jeeve's + AuthenticZ.** Submit Jeeve's wsid + AuthenticZ wsid. Expect MODS_LINE includes `JeevesPatches` and `JeevesPatches_AZ`.
5. **Rule B — prefix-base picks non-alphabetical.** Find a wsid where alphabetical-first ≠ prefix-base; verify prefix-base wins.
6. **Hint text — `_Lite` / `_legacy_*`.** Picker row shows the appropriate hint string.
7. **Coordinated exemption.** fhqMotoriusZone: all 5 branches in MODS_LINE for both B41 and B42 users.
8. **Stored-selection override.** User has `branchSelections[wsid] = [explicit]` from previous session. Rules don't override on next sort — runResort runs with the explicit selection after initial render.