-- pgmnemo 0.19.1 -> 0.20.0
-- «Граф, который достаёт» — Graph-as-Pool-Expander
--
-- Changes (R2_ARCH_VARIANTS §1,3,7-8; R2_DB_FEASIBILITY §3,8):
--
-- §1  recall_hybrid (11-param): add graph_expand CTE (Variant A) + entity_expanded CTE
--     (Variant C) + retrieval_source TEXT output column. Drop old signature (return
--     type change requires DROP + CREATE).
--
-- §2  stats(): add 7 GUC columns for graph expansion settings. Drop old signature
--     (return type change requires DROP + CREATE).
--
-- GUC Catalog (R-U4 risk mitigation) — each GUC appears in:
--   a) Function body (current_setting reads below)
--   b) stats() return columns (§2)
--   c) COMMENT ON FUNCTION docstring (§3)
--   d) This migration header comment
--
-- GUC summary (ALL default 0.0 / disabled by default):
--   pgmnemo.graph_expand_weight         REAL  [0.0, 0.5]  def 0.0  — master on/off Variant A
--   pgmnemo.graph_expand_depth          INT   [1, 2]      def 1    — BFS depth (max 2; depth=2 conditional)
--   pgmnemo.graph_expand_ann_k          INT   [10, 50]    def 15   — ANN anchor oversampling
--   pgmnemo.graph_expand_per_node       INT   [3, 50]     def 10   — hub-cap per source node
--   pgmnemo.graph_entity_expand_weight  REAL  [0.0, 0.3]  def 0.0  — master on/off Variant C
--   pgmnemo.graph_entity_min_overlap    INT   >= 1        def 1    — min matching entity keys
--   pgmnemo.graph_entity_max_expansion  INT   >= 1        def 50   — max GIN candidates per key
--
-- Safety constraints (all defaults safe for production):
--   IRON: all graph weights default 0.0 — no retrieval change without explicit opt-in
--   IRON: no perf claims in CHANGELOG until PREREG_020_EXPAND (SHA 4eb3a2b8) ablation complete
--   GIN bug fix (R2_DB §7): metadata @> jsonb_build_object('entity_keys', jsonb_build_array(key))
--                            NOT jsonb_build_array(key) — correct nested containment pattern
--   Hub-cap (R2_DB §5): LATERAL LIMIT per source node mandatory for BFS safety
--   Causal-only (R2_DB §8.2): Variant A uses edge_kind='causal' only; temporal adds noise
--
-- G-UPGRADE-PARITY: pgmnemo--0.20.0.sql = pgmnemo--0.19.1.sql || this file
-- pg_regress: see tests/sql/test_v0200_graph_expand.sql

-- ─────────────────────────────────────────────────────────────────────────────
-- §1: recall_hybrid (11-param) — new return type requires DROP + CREATE
--     Adds: entity_expanded CTE (Variant C), graph_expand CTE (Variant A),
--           retrieval_source TEXT output column
-- ─────────────────────────────────────────────────────────────────────────────

DROP FUNCTION IF EXISTS pgmnemo.recall_hybrid(
    vector, text, integer, text, integer,
    double precision, double precision, integer, text, text[], real
);

CREATE OR REPLACE FUNCTION pgmnemo.recall_hybrid(
    query_embedding   vector(1024),
    query_text        TEXT,
    k                 INT              DEFAULT 10,
    role_filter       TEXT             DEFAULT NULL,
    project_id_filter INT              DEFAULT NULL,
    vec_weight        DOUBLE PRECISION DEFAULT 0.4,
    bm25_weight       DOUBLE PRECISION DEFAULT 0.4,
    rrf_k             INT              DEFAULT 60,
    exclude_dag_id    TEXT             DEFAULT NULL,
    p_content_types   text[]           DEFAULT NULL,
    p_min_score       REAL             DEFAULT NULL
)
RETURNS TABLE (
    lesson_id        BIGINT,
    score            DOUBLE PRECISION,
    vec_score        DOUBLE PRECISION,
    bm25_score       DOUBLE PRECISION,
    rrf_score        DOUBLE PRECISION,
    role             TEXT,
    project_id       INT,
    topic            TEXT,
    lesson_text      TEXT,
    importance       SMALLINT,
    metadata         JSONB,
    commit_sha       TEXT,
    artifact_hash    TEXT,
    verified_at      TIMESTAMPTZ,
    created_at       TIMESTAMPTZ,
    confidence       REAL,
    match_confidence REAL,
    retrieval_source TEXT             -- v0.20.0: ann|graph|entity
)
LANGUAGE plpgsql
VOLATILE
AS $func$
#variable_conflict use_column
DECLARE
    _ef_search          INT;
    _include_unverified BOOLEAN;
    _tsquery            TSQUERY;
    _has_text           BOOLEAN;
    _has_vec            BOOLEAN;
    _graph_weight       DOUBLE PRECISION;   -- old proximity multiplier (backward compat)
    _max_depth          CONSTANT INT := 2;  -- D3(v0.19.0): depth cap 5→2
    _rrf_k_f            DOUBLE PRECISION;
    _aux_scale          CONSTANT DOUBLE PRECISION := (0.8 / 61.0) / 0.76;
    _as_of_ts           TIMESTAMPTZ;
    _raw_blend_weight   DOUBLE PRECISION;
    _ghost_count        INT;
    _fetch_k_vec        INT;
    _fetch_k_bm25       INT;
    _conf_boost_w       DOUBLE PRECISION;
    _lexical_text       TEXT;
    _bm25_budget_ms     INT;
    _bm25_timed_out     BOOLEAN := FALSE;
    -- v0.20.0 Variant A — BFS pool expansion (GUC: pgmnemo.graph_expand_weight)
    _graph_expand_weight    REAL;    -- [0.0, 0.5] default 0.0
    _graph_expand_depth     INT;     -- [1, 2]     default 1
    _graph_expand_ann_k     INT;     -- [10, 50]   default 15
    _graph_expand_per_node  INT;     -- [3, 50]    default 10
    -- v0.20.0 Variant C — Entity GIN expansion (GUC: pgmnemo.graph_entity_expand_weight)
    _graph_entity_weight        REAL;    -- [0.0, 0.3] default 0.0
    _graph_entity_min_overlap   INT;     -- >= 1      default 1
    _graph_entity_max_expansion INT;     -- >= 1      default 50
BEGIN
    _has_vec  := query_embedding IS NOT NULL;
    _has_text := query_text IS NOT NULL AND length(trim(query_text)) > 0;

    IF NOT _has_vec AND NOT _has_text THEN
        RAISE EXCEPTION
            'pgmnemo.recall_hybrid: both query_embedding and query_text are NULL/empty -- '
            'at least one retrieval signal is required';
    END IF;

    IF NOT _has_vec AND _has_text THEN
        RAISE NOTICE
            'pgmnemo: query_embedding IS NULL -- falling back to text-only recall; no semantic similarity';
    END IF;

    vec_weight  := GREATEST(0.0, LEAST(1.0, vec_weight));
    bm25_weight := GREATEST(0.0, LEAST(1.0, bm25_weight));
    _rrf_k_f    := GREATEST(1.0, rrf_k::DOUBLE PRECISION);
    _raw_blend_weight := 1.0 / (_rrf_k_f + 1.0);

    BEGIN
        _ef_search := COALESCE(
            NULLIF(current_setting('pgmnemo.ef_search', TRUE), '')::INT, 100);
        IF _ef_search BETWEEN 10 AND 500 THEN
            EXECUTE format('SET LOCAL pgvector.hnsw.ef_search = %s', _ef_search);
        END IF;
    EXCEPTION WHEN OTHERS THEN
        _ef_search := 100;
    END;

    BEGIN
        _include_unverified := COALESCE(
            current_setting('pgmnemo.include_unverified', TRUE)::BOOLEAN, FALSE);
    EXCEPTION WHEN OTHERS THEN _include_unverified := FALSE;
    END;

    BEGIN
        _as_of_ts := NULLIF(current_setting('pgmnemo.as_of_timestamp', TRUE), '')::TIMESTAMPTZ;
    EXCEPTION WHEN OTHERS THEN _as_of_ts := NULL;
    END;

    BEGIN
        _graph_weight := GREATEST(0.0, LEAST(0.5, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_proximity_weight', TRUE), '')::DOUBLE PRECISION,
            0.0)));
    EXCEPTION WHEN OTHERS THEN _graph_weight := 0.0;
    END;

    BEGIN
        _conf_boost_w := GREATEST(0.0, LEAST(0.01, COALESCE(
            NULLIF(current_setting('pgmnemo.confidence_boost_weight', TRUE), '')::DOUBLE PRECISION,
            0.0)));
    EXCEPTION WHEN OTHERS THEN _conf_boost_w := 0.0;
    END;

    BEGIN
        _bm25_budget_ms := GREATEST(1, COALESCE(
            NULLIF(current_setting('pgmnemo.bm25_budget_ms', TRUE), '')::INT, 250));
    EXCEPTION WHEN OTHERS THEN _bm25_budget_ms := 250;
    END;

    -- v0.20.0: read Variant A GUCs (pgmnemo.graph_expand_weight)
    BEGIN
        _graph_expand_weight := GREATEST(0.0, LEAST(0.5, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_weight', TRUE), '')::REAL, 0.0)));
    EXCEPTION WHEN OTHERS THEN _graph_expand_weight := 0.0;
    END;

    BEGIN
        _graph_expand_depth := GREATEST(1, LEAST(2, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_depth', TRUE), '')::INT, 1)));
    EXCEPTION WHEN OTHERS THEN _graph_expand_depth := 1;
    END;

    BEGIN
        _graph_expand_ann_k := GREATEST(10, LEAST(50, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_ann_k', TRUE), '')::INT, 15)));
    EXCEPTION WHEN OTHERS THEN _graph_expand_ann_k := 15;
    END;

    BEGIN
        _graph_expand_per_node := GREATEST(3, LEAST(50, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_per_node', TRUE), '')::INT, 10)));
    EXCEPTION WHEN OTHERS THEN _graph_expand_per_node := 10;
    END;

    -- v0.20.0: read Variant C GUCs (pgmnemo.graph_entity_expand_weight)
    BEGIN
        _graph_entity_weight := GREATEST(0.0, LEAST(0.3, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_entity_expand_weight', TRUE), '')::REAL, 0.0)));
    EXCEPTION WHEN OTHERS THEN _graph_entity_weight := 0.0;
    END;

    BEGIN
        _graph_entity_min_overlap := GREATEST(1, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_entity_min_overlap', TRUE), '')::INT, 1));
    EXCEPTION WHEN OTHERS THEN _graph_entity_min_overlap := 1;
    END;

    BEGIN
        _graph_entity_max_expansion := GREATEST(1, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_entity_max_expansion', TRUE), '')::INT, 50));
    EXCEPTION WHEN OTHERS THEN _graph_entity_max_expansion := 50;
    END;

    IF _has_text THEN
        _lexical_text := left(trim(query_text), 200);
        BEGIN
            _tsquery := websearch_to_tsquery('simple', _lexical_text);
        EXCEPTION WHEN OTHERS THEN
            BEGIN
                _tsquery := plainto_tsquery('simple', _lexical_text);
            EXCEPTION WHEN OTHERS THEN _has_text := FALSE;
            END;
        END;
    END IF;

    _fetch_k_vec  := GREATEST(k * 4, _ef_search);
    _fetch_k_bm25 := GREATEST(k * 4, 40);

    -- ── BM25 candidates ──────────────────────────────────────────────────────
    BEGIN
        CREATE TEMP TABLE _pgmnemo_bm25_work (
            id             BIGINT          PRIMARY KEY,
            raw_bm25_score DOUBLE PRECISION NOT NULL DEFAULT 0.0
        ) ON COMMIT DROP;
    EXCEPTION WHEN duplicate_table THEN
        TRUNCATE TABLE _pgmnemo_bm25_work;
    END;

    IF _has_text THEN
        BEGIN
            EXECUTE format('SET LOCAL statement_timeout = %s', _bm25_budget_ms);

            INSERT INTO _pgmnemo_bm25_work (id, raw_bm25_score)
            SELECT
                al.id,
                ts_rank_cd(al.full_text, _tsquery, 32)::DOUBLE PRECISION
            FROM pgmnemo.agent_lesson al
            WHERE al.is_active
              AND al.full_text @@ _tsquery
              AND (_include_unverified OR al.verified_at IS NOT NULL)
              AND (recall_hybrid.role_filter IS NULL OR al.role = recall_hybrid.role_filter)
              AND (recall_hybrid.project_id_filter IS NULL
                   OR al.project_id = recall_hybrid.project_id_filter)
              AND (recall_hybrid.exclude_dag_id IS NULL
                   OR al.source_dag_id IS DISTINCT FROM recall_hybrid.exclude_dag_id)
              AND (recall_hybrid.p_content_types IS NULL
                   OR al.content_type = ANY(recall_hybrid.p_content_types))
              AND (_as_of_ts IS NULL
                   OR (al.t_valid_from <= _as_of_ts AND al.t_valid_to > _as_of_ts))
              AND (_as_of_ts IS NOT NULL OR al.t_valid_to = 'infinity'::TIMESTAMPTZ)
            ORDER BY 2 DESC
            LIMIT _fetch_k_bm25;

            EXECUTE 'SET LOCAL statement_timeout = 0';

        EXCEPTION WHEN query_canceled THEN
            _bm25_timed_out := TRUE;
            _has_text       := FALSE;
            RAISE NOTICE
                'pgmnemo.recall_hybrid: BM25 signal exceeded %ms budget — degrading to '
                'vector-only recall. Tune pgmnemo.bm25_budget_ms or shorten query_text.',
                _bm25_budget_ms;
        END;
    END IF;

    -- ── ANN vector candidates (EXECUTE with literal LIMIT for planner) ───────
    BEGIN
        CREATE TEMP TABLE _pgmnemo_vc (
            id             BIGINT,
            role           TEXT,
            project_id     INT,
            topic          TEXT,
            lesson_text    TEXT,
            importance     SMALLINT,
            metadata       JSONB,
            commit_sha     TEXT,
            artifact_hash  TEXT,
            verified_at    TIMESTAMPTZ,
            created_at     TIMESTAMPTZ,
            confidence     REAL,
            raw_vec_score  DOUBLE PRECISION
        ) ON COMMIT DROP;
    EXCEPTION WHEN duplicate_table THEN
        TRUNCATE TABLE _pgmnemo_vc;
    END;

    IF _has_vec THEN
        EXECUTE format($vec_sql$
            INSERT INTO _pgmnemo_vc
            SELECT
                al.id,
                al.role,        al.project_id, al.topic,      al.lesson_text,
                al.importance,  al.metadata,   al.commit_sha, al.artifact_hash,
                al.verified_at, al.created_at, al.confidence,
                (1.0 - (al.embedding <=> $1))::DOUBLE PRECISION  AS raw_vec_score
            FROM pgmnemo.agent_lesson al
            WHERE al.is_active
              AND al.embedding IS NOT NULL
              AND ($2 OR al.verified_at IS NOT NULL)
              AND ($3 IS NULL OR al.role = $3)
              AND ($4 IS NULL OR al.project_id = $4)
              AND ($5 IS NULL OR al.source_dag_id IS DISTINCT FROM $5)
              AND ($6 IS NULL OR al.content_type = ANY($6))
              AND ($7 IS NULL OR (al.t_valid_from <= $7 AND al.t_valid_to > $7))
              AND ($7 IS NOT NULL OR al.t_valid_to = 'infinity'::TIMESTAMPTZ)
            ORDER BY al.embedding <=> $1
            LIMIT %s
        $vec_sql$, _fetch_k_vec)
        USING query_embedding, _include_unverified, role_filter, project_id_filter,
              exclude_dag_id, p_content_types, _as_of_ts;
    END IF;

    RETURN QUERY
    WITH RECURSIVE

    -- ── Phase C: Entity key extraction from query text ────────────────────────
    -- Calls pgmnemo.extract_entity_keys(query_text) — same function used at write-time
    -- Guard: enabled only when graph_entity_expand_weight > 0 and query_text available
    query_entity_keys AS (
        SELECT DISTINCT ek
        FROM unnest(
            CASE WHEN _graph_entity_weight > 0.0
                      AND _has_text
                 THEN pgmnemo.extract_entity_keys(query_text)
                 ELSE ARRAY[]::TEXT[]
            END
        ) AS t(ek)
        WHERE ek IS NOT NULL AND ek <> ''
    ),

    -- ── Phase C: GIN-index entity expansion (LATERAL per-key — prevents SeqScan) ──
    -- CRITICAL FIX (R2_DB §7): jsonb_build_object, NOT jsonb_build_array
    -- Wrong:   metadata @> jsonb_build_array(key)           → returns 0 rows always
    -- Correct: metadata @> jsonb_build_object('entity_keys', jsonb_build_array(key))
    entity_candidates AS (
        SELECT inner_al.id AS entity_id, COUNT(DISTINCT qek.ek) AS key_overlap
        FROM query_entity_keys qek,
             LATERAL (
                 SELECT al2.id
                 FROM pgmnemo.agent_lesson al2
                 WHERE al2.is_active
                   AND al2.metadata @> jsonb_build_object(
                           'entity_keys', jsonb_build_array(qek.ek))
                 LIMIT _graph_entity_max_expansion
             ) inner_al
        GROUP BY inner_al.id
        HAVING COUNT(DISTINCT qek.ek) >= _graph_entity_min_overlap
    ),

    -- ── Phase A: BFS anchor selection (top-K from ANN pool by cosine score) ──
    -- Guard: only populated when graph_expand_weight > 0
    bfs_anchors AS (
        SELECT id
        FROM _pgmnemo_vc
        WHERE _graph_expand_weight > 0.0
        ORDER BY raw_vec_score DESC
        LIMIT _graph_expand_ann_k
    ),

    -- ── Phase A: BFS pool expansion (causal edges only, depth=1, hub-cap) ────
    -- R2_DB §8.2: causal-only — temporal edges produce 0pp lift at cos < 0.6
    -- Hub-cap via LATERAL LIMIT per source node (mandatory: p99 out-degree = 222)
    -- Cycle guard via visited BIGINT[] (D1 from v0.19.0, carried forward)
    graph_expand(reached_id, depth, path_weight, visited) AS (
        -- Seed: ANN anchors at depth 0
        SELECT ba.id, 0, 1.0::REAL, ARRAY[ba.id]
        FROM bfs_anchors ba

        UNION ALL

        -- Recursive: follow causal edges with hub-cap (LATERAL LIMIT per source node)
        SELECT nb.target_id,
               ge.depth + 1,
               (ge.path_weight * COALESCE(nb.weight, 1.0))::REAL,
               ge.visited || nb.target_id
        FROM graph_expand ge,
             LATERAL (
                 -- Hub-cap: LIMIT _graph_expand_per_node per source node (prevents hub explosion)
                 SELECT me.target_id, me.weight
                 FROM pgmnemo.mem_edge me
                 WHERE me.source_id = ge.reached_id
                   AND me.edge_kind = 'causal'        -- causal edges ONLY (Variant A spec)
                   AND me.valid_until IS NULL
                   AND NOT (me.target_id = ANY(ge.visited))  -- cycle guard
                 ORDER BY me.weight DESC NULLS LAST
                 LIMIT _graph_expand_per_node           -- hub-cap: GUC pgmnemo.graph_expand_per_node
             ) nb
        WHERE ge.depth < _graph_expand_depth           -- GUC pgmnemo.graph_expand_depth
    ),

    -- ── Phase A: Aggregate BFS results — best path per node, exclude original pool ──
    graph_scores AS (
        SELECT reached_id                AS id,
               MAX(path_weight)          AS graph_score
        FROM graph_expand
        WHERE depth > 0                              -- exclude seeds (depth=0)
          AND reached_id NOT IN (SELECT id FROM _pgmnemo_vc)
          AND reached_id NOT IN (SELECT id FROM _pgmnemo_bm25_work)
        GROUP BY reached_id
    ),

    -- ── Phase C: Entity candidates — exclude original pool and graph pool ─────
    entity_scores AS (
        SELECT ec.entity_id                          AS id,
               ec.key_overlap::INT
        FROM entity_candidates ec
        WHERE ec.entity_id NOT IN (SELECT id FROM _pgmnemo_vc)
          AND ec.entity_id NOT IN (SELECT id FROM _pgmnemo_bm25_work)
          AND ec.entity_id NOT IN (SELECT id FROM graph_scores)
    ),

    -- ── Merge all candidate pools with source attribution ─────────────────────
    all_candidates AS (
        -- [1] ANN candidates (may overlap with BM25)
        SELECT v.id, v.role, v.project_id, v.topic, v.lesson_text,
               v.importance, v.metadata, v.commit_sha, v.artifact_hash,
               v.verified_at, v.created_at, v.confidence,
               v.raw_vec_score,
               COALESCE(bw.raw_bm25_score, 0.0::DOUBLE PRECISION) AS raw_bm25_score,
               NULL::REAL                   AS graph_score,
               NULL::INT                    AS key_overlap,
               'ann'::TEXT                  AS retrieval_source
        FROM _pgmnemo_vc v
        LEFT JOIN _pgmnemo_bm25_work bw ON bw.id = v.id

        UNION ALL

        -- [2] BM25-only candidates (not in ANN pool)
        SELECT al.id, al.role, al.project_id, al.topic, al.lesson_text,
               al.importance, al.metadata, al.commit_sha, al.artifact_hash,
               al.verified_at, al.created_at, al.confidence,
               0.0::DOUBLE PRECISION        AS raw_vec_score,
               bw.raw_bm25_score,
               NULL::REAL                   AS graph_score,
               NULL::INT                    AS key_overlap,
               'ann'::TEXT                  AS retrieval_source
        FROM _pgmnemo_bm25_work bw
        JOIN pgmnemo.agent_lesson al ON al.id = bw.id
        WHERE bw.id NOT IN (SELECT id FROM _pgmnemo_vc)

        UNION ALL

        -- [3] Graph-expanded candidates (BFS pool, new lessons outside ANN+BM25)
        SELECT al.id, al.role, al.project_id, al.topic, al.lesson_text,
               al.importance, al.metadata, al.commit_sha, al.artifact_hash,
               al.verified_at, al.created_at, al.confidence,
               CASE WHEN _has_vec
                    THEN (1.0 - (al.embedding <=> query_embedding))::DOUBLE PRECISION
                    ELSE 0.0::DOUBLE PRECISION
               END                          AS raw_vec_score,
               0.0::DOUBLE PRECISION        AS raw_bm25_score,
               gs.graph_score,
               NULL::INT                    AS key_overlap,
               'graph'::TEXT                AS retrieval_source
        FROM graph_scores gs
        JOIN pgmnemo.agent_lesson al ON al.id = gs.id
        WHERE al.is_active
          AND (_include_unverified OR al.verified_at IS NOT NULL)
          AND (recall_hybrid.role_filter IS NULL OR al.role = recall_hybrid.role_filter)
          AND (recall_hybrid.project_id_filter IS NULL
               OR al.project_id = recall_hybrid.project_id_filter)
          AND (recall_hybrid.exclude_dag_id IS NULL
               OR al.source_dag_id IS DISTINCT FROM recall_hybrid.exclude_dag_id)
          AND (recall_hybrid.p_content_types IS NULL
               OR al.content_type = ANY(recall_hybrid.p_content_types))
          AND (_as_of_ts IS NULL
               OR (al.t_valid_from <= _as_of_ts AND al.t_valid_to > _as_of_ts))
          AND (_as_of_ts IS NOT NULL OR al.t_valid_to = 'infinity'::TIMESTAMPTZ)

        UNION ALL

        -- [4] Entity-expanded candidates (GIN pool, outside ANN+BM25+graph)
        SELECT al.id, al.role, al.project_id, al.topic, al.lesson_text,
               al.importance, al.metadata, al.commit_sha, al.artifact_hash,
               al.verified_at, al.created_at, al.confidence,
               CASE WHEN _has_vec
                    THEN (1.0 - (al.embedding <=> query_embedding))::DOUBLE PRECISION
                    ELSE 0.0::DOUBLE PRECISION
               END                          AS raw_vec_score,
               0.0::DOUBLE PRECISION        AS raw_bm25_score,
               NULL::REAL                   AS graph_score,
               es.key_overlap,
               'entity'::TEXT               AS retrieval_source
        FROM entity_scores es
        JOIN pgmnemo.agent_lesson al ON al.id = es.id
        WHERE al.is_active
          AND (_include_unverified OR al.verified_at IS NOT NULL)
          AND (recall_hybrid.role_filter IS NULL OR al.role = recall_hybrid.role_filter)
          AND (recall_hybrid.project_id_filter IS NULL
               OR al.project_id = recall_hybrid.project_id_filter)
          AND (recall_hybrid.exclude_dag_id IS NULL
               OR al.source_dag_id IS DISTINCT FROM recall_hybrid.exclude_dag_id)
          AND (recall_hybrid.p_content_types IS NULL
               OR al.content_type = ANY(recall_hybrid.p_content_types))
          AND (_as_of_ts IS NULL
               OR (al.t_valid_from <= _as_of_ts AND al.t_valid_to > _as_of_ts))
          AND (_as_of_ts IS NOT NULL OR al.t_valid_to = 'infinity'::TIMESTAMPTZ)
    ),

    -- ── RRF ranking over full candidate set ───────────────────────────────────
    rrf_ranked AS (
        SELECT *,
            COUNT(*) OVER ()                                                    AS n_candidates,
            ROW_NUMBER() OVER (ORDER BY raw_vec_score DESC NULLS LAST, id ASC) AS vec_rank,
            CASE WHEN raw_bm25_score > 0
                 THEN RANK() OVER (PARTITION BY (raw_bm25_score > 0)
                                   ORDER BY raw_bm25_score DESC NULLS LAST)
                 ELSE NULL
            END                                                                 AS bm25_rank_sparse
        FROM all_candidates
    ),
    scored AS (
        SELECT
            r.id, r.role, r.project_id, r.topic, r.lesson_text,
            r.importance, r.metadata, r.commit_sha, r.artifact_hash,
            r.verified_at, r.created_at, r.confidence,
            r.raw_vec_score  AS v_score,
            r.raw_bm25_score AS b_score,
            r.graph_score,
            r.key_overlap,
            r.retrieval_source,
            (vec_weight  / (_rrf_k_f + r.vec_rank::DOUBLE PRECISION)
           + bm25_weight / (_rrf_k_f + COALESCE(r.bm25_rank_sparse,
                                                 r.n_candidates + 1)::DOUBLE PRECISION)
           + _raw_blend_weight * (
                 vec_weight  * r.raw_vec_score
               + bm25_weight * r.raw_bm25_score))
                AS rrf_sparse
        FROM rrf_ranked r
    ),

    -- ── Backward-compat: old graph_proximity multiplier (graph_proximity_weight GUC) ──
    anchors AS (
        SELECT id FROM scored ORDER BY rrf_sparse DESC LIMIT 5
    ),
    graph_walk(anchor_id, depth, reached_id, visited) AS (
        SELECT id, 0, id, ARRAY[id] FROM anchors WHERE _graph_weight > 0
        UNION ALL
        SELECT gw.anchor_id, gw.depth + 1, me.target_id, gw.visited || me.target_id
        FROM graph_walk gw
        JOIN pgmnemo.mem_edge me ON me.source_id = gw.reached_id
        WHERE me.edge_kind IN ('causal', 'temporal')
          AND gw.depth < _max_depth
          AND NOT (me.target_id = ANY(gw.visited))
    ),
    graph_proximity AS (
        SELECT gw.reached_id AS lesson_id,
               MAX(1.0 - gw.depth::DOUBLE PRECISION / _max_depth::DOUBLE PRECISION) AS proximity
        FROM graph_walk gw WHERE gw.depth > 0 GROUP BY gw.reached_id
    ),

    -- ── Final scoring: RRF + aux signal + graph expansion bonus ──────────────
    final AS (
        SELECT
            s.id,
            (
                s.rrf_sparse
              + _aux_scale * (
                    0.025 * (s.importance::DOUBLE PRECISION / 5.0)
                  + 0.025 * s.confidence::DOUBLE PRECISION
                  + 0.05 * GREATEST(0.0, 1.0 - LEAST(
                               EXTRACT(EPOCH FROM (NOW() - s.created_at)) / (90.0 * 86400.0), 1.0))
                  + 0.05 * (CASE
                                WHEN s.commit_sha IS NOT NULL AND s.verified_at IS NOT NULL THEN 1.0
                                WHEN s.commit_sha IS NOT NULL                               THEN 0.4
                                ELSE 0.0 END)
                )
              + _conf_boost_w * (s.confidence::DOUBLE PRECISION - 0.5)
              -- v0.20.0 (A): BFS expansion additive contribution
              + _graph_expand_weight * COALESCE(s.graph_score::DOUBLE PRECISION, 0.0)
              -- v0.20.0 (C): entity expansion additive contribution (per matched key)
              + _graph_entity_weight * COALESCE(s.key_overlap, 0)::DOUBLE PRECISION
            ) * (1.0 + _graph_weight * COALESCE(gp.proximity, 0.0))
              AS final_score,
            s.role, s.project_id, s.topic, s.lesson_text, s.importance,
            s.metadata, s.commit_sha, s.artifact_hash, s.verified_at, s.created_at,
            s.confidence, s.v_score, s.b_score, s.rrf_sparse,
            COALESCE(gp.proximity, 0.0) AS prox,
            s.retrieval_source
        FROM scored s
        LEFT JOIN graph_proximity gp ON gp.lesson_id = s.id
    ),
    final_results AS MATERIALIZED (
        SELECT
            f.id                   AS lesson_id,
            f.final_score          AS score,
            f.v_score              AS vec_score,
            f.b_score              AS bm25_score,
            f.rrf_sparse           AS rrf_score,
            f.role,
            f.project_id,
            f.topic,
            f.lesson_text,
            f.importance,
            f.metadata,
            f.commit_sha,
            f.artifact_hash,
            f.verified_at,
            f.created_at,
            f.confidence::REAL,
            LEAST(1.0, GREATEST(0.0, f.v_score))::REAL AS match_confidence,
            f.retrieval_source
        FROM final f
        WHERE (p_min_score IS NULL
               OR LEAST(1.0, GREATEST(0.0, f.v_score))::REAL >= p_min_score)
        ORDER BY f.final_score DESC, f.id ASC
        LIMIT k
    )
    SELECT
        fr.lesson_id, fr.score, fr.vec_score, fr.bm25_score, fr.rrf_score,
        fr.role, fr.project_id, fr.topic, fr.lesson_text, fr.importance,
        fr.metadata, fr.commit_sha, fr.artifact_hash, fr.verified_at, fr.created_at,
        fr.confidence, fr.match_confidence, fr.retrieval_source
    FROM final_results fr
    ORDER BY fr.score DESC, fr.lesson_id ASC;

    IF NOT FOUND AND p_min_score IS NULL THEN
        SELECT COUNT(*)::INT INTO _ghost_count
        FROM pgmnemo.agent_lesson al
        WHERE al.is_active
          AND al.t_valid_to = 'infinity'::TIMESTAMPTZ
          AND al.verified_at IS NULL
          AND (recall_hybrid.role_filter IS NULL OR al.role = recall_hybrid.role_filter)
          AND (recall_hybrid.project_id_filter IS NULL
               OR al.project_id = recall_hybrid.project_id_filter);
        IF _ghost_count > 0 THEN
            RAISE NOTICE
                'pgmnemo: % matching lesson(s) are unverified (ingested without commit_sha/artifact_hash) '
                'and excluded by default. SET pgmnemo.include_unverified = ''on'' for this session, '
                'or pass provenance on ingest.',
                _ghost_count;
        END IF;
    END IF;
END;
$func$;

-- ─────────────────────────────────────────────────────────────────────────────
-- §3: COMMENT ON FUNCTION — GUC catalog (R-U4) — all 7 new GUCs documented
-- ─────────────────────────────────────────────────────────────────────────────
COMMENT ON FUNCTION pgmnemo.recall_hybrid(vector, TEXT, INT, TEXT, INT, DOUBLE PRECISION, DOUBLE PRECISION, INT, TEXT, text[], REAL) IS
    'v0.20.0 — «Граф, который достаёт». Two orthogonal graph expansion mechanisms '
    'added to pool expander architecture (R2_ARCH_VARIANTS §1,3; R2_DB §3): '
    ''
    'Variant A (BFS pool expansion): '
    '  GUC pgmnemo.graph_expand_weight REAL [0.0, 0.5] default 0.0 — master on/off. '
    '  GUC pgmnemo.graph_expand_depth INT [1, 2] default 1 — BFS depth (depth=2 conditional, R2_DB §3.1.2). '
    '  GUC pgmnemo.graph_expand_ann_k INT [10, 50] default 15 — ANN anchor oversampling count. '
    '  GUC pgmnemo.graph_expand_per_node INT [3, 50] default 10 — hub-cap per source node (MANDATORY). '
    '  Expansion: pgmnemo.mem_edge causal edges only (not temporal); cycle guard via visited[]; p95=4.0ms at depth=1. '
    ''
    'Variant C (Entity GIN expansion): '
    '  GUC pgmnemo.graph_entity_expand_weight REAL [0.0, 0.3] default 0.0 — master on/off. '
    '  GUC pgmnemo.graph_entity_min_overlap INT >= 1 default 1 — min entity keys matching. '
    '  GUC pgmnemo.graph_entity_max_expansion INT >= 1 default 50 — max GIN candidates per key. '
    '  GIN pattern: metadata @> jsonb_build_object(''entity_keys'', jsonb_build_array(key)). '
    '  LATERAL per-key (not CROSS JOIN) ensures GIN index pushdown; p95=3.53ms. '
    ''
    'retrieval_source TEXT column: ann (original ANN+BM25 pool), graph (BFS-expanded), entity (GIN-expanded). '
    'Output columns preserved: vec_score (cosine similarity), bm25_score, rrf_score, match_confidence. '
    'RRF fusion sparse-safe (Cormack 2009): an item absent from a ranking gets sentinel rank n+1, '
    'never a median rank — absent items must not collect a fusion boost. '
    'All expansion weights default 0.0 — output is byte-identical to 0.19.x when weights unset. '
    'IRON: no perf claims until PREREG_020_EXPAND (SHA 4eb3a2b8) ablation complete. '
    'v0.18.0 — VOLATILE (uses CREATE TEMP TABLE). Call mark_recalled() separately for recency tracking. '
    'v0.14.1 — HNSW planner regression fix (EXECUTE with literal LIMIT). '
    'v0.9.2 — confidence_boost_weight additive scoring. '
    'v0.8.2 — F2: NOTICE when 0 rows and ghost lessons exist.';

-- ─────────────────────────────────────────────────────────────────────────────
-- §2: stats() — add 7 GUC columns for graph expansion (return type change → DROP + CREATE)
-- ─────────────────────────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS pgmnemo.stats();

CREATE OR REPLACE FUNCTION pgmnemo.stats()
RETURNS TABLE (
    version                          TEXT,
    lesson_count                     BIGINT,
    embedded_count                   BIGINT,
    embedding_coverage_pct           DOUBLE PRECISION,
    tsv_coverage_pct                 DOUBLE PRECISION,
    mem_edge_count                   BIGINT,
    recency_weight                   DOUBLE PRECISION,
    ef_search                        INT,
    importance_weight                DOUBLE PRECISION,
    hybrid_enabled                   BOOLEAN,
    recall_hybrid_available          BOOLEAN,
    oldest_lesson_age_days           INT,
    orphan_count                     BIGINT,
    ghost_count                      BIGINT,
    confidence_mean                  REAL,
    confidence_p10                   REAL,
    confidence_p50                   REAL,
    confidence_p90                   REAL,
    confidence_below_threshold_count INT,
    -- v0.20.0 graph expansion GUC columns (R-U4: GUC catalog — GUC must appear in stats())
    graph_expand_weight              REAL,       -- GUC pgmnemo.graph_expand_weight [0.0,0.5] def 0.0
    graph_expand_depth               INT,        -- GUC pgmnemo.graph_expand_depth [1,2] def 1
    graph_expand_ann_k               INT,        -- GUC pgmnemo.graph_expand_ann_k [10,50] def 15
    graph_expand_per_node            INT,        -- GUC pgmnemo.graph_expand_per_node [3,50] def 10
    graph_entity_expand_weight       REAL,       -- GUC pgmnemo.graph_entity_expand_weight [0.0,0.3] def 0.0
    graph_entity_min_overlap         INT,        -- GUC pgmnemo.graph_entity_min_overlap >= 1 def 1
    graph_entity_max_expansion       INT         -- GUC pgmnemo.graph_entity_max_expansion >= 1 def 50
)
LANGUAGE sql
STABLE
PARALLEL SAFE
AS $func$
    SELECT
        pgmnemo.version()                                                          AS version,
        (SELECT COUNT(*)::BIGINT FROM pgmnemo.agent_lesson)                        AS lesson_count,
        (SELECT COUNT(*)::BIGINT
         FROM pgmnemo.agent_lesson WHERE embedding IS NOT NULL)                    AS embedded_count,
        (SELECT CASE WHEN COUNT(*) > 0
                     THEN ROUND(100.0 *
                          SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END)::NUMERIC
                          / COUNT(*), 2)::DOUBLE PRECISION
                     ELSE 0.0 END
         FROM pgmnemo.agent_lesson)                                                AS embedding_coverage_pct,
        (SELECT CASE WHEN COUNT(*) > 0
                     THEN ROUND(100.0 *
                          SUM(CASE WHEN lesson_tsv IS NOT NULL THEN 1 ELSE 0 END)::NUMERIC
                          / COUNT(*), 2)::DOUBLE PRECISION
                     ELSE 0.0 END
         FROM pgmnemo.agent_lesson)                                                AS tsv_coverage_pct,
        (SELECT COUNT(*)::BIGINT FROM pgmnemo.mem_edge)                            AS mem_edge_count,
        COALESCE(NULLIF(current_setting('pgmnemo.recency_weight',  TRUE), '')::DOUBLE PRECISION,
                 0.05)                                                             AS recency_weight,
        COALESCE(NULLIF(current_setting('pgmnemo.ef_search',       TRUE), '')::INT,
                 100)                                                              AS ef_search,
        COALESCE(NULLIF(current_setting('pgmnemo.importance_weight',TRUE), '')::DOUBLE PRECISION,
                 0.15)                                                             AS importance_weight,
        NOT COALESCE(current_setting('pgmnemo.disable_hybrid', TRUE)::BOOLEAN,
                     FALSE)                                                        AS hybrid_enabled,
        EXISTS (
            SELECT 1 FROM pg_proc p
            JOIN pg_namespace n ON n.oid = p.pronamespace
            WHERE n.nspname = 'pgmnemo' AND p.proname = 'recall_hybrid'
        )                                                                          AS recall_hybrid_available,
        (SELECT COALESCE(
                    EXTRACT(EPOCH FROM (NOW() - MIN(created_at))) / 86400.0, 0
                )::INT
         FROM pgmnemo.agent_lesson)                                                AS oldest_lesson_age_days,
        (SELECT COUNT(*)::BIGINT
         FROM pg_proc p
         JOIN pg_namespace n ON n.oid = p.pronamespace
         LEFT JOIN pg_depend d
             ON d.objid = p.oid AND d.deptype = 'e'
            AND d.refobjid = (SELECT oid FROM pg_extension WHERE extname = 'pgmnemo')
         WHERE n.nspname = 'pgmnemo'
           AND p.proname NOT LIKE '\_%' ESCAPE '\'
           AND d.objid IS NULL)                                                    AS orphan_count,
        (SELECT COUNT(*)::BIGINT
         FROM pgmnemo.agent_lesson
         WHERE verified_at IS NULL
           AND t_valid_to = 'infinity'::TIMESTAMPTZ)                               AS ghost_count,
        (SELECT COALESCE(AVG(confidence), 0.5)::REAL
         FROM pgmnemo.agent_lesson
         WHERE t_valid_to = 'infinity'::TIMESTAMPTZ)                               AS confidence_mean,
        (SELECT COALESCE(
                    PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY confidence), 0.5
                )::REAL
         FROM pgmnemo.agent_lesson
         WHERE t_valid_to = 'infinity'::TIMESTAMPTZ)                               AS confidence_p10,
        (SELECT COALESCE(
                    PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY confidence), 0.5
                )::REAL
         FROM pgmnemo.agent_lesson
         WHERE t_valid_to = 'infinity'::TIMESTAMPTZ)                               AS confidence_p50,
        (SELECT COALESCE(
                    PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY confidence), 0.5
                )::REAL
         FROM pgmnemo.agent_lesson
         WHERE t_valid_to = 'infinity'::TIMESTAMPTZ)                               AS confidence_p90,
        (SELECT COUNT(*)::INT
         FROM pgmnemo.agent_lesson
         WHERE t_valid_to = 'infinity'::TIMESTAMPTZ
           AND confidence < 0.3)                                                   AS confidence_below_threshold_count,
        -- v0.20.0: graph expansion GUC values (R-U4: must appear in stats())
        GREATEST(0.0, LEAST(0.5, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_weight', TRUE), '')::REAL,
            0.0)))                                                                 AS graph_expand_weight,
        GREATEST(1, LEAST(2, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_depth', TRUE), '')::INT,
            1)))                                                                   AS graph_expand_depth,
        GREATEST(10, LEAST(50, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_ann_k', TRUE), '')::INT,
            15)))                                                                  AS graph_expand_ann_k,
        GREATEST(3, LEAST(50, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_expand_per_node', TRUE), '')::INT,
            10)))                                                                  AS graph_expand_per_node,
        GREATEST(0.0, LEAST(0.3, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_entity_expand_weight', TRUE), '')::REAL,
            0.0)))                                                                 AS graph_entity_expand_weight,
        GREATEST(1, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_entity_min_overlap', TRUE), '')::INT,
            1))                                                                    AS graph_entity_min_overlap,
        GREATEST(1, COALESCE(
            NULLIF(current_setting('pgmnemo.graph_entity_max_expansion', TRUE), '')::INT,
            50))                                                                   AS graph_entity_max_expansion;
$func$;

COMMENT ON FUNCTION pgmnemo.stats() IS
    'v0.20.0 — adds 7 graph expansion GUC columns (graph_expand_weight, graph_expand_depth, '
    'graph_expand_ann_k, graph_expand_per_node, graph_entity_expand_weight, '
    'graph_entity_min_overlap, graph_entity_max_expansion). '
    'All graph expansion GUCs default 0.0/disabled. '
    'v0.7.0 diagnostic health-check (26 columns total, was 19). '
    'Single-row; <100ms on N=10k corpus.';
