diff --git a/api/survey_engine/validation.py b/api/survey_engine/validation.py index 194cf88..b6ec615 100644 --- a/api/survey_engine/validation.py +++ b/api/survey_engine/validation.py @@ -557,6 +557,117 @@ def _validate_visible_if(definition: dict[str, Any], question_names: set[str]) - ) +def _validate_construct_string(label: str, key: str, value: Any) -> None: + """A construct_block / construct_item value, if set, is a non-empty string.""" + if value is None: + return + if not isinstance(value, str) or not value.strip(): + raise InvalidDefinition( + f"question {label!r}: {key} must be a non-empty string when set, got {value!r}" + ) + + +def _validate_construct_tags(definition: dict[str, Any]) -> None: + """Construct-membership tags. Two optional custom attributes: + + construct_block: identifier of a reusable scale (e.g. 'phq9', 'gad7'). + construct_item: identifier of an item within that block ('phq9_q3'). + + Where they may appear: + - Top-level plain question: both, no inheritance. + - matrix / paneldynamic container: block only (inherited by leaves). + - Matrix row, matrixdropdown row, + paneldynamic template element: both; block falls back to the + container's value if unset. + + Authoring rules enforced here so a clear 422 reaches the editor (FR-2), + rather than the value rotting silently into the warehouse: + - Each tag is a non-empty string if set. + - A container element (matrix / paneldynamic) does not carry + construct_item: items belong to leaf questions (rows / cells), + not to the container that groups them. + - A leaf carrying construct_item has a construct_block in scope — + either its own or inherited from the container. + + Note: these tags are *provenance*, not analytical-pooling instructions. + Cross-version / cross-survey pooling stays the explicit parent_question_id + opt-in (invariant 5). Two questions sharing a construct_block does not + license `GROUP BY construct_block` for pooled analysis — that is still a + deliberate researcher judgment expressed via parent_question_id. + """ + for element in _iter_elements(definition): + name = element.get("name") + if not isinstance(name, str) or not name: + # Display-only elements have no question identity; skip. + continue + _validate_construct_string(name, "construct_block", element.get("construct_block")) + _validate_construct_string(name, "construct_item", element.get("construct_item")) + owner_block = element.get("construct_block") + owner_item = element.get("construct_item") + qtype = element.get("type") + + if qtype in MATRIX_TYPES: + if isinstance(owner_item, str): + raise InvalidDefinition( + f"question {name!r}: construct_item belongs to a matrix row, " + "not the matrix itself — move it onto the row" + ) + for row in element.get("rows", []) or []: + if not isinstance(row, dict): + # A scalar row (string id) cannot carry construct attrs; skip. + continue + row_id = _choice_value(row) or "" + row_label = f"{name}.{row_id}" + _validate_construct_string(row_label, "construct_block", row.get("construct_block")) + _validate_construct_string(row_label, "construct_item", row.get("construct_item")) + row_block = row.get("construct_block") or owner_block + if isinstance(row.get("construct_item"), str) and not isinstance(row_block, str): + raise InvalidDefinition( + f"question {row_label!r}: construct_item set but no construct_block " + "in scope (set it on the row or the matrix)" + ) + # matrixdropdown columns: a (row, col) cell's construct identity is the + # row's (int_survey_questions only reads construct_* from rows), so a + # column-level tag is silently dropped by the warehouse. Reject loudly + # rather than allow silent data loss — per-column item tagging is a + # future extension if we ever need different items per row dimension. + if element.get("type") == "matrixdropdown": + for column in element.get("columns", []) or []: + if not isinstance(column, dict): + continue + col_label = f"{name}." + for key in ("construct_block", "construct_item"): + if column.get(key) is not None: + raise InvalidDefinition( + f"question {col_label!r}: {key} on a matrixdropdown " + "column is not supported — tag the row instead (a " + "cell's construct identity is its row's)" + ) + elif qtype in REPEATING_TYPES: + if isinstance(owner_item, str): + raise InvalidDefinition( + f"question {name!r}: construct_item belongs to a paneldynamic template " + "element, not the panel itself — move it onto the template element" + ) + for tmpl in _panel_template_elements(element): + tmpl_label = f"{name}.{tmpl['name']}" + _validate_construct_string( + tmpl_label, "construct_block", tmpl.get("construct_block") + ) + _validate_construct_string(tmpl_label, "construct_item", tmpl.get("construct_item")) + tmpl_block = tmpl.get("construct_block") or owner_block + if isinstance(tmpl.get("construct_item"), str) and not isinstance(tmpl_block, str): + raise InvalidDefinition( + f"question {tmpl_label!r}: construct_item set but no construct_block " + "in scope (set it on the template element or the panel)" + ) + else: + if isinstance(owner_item, str) and not isinstance(owner_block, str): + raise InvalidDefinition( + f"question {name!r}: construct_item set without construct_block" + ) + + def _validate_free_text_pii(definition: dict[str, Any]) -> None: """Free-text PII gate (invariant 6): pii_risk must be low/high if set, and a downgrade to 'low' demands an explicit rationale at definition time. Never @@ -587,3 +698,4 @@ def validate_definition(definition: dict[str, Any]) -> None: question_names = _validate_questions(definition) _validate_visible_if(definition, question_names) _validate_free_text_pii(definition) + _validate_construct_tags(definition) diff --git a/api/tests/test_publish_gate.py b/api/tests/test_publish_gate.py index d86f0fb..33ff972 100644 --- a/api/tests/test_publish_gate.py +++ b/api/tests/test_publish_gate.py @@ -653,6 +653,200 @@ def test_enable_if_dangling_reference_rejected() -> None: validate_definition(definition) +# --- construct tags ---------------------------------------------------------- +# construct_block / construct_item are optional authored *provenance* tags +# (this question is item N of a reusable scale). They are NOT pooling — that +# stays the parent_question_id opt-in (invariant 5). Publish-time validation +# guards shape and the block/item co-occurrence; the dbt construct_pair_integrity +# test backstops the warehouse-side invariant. + + +def test_construct_tags_on_plain_question_pass() -> None: + validate_definition(_def(_radio("q1", construct_block="phq9", construct_item="phq9_q1"))) + + +def test_construct_block_alone_passes() -> None: + # A block without an item is fine (e.g. "this question is from PHQ-9 but + # I haven't pinned the item id yet"); only construct_item demands a block. + validate_definition(_def(_radio("q1", construct_block="phq9"))) + + +def test_construct_item_without_block_rejected() -> None: + with pytest.raises(InvalidDefinition, match="construct_item set without construct_block"): + validate_definition(_def(_radio("q1", construct_item="phq9_q1"))) + + +def test_construct_block_must_be_string() -> None: + with pytest.raises(InvalidDefinition, match="construct_block must be a non-empty string"): + validate_definition(_def(_radio("q1", construct_block=42))) + + +def test_construct_item_empty_string_rejected() -> None: + with pytest.raises(InvalidDefinition, match="construct_item must be a non-empty string"): + validate_definition(_def(_radio("q1", construct_block="phq9", construct_item=" "))) + + +def test_construct_tags_on_matrix_inheritance_pass() -> None: + # Authored canonical pattern for a scale-in-a-matrix: block on the matrix, + # item on each row. Row inherits the matrix's block. + definition = _def( + { + "type": "matrix", + "name": "phq9", + "construct_block": "phq9", + "rows": [ + {"value": "q1", "text": "Little interest", "construct_item": "phq9_q1"}, + {"value": "q2", "text": "Down or hopeless", "construct_item": "phq9_q2"}, + ], + "columns": ["0", "1", "2", "3"], + } + ) + validate_definition(definition) + + +def test_construct_item_on_matrix_container_rejected() -> None: + # A matrix groups items; it doesn't *have* an item — that's a row's job. + definition = _def( + { + "type": "matrix", + "name": "m1", + "construct_block": "phq9", + "construct_item": "phq9_q1", # the bug + "rows": ["r1"], + "columns": ["c1"], + } + ) + with pytest.raises(InvalidDefinition, match="construct_item belongs to a matrix row"): + validate_definition(definition) + + +def test_construct_item_on_matrix_row_without_block_rejected() -> None: + # Row carries an item, but neither the row nor the matrix gives a block. + definition = _def( + { + "type": "matrix", + "name": "m1", + "rows": [{"value": "r1", "construct_item": "phq9_q1"}], + "columns": ["c1"], + } + ) + with pytest.raises(InvalidDefinition, match="no construct_block in scope"): + validate_definition(definition) + + +def test_construct_block_on_matrixdropdown_column_rejected() -> None: + # int_survey_questions reads construct_* from rows only — a column-level tag + # would be silently dropped by the warehouse. Reject loudly. + definition = _def( + { + "type": "matrixdropdown", + "name": "md1", + "construct_block": "phq9", + "rows": [{"value": "r1", "construct_item": "phq9_q1"}], + "columns": [ + { + "name": "brand", + "cellType": "dropdown", + "choices": ["a", "b"], + "construct_block": "gad7", # the bug + } + ], + } + ) + with pytest.raises(InvalidDefinition, match="construct_block on a matrixdropdown column"): + validate_definition(definition) + + +def test_construct_item_on_matrixdropdown_column_rejected() -> None: + definition = _def( + { + "type": "matrixdropdown", + "name": "md1", + "construct_block": "phq9", + "rows": [{"value": "r1"}], + "columns": [ + { + "name": "brand", + "cellType": "dropdown", + "choices": ["a", "b"], + "construct_item": "phq9_q1", # the bug + } + ], + } + ) + with pytest.raises(InvalidDefinition, match="construct_item on a matrixdropdown column"): + validate_definition(definition) + + +def test_construct_block_on_row_overrides_matrix() -> None: + # A row may carry its own block — the leaf wins over the container. + definition = _def( + { + "type": "matrix", + "name": "m1", + "construct_block": "phq9", + "rows": [{"value": "r1", "construct_block": "gad7", "construct_item": "gad7_q1"}], + "columns": ["c1"], + } + ) + validate_definition(definition) + + +def test_construct_tags_on_paneldynamic_inheritance_pass() -> None: + # Mirror of the matrix story: block on the panel, item on each template cell. + definition = _def( + { + "type": "paneldynamic", + "name": "screener", + "construct_block": "phq9", + "templateElements": [ + { + "type": "dropdown", + "name": "q1", + "choices": ["0", "1", "2", "3"], + "construct_item": "phq9_q1", + } + ], + } + ) + validate_definition(definition) + + +def test_construct_item_on_panel_container_rejected() -> None: + definition = _def( + { + "type": "paneldynamic", + "name": "screener", + "construct_block": "phq9", + "construct_item": "phq9_q1", # belongs on a template element + "templateElements": [{"type": "dropdown", "name": "q1", "choices": ["a", "b"]}], + } + ) + with pytest.raises( + InvalidDefinition, match="construct_item belongs to a paneldynamic template" + ): + validate_definition(definition) + + +def test_construct_item_on_panel_cell_without_block_rejected() -> None: + definition = _def( + { + "type": "paneldynamic", + "name": "screener", + "templateElements": [ + { + "type": "dropdown", + "name": "q1", + "choices": ["a", "b"], + "construct_item": "phq9_q1", + } + ], + } + ) + with pytest.raises(InvalidDefinition, match="no construct_block in scope"): + validate_definition(definition) + + # --- endpoint wiring + detail surfacing -------------------------------------- diff --git a/dbt/models/intermediate/int_survey_questions.sql b/dbt/models/intermediate/int_survey_questions.sql index bc1ad54..b6cc46d 100644 --- a/dbt/models/intermediate/int_survey_questions.sql +++ b/dbt/models/intermediate/int_survey_questions.sql @@ -59,6 +59,14 @@ scalar_questions as ( -- pii_risk tags free-text questions (design-doc §3.9); null otherwise. -- The fact gates value_text on this; the API defaults absent → 'high'. element ->> 'pii_risk' as pii_risk, + -- construct_block / construct_item are optional provenance tags marking a + -- question as belonging to a reusable scale (e.g. 'phq9' / 'phq9_q3'). + -- Plain questions read both directly; no inheritance to do here. They are + -- NOT a pooling instruction — cross-version/cross-survey pooling stays the + -- parent_question_id opt-in (invariant 5). Shape is gated at publish; the + -- construct_pair_integrity singular test guards against backdoor inserts. + element ->> 'construct_block' as construct_block, + element ->> 'construct_item' as construct_item, coalesce(element ->> 'title', stable_name) as prompt_text, display_order, -- See the header note: rating/boolean → numeric, text inputType number/range @@ -94,6 +102,13 @@ matrix_questions as ( {{ subquestion_name(['e.stable_name', matrix_value('mrow.value')]) }} as stable_name, e.question_type, cast(null as text) as pii_risk, + -- construct_block falls back from the row to the matrix (the whole matrix + -- is typically one block, with each row an item within it). construct_item + -- is leaf-only (an item belongs to a row, not the matrix container); + -- validation rejects construct_item set on the matrix element itself. + coalesce(mrow.value ->> 'construct_block', e.element ->> 'construct_block') + as construct_block, + mrow.value ->> 'construct_item' as construct_item, coalesce(e.element ->> 'title', e.stable_name) || ' — ' || coalesce(mrow.value ->> 'text', {{ matrix_value('mrow.value') }}) as prompt_text, @@ -123,6 +138,14 @@ matrixdropdown_questions as ( as stable_name, e.question_type, cast(null as text) as pii_risk, + -- See the matrix CTE: block inherits from the matrix; item is leaf-only. + -- A matrixdropdown's leaf is the (row, column) cell, but for v1 the item + -- is tagged at the row level (every column of one row shares the item — + -- "rate PHQ-9 item 3 on these two dimensions"); per-column item is a + -- future extension if needed. + coalesce(mrow.value ->> 'construct_block', e.element ->> 'construct_block') + as construct_block, + mrow.value ->> 'construct_item' as construct_item, coalesce(e.element ->> 'title', e.stable_name) || ' — ' || coalesce(mrow.value ->> 'text', {{ matrix_value('mrow.value') }}) @@ -158,6 +181,12 @@ paneldynamic_questions as ( {{ subquestion_name(['e.stable_name', "tmpl.value ->> 'name'"]) }} as stable_name, tmpl.value ->> 'type' as question_type, tmpl.value ->> 'pii_risk' as pii_risk, + -- Block inherits from the panel container; item is leaf-only (a template + -- element is the item-bearing unit, not the panel itself). Mirror of the + -- matrix rule — validation rejects construct_item on the panel itself. + coalesce(tmpl.value ->> 'construct_block', e.element ->> 'construct_block') + as construct_block, + tmpl.value ->> 'construct_item' as construct_item, coalesce(e.element ->> 'title', e.stable_name) || ' — ' || coalesce(tmpl.value ->> 'title', tmpl.value ->> 'name') as prompt_text, diff --git a/dbt/models/marts/_marts.yml b/dbt/models/marts/_marts.yml index 53a984a..46fe8a4 100644 --- a/dbt/models/marts/_marts.yml +++ b/dbt/models/marts/_marts.yml @@ -41,6 +41,23 @@ models: — the shown-set entry governing the cell's visibility (a cell is shown iff its panel is), which shown_set_integrity / routed_past_not_in_shown_set resolve was_shown against. Null for a plain or matrix question. + - name: construct_block + description: > + Optional authored tag identifying a reusable scale this question belongs + to (e.g. 'phq9', 'gad7', 'enps'). Set at definition time via a custom + attribute on the question (or inherited from a matrix / paneldynamic + container). PROVENANCE only — does NOT license cross-survey pooling; + analytical pooling across versions or surveys remains the explicit + parent_question_id opt-in (invariant 5). Null when the author has not + tagged the question as part of a reusable scale. + - name: construct_item + description: > + Optional authored tag identifying this question's position within its + construct_block (e.g. 'phq9_q3'). Always paired with a construct_block + (the construct_pair_integrity singular test enforces this). Leaf-only: + tagged on a plain question, a matrix row, or a paneldynamic template + element — never on a matrix / panel container, which group items rather + than being one. - name: dim_question_version columns: diff --git a/dbt/models/marts/dim_question.sql b/dbt/models/marts/dim_question.sql index 068d3cb..74ab783 100644 --- a/dbt/models/marts/dim_question.sql +++ b/dbt/models/marts/dim_question.sql @@ -11,6 +11,17 @@ -- parent_question_id / parent_question_rationale capture cross-version -- equivalence. They are NEVER auto-populated (invariant 5) — that is a -- researcher judgment — so both are emitted as explicit nulls here. +-- +-- construct_block / construct_item carry authored *provenance* — this question +-- is item N of a reusable scale (e.g. PHQ-9). They are NOT a pooling key: +-- analytical pooling across surveys remains the parent_question_id opt-in +-- (invariant 5). Authored at definition time and read straight from the +-- snapshot — no heuristic or auto-derivation. min() is a deterministic +-- tie-break across versions, NOT a safe-direction default like pii_risk: a tag +-- divergence (v1 says phq9_q1, v2 says gad7_q1) is methodologically the same +-- event as a rename and is surfaced by the construct_tag_stability singular +-- test rather than silently collapsed here. construct_pair_integrity backstops +-- that a populated item always carries a backing block. select {{ surrogate_key(['survey_id', 'stable_name']) }} as question_id, @@ -32,6 +43,8 @@ select -- resolve was_shown against (a cell is shown iff its panel is); null for a -- plain or matrix question. Deterministic per stable_name. min(panel_name) as panel_name, + min(construct_block) as construct_block, + min(construct_item) as construct_item, cast(null as text) as parent_question_id, cast(null as text) as parent_question_rationale from {{ ref('int_survey_questions') }} diff --git a/dbt/tests/singular/construct_pair_integrity.sql b/dbt/tests/singular/construct_pair_integrity.sql new file mode 100644 index 0000000..2c63dde --- /dev/null +++ b/dbt/tests/singular/construct_pair_integrity.sql @@ -0,0 +1,21 @@ +-- Construct-tag integrity. construct_block and construct_item are authored +-- provenance (this question is item N of a reusable scale). They are NOT a +-- pooling key — cross-version/cross-survey pooling stays the parent_question_id +-- opt-in (invariant 5) — but the *pair* still has to make sense: a question +-- tagged with a construct_item must also resolve a construct_block, or "item 3 +-- of nothing" rots silently into the warehouse. +-- +-- Publish-time validation (api.survey_engine.validation._validate_construct_tags) +-- catches the obvious authoring shapes and reaches the editor as a 422; this +-- singular test backstops a backdoor insert / direct dim_question patch and +-- pins the warehouse-side invariant. Passes when it returns zero rows. + +select + question_id, + survey_id, + stable_name, + construct_block, + construct_item +from {{ ref('dim_question') }} +where construct_item is not null + and construct_block is null diff --git a/dbt/tests/singular/construct_tag_stability.sql b/dbt/tests/singular/construct_tag_stability.sql new file mode 100644 index 0000000..9b07cc6 --- /dev/null +++ b/dbt/tests/singular/construct_tag_stability.sql @@ -0,0 +1,46 @@ +-- Construct tag stability across versions of the same question. dim_question +-- aggregates int_survey_questions with min() at the (survey_id, stable_name) +-- grain, so a tag divergence (v1 said 'phq9_q1', v2 said 'gad7_q1') would land +-- silently — min() picks one lexicographically and the disagreement vanishes. +-- The version-by-version truth still lives in raw_responses' embedded snapshot, +-- but the warehouse row would misrepresent at least one version. +-- +-- Methodologically, a question changing construct membership across versions is +-- the same kind of event as a rename: the item is no longer "the same item." The +-- design doc says rename → break question_id → use parent_question_id (invariant +-- 5). So a tag change is an authoring smell: either v1 was a tagging mistake to +-- be acknowledged via a re-publish, or it's a genuine identity change that +-- should be modeled as a rename + parent_question_id link. Either way the +-- divergence has to surface — this test does that. Passes when zero rows. +-- +-- Scope: only flags rows where *both* versions tagged the question. A v1 +-- untagged → v2 tagged (or vice versa) is allowed — that is the legitimate path +-- for backfilling a missing tag on a stable question. + +with by_question_version as ( + select + survey_id, + stable_name, + construct_block, + construct_item + from {{ ref('int_survey_questions') }} + where construct_block is not null or construct_item is not null +), + +divergent as ( + select + survey_id, + stable_name, + count(distinct construct_block) as distinct_blocks, + count(distinct construct_item) as distinct_items + from by_question_version + group by survey_id, stable_name +) + +select + survey_id, + stable_name, + distinct_blocks, + distinct_items +from divergent +where distinct_blocks > 1 or distinct_items > 1 diff --git a/survey-engine-design-doc.md b/survey-engine-design-doc.md index 4195690..4b7164c 100644 --- a/survey-engine-design-doc.md +++ b/survey-engine-design-doc.md @@ -285,6 +285,8 @@ erDiagram text question_type bigint parent_question_id FK text parent_question_rationale + text construct_block + text construct_item timestamptz first_published_at } dim_question_version { @@ -357,6 +359,21 @@ COALESCE(parent_question_id, question_id) AS canonical_question_id Analysts wanting strict per-version behavior group by `question_version_id`; same-named questions already share a `question_id` across a survey's versions. To *additionally* pool across a rename (or a cross-instrument equivalence), opt in by using `canonical_question_id` — the moment of friction that prompts checking the rationale and confirming pooling is appropriate. +#### Construct membership + +Two optional text columns on `dim_question` record that a question came from a reusable, named scale (PHQ-9, GAD-7, eNPS, …): + +| Column | Notes | +|---|---| +| `construct_block` | Identifier of the scale this question belongs to. Authored as a custom JSON attribute on the question (or inherited from its matrix / paneldynamic container — see below). Null when the author has not tagged the question. | +| `construct_item` | Identifier of this question's position within its block (e.g. `phq9_q3`). Always paired with a `construct_block`; the `construct_pair_integrity` singular test enforces this. Leaf-only — set on a plain question, a matrix row, or a paneldynamic template element, never on the container that groups them. | + +Both are **provenance**, not a pooling key. Two questions sharing a `construct_block` does not license `GROUP BY construct_block` for pooled analysis — that is still a deliberate methodological judgment expressed via `parent_question_id` (see "Cross-version equivalence" above and §4.8). The separation is intentional: a researcher may want to tag a survey as containing PHQ-9 items without committing, at definition time, to pooling those items with another survey's PHQ-9 items in analysis. The tags surface the relationship; the pooling decision stays explicit per invariant 5. + +Inheritance rule for the composite types: a `construct_block` on a matrix or paneldynamic container is inherited by each of its leaf sub-questions (rows / template elements); a leaf may override with its own `construct_block`. `construct_item` never inherits — it identifies the leaf and a container has no item identity of its own. + +The columns live on `dim_question` (the stable abstraction), not `dim_question_version`, because construct membership — like cross-version equivalence above — is about the question as a construct, not about a specific rendering of it. + #### Indexes - `(survey_version_id, question_id)` — per-question aggregations within a version. @@ -546,6 +563,10 @@ Analyst and reviewer *data* access is not mediated by the application. Analysts **Rejected.** GDPR right-to-erasure requires content deletion, not just hiding. The tombstoning workflow nulls content while preserving the audit row, satisfying both obligations. +### 4.10 Treating construct_block as an analytical pooling key + +**Rejected.** A `construct_block` tag declares provenance ("this question is part of the PHQ-9 instrument"); it does not declare that two tagged questions are analytically equivalent. Reusable scales are sometimes administered with subtle changes — translated wording, response-scale anchor edits, dropped items — that a researcher may or may not be willing to pool across. Treating shared `construct_block` as automatic pooling would re-introduce the silent-default failure mode invariant 5 was designed to prevent (see §4.8). Pooling stays the `parent_question_id` opt-in; `construct_block` is metadata that helps a researcher *find* the questions that might be candidates for that judgment, not a substitute for making it. + --- ## 5. Deferred decisions @@ -572,6 +593,7 @@ These are reopened when explicit triggers are met, not on a schedule. | Question rename treated as same question | Stable `question_id` established at first publication and never reused; renames surfaced in lint. | | Analyst silently pools questions across versions/surveys that aren't equivalent | `question_id` is survey-scoped `(survey_id, stable_name)`, so it never pools across surveys; `GROUP BY question_version_id` is strict per-version; pooling across a rename requires the explicit `canonical_question_id` opt-in. | | Reworded item silently treated as the same question because its name was kept | Within a survey, keeping `stable_name` across versions *is* the continuity assertion (per-version wording is preserved in `dim_question_version`); a genuine construct change should be a rename, which breaks `question_id` and forces the explicit equivalence opt-in. | +| Construct tags treated as a pooling key by an over-eager analyst | `construct_block` / `construct_item` are documented as provenance only; the canonical pooling key remains `canonical_question_id` (built from `parent_question_id`); the warehouse exposes no `GROUP BY construct_block` shortcut for cross-survey rollups. | | LLM-generated JSON with subtle logic errors | Round-trip test gate at publish time; pattern library reduces invention surface. | | Two parsers (API and dbt) drift apart over time | dbt reads from `raw_responses` only; normalized tables are a read-model, not an ETL input. | | Analyst confuses "selection count" with "respondent count" | Companion `fact_response` table at respondent-question grain; documented in marts; default examples use `COUNT(DISTINCT respondent_id)`. |