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

13 KiB
Raw Permalink Blame History

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. AB, 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:

  • ArmoredVestsArmoredVestsPatch (boundary: P)
  • ToadTraitsToadTraitsDisablePrepared (boundary: D)
  • LitSortOGSNLitSortOGSN_chocolate (boundary: _)
  • WaterDispenserWaterDispenser2 (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.