From 97e13595ad516ca7ab918142eba246f4110e07d3 Mon Sep 17 00:00:00 2001
From: Tatsuo Ishii <ishii@postgresql.org>
Date: Sun, 30 Aug 2026 07:20:31 +0900
Subject: [PATCH v51 5/9] Row pattern recognition patch (executor and
 commands).

---
 .../pg_stat_statements/expected/select.out    |   52 +
 contrib/pg_stat_statements/sql/select.sql     |   27 +
 src/backend/commands/explain.c                |  458 ++++
 src/backend/executor/Makefile                 |    1 +
 src/backend/executor/execExpr.c               |   80 +
 src/backend/executor/execExprInterp.c         |  257 +++
 src/backend/executor/execRPR.c                | 2044 +++++++++++++++++
 src/backend/executor/meson.build              |    1 +
 src/backend/executor/nodeWindowAgg.c          | 1820 +++++++++++++--
 src/backend/jit/llvm/llvmjit_expr.c           |   78 +-
 src/backend/jit/llvm/llvmjit_types.c          |    2 +
 src/include/executor/execExpr.h               |   14 +
 src/include/executor/execRPR.h                |   39 +
 src/include/executor/nodeWindowAgg.h          |    3 +
 src/include/nodes/execnodes.h                 |  214 ++
 15 files changed, 4833 insertions(+), 257 deletions(-)
 create mode 100644 src/backend/executor/execRPR.c
 create mode 100644 src/include/executor/execRPR.h

diff --git a/contrib/pg_stat_statements/expected/select.out b/contrib/pg_stat_statements/expected/select.out
index a069119c790..51d0069bd5c 100644
--- a/contrib/pg_stat_statements/expected/select.out
+++ b/contrib/pg_stat_statements/expected/select.out
@@ -386,6 +386,58 @@ SELECT calls, query FROM pg_stat_statements ORDER BY query COLLATE "C";
 
 DROP TABLE pgss_a, pgss_b CASCADE;
 --
+-- queries with a row pattern recognition window
+--
+CREATE TABLE pgss_rpr (id integer);
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t 
+---
+ t
+(1 row)
+
+-- DEFINE lists that differ only in which variable name gets which condition
+-- must not collide on one query id
+SELECT count(*) OVER w FROM pgss_rpr
+WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  PATTERN (A B)
+  DEFINE A AS id > 50, B AS id < 50);
+ count 
+-------
+(0 rows)
+
+SELECT count(*) OVER w FROM pgss_rpr
+WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  PATTERN (A B)
+  DEFINE B AS id > 50, A AS id < 50);
+ count 
+-------
+(0 rows)
+
+SELECT calls, query FROM pg_stat_statements ORDER BY query COLLATE "C";
+ calls |                                 query                                  
+-------+------------------------------------------------------------------------
+     0 | SELECT calls, query FROM pg_stat_statements ORDER BY query COLLATE "C"
+     1 | SELECT count(*) OVER w FROM pgss_rpr                                  +
+       | WINDOW w AS (                                                         +
+       |   ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING                    +
+       |   AFTER MATCH SKIP PAST LAST ROW                                      +
+       |   PATTERN (A B)                                                       +
+       |   DEFINE A AS id > $1, B AS id < $2)
+     1 | SELECT count(*) OVER w FROM pgss_rpr                                  +
+       | WINDOW w AS (                                                         +
+       |   ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING                    +
+       |   AFTER MATCH SKIP PAST LAST ROW                                      +
+       |   PATTERN (A B)                                                       +
+       |   DEFINE B AS id > $1, A AS id < $2)
+     1 | SELECT pg_stat_statements_reset() IS NOT NULL AS t
+(4 rows)
+
+DROP TABLE pgss_rpr;
+--
 -- access to pg_stat_statements_info view
 --
 SELECT pg_stat_statements_reset() IS NOT NULL AS t;
diff --git a/contrib/pg_stat_statements/sql/select.sql b/contrib/pg_stat_statements/sql/select.sql
index a10d618c034..d981ef4fbeb 100644
--- a/contrib/pg_stat_statements/sql/select.sql
+++ b/contrib/pg_stat_statements/sql/select.sql
@@ -126,6 +126,33 @@ SELECT calls, query FROM pg_stat_statements ORDER BY query COLLATE "C";
 
 DROP TABLE pgss_a, pgss_b CASCADE;
 
+--
+-- queries with a row pattern recognition window
+--
+CREATE TABLE pgss_rpr (id integer);
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+-- DEFINE lists that differ only in which variable name gets which condition
+-- must not collide on one query id
+SELECT count(*) OVER w FROM pgss_rpr
+WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  PATTERN (A B)
+  DEFINE A AS id > 50, B AS id < 50);
+
+SELECT count(*) OVER w FROM pgss_rpr
+WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  PATTERN (A B)
+  DEFINE B AS id > 50, A AS id < 50);
+
+SELECT calls, query FROM pg_stat_statements ORDER BY query COLLATE "C";
+
+DROP TABLE pgss_rpr;
+
 --
 -- access to pg_stat_statements_info view
 --
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index e35e0a649b3..53b4e818019 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -30,6 +30,7 @@
 #include "nodes/extensible.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
+#include "optimizer/rpr.h"
 #include "parser/analyze.h"
 #include "parser/parsetree.h"
 #include "rewrite/rewriteHandler.h"
@@ -119,6 +120,14 @@ static void show_window_def(WindowAggState *planstate,
 static void show_window_keys(StringInfo buf, PlanState *planstate,
 							 int nkeys, AttrNumber *keycols,
 							 List *ancestors, ExplainState *es);
+static void append_rpr_quantifier(StringInfo buf, RPRPatternElement *elem);
+static char *deparse_rpr_pattern(RPRPattern *pattern);
+static void deparse_rpr_seq(RPRPattern *pattern, int start, int limit,
+							StringInfo buf);
+static int	deparse_rpr_node(RPRPattern *pattern, int idx, int limit,
+							 StringInfo buf);
+static int	rpr_match_end(RPRPattern *pattern, int beginIdx);
+static int	rpr_alt_scope_end(RPRPattern *pattern, int idx);
 static void show_storage_info(char *maxStorageType, int64 maxSpaceUsed,
 							  ExplainState *es);
 static void show_tablesample(TableSampleClause *tsc, PlanState *planstate,
@@ -129,6 +138,7 @@ static void show_incremental_sort_info(IncrementalSortState *incrsortstate,
 static void show_hash_info(HashState *hashstate, ExplainState *es);
 static void show_material_info(MaterialState *mstate, ExplainState *es);
 static void show_windowagg_info(WindowAggState *winstate, ExplainState *es);
+static void show_rpr_nfa_stats(WindowAggState *winstate, ExplainState *es);
 static void show_ctescan_info(CteScanState *ctescanstate, ExplainState *es);
 static void show_table_func_scan_info(TableFuncScanState *tscanstate,
 									  ExplainState *es);
@@ -2898,6 +2908,243 @@ show_sortorder_options(StringInfo buf, Node *sortexpr,
 	}
 }
 
+/*
+ * Append quantifier suffix for a pattern element.
+ */
+static void
+append_rpr_quantifier(StringInfo buf, RPRPatternElement *elem)
+{
+	/* Append quantifier if not {1,1} */
+	if (elem->min == 0 && elem->max == RPR_QUANTITY_INF)
+		appendStringInfoChar(buf, '*');
+	else if (elem->min == 1 && elem->max == RPR_QUANTITY_INF)
+		appendStringInfoChar(buf, '+');
+	else if (elem->min == 0 && elem->max == 1)
+		appendStringInfoChar(buf, '?');
+	else if (elem->max == RPR_QUANTITY_INF)
+		appendStringInfo(buf, "{%d,}", elem->min);
+	else if (elem->min == elem->max && elem->min != 1)
+		appendStringInfo(buf, "{%d}", elem->min);
+	else if (elem->min != 1 || elem->max != 1)
+		appendStringInfo(buf, "{%d,%d}", elem->min, elem->max);
+
+	/* A fixed count is normalized to greedy, so '?' cannot be read as {0,1} */
+	if (RPRElemIsReluctant(elem))
+	{
+		Assert(elem->min != elem->max);
+		appendStringInfoChar(buf, '?');
+	}
+
+	/*
+	 * Append absorption markers: # for the comparison point, ~ for the
+	 * absorbable region.  Neither character can occur in a bare pattern
+	 * variable name, and a name that does contain one is always double-quoted
+	 * by quote_identifier(), so a marker is never read as part of the name.
+	 */
+	if (RPRElemIsAbsorbable(elem))
+	{
+		Assert(elem->max == RPR_QUANTITY_INF);
+		appendStringInfoChar(buf, '#');
+	}
+	else if (RPRElemIsAbsorbableBranch(elem))
+		appendStringInfoChar(buf, '~');
+}
+
+/*
+ * Deparse a compiled RPRPattern (bytecode) back to a pattern string.
+ *
+ * The flat RPRPatternElement[] array is walked by recursive descent.  Each
+ * construct is deparsed within an inherited [start, limit) window: the parent
+ * passes the boundary down, so each construct's extent is fixed by its caller.
+ * Three signals drive the walk:
+ *
+ *   - a GROUP body's end comes from depth, via rpr_match_end(); an ALT's
+ *     scope end comes from its SEP chain, via rpr_alt_scope_end().
+ *   - branch boundaries (where a "|" goes) come from the ALT's SEP chain: each
+ *     branch is terminated by a SEP whose jump links to the next branch's SEP
+ *     (-1 on the last), so a branch runs from its content start up to its SEP.
+ *   - parentheses come from structure (a BEGIN group, an ALT) plus a one-step
+ *     lookahead for a group that wraps a lone ALT.
+ *
+ * depth and the SEP chain are stable across the next/jump values the compiler
+ * assigns to branch tails and nested alternations, which is what makes them
+ * suitable to anchor scope and branch boundaries.
+ *
+ * EXPLAIN parenthesizes every ALT on its own, so a top-level "A | B" deparses
+ * as "(a | b)".  This self-consistent EXPLAIN form is the correctness oracle
+ * here; pg_get_viewdef differs, as its parens come only from an enclosing
+ * GROUP.  Absorption markers (# ~) are orthogonal and handled by
+ * append_rpr_quantifier().
+ *
+ * Two compiler invariants hold throughout: {1,1} groups are unwrapped before
+ * bytecode generation (so every BEGIN/END group carries a non-trivial
+ * quantifier, and a lone ALT inside a group always spans to the group's END),
+ * and a group's quantifier is read from its END element (the BEGIN copy is
+ * ignored).
+ */
+static char *
+deparse_rpr_pattern(RPRPattern *pattern)
+{
+	StringInfoData buf;
+
+	Assert(pattern != NULL && pattern->numElements >= 2);
+
+	initStringInfo(&buf);
+	deparse_rpr_seq(pattern, 0, pattern->numElements, &buf);
+	return buf.data;
+}
+
+/*
+ * Deparse a run of sibling elements in [start, limit), separated by spaces.
+ *
+ * Stops at limit or at the FIN terminator (top-level call passes limit =
+ * numElements, where the last element is FIN).
+ */
+static void
+deparse_rpr_seq(RPRPattern *pattern, int start, int limit, StringInfo buf)
+{
+	int			i = start;
+	bool		first = true;
+
+	while (i < limit && !RPRElemIsFin(&pattern->elements[i]))
+	{
+		if (!first)
+			appendStringInfoChar(buf, ' ');
+		first = false;
+		i = deparse_rpr_node(pattern, i, limit, buf);
+	}
+}
+
+/*
+ * Deparse the single construct starting at index idx, bounded by the
+ * inherited limit.  Returns the index just past the construct.
+ *
+ * A VAR is its name plus quantifier.  A BEGIN opens a group spanning to its
+ * matching END (rpr_match_end); when the group's sole child is an ALT that
+ * runs to the END, the ALT supplies the parentheses and the group only adds
+ * the quantifier, otherwise the group body is wrapped in its own "( )".  An
+ * ALT runs to its SEP-chain scope end (capped by the inherited limit) and
+ * emits "( b1 | b2 | ... )", each branch deparsed within the boundary handed
+ * down by its SEP chain.
+ */
+static int
+deparse_rpr_node(RPRPattern *pattern, int idx, int limit, StringInfo buf)
+{
+	RPRPatternElement *elem = &pattern->elements[idx];
+
+	if (RPRElemIsVar(elem))
+	{
+		Assert(elem->varId < pattern->numVars);
+		appendStringInfoString(buf,
+							   quote_pattern_variable(pattern->varNames[elem->varId]));
+		append_rpr_quantifier(buf, elem);
+		return idx + 1;
+	}
+
+	if (RPRElemIsBegin(elem))
+	{
+		int			end = rpr_match_end(pattern, idx);
+		bool		loneAlt;
+
+		loneAlt = (idx + 1 < end &&
+				   RPRElemIsAlt(&pattern->elements[idx + 1]) &&
+				   rpr_alt_scope_end(pattern, idx + 1) == end);
+
+		if (loneAlt)
+		{
+			/* The ALT child already parenthesizes the whole group body. */
+			(void) deparse_rpr_node(pattern, idx + 1, end, buf);
+		}
+		else
+		{
+			appendStringInfoChar(buf, '(');
+			deparse_rpr_seq(pattern, idx + 1, end, buf);
+			appendStringInfoChar(buf, ')');
+		}
+		append_rpr_quantifier(buf, &pattern->elements[end]);
+		return end + 1;
+	}
+
+	if (RPRElemIsAlt(elem))
+	{
+		int			altEnd = rpr_alt_scope_end(pattern, idx);
+		int			branchStart;
+		int			sepIdx;
+		bool		first = true;
+
+		/* an alternation's SEP-chain scope end never exceeds the limit */
+		Assert(altEnd <= limit);
+
+		appendStringInfoChar(buf, '(');
+		branchStart = elem->next;
+		sepIdx = elem->jump;
+		while (sepIdx != RPR_ELEMIDX_INVALID)
+		{
+			RPRPatternElement *sepElem = &pattern->elements[sepIdx];
+
+			/* The branch runs up to its terminating SEP */
+			Assert(RPRElemIsSep(sepElem));
+			if (!first)
+				appendStringInfoString(buf, " | ");
+			first = false;
+			deparse_rpr_seq(pattern, branchStart, sepIdx, buf);
+
+			/* The last branch's SEP has no link, ending the walk */
+			branchStart = sepElem->next;
+			sepIdx = sepElem->jump;
+		}
+		appendStringInfoChar(buf, ')');
+		return altEnd;
+	}
+
+	pg_unreachable();			/* only VAR, BEGIN and ALT start a node */
+}
+
+/*
+ * Find the END that closes the group opened by the BEGIN at beginIdx: the
+ * first END at the same depth scanning forward.
+ */
+static int
+rpr_match_end(RPRPattern *pattern, int beginIdx)
+{
+	RPRDepth	d = pattern->elements[beginIdx].depth;
+	int			i;
+
+	for (i = beginIdx + 1; i < pattern->numElements; i++)
+	{
+		RPRPatternElement *e = &pattern->elements[i];
+
+		if (RPRElemIsEnd(e) && e->depth == d)
+			return i;
+	}
+	pg_unreachable();			/* a BEGIN always has a matching END */
+}
+
+/*
+ * Scope end of the alternation marker at idx: the element just past its last
+ * branch.  Walk the SEP chain from ALT.jump to the last SEP (jump invalid);
+ * the element right after that SEP is the post-ALT element.  Only ever called
+ * on an ALT.
+ *
+ * Use "last SEP index + 1", not the SEP's next: for a nested ALT the last
+ * SEP's next is redirected past the *enclosing* alternation by the branch-exit
+ * fixup in fillRPRPatternAlt, whereas the last SEP is emitted as the final
+ * element of the alternation, so the index after it is always this ALT's own
+ * post-ALT element.
+ */
+static int
+rpr_alt_scope_end(RPRPattern *pattern, int idx)
+{
+	int			sepIdx;
+
+	Assert(RPRElemIsAlt(&pattern->elements[idx]));
+
+	sepIdx = pattern->elements[idx].jump;
+	while (pattern->elements[sepIdx].jump != RPR_ELEMIDX_INVALID)
+		sepIdx = pattern->elements[sepIdx].jump;
+	return sepIdx + 1;
+}
+
 /*
  * Show the window definition for a WindowAgg node.
  */
@@ -2956,6 +3203,62 @@ show_window_def(WindowAggState *planstate, List *ancestors, ExplainState *es)
 	appendStringInfoChar(&wbuf, ')');
 	ExplainPropertyText("Window", wbuf.data, es);
 	pfree(wbuf.data);
+
+	/* Show Row Pattern Recognition pattern if present */
+	if (wagg->rpPattern != NULL)
+	{
+		char	   *patternStr = deparse_rpr_pattern(wagg->rpPattern);
+
+		ExplainPropertyText("Pattern", patternStr, es);
+
+		pfree(patternStr);
+
+		/*
+		 * Navigation offsets for tuplestore trim are resolved at executor
+		 * init, which runs even for plain EXPLAIN, so read the resolved value
+		 * and its kind from the planstate.
+		 */
+		if (planstate->hasMaxNav)
+		{
+			switch (planstate->navMaxOffsetKind)
+			{
+				case RPR_NAV_OFFSET_NEEDS_EVAL:
+					ExplainPropertyText("Nav Mark Lookback", "runtime", es);
+					break;
+				case RPR_NAV_OFFSET_RETAIN_ALL:
+					ExplainPropertyText("Nav Mark Lookback", "retain all", es);
+					break;
+				case RPR_NAV_OFFSET_FIXED:
+					ExplainPropertyInteger("Nav Mark Lookback", NULL,
+										   planstate->navMaxOffset, es);
+					break;
+			}
+		}
+
+		if (planstate->hasFirstNav)
+		{
+			switch (planstate->navFirstOffsetKind)
+			{
+				case RPR_NAV_OFFSET_NEEDS_EVAL:
+					ExplainPropertyText("Nav Mark Lookahead", "runtime", es);
+					break;
+				case RPR_NAV_OFFSET_FIXED:
+					if (planstate->navFirstOffset == PG_INT64_MAX)
+						ExplainPropertyText("Nav Mark Lookahead", "infinite", es);
+					else
+						ExplainPropertyInteger("Nav Mark Lookahead", NULL,
+											   planstate->navFirstOffset, es);
+					break;
+				default:
+					/* a forward reach is unbounded, never retain all */
+					Assert(planstate->navFirstOffsetKind ==
+						   RPR_NAV_OFFSET_NEEDS_EVAL ||
+						   planstate->navFirstOffsetKind ==
+						   RPR_NAV_OFFSET_FIXED);
+					break;
+			}
+		}
+	}
 }
 
 /*
@@ -3513,6 +3816,7 @@ show_windowagg_info(WindowAggState *winstate, ExplainState *es)
 {
 	char	   *maxStorageType;
 	int64		maxSpaceUsed;
+	WindowAgg  *wagg = (WindowAgg *) winstate->ss.ps.plan;
 
 	Tuplestorestate *tupstore = winstate->buffer;
 
@@ -3525,6 +3829,160 @@ show_windowagg_info(WindowAggState *winstate, ExplainState *es)
 
 	tuplestore_get_stats(tupstore, &maxStorageType, &maxSpaceUsed);
 	show_storage_info(maxStorageType, maxSpaceUsed, es);
+
+	/* Show NFA statistics for Row Pattern Recognition */
+	if (wagg->rpPattern != NULL)
+		show_rpr_nfa_stats(winstate, es);
+}
+
+/*
+ * Show NFA statistics for Row Pattern Recognition on WindowAgg node.
+ */
+static void
+show_rpr_nfa_stats(WindowAggState *winstate, ExplainState *es)
+{
+	if (es->format != EXPLAIN_FORMAT_TEXT)
+	{
+		/* State and context counters */
+		ExplainPropertyInteger("NFA States Peak", NULL, winstate->nfaStatesMax, es);
+		ExplainPropertyInteger("NFA States Total", NULL, winstate->nfaStatesTotalCreated, es);
+		ExplainPropertyInteger("NFA States Merged", NULL, winstate->nfaStatesMerged, es);
+		ExplainPropertyInteger("NFA Contexts Peak", NULL, winstate->nfaContextsMax, es);
+		ExplainPropertyInteger("NFA Contexts Total", NULL, winstate->nfaContextsTotalCreated, es);
+		ExplainPropertyInteger("NFA Contexts Absorbed", NULL, winstate->nfaContextsAbsorbed, es);
+		ExplainPropertyInteger("NFA Contexts Skipped", NULL, winstate->nfaContextsSkipped, es);
+		ExplainPropertyInteger("NFA Contexts Pruned", NULL, winstate->nfaContextsPruned, es);
+
+		/* Match/mismatch counts and length statistics */
+		ExplainPropertyInteger("NFA Matched", NULL, winstate->nfaMatchesSucceeded, es);
+		ExplainPropertyInteger("NFA Mismatched", NULL, winstate->nfaMatchesFailed, es);
+		if (winstate->nfaMatchesSucceeded > 0)
+		{
+			ExplainPropertyInteger("NFA Match Length Min", NULL, winstate->nfaMatchLen.min, es);
+			ExplainPropertyInteger("NFA Match Length Max", NULL, winstate->nfaMatchLen.max, es);
+			ExplainPropertyFloat("NFA Match Length Avg", NULL,
+								 (double) winstate->nfaMatchLen.total / winstate->nfaMatchesSucceeded, 1,
+								 es);
+		}
+		if (winstate->nfaMatchesFailed > 0)
+		{
+			ExplainPropertyInteger("NFA Mismatch Length Min", NULL, winstate->nfaFailLen.min, es);
+			ExplainPropertyInteger("NFA Mismatch Length Max", NULL, winstate->nfaFailLen.max, es);
+			ExplainPropertyFloat("NFA Mismatch Length Avg", NULL,
+								 (double) winstate->nfaFailLen.total / winstate->nfaMatchesFailed, 1,
+								 es);
+		}
+
+		/* Absorbed/skipped context length statistics */
+		if (winstate->nfaContextsAbsorbed > 0)
+		{
+			ExplainPropertyInteger("NFA Absorbed Length Min", NULL, winstate->nfaAbsorbedLen.min, es);
+			ExplainPropertyInteger("NFA Absorbed Length Max", NULL, winstate->nfaAbsorbedLen.max, es);
+			ExplainPropertyFloat("NFA Absorbed Length Avg", NULL,
+								 (double) winstate->nfaAbsorbedLen.total / winstate->nfaContextsAbsorbed, 1,
+								 es);
+		}
+		if (winstate->nfaContextsSkipped > 0)
+		{
+			ExplainPropertyInteger("NFA Skipped Length Min", NULL, winstate->nfaSkippedLen.min, es);
+			ExplainPropertyInteger("NFA Skipped Length Max", NULL, winstate->nfaSkippedLen.max, es);
+			ExplainPropertyFloat("NFA Skipped Length Avg", NULL,
+								 (double) winstate->nfaSkippedLen.total / winstate->nfaContextsSkipped, 1,
+								 es);
+		}
+	}
+	else
+	{
+		/* State and context counters */
+		ExplainIndentText(es);
+		appendStringInfo(es->str,
+						 "NFA States: " INT64_FORMAT " peak, " INT64_FORMAT " total, " INT64_FORMAT " merged\n",
+						 winstate->nfaStatesMax,
+						 winstate->nfaStatesTotalCreated,
+						 winstate->nfaStatesMerged);
+		ExplainIndentText(es);
+		appendStringInfo(es->str,
+						 "NFA Contexts: " INT64_FORMAT " peak, " INT64_FORMAT " total, " INT64_FORMAT " pruned\n",
+						 winstate->nfaContextsMax,
+						 winstate->nfaContextsTotalCreated,
+						 winstate->nfaContextsPruned);
+
+		/* Match/mismatch counts with length min/max/avg */
+		ExplainIndentText(es);
+		appendStringInfoString(es->str, "NFA: ");
+		if (winstate->nfaMatchesSucceeded > 0)
+		{
+			double		avgLen = (double) winstate->nfaMatchLen.total / winstate->nfaMatchesSucceeded;
+
+			appendStringInfo(es->str,
+							 INT64_FORMAT " matched (len " INT64_FORMAT "/" INT64_FORMAT "/%.1f)",
+							 winstate->nfaMatchesSucceeded,
+							 winstate->nfaMatchLen.min,
+							 winstate->nfaMatchLen.max,
+							 avgLen);
+		}
+		else
+		{
+			appendStringInfoString(es->str, "0 matched");
+		}
+		if (winstate->nfaMatchesFailed > 0)
+		{
+			double		avgFail = (double) winstate->nfaFailLen.total / winstate->nfaMatchesFailed;
+
+			appendStringInfo(es->str,
+							 ", " INT64_FORMAT " mismatched (len " INT64_FORMAT "/" INT64_FORMAT "/%.1f)",
+							 winstate->nfaMatchesFailed,
+							 winstate->nfaFailLen.min,
+							 winstate->nfaFailLen.max,
+							 avgFail);
+		}
+		else
+		{
+			appendStringInfoString(es->str, ", 0 mismatched");
+		}
+		appendStringInfoChar(es->str, '\n');
+
+		/* Absorbed/skipped context length statistics */
+		if (winstate->nfaContextsAbsorbed > 0 || winstate->nfaContextsSkipped > 0)
+		{
+			ExplainIndentText(es);
+			appendStringInfoString(es->str, "NFA: ");
+
+			if (winstate->nfaContextsAbsorbed > 0)
+			{
+				double		avgAbsorbed = (double) winstate->nfaAbsorbedLen.total / winstate->nfaContextsAbsorbed;
+
+				appendStringInfo(es->str,
+								 INT64_FORMAT " absorbed (len " INT64_FORMAT "/" INT64_FORMAT "/%.1f)",
+								 winstate->nfaContextsAbsorbed,
+								 winstate->nfaAbsorbedLen.min,
+								 winstate->nfaAbsorbedLen.max,
+								 avgAbsorbed);
+			}
+			else
+			{
+				appendStringInfoString(es->str, "0 absorbed");
+			}
+
+			if (winstate->nfaContextsSkipped > 0)
+			{
+				double		avgSkipped = (double) winstate->nfaSkippedLen.total / winstate->nfaContextsSkipped;
+
+				appendStringInfo(es->str,
+								 ", " INT64_FORMAT " skipped (len " INT64_FORMAT "/" INT64_FORMAT "/%.1f)",
+								 winstate->nfaContextsSkipped,
+								 winstate->nfaSkippedLen.min,
+								 winstate->nfaSkippedLen.max,
+								 avgSkipped);
+			}
+			else
+			{
+				appendStringInfoString(es->str, ", 0 skipped");
+			}
+
+			appendStringInfoChar(es->str, '\n');
+		}
+	}
 }
 
 /*
diff --git a/src/backend/executor/Makefile b/src/backend/executor/Makefile
index 11118d0ce02..2b257427795 100644
--- a/src/backend/executor/Makefile
+++ b/src/backend/executor/Makefile
@@ -25,6 +25,7 @@ OBJS = \
 	execParallel.o \
 	execPartition.o \
 	execProcnode.o \
+	execRPR.o \
 	execReplication.o \
 	execSRF.o \
 	execScan.o \
diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 82e846a1f4f..fa58de60c91 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -1168,6 +1168,86 @@ ExecInitExprRec(Expr *node, ExprState *state,
 				break;
 			}
 
+		case T_RPRNavExpr:
+			{
+				/*
+				 * RPR navigation functions (PREV/NEXT/FIRST/LAST) are
+				 * compiled into EEOP_RPR_NAV_SET / EEOP_RPR_NAV_RESTORE
+				 * opcodes instead of a normal function call.  The SET opcode
+				 * swaps ecxt_outertuple to the target row, the argument
+				 * expression is compiled normally (reads from the swapped
+				 * slot), and the RESTORE opcode restores the original slot.
+				 */
+				RPRNavState *rprnavstate;
+				RPRNavOffsets *entry;
+				RPRNavExpr *nav = (RPRNavExpr *) node;
+				WindowAggState *winstate;
+				int			skip_arg_step;
+
+				Assert(state->parent && IsA(state->parent, WindowAggState));
+				winstate = (WindowAggState *) state->parent;
+
+				/*
+				 * The offsets live in executor state, not on the RPRNavExpr,
+				 * because the plan tree is read-only.  navno indexes the list
+				 * build_define_offsets() filled at startup; the values in it
+				 * are settled per scan by resolve_nav_offsets().
+				 */
+				if (nav->navno < 0 ||
+					nav->navno >= list_length(winstate->rprNavOffsets))
+					elog(ERROR, "RPRNavExpr navno %d out of range for %d offsets entries",
+						 nav->navno, list_length(winstate->rprNavOffsets));
+
+				entry = list_nth(winstate->rprNavOffsets, nav->navno);
+				if (entry->nav != nav)
+					elog(ERROR, "offsets entry %d belongs to a different RPRNavExpr",
+						 nav->navno);
+				rprnavstate = entry->rprnavstate;
+
+				/* Emit SET opcode: swap slot to target row */
+				scratch.opcode = EEOP_RPR_NAV_SET;
+				scratch.d.rpr_nav.rprnavstate = rprnavstate;
+
+				ExprEvalPushStep(state, &scratch);
+
+				/*
+				 * If the target row does not exist, skip evaluation of the
+				 * argument expression and go straight to RESTORE.  The
+				 * EEOP_RPR_NAV_SET step writes a definitive resnull (false
+				 * when the target row exists), so the jump condition is
+				 * always up to date.
+				 */
+				skip_arg_step = state->steps_len;
+				scratch.opcode = EEOP_JUMP_IF_NULL;
+				scratch.resvalue = resv;
+				scratch.resnull = resnull;
+				scratch.d.jump.jumpdone = -1;	/* set below */
+				ExprEvalPushStep(state, &scratch);
+
+				/* Compile the argument expression normally */
+				ExecInitExprRec(nav->arg, state, resv, resnull);
+
+				/* out-of-range jump lands on the RESTORE step */
+				state->steps[skip_arg_step].d.jump.jumpdone = state->steps_len;
+
+				/* Emit RESTORE opcode: restore original slot */
+				scratch.opcode = EEOP_RPR_NAV_RESTORE;
+				scratch.resvalue = resv;
+				scratch.resnull = resnull;
+				scratch.d.rpr_nav.rprnavstate = rprnavstate;
+
+				/*
+				 * The state is shared with the offsets entry, but resulttype
+				 * belongs to the plan node, so every compilation of this
+				 * navigation writes the same pair.
+				 */
+				get_typlenbyval(nav->resulttype,
+								&rprnavstate->resulttyplen,
+								&rprnavstate->resulttypbyval);
+				ExprEvalPushStep(state, &scratch);
+				break;
+			}
+
 		case T_MergeSupportFunc:
 			{
 				/* must be in a MERGE, else something messed up */
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 397219f7a3a..d19e20a2eaa 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -60,8 +60,10 @@
 #include "access/tupconvert.h"
 #include "catalog/pg_type.h"
 #include "commands/sequence.h"
+#include "common/int.h"
 #include "executor/execExpr.h"
 #include "executor/nodeSubplan.h"
+#include "executor/nodeWindowAgg.h"
 #include "funcapi.h"
 #include "miscadmin.h"
 #include "nodes/miscnodes.h"
@@ -586,6 +588,8 @@ ExecInterpExpr(ExprState *state, ExprContext *econtext, bool *isnull)
 		&&CASE_EEOP_WINDOW_FUNC,
 		&&CASE_EEOP_MERGE_SUPPORT_FUNC,
 		&&CASE_EEOP_SUBPLAN,
+		&&CASE_EEOP_RPR_NAV_SET,
+		&&CASE_EEOP_RPR_NAV_RESTORE,
 		&&CASE_EEOP_AGG_STRICT_DESERIALIZE,
 		&&CASE_EEOP_AGG_DESERIALIZE,
 		&&CASE_EEOP_AGG_STRICT_INPUT_CHECK_ARGS,
@@ -2013,6 +2017,24 @@ ExecInterpExpr(ExprState *state, ExprContext *econtext, bool *isnull)
 			EEO_NEXT();
 		}
 
+		/* RPR navigation: swap slot to target row */
+		EEO_CASE(EEOP_RPR_NAV_SET)
+		{
+			ExecEvalRPRNavSet(state, op, econtext);
+			outerslot = econtext->ecxt_outertuple;
+
+			EEO_NEXT();
+		}
+
+		/* RPR navigation: restore slot to original row */
+		EEO_CASE(EEOP_RPR_NAV_RESTORE)
+		{
+			ExecEvalRPRNavRestore(state, op, econtext);
+			outerslot = econtext->ecxt_outertuple;
+
+			EEO_NEXT();
+		}
+
 		/* evaluate a strict aggregate deserialization function */
 		EEO_CASE(EEOP_AGG_STRICT_DESERIALIZE)
 		{
@@ -5988,3 +6010,238 @@ ExecAggPlainTransByRef(AggState *aggstate, AggStatePerTrans pertrans,
 
 	MemoryContextSwitchTo(oldContext);
 }
+
+/*
+ * Evaluate RPR navigation (PREV/NEXT/FIRST/LAST): swap slot to target row.
+ *
+ * Saves the current outertuple into winstate for later restore, computes
+ * the target row position, fetches the corresponding slot from the
+ * tuplestore, and replaces econtext->ecxt_outertuple with it.
+ *
+ * This is called both from the interpreter inline handler and from
+ * JIT-compiled expressions via build_EvalXFunc.
+ */
+void
+ExecEvalRPRNavSet(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
+{
+	WindowAggState *winstate;
+	int64		offset;
+	int64		compound_offset;
+	int64		target_pos;
+	TupleTableSlot *target_slot;
+	RPRNavState *rprnavstate = op->d.rpr_nav.rprnavstate;
+
+	winstate = rprnavstate->winstate;
+
+	/* Save current slot for later restore */
+	winstate->nav_saved_outertuple = econtext->ecxt_outertuple;
+
+	/*
+	 * resolve_nav_offsets() settled both offsets for this scan: it writes
+	 * them as non-null, and where either is negative it raises the error and
+	 * never writes them at all.  Assert the invariants rather than repeating
+	 * those checks here.
+	 */
+	Assert(!rprnavstate->offset.isnull && !rprnavstate->compound_offset.isnull);
+
+	offset = DatumGetInt64(rprnavstate->offset.value);
+	compound_offset = DatumGetInt64(rprnavstate->compound_offset.value);
+
+	Assert(offset >= 0 && compound_offset >= 0);
+
+	/*
+	 * Calculate target position based on navigation direction.  On overflow,
+	 * use -1 so that ExecRPRNavGetSlot treats it as out of range.
+	 */
+	switch (rprnavstate->rprnavexpr->kind)
+	{
+		case RPR_NAV_PREV:
+
+			/*
+			 * currentpos and offset are both non-negative, so the subtraction
+			 * cannot underflow; assert the invariant rather than guarding an
+			 * unreachable overflow.
+			 */
+			Assert(!pg_sub_s64_overflow(winstate->currentpos, offset, &target_pos));
+			target_pos = winstate->currentpos - offset;
+			break;
+		case RPR_NAV_NEXT:
+			if (pg_add_s64_overflow(winstate->currentpos, offset, &target_pos))
+				target_pos = -1;
+			break;
+		case RPR_NAV_FIRST:
+			/* FIRST: offset from match_start, clamped to currentpos */
+			if (pg_add_s64_overflow(winstate->nav_match_start, offset, &target_pos))
+				target_pos = -1;
+			else if (target_pos > winstate->currentpos)
+				target_pos = -1;	/* beyond current match range */
+			break;
+		case RPR_NAV_LAST:
+			/* LAST: offset backward from currentpos, clamped to match_start */
+			if (pg_sub_s64_overflow(winstate->currentpos, offset, &target_pos))
+				target_pos = -1;
+			else if (target_pos < winstate->nav_match_start)
+				target_pos = -1;	/* before match_start */
+			break;
+
+		case RPR_NAV_PREV_FIRST:
+		case RPR_NAV_NEXT_FIRST:
+			{
+				int64		inner_pos;
+
+				/* Inner: match_start + offset */
+				if (pg_add_s64_overflow(winstate->nav_match_start, offset, &inner_pos))
+				{
+					target_pos = -1;
+					break;
+				}
+				if (inner_pos > winstate->currentpos || inner_pos < 0)
+				{
+					target_pos = -1;
+					break;
+				}
+
+				/* Apply outer: PREV subtracts, NEXT adds */
+				if (rprnavstate->rprnavexpr->kind == RPR_NAV_PREV_FIRST)
+				{
+					/*
+					 * inner_pos is in [0, currentpos] and compound_offset is
+					 * non-negative, so this cannot underflow.
+					 */
+					Assert(!pg_sub_s64_overflow(inner_pos, compound_offset, &target_pos));
+					target_pos = inner_pos - compound_offset;
+				}
+				else
+				{
+					if (pg_add_s64_overflow(inner_pos, compound_offset, &target_pos))
+						target_pos = -1;
+				}
+			}
+			break;
+
+		case RPR_NAV_PREV_LAST:
+		case RPR_NAV_NEXT_LAST:
+			{
+				int64		inner_pos;
+
+				/* Inner: currentpos - offset */
+				if (pg_sub_s64_overflow(winstate->currentpos, offset, &inner_pos))
+				{
+					target_pos = -1;
+					break;
+				}
+				if (inner_pos < winstate->nav_match_start)
+				{
+					target_pos = -1;
+					break;
+				}
+
+				/* Apply outer: PREV subtracts, NEXT adds */
+				if (rprnavstate->rprnavexpr->kind == RPR_NAV_PREV_LAST)
+				{
+					/*
+					 * inner_pos is in [nav_match_start, currentpos] (>= 0)
+					 * and compound_offset is non-negative, so this cannot
+					 * underflow.
+					 */
+					Assert(!pg_sub_s64_overflow(inner_pos, compound_offset, &target_pos));
+					target_pos = inner_pos - compound_offset;
+				}
+				else
+				{
+					if (pg_add_s64_overflow(inner_pos, compound_offset, &target_pos))
+						target_pos = -1;
+				}
+			}
+			break;
+		default:
+			elog(ERROR, "unrecognized RPR navigation kind: %d",
+				 (int) rprnavstate->rprnavexpr->kind);
+			break;
+	}
+
+	/*
+	 * Slot swap elision: if target_pos is the current row, skip the
+	 * tuplestore fetch and slot swap entirely.  This benefits LAST(expr),
+	 * PREV(expr, 0), NEXT(expr, 0), and similar cases.
+	 *
+	 * We must still set nav_saved_outertuple (done above) so that
+	 * EEOP_RPR_NAV_RESTORE is a harmless no-op.
+	 */
+	if (target_pos == winstate->currentpos)
+	{
+		/* target row trivially exists; see comment below */
+		*op->resnull = false;
+		return;
+	}
+
+	target_slot = ExecRPRNavGetSlot(winstate, target_pos);
+
+	/*
+	 * Report whether the target row exists through resnull, which the jump
+	 * step tests before the argument expression gets to overwrite it: null
+	 * when the row is out of range, so the jump skips the argument, and a
+	 * definitive false otherwise, since resnull may still hold a stale value
+	 * from a previous evaluation.
+	 */
+	if (target_slot == NULL)
+	{
+		*op->resvalue = (Datum) 0;
+		*op->resnull = true;
+		return;
+	}
+	*op->resnull = false;
+
+	/*
+	 * Update econtext to point to the target slot.  Also decompress the new
+	 * slot's attributes since FETCHSOME already ran for the original slot.
+	 * The caller (interpreter or JIT) is responsible for updating any local
+	 * slot cache (e.g. outerslot) from econtext after we return.
+	 */
+	slot_getallattrs(target_slot);
+	econtext->ecxt_outertuple = target_slot;
+}
+
+/*
+ * Evaluate RPR navigation: restore slot to original row.
+ *
+ * Restores econtext->ecxt_outertuple from the saved slot in winstate.
+ * The caller is responsible for updating any local slot cache.
+ *
+ * For pass-by-reference result types, the result datum points into
+ * nav_slot's tuple memory.  If a subsequent navigation in the same
+ * expression re-fetches nav_slot for a different position, the old
+ * tuple is freed, leaving a dangling pointer.  We prevent this by
+ * copying pass-by-ref results into per-tuple memory, which survives
+ * until the next ResetExprContext.
+ */
+void
+ExecEvalRPRNavRestore(ExprState *state, ExprEvalStep *op,
+					  ExprContext *econtext)
+{
+	WindowAggState *winstate = op->d.rpr_nav.rprnavstate->winstate;
+
+	/*
+	 * When the slot swap was elided (target == currentpos), restoring is a
+	 * no-op, and the argument read the current row's slot rather than
+	 * nav_slot, so no re-fetch of nav_slot can invalidate a pass-by-ref
+	 * result.
+	 */
+	if (econtext->ecxt_outertuple == winstate->nav_saved_outertuple)
+		return;
+
+	econtext->ecxt_outertuple = winstate->nav_saved_outertuple;
+
+	/* Stabilize pass-by-ref result against nav_slot re-fetch */
+	if (!op->d.rpr_nav.rprnavstate->resulttypbyval &&
+		!*op->resnull)
+	{
+		MemoryContext oldContext;
+
+		oldContext = MemoryContextSwitchTo(econtext->ecxt_per_tuple_memory);
+		*op->resvalue = datumCopy(*op->resvalue,
+								  false,
+								  op->d.rpr_nav.rprnavstate->resulttyplen);
+		MemoryContextSwitchTo(oldContext);
+	}
+}
diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
new file mode 100644
index 00000000000..68664568556
--- /dev/null
+++ b/src/backend/executor/execRPR.c
@@ -0,0 +1,2044 @@
+/*-------------------------------------------------------------------------
+ *
+ * execRPR.c
+ *	  NFA-based Row Pattern Recognition engine for window functions.
+ *
+ * This file implements the NFA execution engine for the ROWS BETWEEN
+ * PATTERN clause (SQL Standard Feature R020: Row Pattern Recognition in
+ * Window Functions).
+ *
+ * The engine executes the compiled RPRPattern structure directly, avoiding
+ * regex compilation overhead.  It is called by nodeWindowAgg.c and exposes
+ * the interface declared in executor/execRPR.h.
+ *
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/executor/execRPR.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "common/int.h"
+#include "executor/execRPR.h"
+#include "executor/executor.h"
+#include "miscadmin.h"
+#include "optimizer/rpr.h"
+#include "utils/memutils.h"
+
+/*
+ * For the design and execution model of the NFA engine implemented
+ * in this file, see src/backend/executor/README.rpr.
+ */
+
+/* Bitmap macros for NFA cycle detection (cf. bitmapset.c, tidbitmap.c) */
+#define WORDNUM(x)	((x) / BITS_PER_BITMAPWORD)
+#define BITNUM(x)	((x) % BITS_PER_BITMAPWORD)
+
+/*
+ * Set the visited bit for elemIdx and update the high-water marks
+ * (nfaVisitedMin/MaxWord) so that the next reset only has to clear
+ * the touched range instead of the full nfaVisitedEnds bitmap.
+ */
+static inline void
+nfa_mark_visited(WindowAggState *winstate, int16 elemIdx)
+{
+	int16		w = WORDNUM(elemIdx);
+
+	winstate->nfaVisitedEnds[w] |= ((bitmapword) 1 << BITNUM(elemIdx));
+	winstate->nfaVisitedMinWord = Min(winstate->nfaVisitedMinWord, w);
+	winstate->nfaVisitedMaxWord = Max(winstate->nfaVisitedMaxWord, w);
+}
+
+/* Forward declarations */
+static RPRNFAState *nfa_state_make(WindowAggState *winstate);
+static void nfa_state_free(WindowAggState *winstate, RPRNFAState *state);
+static void nfa_state_free_list(WindowAggState *winstate, RPRNFAState *list);
+static RPRNFAState *nfa_state_clone(WindowAggState *winstate, int16 elemIdx,
+									int32 *counts, bool sourceAbsorbable);
+static bool nfa_states_equal(WindowAggState *winstate, RPRNFAState *s1,
+							 RPRNFAState *s2);
+static void nfa_add_state_unique(WindowAggState *winstate, RPRNFAContext *ctx,
+								 RPRNFAState *state);
+static void nfa_add_matched_state(WindowAggState *winstate, RPRNFAContext *ctx,
+								  RPRNFAState *state, int64 matchEndRow);
+
+static RPRNFAContext *nfa_context_make(WindowAggState *winstate);
+static void nfa_unlink_context(WindowAggState *winstate, RPRNFAContext *ctx);
+
+static void nfa_update_length_stats(int64 count, NFALengthStats *stats, int64 newLen);
+static void nfa_record_context_skipped(WindowAggState *winstate, int64 skippedLen);
+static void nfa_record_context_absorbed(WindowAggState *winstate, int64 absorbedLen);
+
+static void nfa_update_absorption_flags(RPRNFAContext *ctx);
+static bool nfa_states_covered(RPRPattern *pattern, RPRNFAContext *older,
+							   RPRNFAContext *newer);
+static void nfa_try_absorb_context(WindowAggState *winstate, RPRNFAContext *ctx);
+static void nfa_absorb_contexts(WindowAggState *winstate);
+
+static bool nfa_eval_var_match(WindowAggState *winstate,
+							   RPRPatternElement *elem, RPRVarMatch *varMatched);
+static void nfa_match(WindowAggState *winstate, RPRNFAContext *ctx,
+					  RPRVarMatch *varMatched, int64 currentPos);
+static void nfa_route_to_elem(WindowAggState *winstate, RPRNFAContext *ctx,
+							  RPRNFAState *state, RPRPatternElement *nextElem,
+							  int64 currentPos);
+static void nfa_advance_alt(WindowAggState *winstate, RPRNFAContext *ctx,
+							RPRNFAState *state, RPRPatternElement *elem,
+							int64 currentPos);
+static void nfa_advance_begin(WindowAggState *winstate, RPRNFAContext *ctx,
+							  RPRNFAState *state, RPRPatternElement *elem,
+							  int64 currentPos);
+static void nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
+							RPRNFAState *state, RPRPatternElement *elem,
+							int64 currentPos);
+static void nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
+							RPRNFAState *state, RPRPatternElement *elem,
+							int64 currentPos);
+static void nfa_advance_state(WindowAggState *winstate, RPRNFAContext *ctx,
+							  RPRNFAState *state, int64 currentPos);
+static void nfa_advance(WindowAggState *winstate, RPRNFAContext *ctx,
+						int64 currentPos);
+
+static void nfa_reevaluate_dependent_vars(WindowAggState *winstate,
+										  RPRNFAContext *ctx,
+										  int64 currentPos);
+
+/*
+ * The engine runs three phases per row: match (evaluate VARs, prune dead
+ * states), absorb (drop contexts an older context already covers), advance
+ * (expand epsilon transitions until states park on VARs).  Per-element
+ * advance behaviour, the absorption argument and the dual-flag contract are
+ * documented in README.rpr chapters VIII and IX and in the RPRNFAContext
+ * comment in nodes/execnodes.h.
+ */
+
+/*
+ * nfa_state_make
+ *
+ * Allocate an NFA state, reusing from freeList if available.
+ * freeList is stored in WindowAggState for reuse across match attempts.
+ */
+static RPRNFAState *
+nfa_state_make(WindowAggState *winstate)
+{
+	RPRNFAState *state;
+
+	/* Try to reuse from free list first */
+	if (winstate->nfaStateFree != NULL)
+	{
+		state = winstate->nfaStateFree;
+		winstate->nfaStateFree = state->next;
+	}
+	else
+	{
+		/* Allocate in partition context for proper lifetime */
+		state = MemoryContextAlloc(winstate->partcontext, winstate->nfaStateSize);
+	}
+
+	/* Initialize entire state to zero */
+	memset(state, 0, winstate->nfaStateSize);
+
+	/* Update statistics */
+	winstate->nfaStatesActive++;
+	winstate->nfaStatesTotalCreated++;
+	winstate->nfaStatesMax = Max(winstate->nfaStatesMax,
+								 winstate->nfaStatesActive);
+
+	return state;
+}
+
+/*
+ * nfa_state_free
+ *
+ * Return a state to the free list for later reuse.
+ */
+static void
+nfa_state_free(WindowAggState *winstate, RPRNFAState *state)
+{
+	winstate->nfaStatesActive--;
+#ifdef USE_VALGRIND
+	/* real free so Valgrind catches use-after-free instead of recycling */
+	pfree(state);
+#else
+	state->next = winstate->nfaStateFree;
+	winstate->nfaStateFree = state;
+#endif
+}
+
+/*
+ * nfa_state_free_list
+ *
+ * Return all states in a list to the free list.
+ */
+static void
+nfa_state_free_list(WindowAggState *winstate, RPRNFAState *list)
+{
+	RPRNFAState *next;
+
+	for (; list != NULL; list = next)
+	{
+		next = list->next;
+		nfa_state_free(winstate, list);
+	}
+}
+
+/*
+ * nfa_state_clone
+ *
+ * Clone a state from the given elemIdx and counts.
+ * isAbsorbable is computed immediately: inherited AND new element's flag.
+ * Monotonic property: once false, stays false through all transitions.
+ *
+ * Caller is responsible for linking the returned state.
+ */
+static RPRNFAState *
+nfa_state_clone(WindowAggState *winstate, int16 elemIdx,
+				int32 *counts, bool sourceAbsorbable)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	int			maxDepth = pattern->maxDepth;
+	RPRNFAState *state = nfa_state_make(winstate);
+	RPRPatternElement *elem = &pattern->elements[elemIdx];
+
+	state->elemIdx = elemIdx;
+	/* Every reachable caller passes a live state's counts; maxDepth >= 1. */
+	Assert(counts != NULL && maxDepth > 0);
+	memcpy(state->counts, counts, sizeof(int32) * maxDepth);
+
+	/*
+	 * Compute isAbsorbable immediately at transition time. isAbsorbable =
+	 * sourceAbsorbable && (elem->flags & ABSORBABLE_BRANCH) Monotonic: once
+	 * false, stays false (can't re-enter absorbable region).
+	 */
+	state->isAbsorbable = sourceAbsorbable && RPRElemIsAbsorbableBranch(elem);
+
+	return state;
+}
+
+/*
+ * nfa_exit_to
+ *
+ * Move state out of the construct owning depth and onto targetIdx, then
+ * return the target element.  Callers route from there.
+ *
+ * Centralizes three conventions whose violations are silent:
+ *
+ * - Count-clear: zero the exited depth slot so the next occupant enters at
+ *   zero (asserted on entry by nfa_advance_begin/nfa_route_to_elem).
+ * - Arrival increment: landing on an END completes one iteration
+ *   (saturating at RPR_COUNT_INF).
+ * - isAbsorbable is recomputed against the target and is monotonic.
+ *   Reapplying it is idempotent, so clone and in-place callers share this path.
+ */
+static RPRPatternElement *
+nfa_exit_to(WindowAggState *winstate, RPRNFAState *state, int depth,
+			int16 targetIdx)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *nextElem;
+
+	state->counts[depth] = 0;
+	state->elemIdx = targetIdx;
+	nextElem = &pattern->elements[targetIdx];
+
+	state->isAbsorbable = state->isAbsorbable &&
+		RPRElemIsAbsorbableBranch(nextElem);
+
+	if (RPRElemIsEnd(nextElem) &&
+		state->counts[nextElem->depth] < RPR_COUNT_INF)
+		state->counts[nextElem->depth]++;
+
+	return nextElem;
+}
+
+/*
+ * nfa_states_equal
+ *
+ * Check if two states are equivalent (same elemIdx and counts).
+ */
+static bool
+nfa_states_equal(WindowAggState *winstate, RPRNFAState *s1, RPRNFAState *s2)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *elem;
+	int			compareDepth;
+
+	if (s1->elemIdx != s2->elemIdx)
+		return false;
+
+	/*
+	 * Compare counts up to current element's depth.  Two states sharing
+	 * elemIdx are equivalent iff every enclosing-or-current depth count
+	 * matches.
+	 *
+	 * The +1 is the slot arithmetic: comparing through depth N requires
+	 * counts[0..N], i.e., N+1 entries.  Deeper slots (counts[d] with d >
+	 * elem->depth) are excluded because they hold scratch state from inner
+	 * groups.  Per the count-clear policy such a slot is zeroed when its
+	 * owning element exits (see nfa_advance_var and the inline fast path in
+	 * nfa_match), so it must not participate in equivalence judgment.
+	 */
+	elem = &pattern->elements[s1->elemIdx];
+	compareDepth = elem->depth + 1;
+
+	if (memcmp(s1->counts, s2->counts, sizeof(int32) * compareDepth) != 0)
+		return false;
+
+	return true;
+}
+
+/*
+ * nfa_add_state_unique
+ *
+ * Add the state to the end of the ctx->states linked list, but only if a
+ * duplicate state is not already present.
+ * Earlier states have better lexical order (DFS traversal order), so existing
+ * wins; the new state is freed when a duplicate is found.
+ */
+static void
+nfa_add_state_unique(WindowAggState *winstate, RPRNFAContext *ctx, RPRNFAState *state)
+{
+	RPRNFAState *s;
+	RPRNFAState *tail = NULL;
+
+	/*
+	 * Nothing is parked once this advance has recorded: a state kept here
+	 * survives to the next row, where it could complete and replace the match
+	 * that outranks it.
+	 */
+	Assert(!ctx->matchUpdated);
+
+	/* Check for duplicate and find tail */
+	for (s = ctx->states; s != NULL; s = s->next)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		if (nfa_states_equal(winstate, s, state))
+		{
+			/*
+			 * Duplicate found - existing has better lexical order, discard
+			 * new
+			 */
+			nfa_state_free(winstate, state);
+			winstate->nfaStatesMerged++;
+			return;
+		}
+		tail = s;
+	}
+
+	/* No duplicate, add at end */
+	state->next = NULL;
+	if (tail == NULL)
+		ctx->states = state;
+	else
+		tail->next = state;
+}
+
+/*
+ * nfa_add_matched_state
+ *
+ * Record a state that reached FIN, replacing any previous match.
+ *
+ * For SKIP PAST LAST ROW, also prune subsequent contexts whose start row
+ * falls within the match range, as they cannot produce output rows.
+ */
+static void
+nfa_add_matched_state(WindowAggState *winstate, RPRNFAContext *ctx,
+					  RPRNFAState *state, int64 matchEndRow)
+{
+	/*
+	 * One advance records at most one match.  The guards below stop every
+	 * less-preferred path once matchUpdated is set, so arriving here twice
+	 * would mean a path that can reach FIN is not reading it.
+	 */
+	Assert(!ctx->matchUpdated);
+
+	if (ctx->matchedState != NULL)
+		nfa_state_free(winstate, ctx->matchedState);
+
+	ctx->matchedState = state;
+	state->next = NULL;
+	ctx->matchEndRow = matchEndRow;
+
+	/*
+	 * Tell the frames that are unwinding.  FIN is not marked visited, so an
+	 * expansion can reach it more than once, and the later arrival is the
+	 * less preferred one: the paths that cut less-preferred alternatives read
+	 * this and stop rather than record again.
+	 */
+	ctx->matchUpdated = true;
+
+	/* Prune contexts that started within this match's range */
+	if (winstate->rpSkipTo == ST_PAST_LAST_ROW)
+	{
+		int64		skippedLen;
+
+		while (ctx->next != NULL &&
+			   ctx->next->matchStartRow <= matchEndRow)
+		{
+			RPRNFAContext *nextCtx = ctx->next;
+
+			/* Only later-starting contexts are freed; callers walk forward */
+			Assert(nextCtx->matchStartRow > ctx->matchStartRow);
+			Assert(nextCtx->lastProcessedRow >= nextCtx->matchStartRow);
+			skippedLen = nextCtx->lastProcessedRow - nextCtx->matchStartRow + 1;
+			nfa_record_context_skipped(winstate, skippedLen);
+
+			ExecRPRFreeContext(winstate, nextCtx);
+		}
+	}
+}
+
+/*
+ * nfa_context_make
+ *
+ * Allocate an NFA context, reusing from free list if available.
+ */
+static RPRNFAContext *
+nfa_context_make(WindowAggState *winstate)
+{
+	RPRNFAContext *ctx;
+
+	if (winstate->nfaContextFree != NULL)
+	{
+		ctx = winstate->nfaContextFree;
+		winstate->nfaContextFree = ctx->next;
+	}
+	else
+	{
+		/* Allocate in partition context for proper lifetime */
+		ctx = MemoryContextAlloc(winstate->partcontext, sizeof(RPRNFAContext));
+	}
+
+	ctx->next = NULL;
+	ctx->prev = NULL;
+	ctx->states = NULL;
+	ctx->matchStartRow = -1;
+	ctx->matchEndRow = -1;
+	ctx->lastProcessedRow = -1;
+	ctx->matchedState = NULL;
+	ctx->matchUpdated = false;
+
+	/* Initialize two-flag absorption design based on pattern */
+	ctx->hasAbsorbableState = winstate->rpPattern->isAbsorbable;
+	ctx->allStatesAbsorbable = winstate->rpPattern->isAbsorbable;
+
+	/* Update statistics */
+	winstate->nfaContextsActive++;
+	winstate->nfaContextsTotalCreated++;
+	winstate->nfaContextsMax = Max(winstate->nfaContextsMax,
+								   winstate->nfaContextsActive);
+
+	return ctx;
+}
+
+/*
+ * nfa_unlink_context
+ *
+ * Remove a context from the doubly-linked active context list.
+ * Updates head (nfaContext) and tail (nfaContextTail) as needed.
+ */
+static void
+nfa_unlink_context(WindowAggState *winstate, RPRNFAContext *ctx)
+{
+	if (ctx->prev != NULL)
+		ctx->prev->next = ctx->next;
+	else
+		winstate->nfaContext = ctx->next;	/* was head */
+
+	if (ctx->next != NULL)
+		ctx->next->prev = ctx->prev;
+	else
+		winstate->nfaContextTail = ctx->prev;	/* was tail */
+
+	ctx->next = NULL;
+	ctx->prev = NULL;
+}
+
+/*
+ * nfa_update_length_stats
+ *
+ * Helper function to update min/max/total length statistics.
+ * Called when tracking match/mismatch/absorbed/skipped lengths.
+ */
+static void
+nfa_update_length_stats(int64 count, NFALengthStats *stats, int64 newLen)
+{
+	if (count == 1)
+	{
+		stats->min = newLen;
+		stats->max = newLen;
+	}
+	else
+	{
+		stats->min = Min(stats->min, newLen);
+		stats->max = Max(stats->max, newLen);
+	}
+	stats->total += newLen;
+}
+
+/*
+ * nfa_record_context_skipped
+ *
+ * Record a skipped context in statistics.
+ */
+static void
+nfa_record_context_skipped(WindowAggState *winstate, int64 skippedLen)
+{
+	winstate->nfaContextsSkipped++;
+	nfa_update_length_stats(winstate->nfaContextsSkipped,
+							&winstate->nfaSkippedLen,
+							skippedLen);
+}
+
+/*
+ * nfa_record_context_absorbed
+ *
+ * Record an absorbed context in statistics.
+ */
+static void
+nfa_record_context_absorbed(WindowAggState *winstate, int64 absorbedLen)
+{
+	winstate->nfaContextsAbsorbed++;
+	nfa_update_length_stats(winstate->nfaContextsAbsorbed,
+							&winstate->nfaAbsorbedLen,
+							absorbedLen);
+}
+
+/*
+ * nfa_update_absorption_flags
+ *
+ * Update context's absorption flags after state changes.
+ *
+ * Two flags control absorption behavior:
+ *   hasAbsorbableState: true if context has at least one absorbable state.
+ *     This flag is monotonic (true -> false only). Once all absorbable states
+ *     die, no new absorbable states can be created through transitions.
+ *   allStatesAbsorbable: true if ALL states in context are absorbable and no
+ *     match is recorded.  Dynamic (false -> true as non-absorbable states die
+ *     off), except that a recorded match pins it false: absorbing would free
+ *     a match no absorbing context can reproduce.
+ *
+ * Optimization: Once hasAbsorbableState becomes false, both flags remain false
+ * permanently, so we skip recalculation.
+ */
+static void
+nfa_update_absorption_flags(RPRNFAContext *ctx)
+{
+	RPRNFAState *state;
+	bool		hasAbsorbable = false;
+	bool		allAbsorbable = true;
+
+	/*
+	 * Optimization: Once hasAbsorbableState becomes false, it stays false. No
+	 * need to recalculate - both flags remain false permanently.
+	 */
+	if (!ctx->hasAbsorbableState)
+	{
+		ctx->allStatesAbsorbable = false;
+		return;
+	}
+
+	/* No states means no absorbable states */
+	if (ctx->states == NULL)
+	{
+		ctx->hasAbsorbableState = false;
+		ctx->allStatesAbsorbable = false;
+		return;
+	}
+
+	/*
+	 * Iterate through all states to check absorption status. Uses
+	 * state->isAbsorbable which tracks if state is in absorbable region. This
+	 * is different from RPRElemIsAbsorbable(elem) which checks comparison
+	 * point.
+	 */
+	for (state = ctx->states; state != NULL; state = state->next)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		if (state->isAbsorbable)
+			hasAbsorbable = true;
+		else
+			allAbsorbable = false;
+	}
+
+	/*
+	 * A recorded match makes this context non-absorbable: absorption would
+	 * free the match, which no absorbing context can reproduce.
+	 */
+	if (ctx->matchedState != NULL)
+		allAbsorbable = false;
+
+	ctx->hasAbsorbableState = hasAbsorbable;
+	ctx->allStatesAbsorbable = allAbsorbable;
+}
+
+/*
+ * nfa_states_covered
+ *
+ * Check if all states in newer context are "covered" by older context.
+ *
+ * A newer state is covered when older context has an absorbable state at the
+ * same pattern element (elemIdx) with count >= newer's count at that depth.
+ * The covering state must be absorbable because only absorbable states can
+ * guarantee to produce superset matches.
+ *
+ * If all newer states are covered, newer context's eventual matches will be
+ * a subset of older context's matches, making newer redundant.
+ */
+static bool
+nfa_states_covered(RPRPattern *pattern, RPRNFAContext *older, RPRNFAContext *newer)
+{
+	RPRNFAState *newerState;
+
+	for (newerState = newer->states; newerState != NULL; newerState = newerState->next)
+	{
+		RPRNFAState *olderState;
+		RPRPatternElement *elem;
+		int			depth;
+		bool		found = false;
+
+		/* All states are absorbable (caller checks allStatesAbsorbable) */
+		elem = &pattern->elements[newerState->elemIdx];
+		depth = elem->depth;
+
+		/*
+		 * Only compare at absorption comparison points (RPR_ELEM_ABSORBABLE).
+		 * Comparison points are where count-dominance guarantees the newer
+		 * context's future matches are a subset of the older's.
+		 */
+		if (!RPRElemIsAbsorbable(elem))
+			return false;
+
+		for (olderState = older->states; olderState != NULL; olderState = olderState->next)
+		{
+			CHECK_FOR_INTERRUPTS();
+
+			/* Covering state must also be absorbable */
+			if (olderState->isAbsorbable &&
+				olderState->elemIdx == newerState->elemIdx &&
+				olderState->counts[depth] >= newerState->counts[depth])
+			{
+				found = true;
+				break;
+			}
+		}
+
+		if (!found)
+			return false;
+	}
+
+	return true;
+}
+
+/*
+ * nfa_try_absorb_context
+ *
+ * Try to absorb ctx (newer) into an older in-progress context.  If one is
+ * found, ctx is unlinked and freed here.
+ *
+ * Absorption requires three conditions:
+ *   1. ctx must have all states absorbable (allStatesAbsorbable).
+ *      If ctx has any non-absorbable state, it may produce unique matches.
+ *   2. older must have at least one absorbable state (hasAbsorbableState).
+ *      Without absorbable states, older cannot cover newer's states.
+ *   3. All ctx states must be covered by older's absorbable states.
+ *      This ensures older will produce all matches that ctx would produce.
+ *
+ * Context list is ordered by creation time (oldest first via prev chain).
+ * Each row creates at most one context, so earlier contexts have smaller
+ * matchStartRow values.
+ */
+static void
+nfa_try_absorb_context(WindowAggState *winstate, RPRNFAContext *ctx)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRNFAContext *older;
+
+	/* Early exit: ctx must have all states absorbable */
+	if (!ctx->allStatesAbsorbable)
+		return;
+
+	for (older = ctx->prev; older != NULL; older = older->prev)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		/*
+		 * By invariant: ctx->prev chain is in creation order (oldest first),
+		 * and each row creates at most one context. So all contexts in this
+		 * chain have matchStartRow < ctx->matchStartRow.
+		 */
+
+		/* Older must also be in-progress */
+		if (older->states == NULL)
+			continue;
+
+		/* Older must have at least one absorbable state */
+		if (!older->hasAbsorbableState)
+			continue;
+
+		/* Check if all newer states are covered by older */
+		if (nfa_states_covered(pattern, older, ctx))
+		{
+			int64		absorbedLen = ctx->lastProcessedRow - ctx->matchStartRow + 1;
+
+			ExecRPRFreeContext(winstate, ctx);
+			nfa_record_context_absorbed(winstate, absorbedLen);
+			return;
+		}
+	}
+}
+
+/*
+ * nfa_absorb_contexts
+ *
+ * Absorb redundant contexts to reduce memory usage and computation.
+ *
+ * For patterns like A+, newer contexts starting later will produce subset
+ * matches of older contexts with higher counts. By absorbing these redundant
+ * contexts early, we avoid duplicate work.
+ *
+ * Iterates from tail (newest) toward head (oldest) via prev chain.
+ * Only in-progress contexts (states != NULL) are candidates for absorption;
+ * completed contexts represent valid match results.
+ */
+static void
+nfa_absorb_contexts(WindowAggState *winstate)
+{
+	RPRNFAContext *ctx;
+	RPRNFAContext *nextCtx;
+
+	for (ctx = winstate->nfaContextTail; ctx != NULL; ctx = nextCtx)
+	{
+		nextCtx = ctx->prev;
+
+		/*
+		 * Only absorb in-progress contexts; completed contexts are valid
+		 * results
+		 */
+		if (ctx->states != NULL)
+			nfa_try_absorb_context(winstate, ctx);
+	}
+}
+
+/*
+ * nfa_eval_var_match
+ *
+ * Evaluate if a VAR element matches the current row.
+ *
+ * varMatched is a per-row tri-state cache indexed by varId.  Evaluation is
+ * lazy: the variable's DEFINE predicate is evaluated here the first time the
+ * NFA consumes the variable (cache is RPR_VAR_UNEVALUATED), then cached, so a
+ * variable that no active state tests at this row is never evaluated.  This
+ * matches ISO/IEC 19075-5, where a Boolean condition is evaluated only with
+ * the current row tentatively mapped to that variable.  A NULL varMatched
+ * makes every VAR not match; nfa_match() is called that way to force a
+ * mismatch at a frame boundary and at partition-end finalization.
+ *
+ * The caller must have set up the current row (ecxt_outertuple, currentpos,
+ * nav_match_start, nav_slot cache) via rpr_prepare_row() /
+ * nfa_reevaluate_dependent_vars() before consumption.
+ *
+ * Per ISO/IEC 19075-5 Feature R020, pattern variables not listed in DEFINE
+ * are implicitly TRUE -- they match every row.  This is checked via
+ * varId >= list_length.
+ */
+static bool
+nfa_eval_var_match(WindowAggState *winstate, RPRPatternElement *elem,
+				   RPRVarMatch *varMatched)
+{
+	int			varId;
+
+	/* This function should only be called for VAR elements */
+	Assert(RPRElemIsVar(elem));
+
+	if (varMatched == NULL)
+		return false;
+
+	varId = elem->varId;
+	if (varId >= list_length(winstate->defineClauseExprs))
+		return true;
+
+	/* Lazily evaluate this variable's DEFINE predicate on first consumption. */
+	if (varMatched[varId] == RPR_VAR_UNEVALUATED)
+	{
+		ExprState  *exprState = list_nth(winstate->defineClauseExprs, varId);
+		Datum		result;
+		bool		isnull;
+
+		result = ExecEvalExpr(exprState, winstate->rprContext, &isnull);
+		varMatched[varId] = (!isnull && DatumGetBool(result)) ?
+			RPR_VAR_TRUE : RPR_VAR_FALSE;
+	}
+
+	return (varMatched[varId] == RPR_VAR_TRUE);
+}
+
+/*
+ * nfa_match
+ *
+ * Match phase (convergence): evaluate VAR elements against current row.
+ * Only updates counts and removes dead states. Minimal transitions.
+ *
+ * For VAR elements:
+ *   - matched: count++ (saturating at RPR_COUNT_INF), keep state
+ *   - not matched: remove state (exit alternatives already exist from
+ *     previous advance when count >= min was satisfied)
+ *
+ * For VARs that reached max count followed by END:
+ *   - Advance through the END-element chain to the absorption
+ *     comparison point
+ *   - Only deterministic exits are handled.  A count saturates at
+ *     RPR_COUNT_INF, so count >= max does not by itself exclude an unbounded
+ *     VAR; what excludes it is the absorbable-region test, which no unbounded
+ *     VAR inside a still-looping group passes.
+ *   - Chains through END elements while count >= max (must-exit path)
+ *
+ * Non-VAR elements (only an END parked by the chain above) are kept as-is for
+ * advance phase.
+ *
+ * currentPos is unused by the matching logic itself; it is accepted so that
+ * every NFA helper carries the row index for debugging.
+ */
+static void
+nfa_match(WindowAggState *winstate, RPRNFAContext *ctx, RPRVarMatch *varMatched,
+		  int64 currentPos)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *elements = pattern->elements;
+	RPRNFAState **prevPtr = &ctx->states;
+	RPRNFAState *state;
+	RPRNFAState *nextState;
+
+	/* Evaluate VAR elements against current row. */
+	for (state = ctx->states; state != NULL; state = nextState)
+	{
+		RPRPatternElement *elem = &elements[state->elemIdx];
+
+		CHECK_FOR_INTERRUPTS();
+
+		nextState = state->next;
+
+		if (RPRElemIsVar(elem))
+		{
+			bool		matched;
+			int			depth = elem->depth;
+			int32		count = state->counts[depth];
+
+			matched = nfa_eval_var_match(winstate, elem, varMatched);
+
+			if (matched)
+			{
+				/*
+				 * Increment count, saturating at RPR_COUNT_INF to avoid int32
+				 * overflow; a saturated count then compares as "unbounded".
+				 */
+				if (count < RPR_COUNT_INF)
+					count++;
+
+				/* Max constraint should not be exceeded */
+				Assert(elem->max == RPR_QUANTITY_INF || count <= elem->max);
+
+				state->counts[depth] = count;
+
+				/*
+				 * For VAR at max count with END next, advance through END
+				 * chain to reach the absorption comparison point.  Only
+				 * deterministic exits (count >= max, max finite) are handled;
+				 * unbounded VARs stay for advance phase.
+				 *
+				 * In nested patterns like ((A (B C){2}){2})+, a VAR reaching
+				 * its max triggers an exit cascade: inner END increments
+				 * inner group count, which may itself reach max, requiring an
+				 * exit to the next outer END.  The loop below walks this
+				 * chain.
+				 *
+				 * ABSORBABLE_BRANCH marks elements inside the absorbable
+				 * region; ABSORBABLE marks the outermost comparison point
+				 * where count-dominance is evaluated.  We chain through
+				 * BRANCH elements until reaching the ABSORBABLE point or an
+				 * element that can still loop (count < max).
+				 */
+				if (RPRElemIsAbsorbableBranch(elem) &&
+					!RPRElemIsAbsorbable(elem) &&
+					count >= elem->max &&
+					RPRElemIsEnd(&elements[elem->next]))
+				{
+					RPRPatternElement *endElem = &elements[elem->next];
+					int			endDepth = endElem->depth;
+					int32		endCount = state->counts[endDepth];
+
+					/* Increment group count */
+					if (endCount < RPR_COUNT_INF)
+						endCount++;
+					Assert(endElem->max == RPR_QUANTITY_INF ||
+						   endCount <= endElem->max);
+
+					state->elemIdx = elem->next;
+					state->counts[endDepth] = endCount;
+
+					/*
+					 * Leaf VAR exited (reached max): clear its own count so
+					 * the next occupant enters with zero, as nfa_advance_var
+					 * does on exit (this inline path replaces that exit).
+					 * depth > endDepth, so this leaves the group count just
+					 * written intact.
+					 */
+					Assert(endDepth < depth);
+					state->counts[depth] = 0;
+
+					/*
+					 * Chain through END elements within the absorbable region
+					 * (ABSORBABLE_BRANCH) until reaching the comparison point
+					 * (ABSORBABLE).  Continue only on must-exit path (count
+					 * >= max) with END next.
+					 */
+					while (RPRElemIsAbsorbableBranch(endElem) &&
+						   !RPRElemIsAbsorbable(endElem) &&
+						   endCount >= endElem->max &&
+						   RPRElemIsEnd(&elements[endElem->next]))
+					{
+						RPRPatternElement *outerEnd = &elements[endElem->next];
+						int			outerDepth = outerEnd->depth;
+						int32		outerCount = state->counts[outerDepth];
+
+						/*
+						 * Exit this intermediate group: clear its own count
+						 * (count-clear policy).  It sits below the absorbable
+						 * comparison point, so it is excluded from the
+						 * dominance comparison; the comparison point where
+						 * the chain stops keeps its count.
+						 */
+						state->counts[endDepth] = 0;
+
+						/* Increment outer group count */
+						if (outerCount < RPR_COUNT_INF)
+							outerCount++;
+						Assert(outerEnd->max == RPR_QUANTITY_INF ||
+							   outerCount <= outerEnd->max);
+
+						state->elemIdx = endElem->next;
+						state->counts[outerDepth] = outerCount;
+
+						/* Advance to next END in chain */
+						endElem = outerEnd;
+						endDepth = outerDepth;
+						endCount = outerCount;
+					}
+				}
+				/* else: stay at VAR for advance phase */
+			}
+			else
+			{
+				/*
+				 * Not matched - remove state. Exit alternatives were already
+				 * created by advance phase when count >= min was satisfied.
+				 */
+				*prevPtr = nextState;
+				nfa_state_free(winstate, state);
+				continue;
+			}
+		}
+		/* Non-VAR elements: keep as-is for advance phase */
+
+		prevPtr = &state->next;
+	}
+}
+
+/*
+ * nfa_route_to_elem
+ *
+ * Route state to next element. If VAR, add to ctx->states and process
+ * skip path if optional. Otherwise, continue epsilon expansion via recursion.
+ */
+static void
+nfa_route_to_elem(WindowAggState *winstate, RPRNFAContext *ctx,
+				  RPRNFAState *state, RPRPatternElement *nextElem,
+				  int64 currentPos)
+{
+	if (RPRElemIsVar(nextElem))
+	{
+		RPRNFAState *skipState = NULL;
+
+		/*
+		 * Entry-side check of the count-clear policy: a VAR is always routed
+		 * to with a clean slot.  Each element zeroes its own count on exit,
+		 * so a nonzero count here would be a leak from an earlier element
+		 * (see nfa_advance_var / nfa_advance_end exit handling and the inline
+		 * fast path in nfa_match).
+		 */
+		Assert(state->counts[nextElem->depth] == 0);
+
+		/* Create skip state before add_unique, which may free state */
+		if (RPRElemCanSkip(nextElem))
+		{
+			RPRPatternElement *landElem;
+
+			skipState = nfa_state_clone(winstate, nextElem->next,
+										state->counts, state->isAbsorbable);
+
+			/*
+			 * When the skip lands directly on an outer END, increment its
+			 * iteration count, just as the exit path in nfa_advance_var does:
+			 * a skipped iteration still ran, and that count is what the
+			 * group's min check and the cycle guard's below-min fall-through
+			 * both read.
+			 */
+			landElem = &winstate->rpPattern->elements[skipState->elemIdx];
+			if (RPRElemIsEnd(landElem) &&
+				skipState->counts[landElem->depth] < RPR_COUNT_INF)
+				skipState->counts[landElem->depth]++;
+		}
+
+		if (skipState != NULL && RPRElemIsReluctant(nextElem))
+		{
+			/*
+			 * Reluctant optional VAR: prefer skipping.  Explore the skip path
+			 * first so it outranks the enter (match) path; if it reaches FIN
+			 * the shortest match is found and the enter state is dropped.
+			 * This mirrors the reluctant branch of nfa_advance_begin used by
+			 * the leading-position and optional-group paths.
+			 */
+			nfa_advance_state(winstate, ctx, skipState, currentPos);
+
+			if (ctx->matchUpdated)
+			{
+				nfa_state_free(winstate, state);
+				return;
+			}
+
+			nfa_add_state_unique(winstate, ctx, state);
+		}
+		else
+		{
+			/* Greedy (or non-skippable): enter first, then skip */
+			nfa_add_state_unique(winstate, ctx, state);
+
+			if (skipState != NULL)
+				nfa_advance_state(winstate, ctx, skipState, currentPos);
+		}
+	}
+	else
+	{
+		nfa_advance_state(winstate, ctx, state, currentPos);
+	}
+}
+
+/*
+ * nfa_advance_alt
+ *
+ * Handle ALT element: expand all branches in lexical order via DFS.
+ *
+ * ALT.next is the first branch's content and ALT.jump is that branch's
+ * terminating SEP.  Each SEP.jump links to the next branch's SEP (-1 on the
+ * last) and each SEP.next is the next branch's content.  The walk reads SEP
+ * but never enters one -- states are always created at a branch's content.
+ */
+static void
+nfa_advance_alt(WindowAggState *winstate, RPRNFAContext *ctx,
+				RPRNFAState *state, RPRPatternElement *elem,
+				int64 currentPos)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *elements = pattern->elements;
+	RPRElemIdx	branchStart = elem->next;
+	RPRElemIdx	sepIdx = elem->jump;
+
+	while (sepIdx != RPR_ELEMIDX_INVALID)
+	{
+		RPRPatternElement *sepElem;
+		RPRNFAState *newState;
+
+		/* Create independent state at this branch's content */
+		newState = nfa_state_clone(winstate, branchStart,
+								   state->counts, state->isAbsorbable);
+
+		/* Recursively process this branch before the next */
+		nfa_advance_state(winstate, ctx, newState, currentPos);
+
+		/*
+		 * Branches are enumerated in preference order, so once one of them
+		 * has recorded a match the later branches must not be explored: a
+		 * later branch would either reach FIN in this same DFS and replace
+		 * the preferred match, or park states that complete on a later row
+		 * and replace it then.  Same technique as the reluctant paths in
+		 * nfa_route_to_elem and nfa_advance_begin.
+		 *
+		 * PATTERN (A* | B) on a row where A is false and B is true: the A*
+		 * branch is explored first, its skip path runs straight to FIN, and
+		 * the empty match is recorded here.  Breaking leaves that match
+		 * standing, which is what the preference order asks for.  Without the
+		 * break, B would be expanded too, would match the row, and its
+		 * one-row match would replace the empty one, as if the pattern were
+		 * written (B | A*).
+		 */
+		if (ctx->matchUpdated)
+			break;
+
+		Assert(sepIdx >= 0 && sepIdx < pattern->numElements);
+		sepElem = &elements[sepIdx];
+		Assert(RPRElemIsSep(sepElem));
+
+		/* The last branch's SEP has no link, ending the walk */
+		branchStart = sepElem->next;
+		sepIdx = sepElem->jump;
+	}
+
+	nfa_state_free(winstate, state);
+}
+
+/*
+ * nfa_advance_begin
+ *
+ * Handle BEGIN element: group entry logic.
+ * BEGIN is only visited at initial group entry; loop-back from END goes
+ * directly to first child, bypassing BEGIN.  Per the count-clear policy the
+ * group's own count slot is therefore already zero on entry (asserted below).
+ * If min=0, creates a skip path past the group.
+ */
+static void
+nfa_advance_begin(WindowAggState *winstate, RPRNFAContext *ctx,
+				  RPRNFAState *state, RPRPatternElement *elem,
+				  int64 currentPos)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *elements = pattern->elements;
+	RPRNFAState *skipState = NULL;
+
+	/*
+	 * Entry-side check of the count-clear policy: the group's own count slot
+	 * is already zero here.  BEGIN is only visited at initial group entry,
+	 * and the previous occupant of this depth slot cleared it on exit.
+	 */
+	Assert(state->counts[elem->depth] == 0);
+
+	/* Optional group: create skip path (but don't route yet) */
+	if (elem->min == 0)
+	{
+		RPRPatternElement *landElem;
+
+		skipState = nfa_state_clone(winstate, elem->jump,
+									state->counts, state->isAbsorbable);
+
+		/*
+		 * As in nfa_route_to_elem, a skip that lands directly on an outer END
+		 * still counts as an iteration of that END's group.
+		 */
+		landElem = &elements[elem->jump];
+		if (RPRElemIsEnd(landElem) &&
+			skipState->counts[landElem->depth] < RPR_COUNT_INF)
+			skipState->counts[landElem->depth]++;
+	}
+
+	if (skipState != NULL && RPRElemIsReluctant(elem))
+	{
+		/* Reluctant: skip first (prefer fewer iterations), enter second */
+		nfa_route_to_elem(winstate, ctx, skipState,
+						  &elements[elem->jump], currentPos);
+
+		/* The skip matched: do not enter the group over it */
+		if (ctx->matchUpdated)
+		{
+			nfa_state_free(winstate, state);
+			return;
+		}
+
+		state->elemIdx = elem->next;
+		nfa_route_to_elem(winstate, ctx, state,
+						  &elements[state->elemIdx], currentPos);
+	}
+	else
+	{
+		/*
+		 * Greedy-or-non-nullable: route to the first child.  For optional
+		 * groups (skipState != NULL, greedy min=0) additionally create the
+		 * skip path; for non-nullable groups (skipState == NULL, min>0) the
+		 * skip-path action is suppressed by the guard below.
+		 */
+		state->elemIdx = elem->next;
+		nfa_route_to_elem(winstate, ctx, state,
+						  &elements[state->elemIdx], currentPos);
+
+		/* Entering matched: do not take the skip over it */
+		if (ctx->matchUpdated)
+		{
+			if (skipState != NULL)
+				nfa_state_free(winstate, skipState);
+			return;
+		}
+
+		if (skipState != NULL)
+		{
+			nfa_route_to_elem(winstate, ctx, skipState,
+							  &elements[elem->jump], currentPos);
+		}
+	}
+}
+
+/*
+ * nfa_advance_end
+ *
+ * Handle END element: group repetition logic.
+ * Decides whether to loop back or exit based on count vs min/max.
+ */
+static void
+nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
+				RPRNFAState *state, RPRPatternElement *elem,
+				int64 currentPos)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *elements = pattern->elements;
+	int			depth = elem->depth;
+	int32		count = state->counts[depth];
+
+	if (count < elem->min)
+	{
+		RPRPatternElement *jumpElem;
+		RPRNFAState *ffState = NULL;
+		RPRPatternElement *nextElem = NULL;
+
+		/*----------
+		 * Two paths are explored when the group body is nullable
+		 * (RPR_ELEM_EMPTY_LOOP):
+		 *
+		 * 1. Loop-back path: attempt real matches in the next iteration
+		 *    (state, modified below).
+		 *
+		 * 2. Fast-forward path: skip directly to after the group, treating
+		 *    all remaining required iterations as empty matches (ffState).
+		 *    Route to elem->next (not nfa_advance_end) to avoid creating
+		 *    competing greedy/reluctant loop states.
+		 *
+		 * The body decides the order, not the group's own greed: the
+		 * fast-forward comes first exactly when the body prefers the empty
+		 * match (RPR_ELEM_EMPTY_PREFERRED).  If it then reaches FIN, the
+		 * loop-back is dropped so a longer match cannot replace the preferred
+		 * one -- mirroring the min<=count<max branch below.  The ffState
+		 * snapshot is taken BEFORE modifying state, since both paths diverge
+		 * from here.
+		 *----------
+		 */
+		if (RPRElemCanEmptyLoop(elem))
+		{
+			ffState = nfa_state_clone(winstate, state->elemIdx,
+									  state->counts, state->isAbsorbable);
+
+			/*
+			 * nfa_exit_to()'s isAbsorbable recompute is a no-op here:
+			 * EMPTY_LOOP groups are never in an absorbable region.
+			 */
+			nextElem = nfa_exit_to(winstate, ffState, depth, elem->next);
+		}
+
+		/*
+		 * Prepare the loop-back state.  Visited marks are deliberately left
+		 * in place; see the cycle guard in nfa_advance_state.
+		 */
+		state->elemIdx = elem->jump;
+		jumpElem = &elements[state->elemIdx];
+
+		if (ffState != NULL && RPRElemIsEmptyPreferred(elem))
+		{
+			/* Body prefers empty: take the fast-forward (exit) first */
+			nfa_route_to_elem(winstate, ctx, ffState, nextElem,
+							  currentPos);
+
+			/* The fast-forward matched: do not loop back over it */
+			if (ctx->matchUpdated)
+			{
+				nfa_state_free(winstate, state);
+				return;
+			}
+
+			/* Loop-back second */
+			nfa_route_to_elem(winstate, ctx, state, jumpElem,
+							  currentPos);
+		}
+		else
+		{
+			/* Greedy (or non-nullable): loop-back first, fast-forward second */
+			nfa_route_to_elem(winstate, ctx, state, jumpElem,
+							  currentPos);
+
+			/* The loop-back matched: do not fast-forward over it */
+			if (ctx->matchUpdated)
+			{
+				if (ffState != NULL)
+					nfa_state_free(winstate, ffState);
+				return;
+			}
+
+			if (ffState != NULL)
+				nfa_route_to_elem(winstate, ctx, ffState, nextElem,
+								  currentPos);
+		}
+	}
+	else if (elem->max != RPR_QUANTITY_INF && count >= elem->max)
+	{
+		/* Must exit: reached max iterations. */
+		RPRPatternElement *nextElem;
+
+		nextElem = nfa_exit_to(winstate, state, depth, elem->next);
+
+		nfa_route_to_elem(winstate, ctx, state, nextElem, currentPos);
+	}
+	else
+	{
+		/*
+		 * Between min and max (with at least one iteration) - can exit or
+		 * loop. Greedy: loop first (prefer more iterations). Reluctant: exit
+		 * first (prefer fewer iterations).
+		 */
+		RPRNFAState *exitState;
+		RPRPatternElement *jumpElem;
+		RPRPatternElement *nextElem;
+
+		/*
+		 * Create exit state first (need original counts before modifying
+		 * state)
+		 */
+		exitState = nfa_state_clone(winstate, elem->next,
+									state->counts, state->isAbsorbable);
+		nextElem = nfa_exit_to(winstate, exitState, depth, elem->next);
+
+		/* Prepare loop state */
+		state->elemIdx = elem->jump;
+		jumpElem = &elements[state->elemIdx];
+
+		if (RPRElemIsReluctant(elem))
+		{
+			/* Exit first (preferred for reluctant) */
+			nfa_route_to_elem(winstate, ctx, exitState, nextElem,
+							  currentPos);
+
+			/* The exit matched: do not loop over it */
+			if (ctx->matchUpdated)
+			{
+				nfa_state_free(winstate, state);
+				return;
+			}
+
+			/* Loop second */
+			nfa_route_to_elem(winstate, ctx, state, jumpElem,
+							  currentPos);
+		}
+		else
+		{
+			/* Loop first (preferred for greedy) */
+			nfa_route_to_elem(winstate, ctx, state, jumpElem,
+							  currentPos);
+
+			/* The loop matched: do not exit over it */
+			if (ctx->matchUpdated)
+			{
+				nfa_state_free(winstate, exitState);
+				return;
+			}
+
+			/* Exit second */
+			nfa_route_to_elem(winstate, ctx, exitState, nextElem,
+							  currentPos);
+		}
+	}
+}
+
+/*
+ * nfa_advance_var
+ *
+ * Handle VAR element: loop/exit transitions.
+ * After match phase, all VAR states have matched - decide next action.
+ */
+static void
+nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
+				RPRNFAState *state, RPRPatternElement *elem,
+				int64 currentPos)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	int			depth = elem->depth;
+	int32		count = state->counts[depth];
+	bool		canLoop = (elem->max == RPR_QUANTITY_INF || count < elem->max);
+	bool		canExit = (count >= elem->min);
+
+	/* min <= max, so !canExit (count < min) implies canLoop (count < max) */
+	Assert(canLoop || canExit);
+
+	/* elem->next must be a valid index for any reachable VAR */
+	Assert(elem->next >= 0 && elem->next < pattern->numElements);
+
+	if (canLoop && canExit)
+	{
+		/*
+		 * Both loop and exit possible. Greedy: loop first (prefer longer
+		 * match). Reluctant: exit first (prefer shorter match).
+		 */
+		RPRNFAState *cloneState;
+		RPRPatternElement *nextElem;
+		bool		reluctant = RPRElemIsReluctant(elem);
+
+		/*
+		 * Clone state for the first-priority path. For greedy, clone is the
+		 * loop state; for reluctant, clone is the exit state.
+		 */
+		if (reluctant)
+		{
+			/* Clone for exit, original stays for loop */
+			cloneState = nfa_state_clone(winstate, elem->next,
+										 state->counts, state->isAbsorbable);
+			nextElem = nfa_exit_to(winstate, cloneState, depth, elem->next);
+
+			/* Exit first (preferred for reluctant) */
+			nfa_route_to_elem(winstate, ctx, cloneState, nextElem,
+							  currentPos);
+
+			/* The exit matched: do not loop over it */
+			if (ctx->matchUpdated)
+			{
+				nfa_state_free(winstate, state);
+				return;
+			}
+
+			/* Loop second */
+			nfa_add_state_unique(winstate, ctx, state);
+		}
+		else
+		{
+			/* Clone for loop, original used for exit */
+			cloneState = nfa_state_clone(winstate, state->elemIdx,
+										 state->counts, state->isAbsorbable);
+
+			/* Loop first (preferred for greedy) */
+			nfa_add_state_unique(winstate, ctx, cloneState);
+
+			/* Exit second: nfa_match handles only deterministic exits */
+			nextElem = nfa_exit_to(winstate, state, depth, elem->next);
+
+			nfa_route_to_elem(winstate, ctx, state, nextElem,
+							  currentPos);
+		}
+	}
+	else if (canLoop)
+	{
+		/* Loop only: keep state as-is */
+		nfa_add_state_unique(winstate, ctx, state);
+	}
+	else
+	{
+		/* Exit only: advance to next element (canExit necessarily true) */
+		RPRPatternElement *nextElem;
+
+		Assert(canExit);
+		nextElem = nfa_exit_to(winstate, state, depth, elem->next);
+
+		nfa_route_to_elem(winstate, ctx, state, nextElem, currentPos);
+	}
+}
+
+/*
+ * nfa_advance_state
+ *
+ * Recursively process a single state through epsilon transitions.
+ * DFS traversal ensures states are added to ctx->states in lexical order.
+ */
+static void
+nfa_advance_state(WindowAggState *winstate, RPRNFAContext *ctx,
+				  RPRNFAState *state, int64 currentPos)
+{
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *elem;
+
+	Assert(state->elemIdx >= 0 && state->elemIdx < pattern->numElements);
+
+	/* Protect against stack overflow for deeply complex patterns */
+	check_stack_depth();
+
+	/*
+	 * Cycle detection.  Only a nullable END is marked, so a set bit means the
+	 * body just derived an empty match for this iteration: a DFS takes only
+	 * epsilon transitions, so no row was consumed since the last visit.
+	 *
+	 * Nothing else needs guarding: a revisit is a cycle only when it carries
+	 * no progress, and any other loop-back has consumed a row.  Dropping one
+	 * loses the match outright -- ((A | B B){1,3}){3} then finds nothing.
+	 */
+	if (winstate->nfaVisitedEnds[WORDNUM(state->elemIdx)] &
+		((bitmapword) 1 << BITNUM(state->elemIdx)))
+	{
+		RPRPatternElement *hitElem = &pattern->elements[state->elemIdx];
+
+		Assert(RPRElemIsEnd(hitElem) && RPRElemCanEmptyLoop(hitElem));
+
+		if (state->counts[hitElem->depth] >= hitElem->min)
+		{
+			RPRPatternElement *nextElem;
+
+			/* An END always has a valid exit target after finalization. */
+			Assert(hitElem->next != RPR_ELEMIDX_INVALID);
+
+			/*
+			 * Leave the group here, and enumerate that exit at this rank
+			 * rather than discarding the state: discarding demotes "leave the
+			 * group" below the remaining alternatives, and a less-preferred
+			 * branch then consumes rows the match should not have.  At or
+			 * above the lower bound an empty iteration stops the quantifier
+			 * (SQL/RPR follows Perl here).
+			 */
+
+			nextElem = nfa_exit_to(winstate, state, hitElem->depth,
+								   hitElem->next);
+
+			nfa_route_to_elem(winstate, ctx, state, nextElem, currentPos);
+			return;
+		}
+
+		/*
+		 * Below the lower bound the quantifier cannot exit, so fall through
+		 * to the normal must-loop path.  Each empty iteration's arrival
+		 * increment advances the count, so this reaches min and exits above.
+		 * Clearing the marks here instead would also disarm the guard for
+		 * nested reluctant loops, whose empty iterations then recurse without
+		 * bound: (A (B*?)+?){2,} on a single matching row.
+		 */
+	}
+
+	elem = &pattern->elements[state->elemIdx];
+
+	/*
+	 * Only a nullable END is ever tested; see the guard above.
+	 *
+	 * XXX this bounds the cycle, not the cost.  Leaving ALT and BEGIN
+	 * unmarked lets them be re-entered any number of times within one
+	 * expansion, so a run of alternations whose branches are all nullable
+	 * enumerates paths rather than states: nfa_advance_alt() recurses once
+	 * per branch and fillRPRPatternAlt() converges every branch tail on the
+	 * same element, making k such alternations cost 2^k.  (A?|B?){30} takes
+	 * over half an hour, and the recursion depth stays at k, so
+	 * check_stack_depth() never fires.  Bounding the cost needs a revisit key
+	 * of (elemIdx, counts); this bitmap identifies elemIdx alone.
+	 */
+	if (RPRElemCanEmptyLoop(elem))
+		nfa_mark_visited(winstate, state->elemIdx);
+
+	switch (elem->varId)
+	{
+		case RPR_VARID_FIN:
+			/* FIN: record match */
+			nfa_add_matched_state(winstate, ctx, state, currentPos);
+			break;
+
+		case RPR_VARID_ALT:
+			nfa_advance_alt(winstate, ctx, state, elem, currentPos);
+			break;
+
+		case RPR_VARID_BEGIN:
+			nfa_advance_begin(winstate, ctx, state, elem, currentPos);
+			break;
+
+		case RPR_VARID_END:
+			nfa_advance_end(winstate, ctx, state, elem, currentPos);
+			break;
+
+		default:
+			/* VAR element; a SEP would land here, so see fillRPRPatternAlt */
+			Assert(!RPRElemIsSep(elem) && RPRElemIsVar(elem));
+			nfa_advance_var(winstate, ctx, state, elem, currentPos);
+			break;
+	}
+}
+
+/*
+ * nfa_advance
+ *
+ * Advance phase (divergence): transition from all surviving states.
+ * Called after match phase with matched VAR states, or at context creation
+ * for initial epsilon expansion (with currentPos = startPos - 1).
+ *
+ * Processes states in order, using recursive DFS to maintain lexical order.
+ */
+static void
+nfa_advance(WindowAggState *winstate, RPRNFAContext *ctx, int64 currentPos)
+{
+	RPRNFAState *states = ctx->states;
+	RPRNFAState *state;
+
+	ctx->states = NULL;			/* Will rebuild */
+	ctx->matchUpdated = false;
+
+	/* Process each state in lexical order (DFS order from previous advance) */
+	while (states != NULL)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		/*
+		 * Clear visited bitmap before each state's DFS expansion.  Only the
+		 * range touched since the previous reset (tracked via the high-water
+		 * marks updated in nfa_mark_visited) needs to be cleared; for small
+		 * NFAs this is the whole array, but for large NFAs whose DFS only
+		 * reaches a few elements per advance it avoids walking the full
+		 * bitmap.
+		 */
+		if (winstate->nfaVisitedMaxWord >= winstate->nfaVisitedMinWord)
+		{
+			memset(&winstate->nfaVisitedEnds[winstate->nfaVisitedMinWord], 0,
+				   sizeof(bitmapword) *
+				   (winstate->nfaVisitedMaxWord -
+					winstate->nfaVisitedMinWord + 1));
+			winstate->nfaVisitedMinWord = PG_INT16_MAX;
+			winstate->nfaVisitedMaxWord = -1;
+		}
+
+		state = states;
+		states = states->next;
+
+		/*
+		 * Boundary contract: state->next is reset to NULL here, before
+		 * crossing into nfa_advance_state's epsilon-expansion DFS.  The inner
+		 * branches (nfa_advance_var, nfa_advance_begin/end/alt) treat
+		 * state->next as already-NULL and don't reset it themselves; the
+		 * other linking site is nfa_add_state_unique, which sets it when
+		 * appending to ctx->states.
+		 */
+		state->next = NULL;
+
+		nfa_advance_state(winstate, ctx, state, currentPos);
+
+		/*
+		 * Early termination: if a FIN was newly reached in this advance,
+		 * remaining old states have worse lexical order and can be pruned.
+		 * Only check for new FIN arrivals (not ones from previous rows).
+		 */
+		if (ctx->matchUpdated && states != NULL)
+		{
+			nfa_state_free_list(winstate, states);
+			break;
+		}
+	}
+}
+
+/*
+ * nfa_reevaluate_dependent_vars
+ *		Invalidate match_start-dependent DEFINE variables for a context whose
+ *		matchStartRow differs from the shared evaluation's nav_match_start.
+ *
+ * Only variables in defineMatchStartDependent are affected: they are reset to
+ * RPR_VAR_UNEVALUATED so nfa_match() re-evaluates them lazily against this
+ * context's matchStartRow.  match_start-independent variables keep their
+ * cached value across contexts, since they do not read nav_match_start.
+ *
+ * nav_match_start is installed for this context and left in place: FIRST/LAST
+ * read it at evaluation time, which happens later during nfa_match(), so it
+ * must NOT be restored here.  The next context's invalidation, or the next
+ * row's shared setup in advance_reduced_frame_nfa, overwrites it.
+ */
+static void
+nfa_reevaluate_dependent_vars(WindowAggState *winstate, RPRNFAContext *ctx,
+							  int64 currentPos)
+{
+	int			varIdx = -1;
+
+	/* Caller keeps winstate->currentpos at the scan position for lazy eval. */
+	Assert(winstate->currentpos == currentPos);
+
+	/*
+	 * Release the previous context's DEFINE evaluation memory.  Match-start-
+	 * dependent variables are re-evaluated once per context (they are reset
+	 * to UNEVALUATED below), so without this reset their per-tuple scratch
+	 * would accumulate across every context of a row -- bounded only by the
+	 * per-row reset in rpr_prepare_row.  rprContext is the dedicated DEFINE
+	 * context, so this frees neither the input nor the output tuple memory.
+	 */
+	ResetExprContext(winstate->rprContext);
+
+	/* Install this context's match_start for FIRST/LAST and keep it in place. */
+	winstate->nav_match_start = ctx->matchStartRow;
+
+	/* Invalidate nav_slot cache since match_start changed */
+	winstate->nav_slot_pos = -1;
+
+	/* Reset only the dependent variables so they re-evaluate lazily. */
+	while ((varIdx = bms_next_member(winstate->defineMatchStartDependent,
+									 varIdx)) >= 0)
+		winstate->nfaVarMatched[varIdx] = RPR_VAR_UNEVALUATED;
+}
+
+
+/***********************************************************************
+ * API exposed to nodeWindowAgg.c
+ ***********************************************************************/
+
+/*
+ * ExecRPRStartContext
+ *
+ * Start a new match context at given position.
+ * Initializes context, state absorption flags, and performs initial advance
+ * to expand epsilon transitions (ALT branches, optional elements).
+ * Adds context to the tail of winstate->nfaContext list.
+ */
+RPRNFAContext *
+ExecRPRStartContext(WindowAggState *winstate, int64 startPos)
+{
+	RPRNFAContext *ctx;
+	RPRPattern *pattern = winstate->rpPattern;
+	RPRPatternElement *elem;
+
+	ctx = nfa_context_make(winstate);
+	ctx->matchStartRow = startPos;
+	ctx->states = nfa_state_make(winstate); /* initial state at elem 0 */
+
+	elem = &pattern->elements[0];
+
+	if (RPRElemIsAbsorbableBranch(elem))
+	{
+		ctx->states->isAbsorbable = true;
+	}
+	else
+	{
+		ctx->hasAbsorbableState = false;
+		ctx->allStatesAbsorbable = false;
+		ctx->states->isAbsorbable = false;
+	}
+
+	/*
+	 * Add to tail of active context list (doubly-linked, oldest-first).
+	 * matchStartRow increases along the list, so the head holds the smallest
+	 * -- an ordering other code relies on.  At most one context starts at a
+	 * row: the on-demand path in update_reduced_frame creates one only where
+	 * none exists.
+	 */
+	Assert(winstate->nfaContextTail == NULL ||
+		   startPos > winstate->nfaContextTail->matchStartRow);
+	ctx->prev = winstate->nfaContextTail;
+	ctx->next = NULL;
+	if (winstate->nfaContextTail != NULL)
+		winstate->nfaContextTail->next = ctx;
+	else
+		winstate->nfaContext = ctx; /* first context becomes head */
+	winstate->nfaContextTail = ctx;
+
+	/*
+	 * Initial advance (divergence): expand ALT branches and create exit
+	 * states for VAR elements with min=0. This prepares the context for the
+	 * first row's match phase.
+	 *
+	 * Use startPos - 1 as currentPos since no row has been consumed yet. If
+	 * FIN is reached via epsilon transitions, matchEndRow = startPos - 1,
+	 * which is how an empty match is represented.
+	 */
+	nfa_advance(winstate, ctx, startPos - 1);
+
+	return ctx;
+}
+
+/*
+ * ExecRPRGetHeadContext
+ *
+ * Return the head context if its start position matches pos.
+ * Returns NULL if no context exists or head doesn't match pos.
+ */
+RPRNFAContext *
+ExecRPRGetHeadContext(WindowAggState *winstate, int64 pos)
+{
+	RPRNFAContext *ctx = winstate->nfaContext;
+
+	/*
+	 * Contexts are sorted by matchStartRow ascending.  If the head context
+	 * doesn't match pos, no context exists for this position.
+	 */
+	if (ctx == NULL || ctx->matchStartRow != pos)
+		return NULL;
+
+	return ctx;
+}
+
+/*
+ * ExecRPRFreeContext
+ *
+ * Unlink context from active list and return it to free list.
+ * Also frees any states in the context.
+ */
+void
+ExecRPRFreeContext(WindowAggState *winstate, RPRNFAContext *ctx)
+{
+	/* Unlink from active list first */
+	nfa_unlink_context(winstate, ctx);
+
+	/* Update statistics */
+	winstate->nfaContextsActive--;
+
+	if (ctx->states != NULL)
+		nfa_state_free_list(winstate, ctx->states);
+	if (ctx->matchedState != NULL)
+		nfa_state_free(winstate, ctx->matchedState);
+
+	ctx->states = NULL;
+	ctx->matchedState = NULL;
+	ctx->next = winstate->nfaContextFree;
+	winstate->nfaContextFree = ctx;
+}
+
+/*
+ * ExecRPRRecordContextSuccess
+ *
+ * Record a successful context in statistics.
+ */
+void
+ExecRPRRecordContextSuccess(WindowAggState *winstate, int64 matchLen)
+{
+	winstate->nfaMatchesSucceeded++;
+	nfa_update_length_stats(winstate->nfaMatchesSucceeded,
+							&winstate->nfaMatchLen,
+							matchLen);
+}
+
+/*
+ * ExecRPRRecordContextFailure
+ *
+ * Record a failed context in statistics.
+ * If failedLen == 1, count as pruned (failed on first row).
+ * If failedLen > 1, count as mismatched and update length stats.
+ */
+void
+ExecRPRRecordContextFailure(WindowAggState *winstate, int64 failedLen)
+{
+	if (failedLen == 1)
+	{
+		winstate->nfaContextsPruned++;
+	}
+	else
+	{
+		winstate->nfaMatchesFailed++;
+		nfa_update_length_stats(winstate->nfaMatchesFailed,
+								&winstate->nfaFailLen,
+								failedLen);
+	}
+}
+
+/*
+ * ExecRPRProcessRow
+ *
+ * Process all contexts for one row:
+ *   1. Match all contexts (convergence) - evaluate VARs, prune dead states
+ *   2. Absorb redundant contexts - ideal timing after convergence
+ *   3. Advance all contexts (divergence) - create new states for next row
+ */
+void
+ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
+				  bool hasLimitedFrame, int64 frameOffset)
+{
+	RPRNFAContext *ctx;
+	RPRVarMatch *varMatched = winstate->nfaVarMatched;
+	bool		hasDependent = !bms_is_empty(winstate->defineMatchStartDependent);
+
+	/* Allow query cancellation once per row for simple/low-state patterns */
+	CHECK_FOR_INTERRUPTS();
+
+	/*
+	 * Phase 1: Match all contexts (convergence).  Evaluate VAR elements,
+	 * update counts, remove dead states.
+	 */
+	for (ctx = winstate->nfaContext; ctx != NULL; ctx = ctx->next)
+	{
+		if (ctx->states == NULL)
+			continue;
+
+		/* Check frame boundary - finalize the context when it is reached */
+		if (hasLimitedFrame)
+		{
+			int64		ctxFrameEnd;
+
+			/*
+			 * Clamp to PG_INT64_MAX on overflow.  frameOffset can be as large
+			 * as PG_INT64_MAX (e.g. "ROWS <huge> FOLLOWING"), so add the
+			 * offset and the trailing +1 in two separately checked steps to
+			 * avoid signed-integer overflow in the "frameOffset + 1"
+			 * subexpression.
+			 */
+			if (pg_add_s64_overflow(ctx->matchStartRow, frameOffset,
+									&ctxFrameEnd) ||
+				pg_add_s64_overflow(ctxFrameEnd, 1, &ctxFrameEnd))
+				ctxFrameEnd = PG_INT64_MAX;
+
+			/*
+			 * currentPos advances by exactly one per call, and a finalized
+			 * context is skipped by the states == NULL guard above, so it can
+			 * only ever reach ctxFrameEnd, never overshoot it.  The Assert
+			 * turns a future change that broke that invariant into an
+			 * immediate failure rather than a silent slip past the boundary.
+			 */
+			Assert(currentPos <= ctxFrameEnd);
+
+			if (currentPos == ctxFrameEnd)
+			{
+				/* Frame boundary reached: force mismatch */
+				nfa_match(winstate, ctx, NULL, currentPos);
+				continue;
+			}
+		}
+
+		/*
+		 * If this context has a different matchStartRow than the one used in
+		 * the shared evaluation, invalidate its match_start-dependent
+		 * variables so nfa_match() re-evaluates them lazily with this
+		 * context's matchStartRow.
+		 *
+		 * The head context carries no explicit invalidation: it relies on the
+		 * ambient nav_match_start installed by advance_reduced_frame_nfa, so
+		 * it must be reached before any other context overwrites
+		 * nav_match_start.
+		 */
+		Assert(ctx != winstate->nfaContext ||
+			   ctx->matchStartRow == winstate->nav_match_start);
+
+		if (hasDependent && ctx->matchStartRow != winstate->nav_match_start)
+			nfa_reevaluate_dependent_vars(winstate, ctx, currentPos);
+		nfa_match(winstate, ctx, varMatched, currentPos);
+		ctx->lastProcessedRow = currentPos;
+	}
+
+	/*
+	 * Phase 2: Absorb redundant contexts.  After match phase, states have
+	 * converged - ideal for absorption.  First update absorption flags that
+	 * may have changed due to state removal.
+	 */
+	if (winstate->rpPattern->isAbsorbable)
+	{
+		for (ctx = winstate->nfaContext; ctx != NULL; ctx = ctx->next)
+			nfa_update_absorption_flags(ctx);
+
+		nfa_absorb_contexts(winstate);
+	}
+
+	/*
+	 * Phase 3: Advance all contexts (divergence).  Create new states
+	 * (loop/exit) from surviving matched states.
+	 */
+	for (ctx = winstate->nfaContext; ctx != NULL; ctx = ctx->next)
+	{
+		if (ctx->states == NULL)
+			continue;
+
+		/*
+		 * Phase 1 already handled frame boundary exceeded contexts by forcing
+		 * mismatch (nfa_match with NULL), which removes all states (all
+		 * states are at VAR positions after advance). So any surviving
+		 * context here must be within its frame boundary.
+		 *
+		 * Compute the (clamped) frame end the same way as Phase 1, using two
+		 * separately checked adds so that "frameOffset + 1" cannot overflow
+		 * when frameOffset is near PG_INT64_MAX.
+		 */
+#ifdef USE_ASSERT_CHECKING
+		if (hasLimitedFrame)
+		{
+			int64		ctxFrameEnd;
+
+			if (pg_add_s64_overflow(ctx->matchStartRow, frameOffset,
+									&ctxFrameEnd) ||
+				pg_add_s64_overflow(ctxFrameEnd, 1, &ctxFrameEnd))
+				ctxFrameEnd = PG_INT64_MAX;
+			Assert(currentPos < ctxFrameEnd);
+		}
+#endif
+
+		nfa_advance(winstate, ctx, currentPos);
+	}
+}
+
+/*
+ * ExecRPRCleanupDeadContexts
+ *
+ * Remove contexts that have failed (no active states and no match).
+ * These are contexts that failed during normal processing and should be
+ * counted as pruned (if length 1) or mismatched (if length > 1).
+ */
+void
+ExecRPRCleanupDeadContexts(WindowAggState *winstate, RPRNFAContext *excludeCtx)
+{
+	RPRNFAContext *ctx;
+	RPRNFAContext *next;
+
+	for (ctx = winstate->nfaContext; ctx != NULL; ctx = next)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		next = ctx->next;
+
+		/* Skip the target context and contexts still processing */
+		if (ctx == excludeCtx || ctx->states != NULL)
+			continue;
+
+		/*
+		 * Skip contexts that recorded a match (handled by SKIP logic).  Test
+		 * matchedState, not matchEndRow: an empty match ends at matchStartRow
+		 * - 1, so a row-length test would take it for a failure and count it
+		 * as pruned or mismatched.
+		 */
+		if (ctx->matchedState != NULL)
+			continue;
+
+		/*
+		 * Failed context: always removed below.  Only record the failure
+		 * statistic if it actually processed its start row; contexts created
+		 * for beyond-partition rows are removed without being counted.
+		 */
+		if (ctx->lastProcessedRow >= ctx->matchStartRow)
+		{
+			int64		failedLen = ctx->lastProcessedRow - ctx->matchStartRow + 1;
+
+			ExecRPRRecordContextFailure(winstate, failedLen);
+		}
+
+		ExecRPRFreeContext(winstate, ctx);
+	}
+}
+
+/*
+ * ExecRPRFinalizeAllContexts
+ *
+ * Partition-end classification policy: kill any VAR states still pursuing
+ * when rows run out, so cleanup sees a uniform ctx->states == NULL across
+ * every context.  By the time this runs, all genuine FIN reaches have
+ * already been recorded in-flight; three shapes survive here:
+ *
+ *   - Pure pursuit (matchedState == NULL): VAR states waiting for input
+ *     that never arrives (e.g., A+ B mid-pattern at partition end).
+ *   - Empty-match candidate + pursuit (matchedState != NULL,
+ *     matchEndRow < matchStartRow): initial-advance FIN-via-skip recorded
+ *     an empty match while VAR states are still chasing a longer one
+ *     (e.g., greedy A*).
+ *   - Real match + pursuit (matchedState != NULL,
+ *     matchEndRow >= matchStartRow): a match has been recorded and VAR
+ *     states are still looping for a longer one.
+ *
+ * Killing the VAR reclassifies pure pursuit as a failure in cleanup
+ * (otherwise it lingers without contributing to stats).  The other two both
+ * carry a recorded match, so cleanup skips them: an empty match is a
+ * length-0 success, not a failure, and update_reduced_frame registers it as
+ * such through its head-context path.  They still go through the same
+ * uniform path so partition-end classification stays centralized.
+ *
+ * Implementation: nfa_match with NULL forces VAR mismatch; nfa_advance
+ * then drains any remaining epsilon transitions.
+ */
+void
+ExecRPRFinalizeAllContexts(WindowAggState *winstate, int64 lastPos)
+{
+	RPRNFAContext *ctx;
+
+	for (ctx = winstate->nfaContext; ctx != NULL; ctx = ctx->next)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		if (ctx->states != NULL)
+		{
+			nfa_match(winstate, ctx, NULL, lastPos);
+
+			/* Defensive: advance leaves only VAR states, all removed above. */
+			nfa_advance(winstate, ctx, lastPos);
+		}
+	}
+}
diff --git a/src/backend/executor/meson.build b/src/backend/executor/meson.build
index dc45be0b2ce..0ff4a5b1d83 100644
--- a/src/backend/executor/meson.build
+++ b/src/backend/executor/meson.build
@@ -13,6 +13,7 @@ backend_sources += files(
   'execParallel.c',
   'execPartition.c',
   'execProcnode.c',
+  'execRPR.c',
   'execReplication.c',
   'execSRF.c',
   'execScan.c',
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index b86dcbba055..9ab73c614dc 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -39,10 +39,12 @@
 #include "catalog/pg_proc.h"
 #include "common/int.h"
 #include "executor/executor.h"
+#include "executor/execRPR.h"
 #include "executor/instrument.h"
 #include "executor/nodeWindowAgg.h"
 #include "miscadmin.h"
 #include "nodes/nodeFuncs.h"
+#include "nodes/plannodes.h"
 #include "optimizer/clauses.h"
 #include "optimizer/optimizer.h"
 #include "parser/parse_agg.h"
@@ -173,6 +175,21 @@ typedef struct WindowStatePerAggData
 	bool		restart;		/* need to restart this agg in this cycle? */
 } WindowStatePerAggData;
 
+typedef struct
+{
+	WindowAggState *winstate;
+	int64		maxOffset;		/* max backward-reach offset across all nav
+								 * exprs */
+	bool		maxOverflow;	/* true if backward-reach overflow detected */
+	int64		minFirstOffset; /* min forward-from-match_start offset; may be
+								 * negative (PREV_FIRST: inner - outer < 0) */
+	bool		hasMax;			/* any backward-reach nav found */
+	bool		hasFirst;		/* any FIRST-based nav found */
+	bool		validate;		/* fail-closed on a null/negative offset?
+								 * false at init (display only), true at
+								 * execution */
+} EvalDefineOffsetsContext;
+
 static void initialize_windowaggregate(WindowAggState *winstate,
 									   WindowStatePerFunc perfuncstate,
 									   WindowStatePerAgg peraggstate);
@@ -209,6 +226,9 @@ static Datum GetAggInitVal(Datum textInitVal, Oid transtype);
 
 static bool are_peers(WindowAggState *winstate, TupleTableSlot *slot1,
 					  TupleTableSlot *slot2);
+static int	WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
+							  int relpos, int seektype, bool set_mark,
+							  bool *isnull, bool *isout);
 static bool window_gettupleslot(WindowObject winobj, int64 pos,
 								TupleTableSlot *slot);
 
@@ -227,6 +247,25 @@ static uint8 get_notnull_info(WindowObject winobj,
 							  int64 pos, int argno);
 static void put_notnull_info(WindowObject winobj,
 							 int64 pos, int argno, bool isnull);
+static bool rpr_is_defined(WindowAggState *winstate);
+static int64 row_is_in_reduced_frame(WindowObject winobj, int64 pos);
+static void ensure_reduced_frame(WindowObject winobj, int64 pos);
+
+static void clear_reduced_frame(WindowAggState *winstate);
+static int	get_reduced_frame_status(WindowAggState *winstate, int64 pos);
+static void advance_nav_mark(WindowAggState *winstate, int64 currentPos);
+static void advance_reduced_frame_nfa(WindowObject winobj,
+									  RPRNFAContext *targetCtx, int64 pos,
+									  bool hasLimitedFrame, int64 frameOffset);
+static void update_reduced_frame(WindowObject winobj, int64 pos);
+
+/* Forward declarations - DEFINE row evaluation */
+static bool rpr_prepare_row(WindowObject winobj, int64 pos, RPRVarMatch *varMatched);
+static void build_define_offsets(WindowAggState *winstate, List *defineClause);
+static void resolve_nav_offsets(WindowAggState *winstate);
+static void resolve_one_nav(RPRNavOffsets *entry, EvalDefineOffsetsContext *context);
+static bool RPRNavExpr_walker(Node *node, WindowAggState *winstate);
+static void build_nav_offsets(RPRNavExpr *nav, WindowAggState *winstate);
 
 /*
  * Not null info bit array consists of 2-bit items
@@ -820,6 +859,9 @@ eval_windowaggregates(WindowAggState *winstate)
 	 *	   transition function, or
 	 *	 - we have an EXCLUSION clause, or
 	 *	 - if the new frame doesn't overlap the old one
+	 *   - if RPR (Row Pattern Recognition) is enabled, because the reduced
+	 *     frame depends on pattern matching results which can differ entirely
+	 *     from row to row, making inverse transition optimization inapplicable
 	 *
 	 * Note that we don't strictly need to restart in the last case, but if
 	 * we're going to remove all rows from the aggregation anyway, a restart
@@ -834,7 +876,8 @@ eval_windowaggregates(WindowAggState *winstate)
 			(winstate->aggregatedbase != winstate->frameheadpos &&
 			 !OidIsValid(peraggstate->invtransfn_oid)) ||
 			(winstate->frameOptions & FRAMEOPTION_EXCLUSION) ||
-			winstate->aggregatedupto <= winstate->frameheadpos)
+			winstate->aggregatedupto <= winstate->frameheadpos ||
+			rpr_is_defined(winstate))
 		{
 			peraggstate->restart = true;
 			numaggs_restart++;
@@ -963,6 +1006,14 @@ eval_windowaggregates(WindowAggState *winstate)
 	{
 		winstate->aggregatedupto = winstate->frameheadpos;
 		ExecClearTuple(agg_row_slot);
+
+		/*
+		 * If RPR is defined, we do not use aggregatedupto_nonrestarted.  To
+		 * avoid assertion failure below, we reset aggregatedupto_nonrestarted
+		 * to frameheadpos.
+		 */
+		if (rpr_is_defined(winstate))
+			aggregatedupto_nonrestarted = winstate->frameheadpos;
 	}
 
 	/*
@@ -974,7 +1025,7 @@ eval_windowaggregates(WindowAggState *winstate)
 	 */
 	for (;;)
 	{
-		int			ret;
+		int64		ret;
 
 		/* Fetch next row if we didn't already */
 		if (TupIsNull(agg_row_slot))
@@ -995,6 +1046,36 @@ eval_windowaggregates(WindowAggState *winstate)
 		if (ret == 0)
 			goto next_tuple;
 
+		if (rpr_is_defined(winstate))
+		{
+			/*
+			 * If currentpos is already decided but aggregatedupto is not yet
+			 * determined, we've passed the last reduced frame.
+			 */
+			if (get_reduced_frame_status(winstate, winstate->currentpos)
+				!= RF_NOT_DETERMINED &&
+				get_reduced_frame_status(winstate, winstate->aggregatedupto)
+				== RF_NOT_DETERMINED)
+				break;
+
+			/*
+			 * Calculate the reduced frame for aggregatedupto.
+			 */
+			ret = row_is_in_reduced_frame(winstate->agg_winobj,
+										  winstate->aggregatedupto);
+			if (ret == -1)		/* unmatched row */
+				break;
+
+			/*
+			 * Check if current row is inside a match but not the head
+			 * (skipped), and it's the base row for aggregation.
+			 */
+			if (get_reduced_frame_status(winstate,
+										 winstate->aggregatedupto) == RF_SKIPPED &&
+				winstate->aggregatedupto == winstate->aggregatedbase)
+				break;
+		}
+
 		/* Set tuple context for evaluation of aggregate arguments */
 		winstate->tmpcontext->ecxt_outertuple = agg_row_slot;
 
@@ -1204,6 +1285,27 @@ prepare_tuplestore(WindowAggState *winstate)
 		}
 	}
 
+	/* Create read/mark pointers for RPR navigation if needed */
+	if (winstate->nav_winobj)
+	{
+		/*
+		 * Allocate mark and read pointers for RPR navigation.
+		 *
+		 * When the trim offset is FIXED we advance the mark based on
+		 * (currentpos - navMaxOffset) and optionally
+		 * (nfaContext->matchStartRow + navFirstOffset), allowing
+		 * tuplestore_trim() to free rows that are no longer reachable.
+		 * resolve_nav_offsets() runs before the first begin_partition(), so
+		 * the kind here is FIXED or RETAIN_ALL even for a parameterized
+		 * offset; RETAIN_ALL disables trim.
+		 */
+		winstate->nav_winobj->markptr =
+			tuplestore_alloc_read_pointer(winstate->buffer, 0);
+		winstate->nav_winobj->readptr =
+			tuplestore_alloc_read_pointer(winstate->buffer,
+										  EXEC_FLAG_BACKWARD);
+	}
+
 	/*
 	 * If we are in RANGE or GROUPS mode, then determining frame boundaries
 	 * requires physical access to the frame endpoint rows, except in certain
@@ -1260,6 +1362,8 @@ begin_partition(WindowAggState *winstate)
 	winstate->framehead_valid = false;
 	winstate->frametail_valid = false;
 	winstate->grouptail_valid = false;
+	if (rpr_is_defined(winstate))
+		clear_reduced_frame(winstate);
 	winstate->spooled_rows = 0;
 	winstate->currentpos = 0;
 	winstate->frameheadpos = 0;
@@ -1313,6 +1417,13 @@ begin_partition(WindowAggState *winstate)
 		winstate->aggregatedupto = 0;
 	}
 
+	/* reset mark and seek positions for RPR navigation */
+	if (winstate->nav_winobj)
+	{
+		winstate->nav_winobj->markpos = -1;
+		winstate->nav_winobj->seekpos = -1;
+	}
+
 	/* reset mark and seek positions for each real window function */
 	for (int i = 0; i < numfuncs; i++)
 	{
@@ -1481,6 +1592,21 @@ release_partition(WindowAggState *winstate)
 		tuplestore_clear(winstate->buffer);
 	winstate->partition_spooled = false;
 	winstate->next_partition = true;
+
+	/* Reset RPR match results */
+	clear_reduced_frame(winstate);
+
+	/* Reset NFA state for new partition */
+	winstate->nfaContext = NULL;
+	winstate->nfaContextTail = NULL;
+	winstate->nfaContextFree = NULL;
+	winstate->nfaStateFree = NULL;
+	winstate->nfaLastProcessedRow = -1;
+	winstate->nfaStatesActive = 0;
+	winstate->nfaContextsActive = 0;
+
+	/* Invalidate the nav slot position cache for the new partition. */
+	winstate->nav_slot_pos = -1;
 }
 
 /*
@@ -2276,6 +2402,16 @@ calculate_frame_offsets(PlanState *pstate)
 				ereport(ERROR,
 						(errcode(ERRCODE_INVALID_PRECEDING_OR_FOLLOWING_SIZE),
 						 errmsg("frame ending offset must not be negative")));
+
+			/*
+			 * Row pattern recognition forbids a zero-length frame end;
+			 * checked here so a non-constant offset (e.g. a bind parameter)
+			 * is caught, not just a literal 0.
+			 */
+			if (winstate->rpPattern != NULL && offset == 0)
+				ereport(ERROR,
+						errcode(ERRCODE_WINDOWING_ERROR),
+						errmsg("frame ending offset must be positive with row pattern recognition"));
 		}
 	}
 	winstate->all_first = false;
@@ -2312,6 +2448,16 @@ ExecWindowAgg(PlanState *pstate)
 	if (unlikely(winstate->all_first))
 		calculate_frame_offsets(pstate);
 
+	/*
+	 * Resolve navigation offsets the same way, during first call (or after a
+	 * rescan).  Every RPR window holding a navigation comes through here: the
+	 * pass at init resolved the constant offsets for EXPLAIN to display
+	 * without validating them, so this is where a null or negative offset is
+	 * rejected.
+	 */
+	if (unlikely(winstate->navResolvePending))
+		resolve_nav_offsets(winstate);
+
 	/* We need to loop as the runCondition or qual may filter out tuples */
 	for (;;)
 	{
@@ -2409,6 +2555,26 @@ ExecWindowAgg(PlanState *pstate)
 		/* don't evaluate the window functions when we're in pass-through mode */
 		if (winstate->status == WINDOWAGG_RUN)
 		{
+			/*
+			 * If RPR is defined and skip mode is next row, clear the current
+			 * match so the next row triggers re-evaluation.
+			 */
+			if (rpr_is_defined(winstate))
+			{
+				if (winstate->rpSkipTo == ST_NEXT_ROW)
+					clear_reduced_frame(winstate);
+
+				/*
+				 * Drive the row pattern match every row, so it tracks the row
+				 * scan rather than frame access: a window function that skips
+				 * the frame (e.g. nth_value() with a NULL offset) must not
+				 * leave the match state behind currentpos.
+				 */
+				Assert(winstate->nav_winobj != NULL);
+				ensure_reduced_frame(winstate->nav_winobj,
+									 winstate->currentpos);
+			}
+
 			/*
 			 * Evaluate true window functions
 			 */
@@ -2591,12 +2757,28 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 
 	/*
 	 * Create expression contexts.  We need two, one for per-input-tuple
-	 * processing and one for per-output-tuple processing.  We cheat a little
-	 * by using ExecAssignExprContext() to build both.
+	 * processing and one for per-output-tuple processing, plus an optional
+	 * third for row pattern recognition DEFINE evaluation (built just below
+	 * when a DEFINE clause is present).  We cheat a little by using
+	 * ExecAssignExprContext() to build them all.  Each call overwrites
+	 * ps_ExprContext, so the last call must establish the output context.
 	 */
 	ExecAssignExprContext(estate, &winstate->ss.ps);
 	tmpcontext = winstate->ss.ps.ps_ExprContext;
 	winstate->tmpcontext = tmpcontext;
+
+	/*
+	 * Row pattern recognition evaluates DEFINE clauses in a third context,
+	 * reset before each DEFINE evaluation pass.  It must be distinct from
+	 * tmpcontext and ps_ExprContext so its reset frees neither input nor
+	 * output tuple memory.
+	 */
+	if (node->defineClause != NIL)
+	{
+		ExecAssignExprContext(estate, &winstate->ss.ps);
+		winstate->rprContext = winstate->ss.ps.ps_ExprContext;
+	}
+
 	ExecAssignExprContext(estate, &winstate->ss.ps);
 
 	/* Create long-lived context for storage of partition-local memory etc */
@@ -2861,9 +3043,166 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->more_partitions = false;
 	winstate->next_partition = true;
 
+	/*
+	 * RPR stuff, in struct declaration order except for the nav offsets,
+	 * which build_define_offsets() below accumulates into; the four
+	 * NFALengthStats members keep the zeroes palloc0 gave them.
+	 */
+	if (node->rpPattern != NULL)
+	{
+		int			nfaVisitedNWords;
+		WindowObject nav_winobj;
+
+		winstate->rpSkipTo = node->rpSkipTo;
+		winstate->rpPattern = node->rpPattern;
+		winstate->defineClauseExprs = NIL;
+
+		winstate->rprNavOffsets = NIL;
+		winstate->navMaxOffset = 0;
+		winstate->navMaxOffsetKind = RPR_NAV_OFFSET_FIXED;
+		winstate->hasMaxNav = false;
+		winstate->hasFirstNav = false;
+		winstate->navFirstOffset = 0;
+		winstate->navFirstOffsetKind = RPR_NAV_OFFSET_FIXED;
+
+		/*
+		 * Must run this before the ExecInitExpr() loop over defineClause:
+		 * while compiling each RPRNavExpr, ExecInitExpr() reads
+		 * winstate->rprNavOffsets to link the RPRNavState to its entry and
+		 * seed the offset, and this call is what fills that list
+		 */
+		build_define_offsets(winstate, node->defineClause);
+
+		/*
+		 * Compile DEFINE clause expressions.  PREV/NEXT navigation is handled
+		 * by EEOP_RPR_NAV_SET/RESTORE opcodes emitted during ExecInitExpr, so
+		 * no varno rewriting is needed here.  Expressions are kept in DEFINE
+		 * order, so their list index equals the variable's varId.
+		 */
+		foreach_node(TargetEntry, te, node->defineClause)
+		{
+			ExprState  *exprstate;
+
+			exprstate = ExecInitExpr(te->expr, (PlanState *) winstate);
+
+			winstate->defineClauseExprs =
+				lappend(winstate->defineClauseExprs, exprstate);
+		}
+
+		/* Initialize NFA free lists for row pattern matching */
+		winstate->nfaContext = NULL;
+		winstate->nfaContextTail = NULL;
+		winstate->nfaContextFree = NULL;
+		winstate->nfaStateFree = NULL;
+		winstate->nfaStateSize = offsetof(RPRNFAState, counts) +
+			sizeof(int32) * node->rpPattern->maxDepth;
+
+		/*
+		 * Allocate the per-row varMatched cache.  varNames are built in
+		 * DEFINE order, so varId equals the DEFINE list index and no mapping
+		 * is needed.
+		 */
+		if (winstate->defineClauseExprs != NIL)
+			winstate->nfaVarMatched = palloc0(sizeof(RPRVarMatch) *
+											  list_length(winstate->defineClauseExprs));
+		else
+			winstate->nfaVarMatched = NULL;
+
+		/* Copy match_start dependency bitmapset for per-context evaluation */
+		winstate->defineMatchStartDependent = bms_copy(node->defineMatchStartDependent);
+
+		nfaVisitedNWords =
+			(node->rpPattern->numElements - 1) / BITS_PER_BITMAPWORD + 1;
+
+		winstate->nfaVisitedEnds = palloc0(sizeof(bitmapword) *
+										   nfaVisitedNWords);
+
+		/* High-water mark sentinels: no bits set yet. */
+		winstate->nfaVisitedMinWord = PG_INT16_MAX;
+		winstate->nfaVisitedMaxWord = -1;
+
+		winstate->nfaLastProcessedRow = -1;
+		winstate->nfaStatesActive = 0;
+		winstate->nfaStatesMax = 0;
+		winstate->nfaStatesTotalCreated = 0;
+		winstate->nfaStatesMerged = 0;
+		winstate->nfaContextsActive = 0;
+		winstate->nfaContextsMax = 0;
+		winstate->nfaContextsTotalCreated = 0;
+		winstate->nfaContextsAbsorbed = 0;
+		winstate->nfaContextsSkipped = 0;
+		winstate->nfaContextsPruned = 0;
+		winstate->nfaMatchesSucceeded = 0;
+		winstate->nfaMatchesFailed = 0;
+
+		/*
+		 * Nav offsets are resolved (and validated) at execution, like frame
+		 * offsets: on the first scan and after each rescan, for every RPR
+		 * window.
+		 */
+		winstate->navResolvePending = (winstate->rprNavOffsets != NIL);
+
+		/*
+		 * Set up WindowObject for RPR navigation opcodes.  This is separate
+		 * from agg_winobj because it needs its own read pointer to avoid
+		 * interfering with aggregate processing.
+		 */
+		nav_winobj = makeNode(WindowObjectData);
+		nav_winobj->winstate = winstate;
+		nav_winobj->argstates = NIL;
+		nav_winobj->localmem = NULL;
+		nav_winobj->markptr = -1;
+		nav_winobj->readptr = -1;
+		winstate->nav_winobj = nav_winobj;
+
+		winstate->nav_slot_pos = -1;
+		winstate->nav_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+													&TTSOpsMinimalTuple);
+		winstate->nav_saved_outertuple = NULL;
+		winstate->nav_match_start = 0;
+		winstate->rpr_match_start = -1;
+		winstate->rpr_match_length = -1;
+	}
+
 	return winstate;
 }
 
+/*
+ * ExecRPRNavGetSlot
+ *
+ * Fetch tuple at given position for RPR navigation opcodes.
+ * Returns nav_slot with the tuple loaded, or NULL if out of range.
+ */
+TupleTableSlot *
+ExecRPRNavGetSlot(WindowAggState *winstate, int64 pos)
+{
+	WindowObject winobj = winstate->nav_winobj;
+	TupleTableSlot *slot = winstate->nav_slot;
+
+	if (pos < 0)
+		return NULL;
+
+	/*
+	 * If nav_slot already holds this position, return it without re-fetching.
+	 * This is critical when multiple PREV/NEXT calls in the same expression
+	 * navigate to the same row, because re-fetching would free the slot's
+	 * tuple memory and invalidate any pass-by-ref Datum pointers from earlier
+	 * navigation results.
+	 */
+	if (winstate->nav_slot_pos == pos)
+		return slot;
+
+	if (!window_gettupleslot(winobj, pos, slot))
+	{
+		winstate->nav_slot_pos = -1;
+		return NULL;
+	}
+
+	winstate->nav_slot_pos = pos;
+	return slot;
+}
+
+
 /* -----------------
  * ExecEndWindowAgg
  * -----------------
@@ -2911,6 +3250,8 @@ ExecReScanWindowAgg(WindowAggState *node)
 
 	node->status = WINDOWAGG_RUN;
 	node->all_first = true;
+	/* offsets are re-resolved and re-validated at the next scan */
+	node->navResolvePending = (node->rprNavOffsets != NIL);
 
 	/* release tuplestore et al */
 	release_partition(node);
@@ -2921,6 +3262,8 @@ ExecReScanWindowAgg(WindowAggState *node)
 	ExecClearTuple(node->agg_row_slot);
 	ExecClearTuple(node->temp_slot_1);
 	ExecClearTuple(node->temp_slot_2);
+	if (node->nav_slot)
+		ExecClearTuple(node->nav_slot);
 	if (node->framehead_slot)
 		ExecClearTuple(node->framehead_slot);
 	if (node->frametail_slot)
@@ -3418,6 +3761,7 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 	int			notnull_offset;
 	int			notnull_relpos;
 	int			forward;
+	int64		num_reduced_frame;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
@@ -3446,6 +3790,13 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 			/* rejecting relpos > 0 is easy and simplifies code below */
 			if (relpos > 0)
 				goto out_of_frame;
+
+			/*
+			 * RPR cares about frame head pos. Need to call
+			 * update_frameheadpos
+			 */
+			update_frameheadpos(winstate);
+
 			update_frametailpos(winstate);
 			abs_pos = winstate->frametailpos - 1;
 			mark_pos = 0;		/* keep compiler quiet */
@@ -3461,6 +3812,35 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 	 * Get the next nonnull value in the frame, moving forward or backward
 	 * until we find a value or reach the frame's end.
 	 */
+
+	/*
+	 * Check whether current row is in reduced frame.
+	 */
+	num_reduced_frame = row_is_in_reduced_frame(winobj, winstate->frameheadpos);
+	if (num_reduced_frame < 0)	/* unmatched or skipped row */
+		goto out_of_frame;
+	else if (num_reduced_frame > 0) /* the first row of the reduced frame */
+	{
+		/*
+		 * Early check if row could be out of reduced frame.  When RPR is
+		 * enabled, EXCLUDE clause cannot be specified and the frame is always
+		 * contiguous.  So we can safely perform the following checks. Note,
+		 * however, it is possible that a row is out of reduced frame if
+		 * there's a NULL in the middle. So we need to check it in the
+		 * following do loop.
+		 */
+		if (seektype == WINDOW_SEEK_HEAD && relpos >= num_reduced_frame)
+			goto out_of_frame;
+		if (seektype == WINDOW_SEEK_TAIL)
+		{
+			if (notnull_relpos >= num_reduced_frame)
+				goto out_of_frame;
+
+			/* not out of reduced frame. Set abspos as a starting point */
+			abs_pos = winstate->frameheadpos + num_reduced_frame - 1;
+		}
+	}
+
 	do
 	{
 		int			inframe;
@@ -3522,6 +3902,16 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 		}
 advance:
 		abs_pos += forward;
+		if (rpr_is_defined(winstate))
+		{
+			/*
+			 * Check whether we are still in the reduced frame.  (also check
+			 * if we succeeded in getting the target row).
+			 */
+			num_reduced_frame--;
+			if (num_reduced_frame <= 0 && notnull_offset <= notnull_relpos)
+				goto out_of_frame;
+		}
 	} while (notnull_offset <= notnull_relpos);
 
 	if (set_mark)
@@ -3664,128 +4054,1201 @@ put_notnull_info(WindowObject winobj, int64 pos, int argno, bool isnull)
 	mbp[bpos] = mb;
 }
 
-/***********************************************************************
- * API exposed to window functions
- ***********************************************************************/
-
-
 /*
- * WinCheckAndInitializeNullTreatment
- *		Check null treatment clause and sets ignore_nulls
+ * eval_nav_offset
+ *		Evaluate a pre-built row pattern navigation offset ExprState.
  *
- * Window functions should call this to check if they are being called with
- * a null treatment clause when they don't allow it, or to set ignore_nulls.
+ * The offset is a run-time constant (the parser rejects column references in a
+ * navigation offset), so it is evaluated once per scan -- when any parameter
+ * is bound.  Returns the offset as an int64; a NULL or negative result is an
+ * error per the SQL standard (fail-closed, re-checked on every scan).  When
+ * not validating, a NULL is reported as -1 so that it takes the same path a
+ * negative offset takes.
  */
-void
-WinCheckAndInitializeNullTreatment(WindowObject winobj,
-								   bool allowNullTreatment,
-								   FunctionCallInfo fcinfo)
+static int64
+eval_nav_offset(WindowAggState *winstate, ExprState *estate, bool validate)
 {
-	Assert(WindowObjectIsValid(winobj));
-	if (winobj->ignore_nulls != NO_NULLTREATMENT && !allowNullTreatment)
+	ExprContext *econtext = winstate->ss.ps.ps_ExprContext;
+	Datum		val;
+	bool		isnull;
+	int64		offset;
+
+	val = ExecEvalExprSwitchContext(estate, econtext, &isnull);
+
+	if (isnull)
 	{
-		const char *funcname = get_func_name(fcinfo->flinfo->fn_oid);
+		if (validate)
+			ereport(ERROR,
+					errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+					errmsg("row pattern navigation offset must not be null"));
+		return -1;				/* the caller drops it from the reach */
+	}
 
-		if (!funcname)
-			elog(ERROR, "could not get function name");
+	offset = DatumGetInt64(val);
+
+	if (offset < 0 && validate)
 		ereport(ERROR,
-				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-				 errmsg("function %s does not allow RESPECT/IGNORE NULLS",
-						funcname)));
-	}
-	else if (winobj->ignore_nulls == PARSER_IGNORE_NULLS)
-		winobj->ignore_nulls = IGNORE_NULLS;
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("row pattern navigation offset must not be negative"));
+
+	return offset;
 }
 
 /*
- * WinGetPartitionLocalMemory
- *		Get working memory that lives till end of partition processing
+ * build_nav_offsets
+ *		Create the per-navigation offset bookkeeping entry at executor init and
+ *		compile its offset argument expression(s).
  *
- * On first call within a given partition, this allocates and zeroes the
- * requested amount of space.  Subsequent calls just return the same chunk.
- *
- * Memory obtained this way is normally used to hold state that should be
- * automatically reset for each new partition.  If a window function wants
- * to hold state across the whole query, fcinfo->fn_extra can be used in the
- * usual way for that.
+ * The offsets are not evaluated here: a PARAM_EXEC offset (function inlining or
+ * a LATERAL reference) has no value until the node is (re)scanned.  The
+ * concrete value is resolved per scan by resolve_nav_offsets(), mirroring how
+ * calculate_frame_offsets() handles the window frame bounds.
  */
-void *
-WinGetPartitionLocalMemory(WindowObject winobj, Size sz)
+static void
+build_nav_offsets(RPRNavExpr *nav, WindowAggState *winstate)
 {
-	Assert(WindowObjectIsValid(winobj));
-	if (winobj->localmem == NULL)
-		winobj->localmem =
-			MemoryContextAllocZero(winobj->winstate->partcontext, sz);
-	return winobj->localmem;
-}
+	RPRNavOffsets *entry = palloc0_object(RPRNavOffsets);
 
-/*
- * WinGetCurrentPosition
- *		Return the current row's position (counting from 0) within the current
- *		partition.
- */
-int64
-WinGetCurrentPosition(WindowObject winobj)
-{
-	Assert(WindowObjectIsValid(winobj));
-	return winobj->winstate->currentpos;
+	/*
+	 * Parser guarantee (mirrors compute_matchStartDependent): nav's direct
+	 * children are never RPRNavExpr -- compound nesting is flattened in place
+	 * and any other nesting is rejected.  Outer-kind dispatch is sufficient.
+	 */
+	Assert(nav->arg == NULL || !IsA(nav->arg, RPRNavExpr));
+	Assert(nav->offset_arg == NULL || !IsA(nav->offset_arg, RPRNavExpr));
+	Assert(nav->compound_offset_arg == NULL ||
+		   !IsA(nav->compound_offset_arg, RPRNavExpr));
+
+	entry->nav = nav;
+	if (nav->offset_arg != NULL)
+		entry->offset_state = ExecInitExpr(nav->offset_arg,
+										   (PlanState *) winstate);
+	if (nav->compound_offset_arg != NULL)
+		entry->compound_offset_state = ExecInitExpr(nav->compound_offset_arg,
+													(PlanState *) winstate);
+
+	/*
+	 * Own the execution state of the compiled navigation.  ExecInitExprRec()
+	 * runs after this and reaches the entry by nav->navno; the offsets stay
+	 * unset until resolve_nav_offsets() settles them for the scan.
+	 */
+	entry->rprnavstate = makeNode(RPRNavState);
+	entry->rprnavstate->winstate = winstate;
+	entry->rprnavstate->rprnavexpr = nav;
+	entry->rprnavstate->offset.isnull = true;
+	entry->rprnavstate->offset.value = (Datum) 0;
+	entry->rprnavstate->compound_offset.isnull = true;
+	entry->rprnavstate->compound_offset.value = (Datum) 0;
+
+	winstate->rprNavOffsets = lappend(winstate->rprNavOffsets, entry);
 }
 
-/*
- * WinGetPartitionRowCount
- *		Return total number of rows contained in the current partition.
- *
- * Note: this is a relatively expensive operation because it forces the
- * whole partition to be "spooled" into the tuplestore at once.  Once
- * executed, however, additional calls within the same partition are cheap.
- */
-int64
-WinGetPartitionRowCount(WindowObject winobj)
+static bool
+RPRNavExpr_walker(Node *node, WindowAggState *winstate)
 {
-	Assert(WindowObjectIsValid(winobj));
-	spool_tuples(winobj->winstate, -1);
-	return winobj->winstate->spooled_rows;
+	if (node == NULL)
+		return false;
+	if (IsA(node, RPRNavExpr))
+		build_nav_offsets(castNode(RPRNavExpr, node), winstate);
+
+	return expression_tree_walker(node, RPRNavExpr_walker, winstate);
 }
 
 /*
- * WinSetMarkPosition
- *		Set the "mark" position for the window object, which is the oldest row
- *		number (counting from 0) it is allowed to fetch during all subsequent
- *		operations within the current partition.
+ * build_define_offsets
+ *		At executor init, create one RPRNavOffsets entry per navigation in the
+ *		DEFINE clause and compile its offset argument expressions.
  *
- * Window functions do not have to call this, but are encouraged to move the
- * mark forward when possible to keep the tuplestore size down and prevent
- * having to spill rows to disk.
+ * Entries are appended in walk order, the order compute_define_metadata()
+ * numbered them in, so entry i is the navigation with navno i.
+ *
+ * The concrete offset values -- and the tuplestore trim bounds derived from
+ * them -- are resolved later, per scan, by resolve_nav_offsets().  Only an RPR
+ * window reaches here, and the fields this fills are left at their palloc0
+ * defaults on the paths that return early.
  */
-void
-WinSetMarkPosition(WindowObject winobj, int64 markpos)
+static void
+build_define_offsets(WindowAggState *winstate, List *defineClause)
 {
-	WindowAggState *winstate;
+	EvalDefineOffsetsContext ctx;
 
-	Assert(WindowObjectIsValid(winobj));
-	winstate = winobj->winstate;
+	if (defineClause == NIL)
+		return;
 
-	if (markpos < winobj->markpos)
-		elog(ERROR, "cannot move WindowObject's mark position backward");
-	tuplestore_select_read_pointer(winstate->buffer, winobj->markptr);
-	if (markpos > winobj->markpos)
+	foreach_node(TargetEntry, te, defineClause)
 	{
-		tuplestore_skiptuples(winstate->buffer,
-							  markpos - winobj->markpos,
-							  true);
-		winobj->markpos = markpos;
+		RPRNavExpr_walker((Node *) te->expr, winstate);
 	}
-	tuplestore_select_read_pointer(winstate->buffer, winobj->readptr);
-	if (markpos > winobj->seekpos)
+
+	/*
+	 * Resolve the offsets that are already constant at plan time, so EXPLAIN
+	 * (which never executes, hence never reaches resolve_nav_offsets()) shows
+	 * the real trim bounds.  A parameterized offset (PARAM_EXTERN under a
+	 * generic plan, or a PARAM_EXEC) has no value yet and is left for
+	 * resolve_nav_offsets() to bound per scan.
+	 */
+	ctx.winstate = winstate;
+	ctx.maxOffset = 0;
+	ctx.maxOverflow = false;
+	ctx.minFirstOffset = PG_INT64_MAX;
+	ctx.hasMax = false;
+	ctx.hasFirst = false;
+	ctx.validate = false;		/* init resolution is for EXPLAIN display only */
+
+	foreach_ptr(RPRNavOffsets, entry, winstate->rprNavOffsets)
 	{
-		tuplestore_skiptuples(winstate->buffer,
-							  markpos - winobj->seekpos,
-							  true);
-		winobj->seekpos = markpos;
+		RPRNavExpr *nav = entry->nav;
+		bool		is_const;
+
+		/*
+		 * A foldable offset such as PREV(v, 1 + 1) counts as fixed only if
+		 * eval_const_expressions() reached inside the navigation and left a
+		 * Const here, which the expression tree mutator does for us.
+		 */
+		is_const = (nav->offset_arg == NULL || IsA(nav->offset_arg, Const)) &&
+			(nav->compound_offset_arg == NULL ||
+			 IsA(nav->compound_offset_arg, Const));
+
+		if (is_const)
+		{
+			/* constant offset: resolvable now, for EXPLAIN and the scan */
+			resolve_one_nav(entry, &ctx);
+		}
+		else
+		{
+			/*
+			 * A parameterized offset (a bind PARAM_EXTERN or, via
+			 * SRF/function inlining, a correlated PARAM_EXEC) has no
+			 * dependable value at init.  Like a window frame offset it is
+			 * resolved at execution by resolve_nav_offsets(), and EXPLAIN
+			 * shows "runtime".
+			 */
+			if (nav->kind == RPR_NAV_PREV || nav->kind == RPR_NAV_LAST ||
+				nav->kind == RPR_NAV_PREV_LAST || nav->kind == RPR_NAV_NEXT_LAST)
+			{
+				ctx.hasMax = true;
+				winstate->navMaxOffsetKind = RPR_NAV_OFFSET_NEEDS_EVAL;
+			}
+			if (nav->kind == RPR_NAV_FIRST || nav->kind == RPR_NAV_PREV_FIRST ||
+				nav->kind == RPR_NAV_NEXT_FIRST)
+			{
+				ctx.hasFirst = true;
+				winstate->navFirstOffsetKind = RPR_NAV_OFFSET_NEEDS_EVAL;
+			}
+		}
 	}
-}
 
-/*
+	if (ctx.maxOverflow)
+	{
+		/*
+		 * a const/bind overflow forces retain-all, unless a param already
+		 * made this dimension "runtime" (NEEDS_EVAL wins for display)
+		 */
+		if (winstate->navMaxOffsetKind != RPR_NAV_OFFSET_NEEDS_EVAL)
+			winstate->navMaxOffsetKind = RPR_NAV_OFFSET_RETAIN_ALL;
+	}
+	else
+		winstate->navMaxOffset = ctx.maxOffset;
+
+	winstate->hasMaxNav = ctx.hasMax;
+
+	/* minFirstOffset is still PG_INT64_MAX when there is no FIRST */
+	winstate->hasFirstNav = ctx.hasFirst;
+	winstate->navFirstOffset = ctx.minFirstOffset;
+}
+
+/*
+ * resolve_one_nav
+ *		Evaluate one navigation's offset(s) for the current scan, pin the
+ *		resolved values into its RPRNavState, and accumulate the backward and
+ *		forward reach used to size the tuplestore trim.
+ */
+static void
+resolve_one_nav(RPRNavOffsets *entry, EvalDefineOffsetsContext *context)
+{
+	RPRNavExpr *nav = entry->nav;
+	int64		inner;
+	int64		outer;
+
+	/* Inner offset */
+	if (entry->offset_state != NULL)
+		inner = eval_nav_offset(context->winstate, entry->offset_state,
+								context->validate);
+	else if (nav->kind == RPR_NAV_PREV || nav->kind == RPR_NAV_NEXT)
+		inner = 1;
+	else
+		inner = 0;
+
+	/* Outer (compound) offset */
+	if (entry->compound_offset_state != NULL)
+		outer = eval_nav_offset(context->winstate, entry->compound_offset_state,
+								context->validate);
+	else
+		outer = 1;
+
+	/*
+	 * An offset that is negative, or null and therefore reported as -1, is
+	 * rejected at execution, where eval_nav_offset() has already raised the
+	 * error before we get here, so this navigation can never run and needs no
+	 * rows retained.  Leave it out of both reaches, which also keeps the
+	 * arithmetic below on non-negative operands.
+	 */
+	if (inner < 0 || outer < 0)
+	{
+		Assert(!context->validate);
+		return;
+	}
+
+	/*
+	 * Pin the resolved values into the compiled navigation's RPRNavState, so
+	 * ExecEvalRPRNavSet() reads this scan's constant instead of re-evaluating
+	 * the offset per row.
+	 */
+	entry->rprnavstate->offset.isnull = false;
+	entry->rprnavstate->offset.value = Int64GetDatum(inner);
+	entry->rprnavstate->compound_offset.isnull = false;
+	entry->rprnavstate->compound_offset.value = Int64GetDatum(outer);
+
+	/*
+	 * Backward reach: PREV, LAST at any offset including the default 0, and
+	 * compound PREV_LAST/NEXT_LAST.
+	 */
+	if (nav->kind == RPR_NAV_PREV ||
+		nav->kind == RPR_NAV_LAST ||
+		nav->kind == RPR_NAV_PREV_LAST ||
+		nav->kind == RPR_NAV_NEXT_LAST)
+	{
+		context->hasMax = true;
+
+		if (!context->maxOverflow)
+		{
+			int64		reach = 0;
+
+			if (nav->kind == RPR_NAV_PREV || nav->kind == RPR_NAV_LAST)
+				reach = inner;
+			else if (nav->kind == RPR_NAV_PREV_LAST)
+			{
+				if (pg_add_s64_overflow(inner, outer, &reach))
+					context->maxOverflow = true;
+			}
+			else
+				reach = Max(inner - outer, 0);
+
+			if (!context->maxOverflow)
+				context->maxOffset = Max(context->maxOffset, reach);
+		}
+	}
+
+	/* Forward reach from match_start: FIRST, compound PREV_FIRST/NEXT_FIRST */
+	if (nav->kind == RPR_NAV_FIRST ||
+		nav->kind == RPR_NAV_PREV_FIRST ||
+		nav->kind == RPR_NAV_NEXT_FIRST)
+	{
+		int64		reach;
+
+		context->hasFirst = true;
+
+		if (nav->kind == RPR_NAV_FIRST)
+			reach = inner;
+		else if (nav->kind == RPR_NAV_PREV_FIRST)
+			reach = inner - outer;	/* both >= 0, cannot underflow int64 */
+		else
+		{
+			/* NEXT_FIRST: inner + outer, always >= 0; clamp on overflow */
+			if (pg_add_s64_overflow(inner, outer, &reach))
+				reach = PG_INT64_MAX;
+		}
+
+		context->minFirstOffset = Min(context->minFirstOffset, reach);
+	}
+}
+
+/*
+ * resolve_nav_offsets
+ *		Resolve every navigation offset for the current scan and store the
+ *		tuplestore trim bounds in the WindowAggState.
+ *
+ * Called from ExecWindowAgg on the first call and after every rescan -- the
+ * same place calculate_frame_offsets() resolves the window frame bounds.  By
+ * then every parameter (PARAM_EXTERN and PARAM_EXEC alike) is bound, and the
+ * offset is a run-time constant, so a single evaluation per scan is correct.
+ * This keeps the trim finite for a parameterized offset (no retain-all) and
+ * revalidates it (fail-closed) on each scan.
+ */
+static void
+resolve_nav_offsets(WindowAggState *winstate)
+{
+	EvalDefineOffsetsContext ctx;
+
+	/* Servicing the request now; clear the per-scan pending flag */
+	winstate->navResolvePending = false;
+
+	winstate->navMaxOffset = 0;
+	winstate->navMaxOffsetKind = RPR_NAV_OFFSET_FIXED;
+	winstate->hasMaxNav = false;
+	winstate->hasFirstNav = false;
+	winstate->navFirstOffset = 0;
+	winstate->navFirstOffsetKind = RPR_NAV_OFFSET_FIXED;
+
+	if (winstate->rprNavOffsets == NIL)
+		return;
+
+	ctx.winstate = winstate;
+	ctx.maxOffset = 0;
+	ctx.maxOverflow = false;
+	ctx.minFirstOffset = PG_INT64_MAX;
+	ctx.hasMax = false;
+	ctx.hasFirst = false;
+	ctx.validate = true;		/* execution: fail-closed on null/negative */
+
+	foreach_ptr(RPRNavOffsets, entry, winstate->rprNavOffsets)
+	{
+		resolve_one_nav(entry, &ctx);
+	}
+
+	/*
+	 * Backward (PREV/LAST) reach.  On int64 overflow the lookback cannot be
+	 * bounded, so mark the dimension RETAIN_ALL; advance_nav_mark() reads it
+	 * to disable tuplestore trim.
+	 */
+	if (ctx.maxOverflow)
+		winstate->navMaxOffsetKind = RPR_NAV_OFFSET_RETAIN_ALL;
+	else
+		winstate->navMaxOffset = ctx.maxOffset;
+
+	winstate->hasMaxNav = ctx.hasMax;
+
+	/* Forward (FIRST) reach; never needs a retain-all sentinel */
+	winstate->hasFirstNav = ctx.hasFirst;
+	winstate->navFirstOffset = ctx.minFirstOffset;
+}
+
+/*
+ * rpr_is_defined
+ * Return true if row pattern recognition is defined.
+ */
+static bool
+rpr_is_defined(WindowAggState *winstate)
+{
+	return winstate->rpPattern != NULL;
+}
+
+/*
+ * -----------------
+ * row_is_in_reduced_frame
+ * Determine whether a row is in the current row's reduced window frame
+ * according to row pattern matching
+ *
+ * The row must have already been determined to be in a full window frame
+ * and fetched into the slot.
+ *
+ * Returns:
+ * = 0, RPR is not defined.
+ * >0, if the row is the first in the reduced frame. Return the number of rows
+ * in the reduced frame.
+ * -1, if the row is an unmatched row
+ * -2, if the row is inside the current match but is not its first row (an
+ * interior row of the match)
+ * -----------------
+ */
+static int64
+row_is_in_reduced_frame(WindowObject winobj, int64 pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	int			state;
+	int64		rtn;
+
+	if (!rpr_is_defined(winstate))
+	{
+		/*
+		 * RPR is not defined. Assume that we are always in the reduced window
+		 * frame.
+		 */
+		rtn = 0;
+		return rtn;
+	}
+
+	ensure_reduced_frame(winobj, pos);
+
+	state = get_reduced_frame_status(winstate, pos);
+
+	switch (state)
+	{
+		case RF_FRAME_HEAD:
+			rtn = winstate->rpr_match_length;
+			break;
+
+		case RF_SKIPPED:
+			rtn = -2;
+			break;
+
+		case RF_UNMATCHED:
+		case RF_EMPTY_MATCH:
+			rtn = -1;
+			break;
+
+		default:
+			elog(ERROR, "unrecognized state: %d at: " INT64_FORMAT,
+				 state, pos);
+			break;
+	}
+
+	return rtn;
+}
+
+/*
+ * ensure_reduced_frame
+ *		Drive the row pattern match forward so pos is resolved.
+ *
+ * Idempotent: a pos already determined is left untouched, so callers may
+ * invoke this repeatedly for the same row (once per row to track the row
+ * scan, and again when a window function accesses the frame).
+ */
+static void
+ensure_reduced_frame(WindowObject winobj, int64 pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+
+	if (get_reduced_frame_status(winstate, pos) == RF_NOT_DETERMINED)
+	{
+		update_frameheadpos(winstate);
+		update_reduced_frame(winobj, pos);
+	}
+}
+
+/*
+ * clear_reduced_frame
+ * Clear reduced frame status
+ */
+static void
+clear_reduced_frame(WindowAggState *winstate)
+{
+	winstate->rpr_match_start = -1; /* start < 0: no result determined yet */
+	winstate->rpr_match_length = -1;
+}
+
+/*
+ * get_reduced_frame_status
+ *		Look up a position against the current match.
+ *
+ * Returns one of the RF_* constants:
+ *   RF_NOT_DETERMINED  pos has not been processed yet
+ *   RF_FRAME_HEAD      pos is the start of the current match
+ *   RF_SKIPPED         pos is inside the current match but not the start
+ *   RF_UNMATCHED       pos is processed but not part of any match
+ *   RF_EMPTY_MATCH     pos is the start of an empty (zero-length) match
+ *
+ * The result slot encodes four states across two fields, with no separate
+ * "valid"/"matched" flags:
+ *
+ *   start < 0                 not determined (cleared slot)
+ *   start >= 0, length == -1  unmatched (covers only the start row)
+ *   start >= 0, length == 0   empty match (zero-length match at start)
+ *   start >= 0, length >= 1   real match spanning [start, start + length)
+ *
+ * The tests below form a cascade with early returns, so their order is
+ * significant.
+ */
+static int
+get_reduced_frame_status(WindowAggState *winstate, int64 pos)
+{
+	int64		start = winstate->rpr_match_start;
+	int64		length = winstate->rpr_match_length;
+
+	if (start < 0)
+		return RF_NOT_DETERMINED;	/* cleared slot: no result recorded yet */
+
+	/*
+	 * Unmatched (length -1) and empty match (length 0) do not describe a
+	 * positive-length range, so they are classified before the range test.
+	 * Each covers only its own start row; any other position is not part of
+	 * this record and is still undetermined.
+	 */
+	if (length == -1)
+		return (pos == start) ? RF_UNMATCHED : RF_NOT_DETERMINED;
+	if (length == 0)
+		return (pos == start) ? RF_EMPTY_MATCH : RF_NOT_DETERMINED;
+
+	/*
+	 * By here length >= 1, so [start, start + length) is a well-formed range.
+	 */
+	if (pos < start || pos >= start + length)
+		return RF_NOT_DETERMINED;
+
+	/* pos lies within a real match. */
+	if (pos == start)
+		return RF_FRAME_HEAD;
+
+	return RF_SKIPPED;
+}
+
+/*
+ * advance_nav_mark
+ *		Advance the RPR navigation mark, derived from the NFA frontier
+ *		(currentPos) but held back by the navigation's backward reach, so
+ *		tuplestore_trim() can free rows no longer reachable by navigation.
+ *
+ * The nav read pointer is independent of the aggregate and per-function read
+ * pointers, so moving its mark does not affect their fetches; it only bounds
+ * the DEFINE clause's own PREV/LAST/FIRST lookups.  Backward reach (PREV/LAST)
+ * is measured from the frontier.  FIRST reaches back from the head context's
+ * matchStartRow instead, so it is bounded separately; without FIRST the mark
+ * can follow the frontier freely.
+ */
+static void
+advance_nav_mark(WindowAggState *winstate, int64 currentPos)
+{
+	int64		navmarkpos;
+
+	/* No RPR navigation read pointer: nothing to advance */
+	if (winstate->nav_winobj == NULL)
+		return;
+
+	/* RETAIN_ALL (offset overflow) disables trim for the backward dimension */
+	if (winstate->navMaxOffsetKind == RPR_NAV_OFFSET_RETAIN_ALL)
+		return;
+
+	if (currentPos > winstate->navMaxOffset)
+		navmarkpos = currentPos - winstate->navMaxOffset;
+	else
+		navmarkpos = 0;
+
+	if (winstate->hasFirstNav && winstate->nfaContext != NULL)
+	{
+		int64		firstreach;
+
+		/*
+		 * Head context has the smallest matchStartRow (contexts appended in
+		 * nondecreasing order), so bounding by it covers every FIRST reach.
+		 */
+		if (!pg_add_s64_overflow(winstate->nfaContext->matchStartRow,
+								 winstate->navFirstOffset,
+								 &firstreach))
+			navmarkpos = Min(navmarkpos, Max(firstreach, 0));
+	}
+
+	if (navmarkpos > winstate->nav_winobj->markpos)
+		WinSetMarkPosition(winstate->nav_winobj, navmarkpos);
+}
+
+/*
+ * advance_reduced_frame_nfa
+ *		Drive the NFA forward until targetCtx completes or the partition ends.
+ *
+ * This is the match driver, extracted from update_reduced_frame(), which calls
+ * it to advance the match and then records the resolved result.  Row
+ * evaluations are shared across all active contexts.
+ */
+static void
+advance_reduced_frame_nfa(WindowObject winobj, RPRNFAContext *targetCtx,
+						  int64 pos, bool hasLimitedFrame, int64 frameOffset)
+{
+	WindowAggState *winstate = winobj->winstate;
+	int64		currentPos;
+	int64		startPos;
+	int64		saved_currentpos = winstate->currentpos;
+
+	/*
+	 * Determine where to start processing. Usually nfaLastProcessedRow+1 >=
+	 * pos since contexts are created at currentPos+1 during processing.
+	 * However, pos can exceed this when rows are skipped (e.g., unmatched
+	 * rows don't update nfaLastProcessedRow).
+	 */
+	startPos = Max(pos, winstate->nfaLastProcessedRow + 1);
+
+	/*
+	 * Process rows until target context completes or we hit boundaries. Each
+	 * row evaluation is shared across all active contexts.
+	 *
+	 * winstate->currentpos is set to the scan position for the whole row and
+	 * left in place across ExecRPRProcessRow, because DEFINE predicates are
+	 * evaluated lazily during matching (nfa_eval_var_match) and their
+	 * EEOP_RPR_NAV_SET opcodes read currentpos.  It is restored after the
+	 * loop.
+	 */
+	for (currentPos = startPos; targetCtx->states != NULL; currentPos++)
+	{
+		bool		rowExists;
+
+		/*
+		 * Evaluate variables for this row - done only once, shared by all
+		 * contexts.
+		 *
+		 * Set nav_match_start to the head context's matchStartRow for
+		 * FIRST/LAST navigation.  Match_start-dependent variables (FIRST,
+		 * LAST-with-offset) are re-evaluated per-context in ExecRPRProcessRow
+		 * when matchStartRow differs.
+		 */
+		winstate->currentpos = currentPos;
+		winstate->nav_match_start = targetCtx->matchStartRow;
+		rowExists = rpr_prepare_row(winobj, currentPos, winstate->nfaVarMatched);
+
+		/* No more rows in partition? Finalize all contexts */
+		if (!rowExists)
+		{
+			ExecRPRFinalizeAllContexts(winstate, currentPos - 1);
+			/* Clean up dead contexts from finalization */
+			ExecRPRCleanupDeadContexts(winstate, targetCtx);
+			break;
+		}
+
+		/* Update last processed row */
+		winstate->nfaLastProcessedRow = currentPos;
+
+		/*--------------------------
+		 * Process all contexts for this row:
+		 *   1. Match all (convergence)
+		 *   2. Absorb redundant
+		 *   3. Advance all (divergence)
+		 */
+		ExecRPRProcessRow(winstate, currentPos, hasLimitedFrame, frameOffset);
+
+		/*
+		 * Create a new context for the next potential start position. This
+		 * enables overlapping match detection for SKIP TO NEXT ROW.
+		 */
+		ExecRPRStartContext(winstate, currentPos + 1);
+
+		/*
+		 * Clean up dead contexts (failed with no active states and no match).
+		 * This removes contexts that failed during processing and counts them
+		 * appropriately as pruned or mismatched.
+		 */
+		ExecRPRCleanupDeadContexts(winstate, targetCtx);
+
+		/* Advance the nav mark to the frontier so trim can free old rows. */
+		advance_nav_mark(winstate, currentPos);
+	}
+
+	/* Restore the output row position borrowed for the NFA scan. */
+	winstate->currentpos = saved_currentpos;
+}
+
+/*
+ * update_reduced_frame
+ *		Update reduced frame info using multi-context NFA pattern matching.
+ *
+ * Maintains multiple NFA contexts simultaneously, one for each potential
+ * match start position. This allows sharing row evaluations across contexts,
+ * avoiding redundant DEFINE clause evaluations when rewinding for SKIP TO
+ * NEXT ROW mode.
+ *
+ * Key optimizations:
+ * - Row evaluations (expensive DEFINE clauses) happen only once per row
+ * - All active contexts share the same evaluation results
+ * - Contexts persist across calls, enabling O(n) DEFINE evaluations
+ */
+static void
+update_reduced_frame(WindowObject winobj, int64 pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	RPRNFAContext *targetCtx;
+	int			frameOptions = winstate->frameOptions;
+	bool		hasLimitedFrame;
+	int64		frameOffset = 0;
+	int64		matchLen;
+
+	/*
+	 * Check if we have a limited frame (ROWS ... N FOLLOWING). Each context
+	 * needs its own frame end based on matchStartRow + offset.
+	 */
+	hasLimitedFrame = (frameOptions & FRAMEOPTION_ROWS) &&
+		!(frameOptions & FRAMEOPTION_END_UNBOUNDED_FOLLOWING);
+	if (hasLimitedFrame)
+		frameOffset = DatumGetInt64(winstate->endOffsetValue);
+
+	/*
+	 * Case 1: pos is before any existing context's start position. This means
+	 * the position was already processed and determined unmatched. Head is
+	 * the oldest context (lowest matchStartRow) since contexts are added at
+	 * tail with increasing positions.
+	 */
+	if (winstate->nfaContext != NULL &&
+		pos < winstate->nfaContext->matchStartRow)
+	{
+		/* already processed, unmatched */
+		winstate->rpr_match_start = pos;
+		winstate->rpr_match_length = -1;
+		return;
+	}
+
+	/*
+	 * Case 2: Find existing context for this pos, or create new one.
+	 */
+	targetCtx = ExecRPRGetHeadContext(winstate, pos);
+	if (targetCtx == NULL)
+	{
+		/*
+		 * No context exists. If pos is already processed, it means this row
+		 * was already determined to be unmatched or skipped - no need to
+		 * reprocess.
+		 */
+		if (pos <= winstate->nfaLastProcessedRow)
+		{
+			/* already processed, unmatched */
+			winstate->rpr_match_start = pos;
+			winstate->rpr_match_length = -1;
+			return;
+		}
+		/* Not yet processed - create new context and start fresh */
+		targetCtx = ExecRPRStartContext(winstate, pos);
+	}
+	else if (targetCtx->states == NULL)
+	{
+		/*
+		 * The head context already completed in an earlier call.  Reachable
+		 * under SKIP TO NEXT ROW, where overlapping contexts let one reach
+		 * FIN -- recording its result -- before the call for its own start
+		 * row arrives.  Register that result.
+		 */
+		goto register_result;
+	}
+
+	/* Drive the NFA forward until pos's match is resolved. */
+	advance_reduced_frame_nfa(winobj, targetCtx, pos, hasLimitedFrame,
+							  frameOffset);
+
+register_result:
+	Assert(pos == targetCtx->matchStartRow);
+
+	/*
+	 * Record match result.  A determined slot has rpr_match_start >= 0; the
+	 * length then gives the kind: -1 unmatched, 0 empty match, >= 1 real
+	 * match.  A cleared slot keeps rpr_match_start < 0.
+	 */
+	winstate->rpr_match_start = targetCtx->matchStartRow;
+
+	if (targetCtx->matchEndRow < targetCtx->matchStartRow)
+	{
+		matchLen = targetCtx->lastProcessedRow - targetCtx->matchStartRow + 1;
+
+		if (targetCtx->matchedState != NULL)
+		{
+			/* Empty match: FIN reached but 0 rows consumed */
+			winstate->rpr_match_length = 0;
+			ExecRPRRecordContextSuccess(winstate, 0);
+		}
+		else
+		{
+			/* No match */
+			winstate->rpr_match_length = -1;
+			ExecRPRRecordContextFailure(winstate, matchLen);
+		}
+		ExecRPRFreeContext(winstate, targetCtx);
+		return;
+	}
+
+	/* Match succeeded */
+	matchLen = targetCtx->matchEndRow - targetCtx->matchStartRow + 1;
+
+	winstate->rpr_match_length = matchLen;
+	ExecRPRRecordContextSuccess(winstate, matchLen);
+
+	/* Remove the matched context */
+	ExecRPRFreeContext(winstate, targetCtx);
+}
+
+/*
+ * rpr_prepare_row
+ *
+ * Prepare the DEFINE evaluation context for the current row and reset the
+ * per-row tri-state cache to RPR_VAR_UNEVALUATED.
+ * Returns true if the row exists, false if out of partition.
+ *
+ * DEFINE predicates are NOT evaluated here.  Each variable is evaluated lazily
+ * the first time the NFA consumes it (nfa_eval_var_match), so a variable that
+ * no active state tests at this row is never evaluated.  The caller
+ * (advance_reduced_frame_nfa) sets winstate->currentpos to pos for the whole
+ * row, so the deferred evaluation's EEOP_RPR_NAV_SET opcodes calculate target
+ * positions (currentpos +/- offset) correctly.
+ *
+ * Uses 1-slot model: only ecxt_outertuple is set to the current row.
+ * PREV/NEXT/FIRST/LAST navigation is handled by EEOP_RPR_NAV_SET/RESTORE
+ * opcodes during expression evaluation, which temporarily swap the slot.
+ */
+static bool
+rpr_prepare_row(WindowObject winobj, int64 pos, RPRVarMatch *varMatched)
+{
+	WindowAggState *winstate = winobj->winstate;
+	ExprContext *econtext = winstate->rprContext;
+	TupleTableSlot *slot;
+
+	/* Release the previous row's DEFINE evaluation memory */
+	ResetExprContext(econtext);
+
+	/* Fetch current row into temp_slot_1 */
+	slot = winstate->temp_slot_1;
+	if (!window_gettupleslot(winobj, pos, slot))
+		return false;			/* No row exists */
+
+	/* Set up 1-slot context: only ecxt_outertuple */
+	econtext->ecxt_outertuple = slot;
+
+	/* Invalidate nav_slot cache so PREV/NEXT re-fetch for new row */
+	winstate->nav_slot_pos = -1;
+
+	/*
+	 * Reset the per-row cache to "unevaluated"; each variable's DEFINE is
+	 * evaluated lazily at first consumption in nfa_eval_var_match.
+	 */
+	if (varMatched != NULL)
+		memset(varMatched, 0,
+			   sizeof(RPRVarMatch) * list_length(winstate->defineClauseExprs));
+
+	return true;				/* Row exists */
+}
+
+/*
+ * WinGetSlotInFrame
+ * slot: TupleTableSlot to store the result
+ * relpos: signed rowcount offset from the seek position
+ * seektype: WINDOW_SEEK_HEAD or WINDOW_SEEK_TAIL
+ * set_mark: If the row is found/in frame and set_mark is true, the mark is
+ *		moved to the row as a side-effect.
+ * isnull: output argument, receives isnull status of result
+ * isout: output argument, set to indicate whether target row position
+ *		is out of frame (can pass NULL if caller doesn't care about this)
+ *
+ * Returns 0 if we successfully got the slot, or nonzero if out of frame.
+ * (isout is also set in the latter case.)
+ */
+static int
+WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
+				  int relpos, int seektype, bool set_mark,
+				  bool *isnull, bool *isout)
+{
+	WindowAggState *winstate;
+	int64		abs_pos;
+	int64		mark_pos;
+	int64		num_reduced_frame;
+
+	Assert(WindowObjectIsValid(winobj));
+	winstate = winobj->winstate;
+
+	switch (seektype)
+	{
+		case WINDOW_SEEK_CURRENT:
+			elog(ERROR, "WINDOW_SEEK_CURRENT is not supported for WinGetFuncArgInFrame");
+			abs_pos = mark_pos = 0; /* keep compiler quiet */
+			break;
+		case WINDOW_SEEK_HEAD:
+			/* rejecting relpos < 0 is easy and simplifies code below */
+			if (relpos < 0)
+				goto out_of_frame;
+			update_frameheadpos(winstate);
+			abs_pos = winstate->frameheadpos + relpos;
+			mark_pos = abs_pos;
+
+			/*
+			 * Account for exclusion option if one is active, but advance only
+			 * abs_pos not mark_pos.  This prevents changes of the current
+			 * row's peer group from resulting in trying to fetch a row before
+			 * some previous mark position.
+			 *
+			 * Note that in some corner cases such as current row being
+			 * outside frame, these calculations are theoretically too simple,
+			 * but it doesn't matter because we'll end up deciding the row is
+			 * out of frame.  We do not attempt to avoid fetching rows past
+			 * end of frame; that would happen in some cases anyway.
+			 */
+			switch (winstate->frameOptions & FRAMEOPTION_EXCLUSION)
+			{
+				case 0:
+					/* no adjustment needed */
+					break;
+				case FRAMEOPTION_EXCLUDE_CURRENT_ROW:
+					if (abs_pos >= winstate->currentpos &&
+						winstate->currentpos >= winstate->frameheadpos)
+						abs_pos++;
+					break;
+				case FRAMEOPTION_EXCLUDE_GROUP:
+					update_grouptailpos(winstate);
+					if (abs_pos >= winstate->groupheadpos &&
+						winstate->grouptailpos > winstate->frameheadpos)
+					{
+						int64		overlapstart = Max(winstate->groupheadpos,
+													   winstate->frameheadpos);
+
+						abs_pos += winstate->grouptailpos - overlapstart;
+					}
+					break;
+				case FRAMEOPTION_EXCLUDE_TIES:
+					update_grouptailpos(winstate);
+					if (abs_pos >= winstate->groupheadpos &&
+						winstate->grouptailpos > winstate->frameheadpos)
+					{
+						int64		overlapstart = Max(winstate->groupheadpos,
+													   winstate->frameheadpos);
+
+						if (abs_pos == overlapstart)
+							abs_pos = winstate->currentpos;
+						else
+							abs_pos += winstate->grouptailpos - overlapstart - 1;
+					}
+					break;
+				default:
+					elog(ERROR, "unrecognized frame option state: 0x%x",
+						 winstate->frameOptions);
+					break;
+			}
+			num_reduced_frame = row_is_in_reduced_frame(winobj,
+														winstate->frameheadpos);
+			if (num_reduced_frame < 0)
+				goto out_of_frame;
+			else if (num_reduced_frame > 0)
+				if (relpos >= num_reduced_frame)
+					goto out_of_frame;
+			break;
+		case WINDOW_SEEK_TAIL:
+			/* rejecting relpos > 0 is easy and simplifies code below */
+			if (relpos > 0)
+				goto out_of_frame;
+
+			/*
+			 * RPR cares about frame head pos. Need to call
+			 * update_frameheadpos
+			 */
+			update_frameheadpos(winstate);
+
+			update_frametailpos(winstate);
+			abs_pos = winstate->frametailpos - 1 + relpos;
+
+			/*
+			 * Account for exclusion option if one is active.  If there is no
+			 * exclusion, we can safely set the mark at the accessed row.  But
+			 * if there is, we can only mark the frame start, because we can't
+			 * be sure how far back in the frame the exclusion might cause us
+			 * to fetch in future.  Furthermore, we have to actually check
+			 * against frameheadpos here, since it's unsafe to try to fetch a
+			 * row before frame start if the mark might be there already.
+			 */
+			switch (winstate->frameOptions & FRAMEOPTION_EXCLUSION)
+			{
+				case 0:
+					/* no adjustment needed */
+					mark_pos = abs_pos;
+					break;
+				case FRAMEOPTION_EXCLUDE_CURRENT_ROW:
+					if (abs_pos <= winstate->currentpos &&
+						winstate->currentpos < winstate->frametailpos)
+						abs_pos--;
+					update_frameheadpos(winstate);
+					if (abs_pos < winstate->frameheadpos)
+						goto out_of_frame;
+					mark_pos = winstate->frameheadpos;
+					break;
+				case FRAMEOPTION_EXCLUDE_GROUP:
+					update_grouptailpos(winstate);
+					if (abs_pos < winstate->grouptailpos &&
+						winstate->groupheadpos < winstate->frametailpos)
+					{
+						int64		overlapend = Min(winstate->grouptailpos,
+													 winstate->frametailpos);
+
+						abs_pos -= overlapend - winstate->groupheadpos;
+					}
+					update_frameheadpos(winstate);
+					if (abs_pos < winstate->frameheadpos)
+						goto out_of_frame;
+					mark_pos = winstate->frameheadpos;
+					break;
+				case FRAMEOPTION_EXCLUDE_TIES:
+					update_grouptailpos(winstate);
+					if (abs_pos < winstate->grouptailpos &&
+						winstate->groupheadpos < winstate->frametailpos)
+					{
+						int64		overlapend = Min(winstate->grouptailpos,
+													 winstate->frametailpos);
+
+						if (abs_pos == overlapend - 1)
+							abs_pos = winstate->currentpos;
+						else
+							abs_pos -= overlapend - 1 - winstate->groupheadpos;
+					}
+					update_frameheadpos(winstate);
+					if (abs_pos < winstate->frameheadpos)
+						goto out_of_frame;
+					mark_pos = winstate->frameheadpos;
+					break;
+				default:
+					elog(ERROR, "unrecognized frame option state: 0x%x",
+						 winstate->frameOptions);
+					mark_pos = 0;	/* keep compiler quiet */
+					break;
+			}
+
+			num_reduced_frame = row_is_in_reduced_frame(winobj,
+														winstate->frameheadpos);
+			/* zero means a non-RPR window, which has no reduced frame */
+			if (num_reduced_frame < 0)
+				goto out_of_frame;
+			else if (num_reduced_frame > 0)
+			{
+				if (-relpos >= num_reduced_frame)
+					goto out_of_frame;
+				abs_pos = winstate->frameheadpos + relpos +
+					num_reduced_frame - 1;
+			}
+			break;
+		default:
+			elog(ERROR, "unrecognized window seek type: %d", seektype);
+			abs_pos = mark_pos = 0; /* keep compiler quiet */
+			break;
+	}
+
+	if (!window_gettupleslot(winobj, abs_pos, slot))
+		goto out_of_frame;
+
+	/* The code above does not detect all out-of-frame cases, so check */
+	if (row_is_in_frame(winobj, abs_pos, slot, false) <= 0)
+		goto out_of_frame;
+
+	if (isout)
+		*isout = false;
+	if (set_mark)
+	{
+		/*
+		 * If RPR is enabled and seek type is WINDOW_SEEK_TAIL, we set the
+		 * mark position unconditionally to frameheadpos. In this case the
+		 * frame always starts at CURRENT_ROW and never goes back, thus
+		 * setting the mark at the position is safe.
+		 */
+		if (winstate->rpPattern != NULL && seektype == WINDOW_SEEK_TAIL)
+			mark_pos = winstate->frameheadpos;
+		WinSetMarkPosition(winobj, mark_pos);
+	}
+	return 0;
+
+out_of_frame:
+	if (isout)
+		*isout = true;
+	*isnull = true;
+	return -1;
+}
+
+
+/***********************************************************************
+ * API exposed to window functions
+ ***********************************************************************/
+
+
+/*
+ * WinCheckAndInitializeNullTreatment
+ *		Check null treatment clause and sets ignore_nulls
+ *
+ * Window functions should call this to check if they are being called with
+ * a null treatment clause when they don't allow it, or to set ignore_nulls.
+ */
+void
+WinCheckAndInitializeNullTreatment(WindowObject winobj,
+								   bool allowNullTreatment,
+								   FunctionCallInfo fcinfo)
+{
+	Assert(WindowObjectIsValid(winobj));
+	if (winobj->ignore_nulls != NO_NULLTREATMENT && !allowNullTreatment)
+	{
+		const char *funcname = get_func_name(fcinfo->flinfo->fn_oid);
+
+		if (!funcname)
+			elog(ERROR, "could not get function name");
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("function %s does not allow RESPECT/IGNORE NULLS",
+						funcname)));
+	}
+	else if (winobj->ignore_nulls == PARSER_IGNORE_NULLS)
+		winobj->ignore_nulls = IGNORE_NULLS;
+}
+
+/*
+ * WinGetPartitionLocalMemory
+ *		Get working memory that lives till end of partition processing
+ *
+ * On first call within a given partition, this allocates and zeroes the
+ * requested amount of space.  Subsequent calls just return the same chunk.
+ *
+ * Memory obtained this way is normally used to hold state that should be
+ * automatically reset for each new partition.  If a window function wants
+ * to hold state across the whole query, fcinfo->fn_extra can be used in the
+ * usual way for that.
+ */
+void *
+WinGetPartitionLocalMemory(WindowObject winobj, Size sz)
+{
+	Assert(WindowObjectIsValid(winobj));
+	if (winobj->localmem == NULL)
+		winobj->localmem =
+			MemoryContextAllocZero(winobj->winstate->partcontext, sz);
+	return winobj->localmem;
+}
+
+/*
+ * WinGetCurrentPosition
+ *		Return the current row's position (counting from 0) within the current
+ *		partition.
+ */
+int64
+WinGetCurrentPosition(WindowObject winobj)
+{
+	Assert(WindowObjectIsValid(winobj));
+	return winobj->winstate->currentpos;
+}
+
+/*
+ * WinGetPartitionRowCount
+ *		Return total number of rows contained in the current partition.
+ *
+ * Note: this is a relatively expensive operation because it forces the
+ * whole partition to be "spooled" into the tuplestore at once.  Once
+ * executed, however, additional calls within the same partition are cheap.
+ */
+int64
+WinGetPartitionRowCount(WindowObject winobj)
+{
+	Assert(WindowObjectIsValid(winobj));
+	spool_tuples(winobj->winstate, -1);
+	return winobj->winstate->spooled_rows;
+}
+
+/*
+ * WinSetMarkPosition
+ *		Set the "mark" position for the window object, which is the oldest row
+ *		number (counting from 0) it is allowed to fetch during all subsequent
+ *		operations within the current partition.
+ *
+ * Window functions do not have to call this, but are encouraged to move the
+ * mark forward when possible to keep the tuplestore size down and prevent
+ * having to spill rows to disk.
+ */
+void
+WinSetMarkPosition(WindowObject winobj, int64 markpos)
+{
+	WindowAggState *winstate;
+
+	Assert(WindowObjectIsValid(winobj));
+	winstate = winobj->winstate;
+
+	if (markpos < winobj->markpos)
+		elog(ERROR, "cannot move WindowObject's mark position backward");
+	tuplestore_select_read_pointer(winstate->buffer, winobj->markptr);
+	if (markpos > winobj->markpos)
+	{
+		tuplestore_skiptuples(winstate->buffer,
+							  markpos - winobj->markpos,
+							  true);
+		winobj->markpos = markpos;
+	}
+	tuplestore_select_read_pointer(winstate->buffer, winobj->readptr);
+	if (markpos > winobj->seekpos)
+	{
+		tuplestore_skiptuples(winstate->buffer,
+							  markpos - winobj->seekpos,
+							  true);
+		winobj->seekpos = markpos;
+	}
+}
+
+/*
  * WinRowsArePeers
  *		Compare two rows (specified by absolute position in partition) to see
  *		if they are equal according to the ORDER BY clause.
@@ -4030,8 +5493,6 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 	WindowAggState *winstate;
 	ExprContext *econtext;
 	TupleTableSlot *slot;
-	int64		abs_pos;
-	int64		mark_pos;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
@@ -4042,166 +5503,15 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 		return ignorenulls_getfuncarginframe(winobj, argno, relpos, seektype,
 											 set_mark, isnull, isout);
 
-	switch (seektype)
+	if (WinGetSlotInFrame(winobj, slot,
+						  relpos, seektype, set_mark,
+						  isnull, isout) == 0)
 	{
-		case WINDOW_SEEK_CURRENT:
-			elog(ERROR, "WINDOW_SEEK_CURRENT is not supported for WinGetFuncArgInFrame");
-			abs_pos = mark_pos = 0; /* keep compiler quiet */
-			break;
-		case WINDOW_SEEK_HEAD:
-			/* rejecting relpos < 0 is easy and simplifies code below */
-			if (relpos < 0)
-				goto out_of_frame;
-			update_frameheadpos(winstate);
-			abs_pos = winstate->frameheadpos + relpos;
-			mark_pos = abs_pos;
-
-			/*
-			 * Account for exclusion option if one is active, but advance only
-			 * abs_pos not mark_pos.  This prevents changes of the current
-			 * row's peer group from resulting in trying to fetch a row before
-			 * some previous mark position.
-			 *
-			 * Note that in some corner cases such as current row being
-			 * outside frame, these calculations are theoretically too simple,
-			 * but it doesn't matter because we'll end up deciding the row is
-			 * out of frame.  We do not attempt to avoid fetching rows past
-			 * end of frame; that would happen in some cases anyway.
-			 */
-			switch (winstate->frameOptions & FRAMEOPTION_EXCLUSION)
-			{
-				case 0:
-					/* no adjustment needed */
-					break;
-				case FRAMEOPTION_EXCLUDE_CURRENT_ROW:
-					if (abs_pos >= winstate->currentpos &&
-						winstate->currentpos >= winstate->frameheadpos)
-						abs_pos++;
-					break;
-				case FRAMEOPTION_EXCLUDE_GROUP:
-					update_grouptailpos(winstate);
-					if (abs_pos >= winstate->groupheadpos &&
-						winstate->grouptailpos > winstate->frameheadpos)
-					{
-						int64		overlapstart = Max(winstate->groupheadpos,
-													   winstate->frameheadpos);
-
-						abs_pos += winstate->grouptailpos - overlapstart;
-					}
-					break;
-				case FRAMEOPTION_EXCLUDE_TIES:
-					update_grouptailpos(winstate);
-					if (abs_pos >= winstate->groupheadpos &&
-						winstate->grouptailpos > winstate->frameheadpos)
-					{
-						int64		overlapstart = Max(winstate->groupheadpos,
-													   winstate->frameheadpos);
-
-						if (abs_pos == overlapstart)
-							abs_pos = winstate->currentpos;
-						else
-							abs_pos += winstate->grouptailpos - overlapstart - 1;
-					}
-					break;
-				default:
-					elog(ERROR, "unrecognized frame option state: 0x%x",
-						 winstate->frameOptions);
-					break;
-			}
-			break;
-		case WINDOW_SEEK_TAIL:
-			/* rejecting relpos > 0 is easy and simplifies code below */
-			if (relpos > 0)
-				goto out_of_frame;
-			update_frametailpos(winstate);
-			abs_pos = winstate->frametailpos - 1 + relpos;
-
-			/*
-			 * Account for exclusion option if one is active.  If there is no
-			 * exclusion, we can safely set the mark at the accessed row.  But
-			 * if there is, we can only mark the frame start, because we can't
-			 * be sure how far back in the frame the exclusion might cause us
-			 * to fetch in future.  Furthermore, we have to actually check
-			 * against frameheadpos here, since it's unsafe to try to fetch a
-			 * row before frame start if the mark might be there already.
-			 */
-			switch (winstate->frameOptions & FRAMEOPTION_EXCLUSION)
-			{
-				case 0:
-					/* no adjustment needed */
-					mark_pos = abs_pos;
-					break;
-				case FRAMEOPTION_EXCLUDE_CURRENT_ROW:
-					if (abs_pos <= winstate->currentpos &&
-						winstate->currentpos < winstate->frametailpos)
-						abs_pos--;
-					update_frameheadpos(winstate);
-					if (abs_pos < winstate->frameheadpos)
-						goto out_of_frame;
-					mark_pos = winstate->frameheadpos;
-					break;
-				case FRAMEOPTION_EXCLUDE_GROUP:
-					update_grouptailpos(winstate);
-					if (abs_pos < winstate->grouptailpos &&
-						winstate->groupheadpos < winstate->frametailpos)
-					{
-						int64		overlapend = Min(winstate->grouptailpos,
-													 winstate->frametailpos);
-
-						abs_pos -= overlapend - winstate->groupheadpos;
-					}
-					update_frameheadpos(winstate);
-					if (abs_pos < winstate->frameheadpos)
-						goto out_of_frame;
-					mark_pos = winstate->frameheadpos;
-					break;
-				case FRAMEOPTION_EXCLUDE_TIES:
-					update_grouptailpos(winstate);
-					if (abs_pos < winstate->grouptailpos &&
-						winstate->groupheadpos < winstate->frametailpos)
-					{
-						int64		overlapend = Min(winstate->grouptailpos,
-													 winstate->frametailpos);
-
-						if (abs_pos == overlapend - 1)
-							abs_pos = winstate->currentpos;
-						else
-							abs_pos -= overlapend - 1 - winstate->groupheadpos;
-					}
-					update_frameheadpos(winstate);
-					if (abs_pos < winstate->frameheadpos)
-						goto out_of_frame;
-					mark_pos = winstate->frameheadpos;
-					break;
-				default:
-					elog(ERROR, "unrecognized frame option state: 0x%x",
-						 winstate->frameOptions);
-					mark_pos = 0;	/* keep compiler quiet */
-					break;
-			}
-			break;
-		default:
-			elog(ERROR, "unrecognized window seek type: %d", seektype);
-			abs_pos = mark_pos = 0; /* keep compiler quiet */
-			break;
+		econtext->ecxt_outertuple = slot;
+		return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
+							econtext, isnull);
 	}
 
-	if (!window_gettupleslot(winobj, abs_pos, slot))
-		goto out_of_frame;
-
-	/* The code above does not detect all out-of-frame cases, so check */
-	if (row_is_in_frame(winobj, abs_pos, slot, false) <= 0)
-		goto out_of_frame;
-
-	if (isout)
-		*isout = false;
-	if (set_mark)
-		WinSetMarkPosition(winobj, mark_pos);
-	econtext->ecxt_outertuple = slot;
-	return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
-						econtext, isnull);
-
-out_of_frame:
 	if (isout)
 		*isout = true;
 	*isnull = true;
diff --git a/src/backend/jit/llvm/llvmjit_expr.c b/src/backend/jit/llvm/llvmjit_expr.c
index fc80d3e55fe..74b7392f15f 100644
--- a/src/backend/jit/llvm/llvmjit_expr.c
+++ b/src/backend/jit/llvm/llvmjit_expr.c
@@ -128,6 +128,9 @@ llvm_compile_expr(ExprState *state)
 	LLVMValueRef v_aggvalues;
 	LLVMValueRef v_aggnulls;
 
+	/* RPR navigation: when true, EEOP_OUTER_VAR reloads from econtext */
+	bool		has_rpr_nav;
+
 	instr_time	starttime;
 	instr_time	deform_starttime;
 	instr_time	endtime;
@@ -297,6 +300,36 @@ llvm_compile_expr(ExprState *state)
 								   FIELDNO_EXPRCONTEXT_AGGNULLS,
 								   "v.econtext.aggnulls");
 
+	/*
+	 * RPR navigation opcodes (PREV/NEXT) swap ecxt_outertuple to a different
+	 * row mid-expression.  The JIT code loads v_outervalues and v_outernulls
+	 * once in the entry block and reuses them for all EEOP_OUTER_VAR steps.
+	 * After a slot swap, these cached pointers become stale because the new
+	 * slot has its own tts_values/tts_isnull arrays.
+	 *
+	 * When RPR navigation opcodes are present, EEOP_OUTER_VAR reloads the
+	 * slot pointer from econtext->ecxt_outertuple on every access instead of
+	 * using the cached entry-block values.  This avoids the SSA/PHI
+	 * complexity while keeping the rest of the expression JIT-compiled.
+	 * Expressions without RPR navigation use the cached values as before.
+	 */
+	has_rpr_nav = false;
+	if (parent && IsA(parent, WindowAggState) &&
+		((WindowAgg *) parent->plan)->rpPattern != NULL)
+	{
+		for (int opno = 0; opno < state->steps_len; opno++)
+		{
+			ExprEvalOp	opcode = ExecEvalStepOp(state, &state->steps[opno]);
+
+			if (opcode == EEOP_RPR_NAV_SET ||
+				opcode == EEOP_RPR_NAV_RESTORE)
+			{
+				has_rpr_nav = true;
+				break;
+			}
+		}
+	}
+
 	/* allocate blocks for each op upfront, so we can do jumps easily */
 	opblocks = palloc_array(LLVMBasicBlockRef, state->steps_len);
 	for (int opno = 0; opno < state->steps_len; opno++)
@@ -459,8 +492,37 @@ llvm_compile_expr(ExprState *state)
 					}
 					else if (opcode == EEOP_OUTER_VAR)
 					{
-						v_values = v_outervalues;
-						v_nulls = v_outernulls;
+						if (has_rpr_nav)
+						{
+							/*
+							 * RPR navigation swaps ecxt_outertuple
+							 * mid-expression.  Reload slot pointer from
+							 * econtext on every access so we read from the
+							 * current (possibly swapped) slot.
+							 */
+							LLVMValueRef v_tmpslot;
+
+							v_tmpslot = l_load_struct_gep(b,
+														  StructExprContext,
+														  v_econtext,
+														  FIELDNO_EXPRCONTEXT_OUTERTUPLE,
+														  "v_outerslot_reload");
+							v_values = l_load_struct_gep(b,
+														 StructTupleTableSlot,
+														 v_tmpslot,
+														 FIELDNO_TUPLETABLESLOT_VALUES,
+														 "v_outervalues_reload");
+							v_nulls = l_load_struct_gep(b,
+														StructTupleTableSlot,
+														v_tmpslot,
+														FIELDNO_TUPLETABLESLOT_ISNULL,
+														"v_outernulls_reload");
+						}
+						else
+						{
+							v_values = v_outervalues;
+							v_nulls = v_outernulls;
+						}
 					}
 					else if (opcode == EEOP_SCAN_VAR)
 					{
@@ -2433,6 +2495,18 @@ llvm_compile_expr(ExprState *state)
 				LLVMBuildBr(b, opblocks[opno + 1]);
 				break;
 
+			case EEOP_RPR_NAV_SET:
+				build_EvalXFunc(b, mod, "ExecEvalRPRNavSet",
+								v_state, op, v_econtext);
+				LLVMBuildBr(b, opblocks[opno + 1]);
+				break;
+
+			case EEOP_RPR_NAV_RESTORE:
+				build_EvalXFunc(b, mod, "ExecEvalRPRNavRestore",
+								v_state, op, v_econtext);
+				LLVMBuildBr(b, opblocks[opno + 1]);
+				break;
+
 			case EEOP_AGG_STRICT_DESERIALIZE:
 			case EEOP_AGG_DESERIALIZE:
 				{
diff --git a/src/backend/jit/llvm/llvmjit_types.c b/src/backend/jit/llvm/llvmjit_types.c
index c8a1f841293..e78b31d775f 100644
--- a/src/backend/jit/llvm/llvmjit_types.c
+++ b/src/backend/jit/llvm/llvmjit_types.c
@@ -168,6 +168,8 @@ void	   *referenced_functions[] =
 	ExecEvalScalarArrayOp,
 	ExecEvalHashedScalarArrayOp,
 	ExecEvalSubPlan,
+	ExecEvalRPRNavSet,
+	ExecEvalRPRNavRestore,
 	ExecEvalSysVar,
 	ExecEvalWholeRowVar,
 	ExecEvalXmlExpr,
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index c61b3d624d5..571999365b8 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -274,6 +274,10 @@ typedef enum ExprEvalOp
 	EEOP_MERGE_SUPPORT_FUNC,
 	EEOP_SUBPLAN,
 
+	/* row pattern navigation (all eight RPRNavKind kinds) */
+	EEOP_RPR_NAV_SET,
+	EEOP_RPR_NAV_RESTORE,
+
 	/* aggregation related nodes */
 	EEOP_AGG_STRICT_DESERIALIZE,
 	EEOP_AGG_DESERIALIZE,
@@ -695,6 +699,12 @@ typedef struct ExprEvalStep
 			SubPlanState *sstate;
 		}			subplan;
 
+		/* for EEOP_RPR_NAV_SET / EEOP_RPR_NAV_RESTORE */
+		struct
+		{
+			RPRNavState *rprnavstate;
+		}			rpr_nav;
+
 		/* for EEOP_AGG_*DESERIALIZE */
 		struct
 		{
@@ -902,6 +912,10 @@ extern void ExecEvalMergeSupportFunc(ExprState *state, ExprEvalStep *op,
 									 ExprContext *econtext);
 extern void ExecEvalSubPlan(ExprState *state, ExprEvalStep *op,
 							ExprContext *econtext);
+extern void ExecEvalRPRNavSet(ExprState *state, ExprEvalStep *op,
+							  ExprContext *econtext);
+extern void ExecEvalRPRNavRestore(ExprState *state, ExprEvalStep *op,
+								  ExprContext *econtext);
 extern void ExecEvalWholeRowVar(ExprState *state, ExprEvalStep *op,
 								ExprContext *econtext);
 extern void ExecEvalSysVar(ExprState *state, ExprEvalStep *op,
diff --git a/src/include/executor/execRPR.h b/src/include/executor/execRPR.h
new file mode 100644
index 00000000000..fb7dc63a4c6
--- /dev/null
+++ b/src/include/executor/execRPR.h
@@ -0,0 +1,39 @@
+/*-------------------------------------------------------------------------
+ *
+ * execRPR.h
+ *	  prototypes for execRPR.c (NFA-based Row Pattern Recognition engine)
+ *
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/executor/execRPR.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef EXECRPR_H
+#define EXECRPR_H
+
+#include "nodes/execnodes.h"
+
+/* NFA context management */
+extern RPRNFAContext *ExecRPRStartContext(WindowAggState *winstate,
+										  int64 startPos);
+extern RPRNFAContext *ExecRPRGetHeadContext(WindowAggState *winstate,
+											int64 pos);
+extern void ExecRPRFreeContext(WindowAggState *winstate, RPRNFAContext *ctx);
+
+/* NFA processing */
+extern void ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
+							  bool hasLimitedFrame, int64 frameOffset);
+extern void ExecRPRCleanupDeadContexts(WindowAggState *winstate,
+									   RPRNFAContext *excludeCtx);
+extern void ExecRPRFinalizeAllContexts(WindowAggState *winstate, int64 lastPos);
+
+/* NFA statistics */
+extern void ExecRPRRecordContextSuccess(WindowAggState *winstate,
+										int64 matchLen);
+extern void ExecRPRRecordContextFailure(WindowAggState *winstate,
+										int64 failedLen);
+
+#endif							/* EXECRPR_H */
diff --git a/src/include/executor/nodeWindowAgg.h b/src/include/executor/nodeWindowAgg.h
index ada4a1c458c..f6f6645131c 100644
--- a/src/include/executor/nodeWindowAgg.h
+++ b/src/include/executor/nodeWindowAgg.h
@@ -20,4 +20,7 @@ extern WindowAggState *ExecInitWindowAgg(WindowAgg *node, EState *estate, int ef
 extern void ExecEndWindowAgg(WindowAggState *node);
 extern void ExecReScanWindowAgg(WindowAggState *node);
 
+/* RPR navigation support for expression evaluation opcodes */
+extern TupleTableSlot *ExecRPRNavGetSlot(WindowAggState *winstate, int64 pos);
+
 #endif							/* NODEWINDOWAGG_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index f0cb21444b2..06a7b3e9587 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -70,6 +70,7 @@ typedef struct TupleTableSlot TupleTableSlot;
 typedef struct TupleTableSlotOps TupleTableSlotOps;
 typedef struct WalUsage WalUsage;
 typedef struct WorkerNodeInstrumentation WorkerNodeInstrumentation;
+typedef struct WindowAggState WindowAggState;
 
 
 /* ----------------
@@ -1073,6 +1074,52 @@ typedef struct SubPlanState
 	ExprState  *cur_eq_comp;	/* equality comparator for LHS vs. table */
 } SubPlanState;
 
+typedef struct RPRNavState
+{
+	NodeTag		type;
+
+	WindowAggState *winstate;
+	RPRNavExpr *rprnavexpr;
+
+	/*
+	 * Resolved navigation offsets for this execution, captured from
+	 * winstate->rprNavOffsets at expression compile time.  These live in
+	 * executor state (not on the RPRNavExpr) because plan trees are read-only
+	 * and may be shared by concurrent executions.
+	 */
+	NullableDatum offset;		/* inner offset */
+	NullableDatum compound_offset;	/* outer offset for compound nav */
+	int16		resulttyplen;	/* RESTORE: result type length */
+	bool		resulttypbyval; /* RESTORE: result pass-by-value? */
+} RPRNavState;
+
+/*
+ * RPRNavOffsetKind - status of a resolved navigation trim offset
+ * (WindowAggState.navMaxOffset / navFirstOffset)
+ */
+typedef enum RPRNavOffsetKind
+{
+	RPR_NAV_OFFSET_FIXED,		/* resolved constant; use the offset value */
+	RPR_NAV_OFFSET_NEEDS_EVAL,	/* non-constant offset; shows "runtime",
+								 * resolved per scan */
+	RPR_NAV_OFFSET_RETAIN_ALL,	/* offset overflow; retain all rows (no trim) */
+} RPRNavOffsetKind;
+
+/*
+ * RPRNavOffsets - one entry of WindowAggState.rprNavOffsets
+ *
+ * Associates an RPRNavExpr from the (read-only) plan tree with its offsets,
+ * built by build_define_offsets() at executor startup and settled once per
+ * scan by resolve_nav_offsets().  The list is in RPRNavExpr.navno order.
+ */
+typedef struct RPRNavOffsets
+{
+	RPRNavExpr *nav;			/* plan-tree node this entry belongs to */
+	ExprState  *offset_state;	/* inner offset expr, evaluated once per scan */
+	ExprState  *compound_offset_state;	/* outer (compound) offset expr */
+	RPRNavState *rprnavstate;	/* execution state; holds the resolved values */
+} RPRNavOffsets;
+
 /*
  * DomainConstraintState - one item to check during CoerceToDomain
  *
@@ -2524,6 +2571,91 @@ typedef enum WindowAggStatus
 									 * tuples during spool */
 } WindowAggStatus;
 
+/* RPR reduced frame states returned by get_reduced_frame_status() */
+#define RF_NOT_DETERMINED	0	/* not yet processed */
+#define RF_FRAME_HEAD		1	/* start row of a match */
+#define RF_SKIPPED			2	/* interior row of a match */
+#define RF_UNMATCHED		3	/* no match at this row */
+#define RF_EMPTY_MATCH		4	/* empty match (0 rows); treated as unmatched */
+
+/*
+ * RPRNFAState - single NFA state for pattern matching
+ *
+ * counts[] tracks repetition counts at each nesting depth.
+ *
+ * isAbsorbable tracks if state is in absorbable region (ABSORBABLE_BRANCH).
+ * Monotonic property: once false, stays false (can't re-enter region).
+ */
+typedef struct RPRNFAState
+{
+	struct RPRNFAState *next;	/* next state in linked list */
+	int16		elemIdx;		/* current pattern element index */
+	bool		isAbsorbable;	/* true if state is in absorbable region */
+	int32		counts[FLEXIBLE_ARRAY_MEMBER];	/* repetition counts by depth */
+} RPRNFAState;
+
+/*
+ * RPRNFAContext - context for NFA pattern matching execution
+ *
+ * Two-flag absorption design:
+ *   hasAbsorbableState: can this context absorb others? (>=1 absorbable state)
+ *     - Monotonic: true->false only, cannot recover once false
+ *     - Used to skip absorption attempts once all absorbable states are gone
+ *   allStatesAbsorbable: can this context be absorbed? (ALL states
+ *   absorbable, no recorded match)
+ *     - Dynamic: false->true when non-absorbable states die; a recorded
+ *       match pins it false
+ *     - Used to determine if this context is eligible for absorption
+ */
+typedef struct RPRNFAContext
+{
+	struct RPRNFAContext *next; /* next context in linked list */
+	struct RPRNFAContext *prev; /* previous context (for reverse traversal) */
+	RPRNFAState *states;		/* active states (linked list) */
+
+	int64		matchStartRow;	/* row where match started */
+	int64		matchEndRow;	/* last row of the match; below matchStartRow
+								 * for an empty one, -1 before any */
+	int64		lastProcessedRow;	/* last row processed (for fail depth) */
+	RPRNFAState *matchedState;	/* this context's match candidate, or NULL */
+	bool		matchUpdated;	/* matchedState was set or replaced during the
+								 * advance now running */
+
+	/* Two-flag absorption optimization */
+	bool		hasAbsorbableState; /* can absorb others (>=1 absorbable
+									 * state) */
+	bool		allStatesAbsorbable;	/* can be absorbed (ALL states
+										 * absorbable) */
+} RPRNFAContext;
+
+/*
+ * NFALengthStats
+ *
+ * Statistics for length measurements (min/max/total) used for computing
+ * average lengths in EXPLAIN ANALYZE output.
+ */
+typedef struct NFALengthStats
+{
+	int64		min;			/* minimum length */
+	int64		max;			/* maximum length */
+	int64		total;			/* total length (for computing average) */
+} NFALengthStats;
+
+/*
+ * Tri-state result of a DEFINE predicate for one row pattern variable at the
+ * current row.  RPR_VAR_UNEVALUATED is the "not yet evaluated" sentinel and
+ * must be zero so palloc0 initializes the per-row cache to it; the DEFINE is
+ * evaluated lazily at the point the NFA first consumes the variable (see
+ * nfa_eval_var_match).  A NULL DEFINE result folds to RPR_VAR_FALSE
+ * (non-True = not mapped, per ISO/IEC 19075-5).
+ */
+typedef enum RPRVarMatch
+{
+	RPR_VAR_UNEVALUATED = 0,	/* not yet evaluated (sentinel) */
+	RPR_VAR_FALSE,				/* evaluated to non-True (FALSE or NULL) */
+	RPR_VAR_TRUE,				/* evaluated to True */
+} RPRVarMatch;
+
 typedef struct WindowAggState
 {
 	ScanState	ss;				/* its first field is NodeTag */
@@ -2583,10 +2715,56 @@ typedef struct WindowAggState
 	int64		groupheadpos;	/* current row's peer group head position */
 	int64		grouptailpos;	/* " " " " tail position (group end+1) */
 
+	/* these fields are used in Row pattern recognition: */
+	RPSkipTo	rpSkipTo;		/* Row Pattern Skip To type */
+	struct RPRPattern *rpPattern;	/* compiled pattern for NFA execution */
+	List	   *defineClauseExprs;	/* row pattern DEFINE search conditions as
+									 * an ExprState list, in DEFINE order
+									 * (list index == varId) */
+	RPRNFAContext *nfaContext;	/* active matching contexts (head) */
+	RPRNFAContext *nfaContextTail;	/* tail of active contexts (for reverse
+									 * traversal) */
+	RPRNFAContext *nfaContextFree;	/* recycled NFA context nodes */
+	RPRNFAState *nfaStateFree;	/* recycled NFA state nodes */
+	Size		nfaStateSize;	/* pre-calculated RPRNFAState size */
+	RPRVarMatch *nfaVarMatched; /* per-row tri-state cache: varMatched[varId]
+								 * for varId < list_length(defineClauseExprs),
+								 * evaluated lazily */
+	Bitmapset  *defineMatchStartDependent;	/* DEFINE vars needing per-context
+											 * evaluation
+											 * (match_start-dependent) */
+	bitmapword *nfaVisitedEnds; /* nullable ENDs reached in this DFS, indexed
+								 * by elemIdx (cycle detection) */
+	int16		nfaVisitedMinWord;	/* lowest bitmapword index touched since
+									 * last reset (PG_INT16_MAX = none) */
+	int16		nfaVisitedMaxWord;	/* highest bitmapword index touched since
+									 * last reset (-1 = none) */
+	int64		nfaLastProcessedRow;	/* last row processed by NFA (-1 =
+										 * none) */
+
+	/* NFA statistics for EXPLAIN ANALYZE */
+	int64		nfaStatesActive;	/* current active states (internal) */
+	int64		nfaStatesMax;	/* peak active states */
+	int64		nfaStatesTotalCreated;	/* total states allocated */
+	int64		nfaStatesMerged;	/* states merged (deduplicated) */
+	int64		nfaContextsActive;	/* current active contexts (internal) */
+	int64		nfaContextsMax; /* peak active contexts */
+	int64		nfaContextsTotalCreated;	/* total contexts allocated */
+	int64		nfaContextsAbsorbed;	/* contexts absorbed (optimization) */
+	int64		nfaContextsSkipped; /* contexts skipped (SKIP PAST LAST ROW) */
+	int64		nfaContextsPruned;	/* contexts pruned on first row */
+	int64		nfaMatchesSucceeded;	/* successful pattern matches */
+	int64		nfaMatchesFailed;	/* failed pattern matches */
+	NFALengthStats nfaMatchLen; /* successful match length stats */
+	NFALengthStats nfaFailLen;	/* mismatch length stats */
+	NFALengthStats nfaAbsorbedLen;	/* absorbed context length stats */
+	NFALengthStats nfaSkippedLen;	/* skipped context length stats */
+
 	MemoryContext partcontext;	/* context for partition-lifespan data */
 	MemoryContext aggcontext;	/* shared context for aggregate working data */
 	MemoryContext curaggcontext;	/* current aggregate's working data */
 	ExprContext *tmpcontext;	/* short-term evaluation context */
+	ExprContext *rprContext;	/* DEFINE clause evaluation context */
 
 	bool		all_first;		/* true if the scan is starting */
 	bool		partition_spooled;	/* true if all tuples in current partition
@@ -2610,6 +2788,42 @@ typedef struct WindowAggState
 	TupleTableSlot *agg_row_slot;
 	TupleTableSlot *temp_slot_1;
 	TupleTableSlot *temp_slot_2;
+
+	/* RPR navigation */
+
+	/*
+	 * per-execution resolved nav offsets: list of RPRNavOffsets, indexed by
+	 * RPRNavExpr.navno; built by build_define_offsets()
+	 */
+	List	   *rprNavOffsets;
+	bool		navResolvePending;	/* nav offsets need (re)resolving at the
+									 * next ExecWindowAgg call; set at init
+									 * and rescan, cleared by
+									 * resolve_nav_offsets() */
+	bool		hasMaxNav;		/* backward nav in DEFINE: PREV, LAST,
+								 * compound PREV_LAST/NEXT_LAST */
+	bool		hasFirstNav;	/* forward nav in DEFINE: FIRST, compound
+								 * PREV_FIRST/NEXT_FIRST */
+	RPRNavOffsetKind navMaxOffsetKind;	/* status of navMaxOffset */
+	int64		navMaxOffset;	/* max backward nav offset (when FIXED) */
+	RPRNavOffsetKind navFirstOffsetKind;	/* status of navFirstOffset */
+	int64		navFirstOffset; /* min forward reach from match_start (when
+								 * FIXED); negative when a compound PREV_FIRST
+								 * reaches back past it */
+	struct WindowObjectData *nav_winobj;	/* winobj for RPR */
+	int64		nav_slot_pos;	/* position cached in nav_slot, or -1 */
+	TupleTableSlot *nav_slot;	/* slot holding the resolved navigation target
+								 * row (simple or compound
+								 * PREV/NEXT/FIRST/LAST) */
+	TupleTableSlot *nav_saved_outertuple;	/* saved slot during nav swap */
+	int64		nav_match_start;	/* match_start for FIRST/LAST nav */
+
+	/* RPR current match result */
+	int64		rpr_match_start;	/* start of the result; < 0 = not
+									 * determined */
+	int64		rpr_match_length;	/* result kind when start >= 0: -1
+									 * unmatched, 0 empty match, >= 1 real
+									 * match length */
 } WindowAggState;
 
 /* ----------------
-- 
2.43.0

