Appearance
Factor-level dependencies
Factors can declare dependency rules on one another — global, track-independent rules that apply everywhere a factor is asked (#905, capability #904). They generalize track conditional display (#472), which stays track-scoped: a track curates its own flow; a factor dependency states a fact about the domain that no track may contradict.
There are two rule kinds:
- Relevance gating — when the answer to a source factor matches a predicate, a target factor becomes irrelevant (hidden/disabled) everywhere. "If the buyer picked Interior, Geography and Climate can't matter."
- Mutual exclusion — two options on two different factors can never coexist. Picking either one makes the other unavailable, whichever is answered first. "A segmented opening is never built in wood."
Unlike track rules, factor dependencies carry no ordering constraint: buyers can skip around tracks and answer out of order, so rules must hold in both directions. Evaluation is therefore a fixpoint pass (#906, see Runtime evaluation), not #472's single forward sweep — which is exactly why authoring-time validation must reject cycles (see Validation).
Data model
Relevance gating — global rows in track_display_rules
Relevance rules share the track-display-rule table and predicate shape — one storage, one predicate, two scopes (the capability's preferred unification). A NULL track_id makes the row global:
| Column | Track-scoped row (#472) | Global row (#905) |
|---|---|---|
track_id | FK → tracks | NULL |
target_type | 'factor' | 'option' | 'factor' only |
target_id | hidden factor/option id | the factor made irrelevant |
source_factor_id | the earlier factor tested | the factor tested (no ordering constraint) |
operator | 'in' | 'not_in' | same |
value_option_ids_json | JSON array of source option ids | same |
Multiple rules on the same target AND together, matching #472 semantics. The parsed domain shape is RelevanceRuleRecord (src/lib/server/admin/factor-dependencies.ts); the track-scoped layer (admin/track-display-rules.ts) never loads global rows and rejects them in parseDisplayRule.
Mutual exclusion — factor_option_exclusions
Exclusions are deliberately not rows in the shared rule table: its source→target shape persists a direction, and an exclusion has none. The pair is stored once, symmetric by construction:
| Column | Meaning |
|---|---|
id | Opaque UUID. |
option_a_id | FK → factor_options — the lexicographically smaller id. |
option_b_id | FK → factor_options — the larger id. |
The write layer canonicalizes the pair (option_a_id < option_b_id), so the unique index on (option_a_id, option_b_id) makes duplicates impossible in either order. The two options must belong to two different factors — options of one single-select factor are already exclusive.
Validation
Write-time integrity lives in validateDependencySet (pure) / validateFactorDependencies (DB-backed), in src/lib/server/admin/factor-dependencies.ts. Per rule:
- referenced factors and options exist and are unretired;
value_option_idsis non-empty and every id is an option of the source factor;- no self-references — a factor cannot gate itself; an option cannot exclude itself or a sibling on its own factor;
- no duplicate exclusion pairs (checked orderless).
Two graph checks run over the effective rule set — the submission merged with the global rules authored on other factors, so a per-factor save can never sneak a violation through the seam between two factors' Dependencies tabs:
- Cycle rejection. The relevance rules form a directed graph (source → target). A cycle means a factor's relevance transitively depends on itself — there is no well-defined fixpoint — so authoring rejects it, naming the cycle path.
- Contradiction rejection. No single option's exclusions may cover every option of another factor: picking it would leave that factor relevant but unanswerable (the "zero selectable options" dead end).
Each message names the offending rule (1-based, per kind) and the invariant it broke, mirroring #472's authoring ergonomics.
Runtime evaluation
The evaluator is evaluateDependencies (src/lib/factor-dependencies.ts) — pure and client-safe, the dependency-model sibling of applyDisplayRules. It takes (factors, rules, exclusions, answers) and returns a DependencyState: irrelevant factors, excluded options, unresolved conflicts, and which retained answers are currently ignored.
Order independence is by construction. The evaluator reads the answers as a map keyed by factor id and iterates the fixed factor list — it never sees the order answers arrived in, so any permutation of the same answer set is literally the same input (property-verified over all 24 permutations of a 4-answer set in factor-dependencies.spec.ts).
Fixpoint, not a pass. Relevance is recomputed from scratch each round: a factor hidden this round stops gating next round, so cascades release — if Interior hides Geography, Geography's own answer no longer hides anything downstream. Iteration stops when the state repeats. Authoring validation rejects cycles, but if a cyclic rule set reaches the runtime anyway the loop still terminates: on oscillation the evaluator falls back to the subtractive default and keeps hidden only what every oscillating state hid (prefer asking a question that might not matter over never asking one that does); converged: false flags the fallback.
Exclusion semantics. An active answer excludes its paired options on other factors, symmetrically — answering either side first disables the other. An answer on an irrelevant factor excludes nothing.
Answer-invalidation policy
- Irrelevant factor — the answer is retained-but-ignored: it stops feeding gating and scoring (
ignoredFactorIdsreports it) but storage is never touched, so it is restored intact the moment relevance returns. - Mutual conflict (both sides of an exclusion answered) — surfaced in
conflictsfor the buyer to resolve (#907), never silently discarded. While unresolved, both sides are withheld from gating and scoring — symmetric, so evaluation can never depend on which side was answered first.
Scoring integration
toDependencyAwareContext builds the engine SelectionContext from the filtered answer view (filterAnswersByDependencies), so scoring never consumes an answer from an irrelevant factor or an unresolved conflict. With no rules and no exclusions it is exactly toSelectionContext — zero regression for rule-less catalogs.
Coexistence with track display rules
The catalog has two rule layers (#910):
| Factor dependencies (#905/#906) | Track display rules (#472) | |
|---|---|---|
| Scope | Global — every flow | One track |
| States a fact about | The domain ("Interior means Climate can't matter") | One audience's curation ("don't ask homeowners about acoustics") |
| Rows | track_display_rules with track_id NULL + factor_option_exclusions | track_display_rules with a track_id |
| Ordering constraint | None (bidirectional fixpoint) | Forward-only (single pass) |
| Authored on | The factor's Dependencies tab | The track's Display rules tab |
They compose in one pure seam — composeFlowSteps (src/lib/factor-dependencies.ts), consumed by SelectionFlow:
- The dependency layer evaluates first, over the raw answers (the #906 fixpoint).
- The track layer evaluates second, over the dependency-filtered answer view — an answer the dependency layer ignores (irrelevant factor, unresolved conflict) can never fire a track rule. This is the no-drift guarantee: both layers read one answer view, decided in one place.
- Hiding unions: a factor hidden by either layer stays hidden.
Curation never feeds back into domain truth — track rules are not consulted by the dependency fixpoint — so each layer keeps its own semantics, and with either layer absent the composition degenerates to that layer alone.
Track admin surfacing: the track edit screen shows globally-imposed dependencies read-only (Display rules tab, linked to the owning factor's Dependencies tab), and its Details tab warns when global dependencies can render the track unanswerable — every asked factor hidden by the pre-seeds, or a single buyer answer leaving a visible factor with zero selectable options (findDependencyDeadEnds, a deliberately single-answer probe recomputed on every load). Existing #472 rules needed no migration: scoped rows are untouched by all of this.
Buyer flow
SelectionFlow.svelte evaluates the dependency payload (shipped by both /select server loads via loadFactorDependencyView) live against the buyer's answers (#907):
- Irrelevant factors leave the stepper — step count, progress, dot-strip and overview all shrink and grow live (the progress label is
aria-live). Global gating composes with track display rules as a union of hiding (precedence formalization: #910). - Excluded options stay visible but disabled (
aria-disabled, still focusable) with the conflicting answer named: "Not available with Wood". - No silent clearing. Activating an excluded option opens an explicit swap prompt — keep the new answer and clear the old, or cancel. A restored or shared answer set that already holds both sides of an exclusion gets a conflict banner asking which side to keep; until resolved, both sides are withheld from the recommendation (the #906 policy).
- Restore always re-evaluates. Dependency state is derived from the live answers map, so a sessionStorage/resume/share restore can never render a state that violates the current rules.
Seed data
seed/sql/086-factor-dependencies.sql seeds both #904 illustrative use cases against the real catalog: Interior gates Geography and Climate (two global relevance rows), and segmented opening ↔ wood finish exclude each other (one canonicalized pair). It is a catalog fragment, so seed:local and seed:remote both apply it (bug #1885 — before that fix, only the remote path did, and a local reset left the catalog empty). Re-apply just the fragments with npm run seed:fragments:local.
Related work
- #906 — bidirectional fixpoint evaluator (order-independent runtime).
- #907 — buyer flow UX (hidden factors, disabled options, conflict resolution).
- #908 — admin Dependencies tab authoring these rules (how-to: Managing factors › Dependencies).
- #910 — precedence and surfacing alongside track display rules.