---
phase: 33-unique-constraints-cardinality-inference
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/model.rs
  - src/body_parser.rs
  - src/parse.rs
autonomous: true
requirements: [CARD-01, CARD-02, CARD-04, CARD-05, CARD-06, CARD-07]

must_haves:
  truths:
    - "TableRef stores zero or more UNIQUE constraints as Vec<Vec<String>>"
    - "Join stores ref_columns (resolved target-side columns for ON clause synthesis)"
    - "Cardinality enum has exactly two variants: ManyToOne (default) and OneToOne"
    - "Parser accepts UNIQUE (col, ...) after PRIMARY KEY in TABLES entries"
    - "Parser accepts multiple UNIQUE constraints on one table entry"
    - "Parser no longer recognizes MANY TO ONE / ONE TO ONE / ONE TO MANY keywords"
    - "Parser accepts REFERENCES target(col, ...) with explicit column list"
    - "Parser accepts REFERENCES target with no column list (PK implicit)"
    - "PRIMARY KEY is optional on table entries (fact tables need no PK)"
    - "Cardinality is inferred: FK matches PK/UNIQUE on from-side = OneToOne, bare FK = ManyToOne"
    - "REFERENCES target with no column list when target has no PK produces define-time error"
  artifacts:
    - path: "src/model.rs"
      provides: "TableRef.unique_constraints, Join.ref_columns, two-variant Cardinality enum"
    - path: "src/body_parser.rs"
      provides: "UNIQUE parsing, REFERENCES(cols) parsing, cardinality keyword removal"
    - path: "src/parse.rs"
      provides: "infer_cardinality function called from rewrite_ddl_keyword_body"
  key_links:
    - from: "src/body_parser.rs"
      to: "src/model.rs"
      via: "TableRef.unique_constraints populated by parser"
      pattern: "unique_constraints"
    - from: "src/parse.rs"
      to: "src/model.rs"
      via: "infer_cardinality sets Join.ref_columns and Join.cardinality"
      pattern: "infer_cardinality"
    - from: "src/body_parser.rs"
      to: "src/model.rs"
      via: "parse_single_relationship_entry populates Join.ref_columns from REFERENCES(cols)"
      pattern: "ref_columns"
---

<objective>
Extend the data model and parser to support UNIQUE constraints, remove explicit cardinality keywords, add REFERENCES(cols) syntax, and implement cardinality inference.

Purpose: This is the foundation for Snowflake-aligned cardinality inference. All downstream validation, fan trap detection, and ON clause synthesis depend on the model and parser changes made here.

Output: Modified model.rs (UNIQUE + ref_columns + 2-variant enum), body_parser.rs (UNIQUE parsing, no cardinality keywords, REFERENCES(cols)), parse.rs (inference logic).
</objective>

<execution_context>
@/Users/paul/.claude/get-shit-done/workflows/execute-plan.md
@/Users/paul/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/33-unique-constraints-cardinality-inference/33-CONTEXT.md
@.planning/phases/33-unique-constraints-cardinality-inference/33-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From src/model.rs (current state):
```rust
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TableRef {
    pub alias: String,
    pub table: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub pk_columns: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Cardinality {
    #[default]
    ManyToOne,
    OneToOne,
    OneToMany,  // TO BE REMOVED
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Join {
    pub table: String,
    pub on: String,
    pub from_cols: Vec<String>,
    pub join_columns: Vec<JoinColumn>,
    pub from_alias: String,
    pub fk_columns: Vec<String>,
    pub name: Option<String>,
    pub cardinality: Cardinality,
    // ref_columns: DOES NOT EXIST YET -- must be added
}
```

From src/body_parser.rs (current state):
```rust
pub struct KeywordBody {
    pub tables: Vec<TableRef>,
    pub relationships: Vec<Join>,
    pub facts: Vec<Fact>,
    pub hierarchies: Vec<Hierarchy>,
    pub dimensions: Vec<Dimension>,
    pub metrics: Vec<Metric>,
}

fn parse_single_table_entry(entry: &str, entry_offset: usize) -> Result<TableRef, ParseError>;
fn parse_cardinality_tokens(tokens: &[&str], ...) -> Result<Cardinality, ParseError>;  // TO BE DELETED
fn parse_single_relationship_entry(entry: &str, entry_offset: usize) -> Result<Join, ParseError>;
fn find_primary_key(upper_text: &str) -> Option<(usize, usize)>;
fn extract_paren_content(s: &str) -> Option<&str>;
fn find_keyword_ci(upper_text: &str, keyword: &str) -> Option<usize>;
```

From src/parse.rs (current state):
```rust
fn rewrite_ddl_keyword_body(
    kind: DdlKind, name: &str, body_text: &str, body_offset: usize,
) -> Result<Option<String>, ParseError>;
// Calls parse_keyword_body, constructs SemanticViewDefinition, serializes to JSON.
// Inference logic MUST be inserted between parsing and serialization.
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Extend model with UNIQUE constraints, ref_columns, and two-variant Cardinality</name>
  <files>src/model.rs</files>
  <read_first>src/model.rs</read_first>
  <behavior>
    - Test: TableRef with unique_constraints serializes/deserializes correctly (serde roundtrip)
    - Test: Multiple UNIQUE constraints on one TableRef (Vec of Vecs) roundtrips
    - Test: Old JSON without unique_constraints field deserializes with empty Vec (backward compat for loading)
    - Test: Join with ref_columns serializes/deserializes correctly
    - Test: Old JSON without ref_columns field deserializes with empty Vec
    - Test: Cardinality enum has exactly ManyToOne and OneToOne (no OneToMany)
    - Test: Cardinality serde roundtrip for both variants
    - Test: Old JSON with "OneToMany" string fails deserialization (unknown variant)
    - Test: TableRef without pk_columns (empty) is valid (fact tables)
    - Test: Empty unique_constraints is not serialized (skip_serializing_if)
    - Test: Empty ref_columns is not serialized (skip_serializing_if)
  </behavior>
  <action>
1. In `src/model.rs`, add `unique_constraints: Vec<Vec<String>>` field to `TableRef`:
   ```rust
   /// UNIQUE constraints on this table. Each inner Vec is one constraint's column list.
   /// A table can have zero or more UNIQUE constraints (composite allowed).
   #[serde(default, skip_serializing_if = "Vec::is_empty")]
   pub unique_constraints: Vec<Vec<String>>,
   ```
   Place it after `pk_columns`. Add `#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]` is already on struct -- Vec<Vec<String>> derives Arbitrary automatically.

2. Add `ref_columns: Vec<String>` field to `Join`:
   ```rust
   /// Phase 33: Resolved referenced columns on the target table.
   /// Populated during inference: either the target's PK or the explicit UNIQUE columns.
   /// Used by synthesize_on_clause to generate ON clause.
   #[serde(default, skip_serializing_if = "Vec::is_empty")]
   pub ref_columns: Vec<String>,
   ```
   Place it after `fk_columns`.

3. Remove `OneToMany` variant from `Cardinality` enum. The enum becomes:
   ```rust
   #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
   #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
   pub enum Cardinality {
       #[default]
       ManyToOne,
       OneToOne,
   }
   ```

4. Update the doc comments on `Cardinality`:
   ```rust
   /// Cardinality of a relationship between two tables.
   ///
   /// Inferred from PK/UNIQUE constraints at define time (Phase 33).
   /// `ManyToOne`: FK columns on the from-side table are bare (no PK/UNIQUE match).
   /// `OneToOne`: FK columns on the from-side table match a PK or UNIQUE constraint.
   /// Defaults to `ManyToOne` when deserialized from JSON without this field.
   ```

5. Update ALL existing tests in `mod phase31_cardinality_tests`:
   - Remove the `OneToMany` entry from `cardinality_serde_roundtrip` test (was testing all 3 variants -- now test just 2).
   - Change `join_with_cardinality_roundtrip` from `Cardinality::OneToMany` to `Cardinality::OneToOne`.
   - Add a NEW test `old_json_with_one_to_many_is_rejected` that verifies `serde_json::from_str::<Cardinality>(r#""OneToMany""#)` returns `Err`.

6. Add new test module `mod phase33_model_tests` with:
   - `table_ref_with_unique_constraints_roundtrip` -- create TableRef with pk_columns and 2 UNIQUE constraints, serialize, deserialize, verify.
   - `old_json_without_unique_constraints_deserializes` -- JSON without unique_constraints field -> empty Vec.
   - `join_with_ref_columns_roundtrip` -- create Join with ref_columns, serialize, deserialize.
   - `old_json_without_ref_columns_deserializes` -- JSON without ref_columns -> empty Vec.
   - `empty_unique_constraints_not_serialized` -- verify skip_serializing_if works (JSON output has no "unique_constraints" key).
   - `empty_ref_columns_not_serialized` -- verify skip_serializing_if works.
   - `table_ref_without_pk_is_valid` -- TableRef with empty pk_columns, non-empty alias/table.

7. Update ALL places in `src/model.rs` tests that construct `TableRef` to include `unique_constraints: vec![]` in any `TableRef { .. }` literal that does NOT use `..Default::default()`. (Check `phase11_1_model_tests::table_ref_roundtrip` -- it uses explicit fields, must add unique_constraints.)

8. Update `SemanticViewDefinition` struct literal in `phase11_1_model_tests::semantic_view_definition_with_tables_roundtrip` to include `unique_constraints: vec![]` in the TableRef literal since it does not use `..Default::default()`.

9. Similarly, update the `Join` struct literal in `phase31_cardinality_tests::definition_with_cardinality_joins_roundtrips` to include `ref_columns: vec![]` since it uses `..Default::default()` -- actually `..Default::default()` will handle it. Double-check each Join literal.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test model -- --nocapture 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - src/model.rs contains `pub unique_constraints: Vec<Vec<String>>` on TableRef
    - src/model.rs contains `pub ref_columns: Vec<String>` on Join
    - src/model.rs Cardinality enum has exactly 2 variants: `ManyToOne` and `OneToOne`
    - src/model.rs does NOT contain `OneToMany`
    - `cargo test model` exits 0
    - Test `old_json_with_one_to_many_is_rejected` exists and passes
    - Test `table_ref_with_unique_constraints_roundtrip` exists and passes
    - Test `join_with_ref_columns_roundtrip` exists and passes
  </acceptance_criteria>
  <done>Model has UNIQUE constraints on TableRef, ref_columns on Join, two-variant Cardinality enum. All model tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Update parser -- UNIQUE parsing, remove cardinality keywords, REFERENCES(cols)</name>
  <files>src/body_parser.rs</files>
  <read_first>src/body_parser.rs, src/model.rs</read_first>
  <behavior>
    - Test: TABLES entry with PRIMARY KEY + one UNIQUE parses correctly
    - Test: TABLES entry with PRIMARY KEY + multiple UNIQUE constraints parses correctly
    - Test: TABLES entry with no PRIMARY KEY (fact table) parses correctly (alias + table only)
    - Test: TABLES entry with UNIQUE but no PRIMARY KEY parses correctly
    - Test: Relationship with REFERENCES target(col) stores ref_columns on Join
    - Test: Relationship with REFERENCES target (no parens) stores empty ref_columns (resolved later by inference)
    - Test: Relationship with old-style MANY TO ONE after REFERENCES target fails with parse error
    - Test: Relationship with ONE TO MANY fails
    - Test: Relationship with ONE TO ONE after REFERENCES fails
    - Test: REFERENCES target(col1, col2) stores multi-column ref_columns
    - Test: Existing relationship parsing (name, from_alias, fk_columns, to_alias) still works
  </behavior>
  <action>
1. **Add `find_unique` function** (parallel to `find_primary_key`). It finds the keyword `UNIQUE` with word-boundary checking in an already-uppercased string:
   ```rust
   /// Find "UNIQUE" keyword with word-boundary matching in `upper_text`.
   /// Returns `(start, end)` byte offsets where start points at 'U' and end is past 'E'.
   fn find_unique(upper_text: &str) -> Option<(usize, usize)> {
       let bytes = upper_text.as_bytes();
       let kw = b"UNIQUE";
       let kw_len = kw.len(); // 6
       let mut i = 0;
       while i + kw_len <= bytes.len() {
           if &upper_text.as_bytes()[i..i + kw_len] == kw {
               let before_ok = i == 0 || {
                   let c = bytes[i - 1];
                   !c.is_ascii_alphanumeric() && c != b'_'
               };
               let after_ok = i + kw_len == bytes.len() || {
                   let c = bytes[i + kw_len];
                   !c.is_ascii_alphanumeric() && c != b'_'
               };
               if before_ok && after_ok {
                   return Some((i, i + kw_len));
               }
           }
           i += 1;
       }
       None
   }
   ```

2. **Modify `parse_single_table_entry`** to make PRIMARY KEY optional and parse UNIQUE constraints:

   Current flow: alias -> AS -> table_name -> PRIMARY KEY -> (cols)
   New flow: alias -> AS -> table_name -> [PRIMARY KEY (cols)] -> [UNIQUE (cols)]*

   Replace the `pk_pos.is_none()` error with an optional branch:
   ```rust
   let upper = after_as.to_ascii_uppercase();
   let pk_pos = find_primary_key(&upper);

   let (table_name, pk_columns, after_pk_text) = if let Some((pk_start, pk_end)) = pk_pos {
       let table_name = after_as[..pk_start].trim();
       if table_name.is_empty() {
           return Err(ParseError {
               message: format!("Missing physical table name after AS for alias '{alias}'."),
               position: Some(after_as_offset),
           });
       }
       let after_pk = after_as[pk_end..].trim_start();
       if !after_pk.starts_with('(') {
           return Err(ParseError {
               message: "Expected '(' after PRIMARY KEY in TABLES clause.".to_string(),
               position: Some(after_as_offset + pk_end),
           });
       }
       let pk_body = extract_paren_content(after_pk).ok_or_else(|| ParseError {
           message: "Unclosed '(' in PRIMARY KEY column list.".to_string(),
           position: Some(after_as_offset + pk_end),
       })?;
       let pk_columns: Vec<String> = pk_body
           .split(',')
           .map(|c| c.trim().to_string())
           .filter(|c| !c.is_empty())
           .collect();
       // Find position after closing paren
       let close = after_pk.find(')').unwrap();
       let remainder = &after_pk[close + 1..];
       (table_name, pk_columns, remainder)
   } else {
       // No PRIMARY KEY -- fact table. Table name is everything remaining (trimmed).
       // But we need to check if there's a UNIQUE keyword -- table name is before it.
       let unique_pos = find_unique(&upper);
       let table_name = if let Some((u_start, _)) = unique_pos {
           after_as[..u_start].trim()
       } else {
           after_as.trim()
       };
       if table_name.is_empty() {
           return Err(ParseError {
               message: format!("Missing physical table name after AS for alias '{alias}'."),
               position: Some(after_as_offset),
           });
       }
       let remainder = if let Some((u_start, _)) = unique_pos {
           &after_as[u_start..]
       } else {
           ""
       };
       (table_name, vec![], remainder)
   };
   ```

   Then parse zero or more UNIQUE constraints from `after_pk_text`:
   ```rust
   let mut unique_constraints: Vec<Vec<String>> = Vec::new();
   let mut remaining = after_pk_text;
   loop {
       let upper_remaining = remaining.to_ascii_uppercase();
       if let Some((u_start, u_end)) = find_unique(&upper_remaining) {
           let after_unique_kw = remaining[u_end..].trim_start();
           if !after_unique_kw.starts_with('(') {
               return Err(ParseError {
                   message: format!("Expected '(' after UNIQUE keyword for table alias '{alias}'."),
                   position: Some(entry_offset),
               });
           }
           let cols_str = extract_paren_content(after_unique_kw).ok_or_else(|| ParseError {
               message: format!("Unclosed '(' in UNIQUE column list for table alias '{alias}'."),
               position: Some(entry_offset),
           })?;
           let cols: Vec<String> = cols_str
               .split(',')
               .map(|c| c.trim().to_string())
               .filter(|c| !c.is_empty())
               .collect();
           unique_constraints.push(cols);
           let close = after_unique_kw.find(')').unwrap();
           remaining = &after_unique_kw[close + 1..];
       } else {
           break;
       }
   }
   ```

   Return:
   ```rust
   Ok(TableRef {
       alias: alias.to_string(),
       table: table_name.to_string(),
       pk_columns,
       unique_constraints,
   })
   ```

3. **Delete `parse_cardinality_tokens` function entirely.** Remove the function and all references. (lines ~624-663 in current body_parser.rs)

4. **Modify `parse_single_relationship_entry`** to:
   a. After consuming `REFERENCES` and the target alias token, check if the NEXT non-whitespace character is `(`. If so, extract the column list and store as `ref_columns` on the Join. If not, `ref_columns` is empty (will be resolved to target PK during inference).
   b. Remove the call to `parse_cardinality_tokens`. After the target alias (and optional column list), nothing more is expected. If there are extra tokens, produce a parse error.

   Replace lines 737-748 (the `remaining_after_refs` logic):
   ```rust
   let remaining_after_refs = after_paren[refs_pos + "REFERENCES".len()..].trim();

   // Get target alias: first whitespace-delimited token (or everything if followed by '(')
   let (to_alias, after_to) = if remaining_after_refs.is_empty() {
       return Err(ParseError {
           message: format!("Expected target alias after REFERENCES in relationship '{rel_name}'."),
           position: Some(entry_offset),
       });
   } else {
       // Target alias might be immediately followed by '(' (no space): target(col)
       if let Some(paren_pos) = remaining_after_refs.find('(') {
           let before_paren = remaining_after_refs[..paren_pos].trim_end();
           // Could be "target(col)" or "target (col)"
           if before_paren.contains(char::is_whitespace) {
               // "target (col)" -- split at first whitespace
               let (alias, rest) = split_first_token(remaining_after_refs);
               (alias, rest.trim_start())
           } else if before_paren.is_empty() {
               return Err(ParseError {
                   message: format!("Expected target alias after REFERENCES in relationship '{rel_name}'."),
                   position: Some(entry_offset),
               });
           } else {
               // "target(col)" -- alias is before '('
               (before_paren, &remaining_after_refs[paren_pos..])
           }
       } else {
           // No paren at all -- target alias is everything (trimmed)
           let (alias, rest) = split_first_token(remaining_after_refs);
           (alias, rest.trim_start())
       }
   };

   // Parse optional ref_columns from REFERENCES target(col1, col2)
   let (ref_columns, after_ref_cols) = if after_to.starts_with('(') {
       let cols_str = extract_paren_content(after_to).ok_or_else(|| ParseError {
           message: format!("Unclosed '(' in REFERENCES column list for relationship '{rel_name}'."),
           position: Some(entry_offset),
       })?;
       let cols: Vec<String> = cols_str
           .split(',')
           .map(|c| c.trim().to_string())
           .filter(|c| !c.is_empty())
           .collect();
       let close = after_to.find(')').unwrap();
       (cols, after_to[close + 1..].trim())
   } else {
       (vec![], after_to)
   };

   // Reject any remaining tokens (old cardinality keywords or garbage)
   if !after_ref_cols.is_empty() {
       return Err(ParseError {
           message: format!(
               "Unexpected tokens after REFERENCES target in relationship '{rel_name}': '{after_ref_cols}'. \
                Cardinality is now inferred from PK/UNIQUE constraints; explicit keywords are no longer supported.",
           ),
           position: Some(entry_offset),
       });
   }
   ```

   Update the returned Join:
   ```rust
   Ok(Join {
       table: to_alias.to_string(),
       from_alias: from_alias.to_string(),
       fk_columns,
       ref_columns,
       name: Some(rel_name.to_string()),
       cardinality: Cardinality::default(), // will be set by inference
       on: String::new(),
       from_cols: vec![],
       join_columns: vec![],
   })
   ```

5. **Update body_parser.rs unit tests:**
   - Delete or rewrite ALL tests in the `#[cfg(test)]` section that test cardinality keyword parsing:
     - `cardinality_many_to_one` -> DELETE
     - `one_to_one_cardinality` -> DELETE
     - `one_to_many_cardinality` -> DELETE
     - `cardinality_default` -> KEEP but verify cardinality is ManyToOne (default, from inference, not from keyword)
     - `many_to_many_rejected` -> REWRITE: test that `rel AS a(fk) REFERENCES b MANY TO ONE` produces an error containing "no longer supported"
     - `cardinality_to_alias_clean` -> DELETE (tested cardinality keyword not bleeding into alias)
   - Add NEW tests:
     - `unique_constraint_parsing` -- TABLES entry with PK + UNIQUE parses correctly
     - `multiple_unique_constraints` -- TABLES entry with PK + 2 UNIQUEs parses
     - `table_without_primary_key` -- TABLES entry `f AS fact_table` parses with empty pk_columns
     - `table_with_unique_no_pk` -- TABLES entry `f AS fact_table UNIQUE (email)` parses
     - `references_with_column_list` -- relationship REFERENCES target(col) stores ref_columns
     - `references_without_column_list` -- relationship REFERENCES target stores empty ref_columns
     - `references_multi_column_list` -- REFERENCES target(a, b) stores 2 ref_columns
     - `old_cardinality_keywords_rejected` -- `rel AS a(fk) REFERENCES b ONE TO ONE` errors
     - `trailing_text_after_references_rejected` -- `rel AS a(fk) REFERENCES b garbage` errors
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test body_parser -- --nocapture 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - src/body_parser.rs contains `fn find_unique(` function
    - src/body_parser.rs does NOT contain `fn parse_cardinality_tokens(`
    - src/body_parser.rs `parse_single_table_entry` handles optional PRIMARY KEY
    - src/body_parser.rs `parse_single_table_entry` parses UNIQUE constraints into `unique_constraints`
    - src/body_parser.rs `parse_single_relationship_entry` stores `ref_columns` from REFERENCES target(cols)
    - src/body_parser.rs `parse_single_relationship_entry` errors on trailing tokens after REFERENCES target
    - `cargo test body_parser` exits 0
  </acceptance_criteria>
  <done>Parser supports UNIQUE constraints, REFERENCES(cols), rejects cardinality keywords. All parser tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Implement cardinality inference in parse.rs</name>
  <files>src/parse.rs</files>
  <read_first>src/parse.rs, src/model.rs, src/body_parser.rs</read_first>
  <behavior>
    - Test: infer_cardinality resolves ref_columns to target PK when Join.ref_columns is empty
    - Test: infer_cardinality keeps Join.ref_columns when already set (from REFERENCES target(cols))
    - Test: infer_cardinality returns error when REFERENCES target has no PK and ref_columns empty
    - Test: infer_cardinality sets OneToOne when FK columns match PK on from-side table
    - Test: infer_cardinality sets OneToOne when FK columns match a UNIQUE on from-side table
    - Test: infer_cardinality sets ManyToOne when FK columns are bare (no PK/UNIQUE match on from-side)
    - Test: infer_cardinality is case-insensitive for column matching
    - Test: rewrite_ddl_keyword_body produces JSON with ref_columns and inferred cardinality populated
  </behavior>
  <action>
1. Add `use std::collections::HashSet;` at the top of parse.rs if not already present.

2. Add `infer_cardinality` function in parse.rs (after `rewrite_ddl_keyword_body`, before FFI section):
   ```rust
   use crate::model::{Cardinality, TableRef, Join};

   /// Infer cardinality for each relationship based on PK/UNIQUE constraints.
   /// Also resolves ref_columns (the columns on the target side of the FK reference).
   ///
   /// Two checks per relationship:
   /// 1. Resolve ref_columns: if empty, use target's PK. If target has no PK, error.
   /// 2. Infer cardinality: if FK columns match PK/UNIQUE on the from_alias table -> OneToOne, else ManyToOne.
   fn infer_cardinality(
       tables: &[TableRef],
       relationships: &mut [Join],
   ) -> Result<(), ParseError> {
       for join in relationships.iter_mut() {
           if join.fk_columns.is_empty() {
               continue;
           }

           let to_alias_lower = join.table.to_ascii_lowercase();
           let from_alias_lower = join.from_alias.to_ascii_lowercase();

           // Find target table (REFERENCES target)
           let target = tables.iter()
               .find(|t| t.alias.to_ascii_lowercase() == to_alias_lower);

           // Find source table (from_alias side)
           let source = tables.iter()
               .find(|t| t.alias.to_ascii_lowercase() == from_alias_lower);

           // Step 1: Resolve ref_columns
           if join.ref_columns.is_empty() {
               // REFERENCES target (no column list) -> use target's PK
               match target {
                   Some(t) if !t.pk_columns.is_empty() => {
                       join.ref_columns = t.pk_columns.clone();
                   }
                   Some(t) => {
                       let rel_name = join.name.as_deref().unwrap_or("?");
                       return Err(ParseError {
                           message: format!(
                               "Table '{}' has no PRIMARY KEY. \
                                Specify referenced columns explicitly: REFERENCES {}(col).",
                               t.alias, t.alias
                           ),
                           position: None,
                       });
                   }
                   None => {
                       // Target not found -- will be caught by graph validation later
                   }
               }
           }
           // When ref_columns was set explicitly (REFERENCES target(cols)),
           // validation against PK/UNIQUE on target happens in graph.rs (CARD-03).

           // Step 2: FK column count must match ref column count
           if !join.ref_columns.is_empty() && join.fk_columns.len() != join.ref_columns.len() {
               let rel_name = join.name.as_deref().unwrap_or("?");
               return Err(ParseError {
                   message: format!(
                       "FK column count ({}) does not match referenced column count ({}) \
                        in relationship '{rel_name}'.",
                       join.fk_columns.len(),
                       join.ref_columns.len(),
                   ),
                   position: None,
               });
           }

           // Step 3: Infer cardinality from FK-side constraints (CARD-04)
           if let Some(source) = source {
               let fk_set: HashSet<String> = join.fk_columns.iter()
                   .map(|c| c.to_ascii_lowercase())
                   .collect();

               // Check against source PK
               let pk_set: HashSet<String> = source.pk_columns.iter()
                   .map(|c| c.to_ascii_lowercase())
                   .collect();

               if !pk_set.is_empty() && fk_set == pk_set {
                   join.cardinality = Cardinality::OneToOne;
               } else {
                   // Check against source UNIQUE constraints
                   let matches_unique = source.unique_constraints.iter().any(|uc| {
                       let uc_set: HashSet<String> = uc.iter()
                           .map(|c| c.to_ascii_lowercase())
                           .collect();
                       fk_set == uc_set
                   });
                   join.cardinality = if matches_unique {
                       Cardinality::OneToOne
                   } else {
                       Cardinality::ManyToOne
                   };
               }
           }
       }
       Ok(())
   }
   ```

3. **Insert inference call in `rewrite_ddl_keyword_body`** between parsing and JSON serialization:

   After line `let keyword_body = parse_keyword_body(body_text, body_offset)?;` (line ~456), change to:
   ```rust
   let mut keyword_body = parse_keyword_body(body_text, body_offset)?;

   // Phase 33: Infer cardinality and resolve ref_columns before serialization.
   infer_cardinality(&keyword_body.tables, &mut keyword_body.relationships)?;
   ```

   Note: `keyword_body` changes from `let` to `let mut`.

4. **Add unit tests** for `infer_cardinality` in `parse.rs` `#[cfg(test)]` module (add new `mod phase33_inference_tests`):
   ```rust
   mod phase33_inference_tests {
       use super::*;
       use crate::model::{Cardinality, Join, TableRef};

       fn make_table(alias: &str, pk: &[&str], unique: &[&[&str]]) -> TableRef {
           TableRef {
               alias: alias.to_string(),
               table: alias.to_string(),
               pk_columns: pk.iter().map(|s| s.to_string()).collect(),
               unique_constraints: unique.iter()
                   .map(|cols| cols.iter().map(|s| s.to_string()).collect())
                   .collect(),
           }
       }

       fn make_join(name: &str, from: &str, to: &str, fk: &[&str], ref_cols: &[&str]) -> Join {
           Join {
               name: Some(name.to_string()),
               from_alias: from.to_string(),
               table: to.to_string(),
               fk_columns: fk.iter().map(|s| s.to_string()).collect(),
               ref_columns: ref_cols.iter().map(|s| s.to_string()).collect(),
               ..Default::default()
           }
       }

       #[test]
       fn resolves_ref_columns_to_target_pk() { ... }

       #[test]
       fn keeps_explicit_ref_columns() { ... }

       #[test]
       fn error_when_target_has_no_pk_and_no_explicit_ref() { ... }

       #[test]
       fn infers_one_to_one_from_pk_match() { ... }

       #[test]
       fn infers_one_to_one_from_unique_match() { ... }

       #[test]
       fn infers_many_to_one_when_fk_is_bare() { ... }

       #[test]
       fn case_insensitive_column_matching() { ... }

       #[test]
       fn fk_ref_column_count_mismatch_error() { ... }
   }
   ```

5. **Update existing parse.rs tests** that construct `TableRef` or `Join` to include the new fields where not using `..Default::default()`. Search for all `TableRef {` and `Join {` in parse.rs tests.

6. **Update `tests/parse_proptest.rs`**: Delete or rewrite the `arb_cardinality_keyword` strategy and `relationship_cardinality_keyword_variants` proptest. The new relationship format has no cardinality keywords. Replace with a proptest that verifies REFERENCES target and REFERENCES target(col) both work with random identifiers.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test parse -- --nocapture 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - src/parse.rs contains `fn infer_cardinality(`
    - src/parse.rs `rewrite_ddl_keyword_body` calls `infer_cardinality` before JSON serialization
    - src/parse.rs contains `let mut keyword_body = parse_keyword_body`
    - `cargo test parse` exits 0
    - tests/parse_proptest.rs does NOT contain `arb_cardinality_keyword` or `ONE TO MANY`
    - `cargo test parse_proptest` exits 0
  </acceptance_criteria>
  <done>Cardinality inference runs at parse time. ref_columns resolved from target PK or explicit REFERENCES(cols). FK-side PK/UNIQUE match determines OneToOne vs ManyToOne. All parse tests and proptests pass.</done>
</task>

</tasks>

<verification>
```bash
# Verify all unit tests pass after plan 01 changes
cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test 2>&1 | tail -5
```

Note: `just test-sql` and `just test-ducklake-ci` will FAIL after this plan because existing sqllogictest files still use cardinality keywords. This is expected -- Plan 02 updates those tests.
</verification>

<success_criteria>
- model.rs: TableRef has unique_constraints, Join has ref_columns, Cardinality is 2-variant
- body_parser.rs: Parses UNIQUE, no cardinality keywords, REFERENCES(cols) syntax
- parse.rs: infer_cardinality resolves ref_columns and sets cardinality before JSON serialization
- `cargo test` passes (unit tests only -- sqllogictests will be fixed in plan 02)
</success_criteria>

<output>
After completion, create `.planning/phases/33-unique-constraints-cardinality-inference/33-01-SUMMARY.md`
</output>
