---
phase: 30-derived-metrics
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/body_parser.rs
  - src/graph.rs
  - src/ddl/define.rs
autonomous: true
requirements: [DRV-01, DRV-04, DRV-05]

must_haves:
  truths:
    - "Derived metrics (no table prefix) are parsed alongside qualified metrics in the METRICS clause"
    - "Defining a semantic view with derived metric cycles produces a clear error at CREATE time"
    - "Defining a semantic view with derived metric references to non-existent metrics produces a clear error at CREATE time"
    - "Defining a semantic view with an aggregate function inside a derived metric expression produces a clear error at CREATE time"
    - "Existing FACTS, DIMENSIONS, and qualified METRICS parsing is unchanged"
  artifacts:
    - path: "src/body_parser.rs"
      provides: "parse_metrics_clause function for mixed qualified/unqualified metric entries"
      contains: "fn parse_metrics_clause"
    - path: "src/graph.rs"
      provides: "validate_derived_metrics with cycle detection, unknown ref checking, aggregate prohibition"
      contains: "fn validate_derived_metrics"
    - path: "src/graph.rs"
      provides: "contains_aggregate_function scanner"
      contains: "fn contains_aggregate_function"
    - path: "src/ddl/define.rs"
      provides: "validate_derived_metrics wired into bind()"
      contains: "validate_derived_metrics"
  key_links:
    - from: "src/body_parser.rs"
      to: "src/model.rs"
      via: "Metric struct with source_table: None for derived metrics"
      pattern: "source_table: None"
    - from: "src/ddl/define.rs"
      to: "src/graph.rs"
      via: "validate_derived_metrics call in bind()"
      pattern: "validate_derived_metrics"
    - from: "src/graph.rs"
      to: "src/graph.rs"
      via: "contains_aggregate_function called from validate_derived_metrics"
      pattern: "contains_aggregate_function"
---

<objective>
Parse derived metrics (unqualified entries) in the METRICS clause and validate them at CREATE time.

Purpose: Enable users to declare metrics without a table prefix (e.g., `profit AS revenue - cost`) and catch definition errors (cycles, unknown references, aggregate functions) before the view is stored. This is the foundation for Plan 02 which implements expansion-time inlining.

Output: body_parser.rs with mixed qualified/unqualified metric parsing, graph.rs with derived metric validation (3 checks), define.rs with validation wired into bind().
</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/30-derived-metrics/30-RESEARCH.md

# Phase 29 summaries -- directly relevant: fact parsing, validation, word-boundary patterns
@.planning/phases/29-facts-clause-hierarchies/29-01-SUMMARY.md
@.planning/phases/29-facts-clause-hierarchies/29-02-SUMMARY.md

@src/body_parser.rs
@src/graph.rs
@src/ddl/define.rs
@src/model.rs

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

From src/model.rs:
```rust
pub struct Metric {
    pub name: String,
    pub expr: String,
    pub source_table: Option<String>,  // None = derived metric
    pub output_type: Option<String>,
}
```

From src/body_parser.rs:
```rust
pub(crate) fn parse_qualified_entries(
    body: &str,
    base_offset: usize,
) -> Result<Vec<(String, String, String)>, ParseError>
// Returns (source_alias, bare_name, expr) tuples
// Currently used by METRICS clause -- will be replaced by parse_metrics_clause
```

From src/graph.rs:
```rust
pub fn find_fact_references<'a>(expr: &str, fact_names: &[&'a str]) -> Vec<&'a str>;
pub fn validate_facts(def: &SemanticViewDefinition) -> Result<(), String>;
pub fn validate_hierarchies(def: &SemanticViewDefinition) -> Result<(), String>;
// Patterns to reuse: build_fact_dag, check_fact_cycles (Kahn's algorithm)
```

From src/ddl/define.rs:
```rust
// Validation chain in bind():
crate::graph::validate_graph(&def)?;
crate::graph::validate_facts(&def)?;
crate::graph::validate_hierarchies(&def)?;
// validate_derived_metrics goes AFTER validate_facts (facts must be valid first)
```

From src/body_parser.rs -- metrics clause handler in parse_keyword_body():
```rust
"metrics" => {
    metrics_raw = parse_qualified_entries(bound.content, bound.content_offset)?;
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Parse mixed qualified/unqualified metric entries in body_parser.rs</name>
  <files>src/body_parser.rs</files>
  <behavior>
    - Test: parse_metrics_clause("li.revenue AS SUM(li.amount)") returns vec with (Some("li"), "revenue", "SUM(li.amount)")
    - Test: parse_metrics_clause("profit AS revenue - cost") returns vec with (None, "profit", "revenue - cost")
    - Test: parse_metrics_clause with BOTH qualified and unqualified entries in same clause parses correctly
    - Test: parse_metrics_clause("li.revenue AS SUM(amount), profit AS revenue - cost, li.cost AS SUM(li.unit_cost)") returns 3 entries with correct Some/None source_table
    - Test: parse_metrics_clause with trailing comma handles correctly (no empty entry)
    - Test: parse_metrics_clause with newline-separated entries handles correctly
    - Test: parse_keyword_body with derived metrics produces Metric structs where derived metrics have source_table: None
    - Test: parse_keyword_body with ONLY derived metrics (no qualified metrics) works
    - Test: existing parse_qualified_entries is UNCHANGED -- FACTS and DIMENSIONS still require qualified entries and reject unqualified
    - Test: parse_qualified_entries("revenue AS SUM(amount)", 0) still returns error (missing dot)
  </behavior>
  <action>
    Create a new `parse_metrics_clause` function in body_parser.rs that handles both qualified (`alias.name AS expr`) and unqualified (`name AS expr`) entries. The function returns `Vec<(Option<String>, String, String)>` where the Option is the source alias (None for derived metrics).

    Implementation approach:
    1. Create `parse_metrics_clause(body, base_offset) -> Result<Vec<(Option<String>, String, String)>, ParseError>` that splits at depth-0 commas (reuse `split_at_depth0_commas`), then for each entry:
       - Find `AS` keyword (case-insensitive, reuse existing `find_as_keyword` or equivalent pattern)
       - Check if the part before AS contains a dot: if yes, parse as qualified (alias.name); if no, parse as unqualified (just name)
       - Return `(Some(alias), name, expr)` or `(None, name, expr)`
    2. In `parse_keyword_body`, change the `"metrics"` match arm from `parse_qualified_entries` to `parse_metrics_clause`
    3. Update the metrics mapping block (currently `metrics_raw.into_iter().map(|(alias, bare_name, expr)| Metric { ... })`) to handle `Option<String>` for source_table
    4. Keep `parse_qualified_entries` completely unchanged -- FACTS and DIMENSIONS still use it

    IMPORTANT: Do NOT modify `parse_qualified_entries`. Create a separate function. The METRICS clause is the ONLY clause that allows both forms. A typo in FACTS/DIMENSIONS (missing dot) must still produce an error.

    IMPORTANT: The `find_as_keyword` helper used in `parse_single_qualified_entry` finds `AS` at word boundaries. Reuse the same pattern or extract a shared helper. The AS detection must be case-insensitive and respect word boundaries (avoid matching "AS" inside "REASON").
  </action>
  <verify>
    <automated>cargo test body_parser::tests -- --nocapture 2>&1 | tail -20</automated>
  </verify>
  <done>
    - parse_metrics_clause parses both qualified and unqualified entries
    - parse_keyword_body uses parse_metrics_clause for METRICS, producing Metric structs with source_table: None for derived metrics
    - parse_qualified_entries is unchanged (FACTS/DIMENSIONS still error on unqualified entries)
    - All existing body_parser tests pass unchanged
    - New tests cover: pure unqualified, mixed, edge cases (trailing comma, newlines)
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Validate derived metrics at CREATE time in graph.rs and define.rs</name>
  <files>src/graph.rs, src/ddl/define.rs</files>
  <behavior>
    - Test: contains_aggregate_function("SUM(revenue)") returns Some("sum")
    - Test: contains_aggregate_function("COUNT(DISTINCT order_id)") returns Some("count")
    - Test: contains_aggregate_function("AVG(price)") returns Some("avg")
    - Test: contains_aggregate_function("revenue - cost") returns None (no aggregate)
    - Test: contains_aggregate_function("revenue * 100") returns None
    - Test: contains_aggregate_function("SUMMARY") returns None (not followed by open-paren)
    - Test: contains_aggregate_function("'SUM of values'") returns None (inside string literal -- best effort)
    - Test: validate_derived_metrics rejects cycle: a -> b -> a
    - Test: validate_derived_metrics rejects unknown metric reference: "profit AS revenue - nonexistent" where "nonexistent" is not a metric name
    - Test: validate_derived_metrics rejects aggregate in derived: "bad AS SUM(revenue)"
    - Test: validate_derived_metrics accepts valid: "profit AS revenue - cost" where revenue and cost are base metrics
    - Test: validate_derived_metrics accepts stacking: "margin AS profit / revenue" where profit is derived and revenue is base
    - Test: validate_derived_metrics accepts single-metric reference: "neg_revenue AS -revenue"
    - Test: validate_derived_metrics produces "did you mean?" suggestion for close misspellings
    - Test: validate_derived_metrics with no derived metrics returns Ok(()) immediately
    - Test: validate_derived_metrics checks metric name uniqueness across base and derived (duplicate name rejected)
  </behavior>
  <action>
    Add derived metric validation to graph.rs and wire it into define.rs.

    In graph.rs, add:

    1. `AGGREGATE_FUNCTIONS` constant: array of known SQL aggregate function names (lowercase): sum, count, avg, min, max, stddev, stddev_pop, stddev_samp, variance, var_pop, var_samp, string_agg, listagg, group_concat, array_agg, any_value, approx_count_distinct, median, mode, percentile_cont, percentile_disc, corr, covar_pop, covar_samp, regr_avgx, regr_avgy, regr_count, regr_intercept, regr_r2, regr_slope, regr_sxx, regr_sxy, regr_syy, bit_and, bit_or, bit_xor, bool_and, bool_or.

    2. `contains_aggregate_function(expr: &str) -> Option<&'static str>`: Scan expression for aggregate function calls. For each function name in AGGREGATE_FUNCTIONS, check if it appears in the expression at a word boundary followed by `(` (with optional whitespace between name and paren). Case-insensitive matching. Skip matches inside single-quoted string literals (track string literal state while scanning). Return the first aggregate function name found, or None.

    3. `validate_derived_metrics(def: &SemanticViewDefinition) -> Result<(), String>`:
       a. Filter derived metrics (source_table.is_none())
       b. If none, return Ok(())
       c. Check metric name uniqueness: ensure no two metrics (base or derived) share the same name (case-insensitive). Error: "duplicate metric name '{name}'"
       d. For each derived metric: check `contains_aggregate_function(&met.expr)`. Error: "derived metric '{name}' must not contain aggregate function '{func}'. Derived metrics compose other metrics; use a regular metric for aggregation."
       e. Collect all metric names (base + derived). For each derived metric, scan its expression using `find_fact_references` (reuse from graph.rs -- it does word-boundary matching for any name set). Check that each referenced metric name exists in the all-metric-names set. For unknown references, use `suggest_closest` for "did you mean?" Error: "unknown metric '{ref}' referenced in derived metric '{name}'. Available metrics: [...]"
       f. Build derived metric dependency DAG (only derived-to-derived edges). Run Kahn's algorithm (reuse pattern from `check_fact_cycles`). If leftover nodes exist, report cycle. Error: "cycle detected in derived metrics: {cycle_path}"

    In define.rs, add `validate_derived_metrics` call AFTER `validate_hierarchies`:
    ```rust
    crate::graph::validate_derived_metrics(&def)
        .map_err(|e| Box::<dyn std::error::Error>::from(e))?;
    ```

    IMPORTANT for reference detection: Use `find_fact_references` to scan derived metric expressions for metric name references. This function does word-boundary matching and is already proven. The "fact" in the name is misleading -- it scans for any name at word boundaries. Pass all metric names (base + derived) as the names to scan for.

    IMPORTANT for cycle detection: The DAG only contains derived metric nodes. References to base metrics are external edges (resolved before derived metrics) and must NOT contribute to in-degree. Only derived-to-derived references form the DAG for cycle detection.
  </action>
  <verify>
    <automated>cargo test graph::tests -- --nocapture 2>&1 | tail -20</automated>
  </verify>
  <done>
    - contains_aggregate_function detects SUM, COUNT, AVG, MIN, MAX and other aggregate functions at word boundaries followed by open-paren
    - contains_aggregate_function ignores non-aggregate uses (no paren, inside string literals)
    - validate_derived_metrics rejects cycles, unknown references (with "did you mean?"), and aggregate functions in derived metrics
    - validate_derived_metrics accepts valid derived metrics (single ref, stacking, no aggregates)
    - validate_derived_metrics checks metric name uniqueness across base and derived
    - validate_derived_metrics wired into define.rs bind() after validate_hierarchies
    - All existing graph.rs tests pass unchanged
    - cargo test passes clean
  </done>
</task>

</tasks>

<verification>
```bash
# All body_parser tests pass (new + existing)
cargo test body_parser::tests

# All graph tests pass (new + existing)
cargo test graph::tests

# Full cargo test suite
cargo test
```
</verification>

<success_criteria>
- METRICS clause accepts both `alias.name AS expr` (base) and `name AS expr` (derived) entries
- Derived metrics stored with `source_table: None` in the Metric struct
- FACTS and DIMENSIONS clauses are unchanged (still reject unqualified entries)
- Define-time validation catches: cycles, unknown metric references, aggregate functions
- All existing tests pass unchanged
- `cargo test` passes clean
</success_criteria>

<output>
After completion, create `.planning/phases/30-derived-metrics/30-01-SUMMARY.md`
</output>
