public inbox for [email protected]  
help / color / mirror / Atom feed
From: Henson Choi <[email protected]>
To: Tatsuo Ishii <[email protected]>
Cc: [email protected]
Cc: [email protected]
Cc: [email protected]
Cc: [email protected]
Cc: [email protected]
Cc: [email protected]
Cc: [email protected]
Cc: [email protected]
Subject: Re: Row pattern recognition
Date: Sun, 19 Apr 2026 22:52:26 +0900
Message-ID: <CAAAe_zBHrBBM2KYKJYSvP=vr=6fv7kFDr8qZZWFd7==sb4VMxg@mail.gmail.com> (raw)
In-Reply-To: <[email protected]>
References: <CAAAe_zB7rAEJtT6hXgF85=_Tj8Nti45ZHbQw26gxTF2DBs3hJw@mail.gmail.com>
	<[email protected]>
	<CAAAe_zBju06XD97Yj30zmZJ+HL-E3U9J5Ok+Oir8D0cJU_+9ag@mail.gmail.com>
	<[email protected]>

 Hi Tatsuo,

Thank you for the careful review and for confirming the build on
your side.

Ok, I will not rebase current v46 and continue to apply your
> incremental patches and review them, until we agree to ship v47.  I
> may see conflicts while creating v47 patch sets. I will ask help if if
> your assistance needed. Thanks in advance.
>

Understood.  Please let me know whenever a conflict comes up while
assembling v47 and I will help resolve it on my side.


> I confirmed 0001-0008 applied cleanly and see no compile
> warning. Regression test passed, no crash.
>

Thank you for confirming.


> > Regarding the README.rpr suggestion from the 0008 review: the
> > documentation in execRPR.c has dependencies spread across the patch
> > series, so separating it mid-review would be disruptive. I plan to
> > split it out as part of the final patch list once all 31 patches have
> > been reviewed.
>
> Ok.


Thank you.  I will prepare the README.rpr split as a follow-up patch
after the current 0031 once the series review is complete.

Looking forward to seeing revised incremental patch sets.


Attached is the revised v47 series, with the review comments on
0006, 0007, and 0008 incorporated.  Numbering and subjects are
unchanged from the previous send.

Since the edits in 0006-0008 propagate into the later patches --
mostly as context-line shifts, and in 0017/0023 as updates to the
rpr_integration expected output -- I have attached the full
0001-0031 set rather than only the updated patches.  Applying this
set on top of v46 should give the same final tree as before, just
with the review adjustments folded in.

Summary of changes from the previous v47:

  - 0006 (Fix DEFINE expression handling): per your comment, the
    first occurrence of "RPR" now carries the "(Row Pattern
    Recognition)" expansion and the later occurrence is the bare
    abbreviation.

  - 0007 (Add RPR planner integration tests): overall
    strengthening of the tests and their comments.  Each block
    now carries an intent comment describing what the block is
    trying to cover and what the expected plan or result is,
    the "Non-RPR / RPR" comment pair is reworded as complete
    sentences, and B8 is reworked so that the EXPLAIN plan
    actually exercises Incremental Sort.

    Because some blocks cover features that are only
    introduced later in the series, a few tests in 0007
    temporarily produce error output.  Concretely, B4
    (RPR + prepared statements) uses the 2-argument form
    PREV(val, $1), which is not yet available at the 0007
    point: it is introduced in 0017 (1-slot PREV/NEXT
    navigation), and the accompanying "Nav Mark Lookback"
    EXPLAIN property referenced in the B4 comments is added
    in 0023.  The rpr_integration expected-output file is
    updated by 0017 (to clear the PREV/prepared-statement
    errors) and then again by 0023 (to add the
    "Nav Mark Lookback" lines to the EXPLAIN output), so that
    by the end of the series the output is clean.

    XXX notes have been added in three places where a test
    records behaviour that may be revisited later:

      (1) The subquery-output test documents why the column
          guard in allpaths.c has to be conservative: a column
          referenced only by DEFINE is indistinguishable from
          an exposed column at the targetlist level, so
          remove_unused_subquery_outputs() cannot apply its
          NULL-replacement selectively without risking DEFINE
          evaluation on NULL inputs.  The XXX flags this as a
          candidate for a precise follow-up optimization.

      (2) The B7 Recursive CTE test carries an XXX noting that
          it is unclear whether this case falls under the
          ISO/IEC 9075-2 4.18.5 / 6.17.5 prohibition, and even
          if it does not, whether a query that does trigger
          the prohibition can be constructed at all.  The
          disposition is left to the community.

      (3) The B9 volatile-in-DEFINE test records current
          behaviour using "random() >= 0.0" (structurally
          equivalent to TRUE, so the expected output stays
          deterministic).  The XXX flags that if we decide to
          reject volatile functions in DEFINE, this test must
          be converted into an error-case test.  See the
          dedicated section at the end of this mail.

  - 0008 (Replace reduced frame map with single match result):
      * Comment block X-1 now uses the full field names
        rpr_match_valid / rpr_match_matched / rpr_match_length.
      * get_reduced_frame_status() sentence rephrased in the
        passive voice, as you suggested:
          "Row's status against the current match result can be
           obtained by calling get_reduced_frame_status()."
      * README.rpr split deferred per the above.

  - 0009-0031: no semantic code changes.  Most patches only
    carry context-line shifts from the edits above.  The two
    exceptions are 0017 and 0023, whose rpr_integration.out
    hunks are updated to match the revised B4 test in 0007
    (clearing the temporary errors and adding the
    "Nav Mark Lookback" lines, respectively).

On volatile functions in DEFINE (your 2026-04-13 mail): the SQL
standard itself is relatively narrow here -- the only explicit
restriction it places is that a navigation function's offset must
be a runtime constant.  Volatile expressions in the DEFINE
predicate are not forbidden by the standard.

That said, once we move into the pattern-matching machinery, the
observable semantics of a volatile DEFINE become quite murky.
Depending on the situation, the DEFINE predicate may be evaluated
once per row, or separately per active matching context, or some
combination of both as the NFA explores alternatives.  A user
writing "random() > 0.5" in DEFINE would have a hard time
predicting, let alone relying on, the evaluation count -- and I
have not been able to think of a realistic use case that actually
benefits from this flexibility.

Given the gap between "permitted by the standard" and "meaningful
to the user," my own leaning is toward prohibiting volatile
functions in DEFINE at transformation time, and -- if we go that
way -- also examining whether the new check would make the
existing runtime-constant check on the navigation-function offset
redundant, or whether the two could be folded into a single check
pass.  That said, this is ultimately a policy call, and I would rather defer
the final decision to your judgment as a senior member of the
community: does prohibition seem like the right direction, or
would you prefer to keep the current behaviour in place?

On sequencing, if we do take it up, I would suggest handling it
after the 31-patch set, alongside the README.rpr split as
follow-up work on top of 0031.  Whether it ultimately lands
inside v47 or as a separate piece on top does not need to be
decided right now -- there is still room to discuss it as the
review progresses, and I am happy to adjust either way based on
your direction.

Regards,
Henson

From f79d4358bc0033bc4fe8b4f8c9e32904d3df6a93 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 24 Mar 2026 19:04:19 +0900
Subject: [PATCH] Remove unused regex/regex.h include from nodeWindowAgg.c

---
 src/backend/executor/nodeWindowAgg.c | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 4f882b877b1..185d7a0d5ae 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -50,7 +50,6 @@
 #include "optimizer/rpr.h"
 #include "parser/parse_agg.h"
 #include "parser/parse_coerce.h"
-#include "regex/regex.h"
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/datum.h"
-- 
2.50.1 (Apple Git-155)


From 31e07dcbd5391b7ff9ef8293fcb090cf8f845c71 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 00:25:40 +0900
Subject: [PATCH] Add CHECK_FOR_INTERRUPTS() to nfa_add_state_unique() for
 state explosion patterns

---
 src/backend/executor/execRPR.c | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index bab5257f68f..cf54e0c76c3 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -1763,6 +1763,8 @@ nfa_add_state_unique(WindowAggState *winstate, RPRNFAContext *ctx, RPRNFAState *
 	/* 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))
 		{
 			/*
-- 
2.50.1 (Apple Git-155)


From 0f15fdabc01fc1503f2a13253df65844ece4c86d Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 11:03:39 +0900
Subject: [PATCH] Add CHECK_FOR_INTERRUPTS() to nfa_try_absorb_context() loop

---
 src/backend/executor/execRPR.c | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index cf54e0c76c3..58f9da0b814 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -2084,6 +2084,8 @@ nfa_try_absorb_context(WindowAggState *winstate, RPRNFAContext *ctx)
 
 	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
-- 
2.50.1 (Apple Git-155)


From 6601dda3d297ee8928bbe1c035102d683c78251f Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 00:20:05 +0900
Subject: [PATCH] Fix in-place modification of defineClause TargetEntry in
 setrefs.c

---
 src/backend/optimizer/plan/setrefs.c | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 69cd1861e9b..813a326bd78 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -2633,7 +2633,7 @@ set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset)
 					   NUM_EXEC_QUAL(plan));
 
 	/*
-	 * Modifies an expression tree in each DEFINE clause so that all Var
+	 * Replace an expression tree in each DEFINE clause so that all Var
 	 * nodes's varno refers to OUTER_VAR.
 	 */
 	if (IsA(plan, WindowAgg))
@@ -2646,6 +2646,7 @@ set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset)
 			{
 				TargetEntry *tle = (TargetEntry *) lfirst(l);
 
+				tle = flatCopyTargetEntry(tle);
 				tle->expr = (Expr *)
 					fix_upper_expr(root,
 								   (Node *) tle->expr,
@@ -2654,6 +2655,7 @@ set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset)
 								   rtoffset,
 								   NRM_EQUAL,
 								   NUM_EXEC_QUAL(plan));
+				lfirst(l) = tle;
 			}
 		}
 	}
-- 
2.50.1 (Apple Git-155)


From 31e7dacb8b0fa6ead63ff92c19aa5dfb0cde76a1 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 00:37:50 +0900
Subject: [PATCH] Fix mark handling for last_value() under RPR

Enable mark advancement in window_last_value() for
better tuplestore memory usage in non-RPR cases, while
adding a guard in WinGetFuncArgInFrame to suppress it
for RPR SEEK_TAIL to prevent position invalidation
from reduced frame shifts.
---
 src/backend/executor/nodeWindowAgg.c | 10 ++++++++++
 src/backend/utils/adt/windowfuncs.c  |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 185d7a0d5ae..aed7cbef99a 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -4932,7 +4932,17 @@ WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
 	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:
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index efb60c99052..74ef109f72e 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -682,7 +682,7 @@ window_last_value(PG_FUNCTION_ARGS)
 
 	WinCheckAndInitializeNullTreatment(winobj, true, fcinfo);
 	result = WinGetFuncArgInFrame(winobj, 0,
-								  0, WINDOW_SEEK_TAIL, false,
+								  0, WINDOW_SEEK_TAIL, true,
 								  &isnull, NULL);
 	if (isnull)
 		PG_RETURN_NULL();
-- 
2.50.1 (Apple Git-155)


From b0f0184ef082dbd0a4744d08f6b0b7d5366c2733 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 11:55:02 +0900
Subject: [PATCH] Fix DEFINE expression handling in RPR window planning

transformDefineClause() added full DEFINE expressions including
RPRNavExpr (PREV/NEXT) nodes to the query targetlist.  These
propagated to upper WindowAgg nodes that lack RPR navigation state,
causing a SIGSEGV when RPR and non-RPR windows coexist in the same
query.

Add only the Var nodes referenced by DEFINE expressions to the
targetlist, and protect those Vars from removal by
remove_unused_subquery_outputs() so they remain available in the
tuplestore slot for pattern matching evaluation.

Move the subquery wrapping tests from rpr.sql to rpr_integration.sql.
---
 src/backend/optimizer/path/allpaths.c |  68 +++++++++++++++++
 src/backend/parser/parse_rpr.c        | 106 ++++++++++++++------------
 2 files changed, 126 insertions(+), 48 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index f42a2bae14a..470029e42e0 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -4750,6 +4750,74 @@ remove_unused_subquery_outputs(Query *subquery, RelOptInfo *rel,
 		if (contain_volatile_functions(texpr))
 			continue;
 
+		/*
+		 * If any RPR (Row Pattern Recognition) window clause references this
+		 * column in its DEFINE clause, don't remove it.  The DEFINE
+		 * expression needs these columns in the tuplestore slot for pattern
+		 * matching evaluation, even if the outer query doesn't reference
+		 * them.
+		 */
+		if (IsA(texpr, Var))
+		{
+			Var		   *var = (Var *) texpr;
+			ListCell   *wlc;
+			bool		needed_by_define = false;
+
+			foreach(wlc, subquery->windowClause)
+			{
+				WindowClause *wc = lfirst_node(WindowClause, wlc);
+
+				if (wc->defineClause != NIL)
+				{
+					List	   *vars = pull_var_clause((Node *) wc->defineClause, 0);
+					ListCell   *vlc;
+
+					foreach(vlc, vars)
+					{
+						Var		   *dvar = (Var *) lfirst(vlc);
+
+						if (dvar->varattno == var->varattno)
+						{
+							needed_by_define = true;
+							break;
+						}
+					}
+					list_free(vars);
+					if (needed_by_define)
+						break;
+				}
+			}
+			if (needed_by_define)
+				continue;
+		}
+
+		/*
+		 * If it's a window function referencing a window clause with RPR,
+		 * don't remove it.  Even when the window function result is unused by
+		 * the outer query, the RPR pattern matching (frame reduction via
+		 * DEFINE/PATTERN) must still execute.  Replacing this with NULL would
+		 * leave no active window functions for the WindowClause, causing the
+		 * planner to omit the WindowAgg node entirely.
+		 */
+		if (IsA(texpr, WindowFunc))
+		{
+			WindowFunc *wfunc = (WindowFunc *) texpr;
+			ListCell   *wlc;
+
+			foreach(wlc, subquery->windowClause)
+			{
+				WindowClause *wc = lfirst_node(WindowClause, wlc);
+
+				if (wc->winref == wfunc->winref &&
+					wc->defineClause != NIL)
+				{
+					break;
+				}
+			}
+			if (wlc != NULL)
+				continue;
+		}
+
 		/*
 		 * OK, we don't need it.  Replace the expression with a NULL constant.
 		 * Preserve the exposed type of the expression, in case something
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 55283ab4bbe..db1309ca311 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -28,6 +28,7 @@
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
+#include "optimizer/optimizer.h"
 #include "optimizer/rpr.h"
 #include "parser/parse_clause.h"
 #include "parser/parse_collate.h"
@@ -310,9 +311,7 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 	restargets = NIL;
 	foreach(lc, windef->rpCommonSyntax->rpDefs)
 	{
-		TargetEntry *te,
-				   *teDefine;
-		int			defineExprLocation;
+		TargetEntry *teDefine;
 
 		restarget = (ResTarget *) lfirst(lc);
 		name = restarget->name;
@@ -335,57 +334,68 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 		restargets = lappend(restargets, restarget);
 
 		/*
-		 * Transform the DEFINE expression (restarget->val) and add it to the
-		 * targetlist as a TargetEntry if not already present, so the planner
-		 * can propagate the referenced columns to the outer plan's
-		 * targetlist.
+		 * Transform the DEFINE expression.  We must NOT add the whole
+		 * expression to the query targetlist, because it may contain
+		 * RPRNavExpr nodes (PREV/NEXT) that can only be evaluated inside the
+		 * owning WindowAgg.
 		 *
-		 * Note: findTargetlistEntrySQL99 transforms and clobbers
-		 * restarget->val.
+		 * Instead, we transform the expression directly and only ensure that
+		 * the individual Var nodes it references are present in the
+		 * targetlist, so the planner can propagate the referenced columns.
 		 */
+		{
+			Node	   *expr;
+			List	   *vars;
+			ListCell   *lc2;
 
-		/*
-		 * Save the original expression location before transformation.
-		 * findTargetlistEntrySQL99 may return an existing TargetEntry whose
-		 * location points to where it was originally created (e.g., ORDER
-		 * BY), not the DEFINE clause. We need to preserve the DEFINE location
-		 * for accurate error reporting.
-		 */
-		defineExprLocation = exprLocation(restarget->val);
+			expr = transformExpr(pstate, restarget->val,
+								 EXPR_KIND_RPR_DEFINE);
+
+			/*
+			 * Pull out Var nodes from the transformed expression and ensure
+			 * each one is present in the targetlist.  This is needed so the
+			 * planner propagates the referenced columns through the plan
+			 * tree, making them available to the WindowAgg's DEFINE
+			 * evaluation.
+			 */
+			vars = pull_var_clause(expr, 0);
+			foreach(lc2, vars)
+			{
+				Var		   *var = (Var *) lfirst(lc2);
+				bool		found = false;
+				ListCell   *tl;
 
-		te = findTargetlistEntrySQL99(pstate, restarget->val,
-									  targetlist, EXPR_KIND_RPR_DEFINE);
+				foreach(tl, *targetlist)
+				{
+					TargetEntry *tle = (TargetEntry *) lfirst(tl);
 
-		/* -------------------
-		 * Copy the TargetEntry for defineClause and always set the pattern
-		 * variable name. We use copyObject so the original targetlist entry
-		 * is not modified.
-		 *
-		 * Note: We must always set resname to the pattern variable name.
-		 * findTargetlistEntrySQL99 creates new TEs with resname = NULL
-		 * (resjunk entries), but returns existing TEs unchanged when the
-		 * expression already exists in targetlist.
-		 *
-		 * Example: "SELECT id, flag, ... WINDOW w AS (... DEFINE T AS flag)"
-		 *
-		 * 1. SELECT list processing creates: TE{resname="flag", expr=flag}
-		 * 2. DEFINE T AS flag: findTargetlistEntrySQL99 finds existing TE
-		 * 3. te->resname is "flag" (from SELECT), not NULL
-		 * 4. Without unconditionally setting resname, teDefine->resname
-		 *    would remain "flag" instead of pattern variable name "T"
-		 * 5. buildRPRPattern builds defineVariableList from resname, so
-		 *    it would contain ["flag"] instead of ["T"]
-		 * 6. Pattern variable "T" not found -> Assert failure crash
-		 */
-		teDefine = copyObject(te);
-		teDefine->resname = pstrdup(name);
+					if (IsA(tle->expr, Var) &&
+						((Var *) tle->expr)->varno == var->varno &&
+						((Var *) tle->expr)->varattno == var->varattno)
+					{
+						found = true;
+						break;
+					}
+				}
+				if (!found)
+				{
+					TargetEntry *newtle;
 
-		/*
-		 * Update the expression location to point to the DEFINE clause. This
-		 * ensures error messages reference the correct source location.
-		 */
-		if (defineExprLocation >= 0 && IsA(teDefine->expr, Var))
-			((Var *) teDefine->expr)->location = defineExprLocation;
+					newtle = makeTargetEntry((Expr *) copyObject(var),
+											 list_length(*targetlist) + 1,
+											 NULL,
+											 true);
+					*targetlist = lappend(*targetlist, newtle);
+				}
+			}
+			list_free(vars);
+
+			/* Build the defineClause entry directly from the transformed expr */
+			teDefine = makeTargetEntry((Expr *) expr,
+									   list_length(defineClause) + 1,
+									   pstrdup(name),
+									   true);
+		}
 
 		/* build transformed DEFINE clause (list of TargetEntry) */
 		defineClause = lappend(defineClause, teDefine);
-- 
2.50.1 (Apple Git-155)


From ee4eee935abcc5d0e089b19e88a81e8ecac52607 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 14:09:53 +0900
Subject: [PATCH] Add RPR planner integration tests

Add rpr_integration.sql covering planner optimization interactions
with RPR windows: frame optimization, run condition pushdown, window
deduplication, unused window removal, inverse transition, cost
estimation, subquery flattening, DEFINE expression propagation, and
LIMIT.  Also covers integration scenarios with partitioned tables,
LATERAL, recursive CTEs, incremental sort, volatile functions, and
correlated subqueries.
---
 src/test/regress/expected/rpr_integration.out | 1449 +++++++++++++++++
 src/test/regress/parallel_schedule            |    2 +-
 src/test/regress/sql/rpr_integration.sql      |  934 +++++++++++
 3 files changed, 2384 insertions(+), 1 deletion(-)
 create mode 100644 src/test/regress/expected/rpr_integration.out
 create mode 100644 src/test/regress/sql/rpr_integration.sql

diff --git a/src/test/regress/expected/rpr_integration.out b/src/test/regress/expected/rpr_integration.out
new file mode 100644
index 00000000000..0ee612b74fb
--- /dev/null
+++ b/src/test/regress/expected/rpr_integration.out
@@ -0,0 +1,1449 @@
+-- ============================================================
+-- RPR Integration Tests
+-- Planner optimization interaction tests for Row Pattern Recognition
+-- ============================================================
+--
+-- Verifies that each planner optimization correctly handles RPR windows.
+-- Even if individual optimizations are tested elsewhere, this file
+-- provides a single checkpoint for all planner/RPR interactions.
+--
+-- A. Planner Optimization Protection Tests
+--    A1. Frame optimization bypass
+--    A2. Run condition pushdown bypass
+--    A3. Window dedup prevention (RPR vs non-RPR)
+--    A4. Window dedup prevention (same PATTERN, different DEFINE)
+--    A5. Unused window removal prevention
+--    A6. Inverse transition bypass
+--    A7. Cost estimation RPR awareness
+--    A8. Subquery flattening prevention
+--    A9. DEFINE expression non-propagation
+--    A10. RPR + LIMIT
+--
+-- B. Integration Scenario Tests
+--    B1. RPR + CTE
+--    B2. RPR + JOIN
+--    B3. RPR + Set operations
+--    B4. RPR + Prepared statements
+--    B5. RPR + Partitioned table
+--    B6. RPR + LATERAL
+--    B7. RPR + Recursive CTE
+--    B8. RPR + Incremental sort
+--    B9. RPR + Volatile function in DEFINE
+--    B10. RPR + Correlated subquery
+--
+CREATE TABLE rpr_integ (id INT, val INT);
+INSERT INTO rpr_integ VALUES
+    (1, 10), (2, 20), (3, 15), (4, 25), (5, 5),
+    (6, 30), (7, 35), (8, 20), (9, 40), (10, 45);
+-- ============================================================
+-- A1. Frame optimization bypass
+-- ============================================================
+-- optimize_window_clauses() must not apply frame optimization to RPR windows.
+-- Non-RPR case: frame can be optimized (RANGE -> ROWS conversion, etc.).
+-- RPR case: frame must stay as specified (ROWS BETWEEN CURRENT ROW AND
+-- UNBOUNDED FOLLOWING).
+-- Non-RPR window with default frame -> frame optimization applied
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w FROM rpr_integ
+WINDOW w AS (ORDER BY id);
+            QUERY PLAN             
+-----------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id)
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_integ
+(5 rows)
+
+-- RPR window -> frame optimization must NOT change the frame
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val));
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_integ
+(6 rows)
+
+-- ============================================================
+-- A2. Run condition pushdown bypass
+-- ============================================================
+-- Verify that find_window_run_conditions() does not push a monotonic
+-- filter down as a Run Condition on RPR windows.  RPR match counts are
+-- determined by pattern matching rather than by a monotonic
+-- accumulation over the frame, so a filter such as "cnt > 0" cannot be
+-- used to stop evaluating the window function early.
+-- Non-RPR baseline: the filter is expected to appear as a Run Condition.
+EXPLAIN (COSTS OFF)
+SELECT * FROM (
+    SELECT count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+) t WHERE cnt > 0;
+                                          QUERY PLAN                                           
+-----------------------------------------------------------------------------------------------
+ Subquery Scan on t
+   ->  WindowAgg
+         Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Run Condition: (count(*) OVER w > 0)
+         ->  Sort
+               Sort Key: rpr_integ.id
+               ->  Seq Scan on rpr_integ
+(7 rows)
+
+-- RPR case: the filter must appear as a Filter above the WindowAgg,
+-- not as a Run Condition.
+EXPLAIN (COSTS OFF)
+SELECT * FROM (
+    SELECT count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0;
+                                          QUERY PLAN                                           
+-----------------------------------------------------------------------------------------------
+ Subquery Scan on t
+   Filter: (t.cnt > 0)
+   ->  WindowAgg
+         Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a b+
+         ->  Sort
+               Sort Key: rpr_integ.id
+               ->  Seq Scan on rpr_integ
+(8 rows)
+
+-- Verify that the RPR query still returns every row whose match count is
+-- greater than zero, confirming the filter is evaluated above the
+-- WindowAgg rather than cutting off pattern matching prematurely.
+SELECT * FROM (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  3 |  15 |   2
+  5 |   5 |   3
+  8 |  20 |   3
+(4 rows)
+
+-- ============================================================
+-- A3. Window dedup prevention (RPR vs non-RPR)
+-- ============================================================
+-- Verify that PostgreSQL does not merge an RPR window with a non-RPR
+-- window even when both share the same ORDER BY and frame
+-- specification.  RPR pattern matching produces results that are
+-- semantically different from a plain frame-based aggregate, so the
+-- two windows must remain as separate WindowAgg nodes.  Inline window
+-- specs are used throughout this section because only inline windows
+-- are subject to the dedup path; distinct named windows are always
+-- kept separate regardless of equivalence.
+-- Non-RPR baseline: two inline windows with identical spec are
+-- deduped by the planner into a single WindowAgg node, confirming
+-- that the dedup path is active for non-RPR windows.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS cnt,
+    sum(val)  OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS total
+FROM rpr_integ;
+                                   QUERY PLAN                                   
+--------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_integ
+(5 rows)
+
+-- An inline RPR window and an inline non-RPR window share the same
+-- ORDER BY and frame but must remain as distinct WindowAgg nodes.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS rpr_cnt,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS normal_cnt
+FROM rpr_integ;
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w2 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a b+
+         ->  Sort
+               Sort Key: id
+               ->  Seq Scan on rpr_integ
+(8 rows)
+
+-- Verify that the two windows return independent counts per row,
+-- confirming they were not merged into a single WindowAgg.
+SELECT
+    id, val,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS rpr_cnt,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS normal_cnt
+FROM rpr_integ
+ORDER BY id;
+ id | val | rpr_cnt | normal_cnt 
+----+-----+---------+------------
+  1 |  10 |       2 |         10
+  2 |  20 |       0 |          9
+  3 |  15 |       2 |          8
+  4 |  25 |       0 |          7
+  5 |   5 |       3 |          6
+  6 |  30 |       0 |          5
+  7 |  35 |       0 |          4
+  8 |  20 |       3 |          3
+  9 |  40 |       0 |          2
+ 10 |  45 |       0 |          1
+(10 rows)
+
+-- ============================================================
+-- A4. Window dedup prevention (same PATTERN, different DEFINE)
+-- ============================================================
+-- Verify that inline-window dedup does not merge two RPR windows
+-- that share the same PATTERN structure but have different DEFINE
+-- conditions.  Even though the ORDER BY, frame, and PATTERN coincide,
+-- the differing DEFINE expressions classify rows differently and
+-- must therefore yield two separate WindowAgg nodes.  Inline specs
+-- are used here because dedup only applies to inline windows.
+-- Baseline: two inline RPR windows that are structurally identical
+-- (same ORDER BY, frame, PATTERN, and DEFINE) are deduped by the
+-- parser into a single WindowAgg node, confirming that parser-level
+-- dedup is active for RPR windows whose DEFINE matches.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS cnt,
+    sum(val)  OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS total
+FROM rpr_integ;
+                                   QUERY PLAN                                   
+--------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_integ
+(6 rows)
+
+-- Two inline RPR windows with the same PATTERN but opposite DEFINE
+-- conditions must remain as separate WindowAgg nodes.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS cnt_up,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val < PREV(val)) AS cnt_down
+FROM rpr_integ;
+                                      QUERY PLAN                                      
+--------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w2 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a b+
+         ->  Sort
+               Sort Key: id
+               ->  Seq Scan on rpr_integ
+(9 rows)
+
+-- Verify that the two windows return different counts per row,
+-- confirming the DEFINE conditions were not collapsed by dedup.
+SELECT
+    id, val,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS cnt_up,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val < PREV(val)) AS cnt_down
+FROM rpr_integ
+ORDER BY id;
+ id | val | cnt_up | cnt_down 
+----+-----+--------+----------
+  1 |  10 |      2 |        0
+  2 |  20 |      0 |        2
+  3 |  15 |      2 |        0
+  4 |  25 |      0 |        2
+  5 |   5 |      3 |        0
+  6 |  30 |      0 |        0
+  7 |  35 |      0 |        2
+  8 |  20 |      3 |        0
+  9 |  40 |      0 |        0
+ 10 |  45 |      0 |        0
+(10 rows)
+
+-- ============================================================
+-- A5. Unused window removal prevention
+-- ============================================================
+-- Verify that remove_unused_subquery_outputs() does not drop an RPR
+-- window function even when the outer query does not reference its
+-- result.  The RPR WindowAgg node is responsible for performing pattern
+-- matching, so removing the window function would silently skip the
+-- pattern match even though the surrounding query still depends on
+-- RPR semantics.
+-- The outer query ignores the per-row window result, yet pattern
+-- matching must still execute.  The plan must still contain a
+-- WindowAgg node below the outer Aggregate; if the window were
+-- removed, only Aggregate + Seq Scan would appear.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM (
+    SELECT count(*) OVER w FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Aggregate
+   ->  WindowAgg
+         Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a+"
+         ->  Seq Scan on rpr_integ
+(5 rows)
+
+SELECT count(*) FROM (
+    SELECT count(*) OVER w FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+ count 
+-------
+    10
+(1 row)
+
+-- The DEFINE expression references PREV(val), so the window must be
+-- preserved even if the outer query only aggregates over the count.
+-- The plan must still contain a WindowAgg with the PATTERN/DEFINE
+-- intact.
+EXPLAIN (COSTS OFF)
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Aggregate
+   ->  WindowAgg
+         Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a+"
+         ->  Seq Scan on rpr_integ
+(5 rows)
+
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+ count | sum 
+-------+-----
+    10 |   6
+(1 row)
+
+-- The DEFINE expression contains no navigation, but the RPR window
+-- must still be preserved because the match structure itself affects
+-- the count.  The plan must retain the WindowAgg.
+EXPLAIN (COSTS OFF)
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS TRUE)
+) t;
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Aggregate
+   ->  WindowAgg
+         Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a+"
+         ->  Seq Scan on rpr_integ
+(5 rows)
+
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS TRUE)
+) t;
+ count | sum 
+-------+-----
+    10 |  10
+(1 row)
+
+-- XXX: "val" is non-resjunk in the subquery output and is not
+-- referenced by the outer query.  Without a guard,
+-- remove_unused_subquery_outputs() would replace it with NULL in
+-- the subquery output, and that replacement propagates to the
+-- scan's targetlist -- DEFINE would then evaluate with NULL
+-- inputs.  The targetlist has no way to distinguish "exposed to
+-- the outer query" from "referenced only by DEFINE", so the
+-- optimization cannot be applied selectively.  The column guard
+-- in allpaths.c blocks this replacement for any column referenced
+-- by an RPR DEFINE clause, keeping the WindowAgg with DEFINE
+-- active in the plan.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM (
+    SELECT val, count(*) OVER w FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t;
+                                          QUERY PLAN                                           
+-----------------------------------------------------------------------------------------------
+ Aggregate
+   ->  WindowAgg
+         Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a b+
+         ->  Sort
+               Sort Key: rpr_integ.id
+               ->  Seq Scan on rpr_integ
+(7 rows)
+
+SELECT count(*) FROM (
+    SELECT val, count(*) OVER w FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t;
+ count 
+-------
+    10
+(1 row)
+
+-- ============================================================
+-- A6. Inverse transition bypass
+-- ============================================================
+-- Verify that RPR windows do not use the moving aggregate (inverse
+-- transition) optimization.  Moving aggregates maintain state by
+-- adding arriving rows and subtracting leaving rows, but an RPR
+-- reduced frame is not a sliding window; the set of rows included in
+-- the frame is determined by pattern matching and cannot be derived
+-- incrementally from the previous frame.
+-- sum() would normally be eligible for the moving aggregate
+-- optimization; under RPR it must be computed from scratch over each
+-- reduced frame, and the returned values must match the pattern.
+-- Note: inverse-transition selection is not exposed in the plan, so
+-- there is no direct EXPLAIN assertion for it.  The structural
+-- guarantee is that RPR uses its own navigation mark, distinct from
+-- the moving-aggregate mark, so the inverse-transition path is
+-- never reached on the RPR side.  This test verifies that
+-- separation indirectly: if inverse transition leaked into the RPR
+-- path, state would mix across match boundaries and pattern_sum
+-- would diverge from the expected output, failing the regression.
+SELECT id, val,
+    sum(val) OVER w AS pattern_sum
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+ id | val | pattern_sum 
+----+-----+-------------
+  1 |  10 |          30
+  2 |  20 |            
+  3 |  15 |          40
+  4 |  25 |            
+  5 |   5 |          70
+  6 |  30 |            
+  7 |  35 |            
+  8 |  20 |         105
+  9 |  40 |            
+ 10 |  45 |            
+(10 rows)
+
+-- ============================================================
+-- A7. Cost estimation RPR awareness
+-- ============================================================
+-- cost_windowagg() must account for DEFINE expression evaluation cost.
+-- Verify RPR WindowAgg cost > non-RPR WindowAgg cost.
+CREATE FUNCTION get_windowagg_cost(query text) RETURNS numeric AS $$
+DECLARE
+    plan json;
+    cost numeric;
+BEGIN
+    EXECUTE 'EXPLAIN (FORMAT JSON) ' || query INTO plan;
+    cost := (plan->0->'Plan'->>'Total Cost')::numeric;
+    RETURN cost;
+END;
+$$ LANGUAGE plpgsql;
+SELECT get_windowagg_cost(
+    'SELECT count(*) OVER w FROM rpr_integ
+     WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+                  PATTERN (A B+ C+) DEFINE B AS val > PREV(val), C AS val < PREV(val))')
+    >
+    get_windowagg_cost(
+    'SELECT count(*) OVER w FROM rpr_integ
+     WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)')
+    AS rpr_cost_is_higher;
+ rpr_cost_is_higher 
+--------------------
+ t
+(1 row)
+
+DROP FUNCTION get_windowagg_cost(text);
+-- ============================================================
+-- A8. Subquery flattening prevention
+-- ============================================================
+-- Verify that a subquery containing an RPR window is not flattened
+-- into the outer query.  is_simple_subquery() already blocks pullup
+-- for subqueries with window functions in general; this test confirms
+-- the rule continues to apply to RPR windows, so EXPLAIN must still
+-- show a Subquery Scan above the RPR WindowAgg.
+EXPLAIN (COSTS OFF)
+SELECT * FROM (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) sub
+WHERE cnt > 0;
+                                          QUERY PLAN                                           
+-----------------------------------------------------------------------------------------------
+ Subquery Scan on sub
+   Filter: (sub.cnt > 0)
+   ->  WindowAgg
+         Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a b+
+         ->  Sort
+               Sort Key: rpr_integ.id
+               ->  Seq Scan on rpr_integ
+(8 rows)
+
+-- ============================================================
+-- A9. DEFINE expression non-propagation
+-- ============================================================
+-- Verify that DEFINE expressions are not propagated into the
+-- targetlist of any upper WindowAgg node.  Only the column references
+-- consumed by DEFINE should be passed up; the full DEFINE expression
+-- is meaningful only inside the RPR WindowAgg that owns it.
+-- EXPLAIN VERBOSE is therefore expected to show a clean targetlist on
+-- the outer WindowAgg, with no DEFINE-derived expression leaking in.
+-- Note: columns referenced by DEFINE (e.g., "val") may appear as
+-- resjunk entries in upper WindowAgg targetlists -- that is a
+-- harmless byproduct of the column guard's broad scope and does not
+-- affect client output.  The claim here is limited to the full
+-- DEFINE boolean expression.
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT
+    count(*) OVER w_rpr AS rpr_cnt,
+    count(*) OVER w_normal AS normal_cnt
+FROM rpr_integ
+WINDOW
+    w_rpr AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)),
+    w_normal AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING);
+                                            QUERY PLAN                                             
+---------------------------------------------------------------------------------------------------
+ WindowAgg
+   Output: (count(*) OVER w_rpr), count(*) OVER w_normal, id, val
+   Window: w_normal AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   ->  WindowAgg
+         Output: id, val, count(*) OVER w_rpr
+         Window: w_rpr AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a b+
+         ->  Sort
+               Output: id, val
+               Sort Key: rpr_integ.id
+               ->  Seq Scan on public.rpr_integ
+                     Output: id, val
+(12 rows)
+
+-- Executing the same query shows the client result is limited to
+-- the two projected columns; "id" and "val" that appeared in the
+-- upper WindowAgg Output line are resjunk-only and do not reach
+-- the client.
+SELECT
+    count(*) OVER w_rpr AS rpr_cnt,
+    count(*) OVER w_normal AS normal_cnt
+FROM rpr_integ
+WINDOW
+    w_rpr AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)),
+    w_normal AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+ORDER BY rpr_cnt DESC, normal_cnt DESC;
+ rpr_cnt | normal_cnt 
+---------+------------
+       3 |          6
+       3 |          3
+       2 |         10
+       2 |          8
+       0 |          9
+       0 |          7
+       0 |          5
+       0 |          4
+       0 |          2
+       0 |          1
+(10 rows)
+
+-- ============================================================
+-- A10. RPR + LIMIT
+-- ============================================================
+-- LIMIT must not interfere with RPR pattern matching.  The Limit
+-- node must sit above the WindowAgg so that pattern matching runs
+-- on the full partition first; the result is then a prefix of the
+-- un-LIMITed output.  Pushing Limit below the WindowAgg would
+-- truncate input before matching and silently drop valid matches.
+EXPLAIN (COSTS OFF)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+LIMIT 5;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Limit
+   ->  WindowAgg
+         Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+         Pattern: a b+
+         ->  Sort
+               Sort Key: id
+               ->  Seq Scan on rpr_integ
+(7 rows)
+
+-- Reference: un-LIMITed result against which the LIMIT 5 result is
+-- compared.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
+-- LIMIT 5 case; the first five rows must match the reference above.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+LIMIT 5;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+(5 rows)
+
+-- ============================================================
+-- B1. RPR + CTE
+-- ============================================================
+-- Verify that an RPR window embedded inside a CTE behaves the same as
+-- a direct RPR query:
+--   (1) A single-reference CTE is inlined by the planner and yields
+--       per-row results identical to the direct RPR query.
+--   (2) A multi-reference CTE is materialized (CTE Scan appears in
+--       the plan) so pattern matching runs once, and every reference
+--       observes the same match results.
+-- Baseline: direct RPR produces the per-row reference output.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
+-- Single-reference CTE: plan has no "CTE rpr_result" scope, showing
+-- the CTE was inlined into the surrounding query.
+EXPLAIN (COSTS OFF)
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT id, val, cnt FROM rpr_result ORDER BY id;
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  Sort
+         Sort Key: rpr_integ.id
+         ->  Seq Scan on rpr_integ
+(6 rows)
+
+-- Result must match the baseline row-for-row.
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT id, val, cnt FROM rpr_result ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
+-- Multi-reference CTE (self-join): plan has a "CTE rpr_result" scope
+-- and CTE Scan nodes on both sides, showing the CTE was materialized
+-- and pattern matching ran only once.
+EXPLAIN (COSTS OFF)
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT r1.id, r1.cnt
+FROM rpr_result r1
+JOIN rpr_result r2 ON r1.id = r2.id AND r1.cnt = r2.cnt
+WHERE r1.cnt > 0
+ORDER BY r1.id;
+                                           QUERY PLAN                                            
+-------------------------------------------------------------------------------------------------
+ Merge Join
+   Merge Cond: ((r2.id = r1.id) AND (r2.cnt = r1.cnt))
+   CTE rpr_result
+     ->  WindowAgg
+           Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+           Pattern: a b+
+           ->  Sort
+                 Sort Key: rpr_integ.id
+                 ->  Seq Scan on rpr_integ
+   ->  Incremental Sort
+         Sort Key: r2.id, r2.cnt
+         Presorted Key: r2.id
+         ->  CTE Scan on rpr_result r2
+   ->  Sort
+         Sort Key: r1.id, r1.cnt
+         ->  CTE Scan on rpr_result r1
+               Filter: (cnt > 0)
+(17 rows)
+
+-- Result: both references see the same match counts, so the self-join
+-- preserves all matched rows from the baseline.
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT r1.id, r1.cnt
+FROM rpr_result r1
+JOIN rpr_result r2 ON r1.id = r2.id AND r1.cnt = r2.cnt
+WHERE r1.cnt > 0
+ORDER BY r1.id;
+ id | cnt 
+----+-----
+  1 |   2
+  3 |   2
+  5 |   3
+  8 |   3
+(4 rows)
+
+-- ============================================================
+-- B2. RPR + JOIN
+-- ============================================================
+-- Verify that an RPR subquery can be joined with another relation.
+-- Two aspects are checked against a non-RPR baseline:
+--   (1) Flattening: a non-RPR subquery is pulled up by the planner
+--       (no Subquery Scan in the plan); an RPR subquery is kept
+--       un-flattened (Subquery Scan above WindowAgg).
+--   (2) Join correctness: the join aligns each RPR match row with
+--       the dimension-table row on the same key.
+CREATE TABLE rpr_integ2 (id INT, label TEXT);
+INSERT INTO rpr_integ2 VALUES
+    (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'),
+    (6, 'f'), (7, 'g'), (8, 'h'), (9, 'i'), (10, 'j');
+-- Baseline: a non-RPR subquery is flattened by the planner.  No
+-- Subquery Scan node appears; the inner SELECT is merged into the
+-- outer join.
+EXPLAIN (COSTS OFF)
+SELECT r.id, r.val, j.label
+FROM (SELECT id, val FROM rpr_integ) r
+JOIN rpr_integ2 j ON r.id = j.id
+ORDER BY r.id;
+              QUERY PLAN              
+--------------------------------------
+ Merge Join
+   Merge Cond: (j.id = rpr_integ.id)
+   ->  Sort
+         Sort Key: j.id
+         ->  Seq Scan on rpr_integ2 j
+   ->  Sort
+         Sort Key: rpr_integ.id
+         ->  Seq Scan on rpr_integ
+(8 rows)
+
+-- RPR subquery JOIN: the Subquery Scan is preserved above the
+-- WindowAgg, confirming the RPR subquery is not flattened.
+EXPLAIN (COSTS OFF)
+SELECT r.id, r.cnt, j.label
+FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+JOIN rpr_integ2 j ON r.id = j.id
+WHERE r.cnt > 0
+ORDER BY r.id;
+                                             QUERY PLAN                                              
+-----------------------------------------------------------------------------------------------------
+ Merge Join
+   Merge Cond: (r.id = j.id)
+   ->  Subquery Scan on r
+         Filter: (r.cnt > 0)
+         ->  WindowAgg
+               Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+               Pattern: a b+
+               ->  Sort
+                     Sort Key: rpr_integ.id
+                     ->  Seq Scan on rpr_integ
+   ->  Sort
+         Sort Key: j.id
+         ->  Seq Scan on rpr_integ2 j
+(13 rows)
+
+-- Result: matched RPR rows align with dimension rows on id, showing
+-- the join correctly pairs per-row match counts with their labels.
+SELECT r.id, r.cnt, j.label
+FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+JOIN rpr_integ2 j ON r.id = j.id
+WHERE r.cnt > 0
+ORDER BY r.id;
+ id | cnt | label 
+----+-----+-------
+  1 |   2 | a
+  3 |   2 | c
+  5 |   3 | e
+  8 |   3 | h
+(4 rows)
+
+-- ============================================================
+-- B3. RPR + Set operations
+-- ============================================================
+-- Verify that RPR results combine correctly with non-RPR results
+-- under a UNION ALL.  The plan must show an Append node with two
+-- independent child plans: the RPR branch with Pattern/DEFINE active,
+-- and the non-RPR branch with a plain WindowAgg.  Each child scans
+-- the base relation on its own and contributes its rows to the
+-- unioned output.
+-- Plan: Append with two independent children.  The RPR branch has a
+-- WindowAgg carrying Pattern/Nav Mark Lookback; the non-RPR branch
+-- has a plain WindowAgg with no pattern metadata.
+EXPLAIN (COSTS OFF)
+SELECT id, cnt, 'rpr' AS source FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0
+UNION ALL
+SELECT id, count(*) OVER (ORDER BY id) AS cnt, 'normal' AS source
+FROM rpr_integ
+ORDER BY source, id;
+                                                QUERY PLAN                                                 
+-----------------------------------------------------------------------------------------------------------
+ Sort
+   Sort Key: ('rpr'::text), t.id
+   ->  Append
+         ->  Subquery Scan on t
+               Filter: (t.cnt > 0)
+               ->  WindowAgg
+                     Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+                     Pattern: a b+
+                     ->  Sort
+                           Sort Key: rpr_integ.id
+                           ->  Seq Scan on rpr_integ
+         ->  WindowAgg
+               Window: w1 AS (ORDER BY rpr_integ_1.id)
+               ->  Sort
+                     Sort Key: rpr_integ_1.id
+                     ->  Seq Scan on rpr_integ rpr_integ_1
+(16 rows)
+
+-- Result: rows from both branches are present in the unioned output.
+-- The RPR branch emits only matched rows (cnt > 0), while the
+-- non-RPR branch emits all rows with its own count values.
+SELECT id, cnt, 'rpr' AS source FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0
+UNION ALL
+SELECT id, count(*) OVER (ORDER BY id) AS cnt, 'normal' AS source
+FROM rpr_integ
+ORDER BY source, id;
+ id | cnt | source 
+----+-----+--------
+  1 |   1 | normal
+  2 |   2 | normal
+  3 |   3 | normal
+  4 |   4 | normal
+  5 |   5 | normal
+  6 |   6 | normal
+  7 |   7 | normal
+  8 |   8 | normal
+  9 |   9 | normal
+ 10 |  10 | normal
+  1 |   2 | rpr
+  3 |   2 | rpr
+  5 |   3 | rpr
+  8 |   3 | rpr
+(14 rows)
+
+-- ============================================================
+-- B4. RPR + Prepared statements
+-- ============================================================
+-- Verify that RPR queries survive the prepared-statement path by
+-- exercising both plancache modes with a parameter that feeds into
+-- RPR's navigation offset (PREV(val, $1)).  The parameter surfaces
+-- the RPR-specific plancache difference:
+--   - custom plan: "Nav Mark Lookback" is resolved to the literal
+--     parameter value at plan time (e.g., "Nav Mark Lookback: 1").
+--   - generic plan: "Nav Mark Lookback" is deferred to execution and
+--     appears as "Nav Mark Lookback: runtime" in the plan.
+-- The result must be identical under both modes.
+-- Register the prepared statement; DEFINE uses PREV(val, $1) so the
+-- parameter reaches RPR's navigation machinery.
+PREPARE rpr_prev(int) AS
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val, $1))
+ORDER BY id;
+ERROR:  function prev(integer, integer) does not exist
+LINE 7:     DEFINE B AS val > PREV(val, $1))
+                              ^
+DETAIL:  No function of that name accepts the given number of arguments.
+-- Custom plan: Nav Mark Lookback resolved to the literal 1.
+SET plan_cache_mode = force_custom_plan;
+EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
+ERROR:  prepared statement "rpr_prev" does not exist
+EXECUTE rpr_prev(1);
+ERROR:  prepared statement "rpr_prev" does not exist
+-- Generic plan: Nav Mark Lookback deferred to execution, shown as
+-- "runtime" in the plan.  Result must match the custom-plan result
+-- exactly.
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
+ERROR:  prepared statement "rpr_prev" does not exist
+EXECUTE rpr_prev(1);
+ERROR:  prepared statement "rpr_prev" does not exist
+RESET plan_cache_mode;
+DEALLOCATE rpr_prev;
+ERROR:  prepared statement "rpr_prev" does not exist
+-- ============================================================
+-- B5. RPR + Partitioned table
+-- ============================================================
+-- Verify that RPR pattern matching works correctly when the source
+-- relation is partitioned.  The planner must gather rows from every
+-- partition into a single ordered stream before RPR can see them,
+-- because pattern matching is sequential across the entire
+-- partition-by group and cannot be performed independently on each
+-- table partition.
+CREATE TABLE rpr_part (id INT, val INT) PARTITION BY RANGE (id);
+CREATE TABLE rpr_part_1 PARTITION OF rpr_part FOR VALUES FROM (1) TO (6);
+CREATE TABLE rpr_part_2 PARTITION OF rpr_part FOR VALUES FROM (6) TO (11);
+INSERT INTO rpr_part SELECT id, val FROM rpr_integ;
+-- Plan: partition scans are combined with Append (or Merge Append),
+-- sorted into a single ordered stream, and fed into one WindowAgg
+-- that performs RPR pattern matching across the combined stream.
+EXPLAIN (COSTS OFF)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_part
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+                                       QUERY PLAN                                       
+----------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY rpr_part.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  Sort
+         Sort Key: rpr_part.id
+         ->  Append
+               ->  Seq Scan on rpr_part_1
+               ->  Seq Scan on rpr_part_2
+(8 rows)
+
+-- Baseline: the same query against the non-partitioned rpr_integ
+-- produces the per-row reference output.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
+-- Result against the partitioned table must match the baseline
+-- row-for-row.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_part
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
+DROP TABLE rpr_part;
+-- ============================================================
+-- B6. RPR + LATERAL
+-- ============================================================
+-- RPR inside a LATERAL subquery.  Qualified column references from
+-- the outer query are not yet supported in DEFINE, so this tests
+-- the basic case where LATERAL provides the correlation filter
+-- (WHERE id <= o.id) and DEFINE uses only local columns.  The plan
+-- must show a Nested Loop driving the outer relation into the inner
+-- subquery scan, with the RPR WindowAgg re-executed for each outer
+-- row and the correlation surfacing as a scan-level Filter on
+-- "id <= o.id".
+-- Plan: Nested Loop with the RPR WindowAgg in the inner leg, driven
+-- by the filtered outer rows (o.id IN (5, 10)).
+EXPLAIN (COSTS OFF)
+SELECT o.id AS outer_id, r.id, r.cnt
+FROM rpr_integ o,
+LATERAL (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WHERE id <= o.id
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+WHERE r.cnt > 0 AND o.id IN (5, 10)
+ORDER BY o.id, r.id;
+                                                QUERY PLAN                                                 
+-----------------------------------------------------------------------------------------------------------
+ Sort
+   Sort Key: o.id, r.id
+   ->  Nested Loop
+         ->  Seq Scan on rpr_integ o
+               Filter: (id = ANY ('{5,10}'::integer[]))
+         ->  Subquery Scan on r
+               Filter: (r.cnt > 0)
+               ->  WindowAgg
+                     Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+                     Pattern: a b+
+                     ->  Sort
+                           Sort Key: rpr_integ.id
+                           ->  Seq Scan on rpr_integ
+                                 Filter: (id <= o.id)
+(14 rows)
+
+-- Result: for each of the two outer ids (5 and 10), the LATERAL
+-- subquery produces RPR match counts over the restricted input.
+SELECT o.id AS outer_id, r.id, r.cnt
+FROM rpr_integ o,
+LATERAL (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WHERE id <= o.id
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+WHERE r.cnt > 0 AND o.id IN (5, 10)
+ORDER BY o.id, r.id;
+ outer_id | id | cnt 
+----------+----+-----
+        5 |  1 |   2
+        5 |  3 |   2
+       10 |  1 |   2
+       10 |  3 |   2
+       10 |  5 |   3
+       10 |  8 |   3
+(6 rows)
+
+-- ============================================================
+-- B7. RPR + Recursive CTE
+-- ============================================================
+-- Verify that an RPR window can appear inside the non-recursive
+-- (base) leg of a recursive CTE.  The plan must show the RPR
+-- WindowAgg sitting under the Recursive Union as the base-leg
+-- child, with the WorkTable Scan feeding the recursive leg above
+-- it.  This confirms that RPR output can seed a recursive CTE
+-- (window functions cannot appear in the recursive leg itself, a
+-- PostgreSQL restriction, so this is the natural place to exercise
+-- "RPR under Recursive Union").
+--
+-- XXX: Whether this case falls under the ISO/IEC 9075-2 4.18.5 /
+-- 6.17.5 prohibition is not something I can judge.  If this case
+-- is not prohibited, the open question is whether a query that
+-- does trigger the prohibition can be constructed at all.
+-- Whether to prohibit this case is left to the community.
+-- Plan: Recursive Union with the RPR WindowAgg on the base leg and
+-- the WorkTable Scan on the recursive leg.
+EXPLAIN (COSTS OFF)
+WITH RECURSIVE seq AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+    UNION ALL
+    SELECT id + 100, val, cnt FROM seq WHERE id < 3
+)
+SELECT id, val, cnt FROM seq ORDER BY id;
+                                              QUERY PLAN                                               
+-------------------------------------------------------------------------------------------------------
+ Sort
+   Sort Key: seq.id
+   CTE seq
+     ->  Recursive Union
+           ->  WindowAgg
+                 Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+                 Pattern: a b+
+                 ->  Sort
+                       Sort Key: rpr_integ.id
+                       ->  Seq Scan on rpr_integ
+           ->  WorkTable Scan on seq seq_1
+                 Filter: (id < 3)
+   ->  CTE Scan on seq
+(13 rows)
+
+-- Result: the base leg contributes the RPR match counts; the
+-- recursive leg propagates those counts with shifted ids.
+WITH RECURSIVE seq AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+    UNION ALL
+    SELECT id + 100, val, cnt FROM seq WHERE id < 3
+)
+SELECT id, val, cnt FROM seq ORDER BY id;
+ id  | val | cnt 
+-----+-----+-----
+   1 |  10 |   2
+   2 |  20 |   0
+   3 |  15 |   2
+   4 |  25 |   0
+   5 |   5 |   3
+   6 |  30 |   0
+   7 |  35 |   0
+   8 |  20 |   3
+   9 |  40 |   0
+  10 |  45 |   0
+ 101 |  10 |   2
+ 102 |  20 |   0
+(12 rows)
+
+-- ============================================================
+-- B8. RPR + Incremental sort
+-- ============================================================
+-- Verify that RPR pattern matching works correctly when the input
+-- to WindowAgg arrives via an incremental sort.  The index on (id)
+-- provides presorted input for the first ORDER BY key, so
+-- "ORDER BY id, val" lets the planner use Incremental Sort to order
+-- only on the second key.  The plan must show Incremental Sort
+-- below the RPR WindowAgg, and RPR must produce the same per-row
+-- match counts as it would with a plain Sort.
+CREATE INDEX rpr_integ_id_idx ON rpr_integ (id);
+SET enable_seqscan = off;
+-- Plan: RPR WindowAgg above an Incremental Sort above an Index Scan.
+-- The Incremental Sort declares "Presorted Key: id" and sorts only
+-- on val within each id group.
+EXPLAIN (COSTS OFF)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id, val
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val));
+                                     QUERY PLAN                                     
+------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id, val ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  Incremental Sort
+         Sort Key: id, val
+         Presorted Key: id
+         ->  Index Scan using rpr_integ_id_idx on rpr_integ
+(7 rows)
+
+-- Result: RPR over the incrementally sorted stream produces match
+-- counts per row.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id, val
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id, val;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
+RESET enable_seqscan;
+DROP INDEX rpr_integ_id_idx;
+-- ============================================================
+-- B9. RPR + Volatile function in DEFINE
+-- ============================================================
+-- Records the current behaviour: DEFINE today accepts volatile
+-- functions such as random() and the query runs to completion.
+-- To keep the expected output deterministic the predicate uses
+-- "random() >= 0.0", which is structurally equivalent to TRUE and
+-- therefore does not perturb the match result.  The interesting
+-- property is that volatile invocation does not crash or short-
+-- circuit pattern matching.
+--
+-- XXX: volatile functions in DEFINE are slated to be rejected at
+-- parse time.  Under RPR's NFA engine the same row's DEFINE
+-- predicate may be evaluated multiple times (backtracking,
+-- PREV/NEXT navigation), so a truly volatile result would make
+-- pattern matching non-deterministic.  When the prohibition lands,
+-- this test must be replaced with an error-case test that expects
+-- random() in DEFINE to be rejected.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val) AND random() >= 0.0)
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
+-- ============================================================
+-- B10. RPR + Correlated subquery in WHERE
+-- ============================================================
+-- Verify that an RPR window placed inside a correlated scalar
+-- subquery is executed once per outer row.  DEFINE still references
+-- only local columns (qualified refs from the outer query are not
+-- supported in DEFINE); the correlation lives in the subquery's
+-- WHERE clause as "i.id <= o.id".  The plan must show a SubPlan
+-- attached to the outer scan, with the RPR WindowAgg driven by a
+-- per-row scan filter carrying the correlation predicate.
+-- Plan: SubPlan attached to the outer Seq Scan; the inner scan
+-- carries "Filter: (id <= o.id)", confirming the correlation is
+-- evaluated per outer row.
+EXPLAIN (COSTS OFF)
+SELECT o.id, o.val,
+    (SELECT count(*) OVER w
+     FROM rpr_integ i
+     WHERE i.id <= o.id
+     WINDOW w AS (ORDER BY id
+         ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+         PATTERN (A B+)
+         DEFINE B AS val > PREV(val))
+     ORDER BY id
+     LIMIT 1) AS first_cnt
+FROM rpr_integ o
+ORDER BY o.id;
+                                             QUERY PLAN                                              
+-----------------------------------------------------------------------------------------------------
+ Sort
+   Sort Key: o.id
+   ->  Seq Scan on rpr_integ o
+         SubPlan expr_1
+           ->  Limit
+                 ->  WindowAgg
+                       Window: w AS (ORDER BY i.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+                       Pattern: a b+
+                       ->  Sort
+                             Sort Key: i.id
+                             ->  Seq Scan on rpr_integ i
+                                   Filter: (id <= o.id)
+(12 rows)
+
+-- Result: each outer row receives the first_cnt from its own
+-- correlated RPR subquery.
+SELECT o.id, o.val,
+    (SELECT count(*) OVER w
+     FROM rpr_integ i
+     WHERE i.id <= o.id
+     WINDOW w AS (ORDER BY id
+         ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+         PATTERN (A B+)
+         DEFINE B AS val > PREV(val))
+     ORDER BY id
+     LIMIT 1) AS first_cnt
+FROM rpr_integ o
+ORDER BY o.id;
+ id | val | first_cnt 
+----+-----+-----------
+  1 |  10 |         0
+  2 |  20 |         2
+  3 |  15 |         2
+  4 |  25 |         2
+  5 |   5 |         2
+  6 |  30 |         2
+  7 |  35 |         2
+  8 |  20 |         2
+  9 |  40 |         2
+ 10 |  45 |         2
+(10 rows)
+
+-- Cleanup
+DROP TABLE rpr_integ;
+DROP TABLE rpr_integ2;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 6a2d19d4410..dbfa3e8f2cc 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -107,7 +107,7 @@ test: select_views portals_p2 foreign_key cluster dependency guc bitmapops combo
 # ----------
 # Row Pattern Recognition tests
 # ----------
-test: rpr rpr_base rpr_explain rpr_nfa
+test: rpr rpr_base rpr_explain rpr_nfa rpr_integration
 
 # ----------
 # Another group of parallel tests (JSON related)
diff --git a/src/test/regress/sql/rpr_integration.sql b/src/test/regress/sql/rpr_integration.sql
new file mode 100644
index 00000000000..7d70f5e4b98
--- /dev/null
+++ b/src/test/regress/sql/rpr_integration.sql
@@ -0,0 +1,934 @@
+-- ============================================================
+-- RPR Integration Tests
+-- Planner optimization interaction tests for Row Pattern Recognition
+-- ============================================================
+--
+-- Verifies that each planner optimization correctly handles RPR windows.
+-- Even if individual optimizations are tested elsewhere, this file
+-- provides a single checkpoint for all planner/RPR interactions.
+--
+-- A. Planner Optimization Protection Tests
+--    A1. Frame optimization bypass
+--    A2. Run condition pushdown bypass
+--    A3. Window dedup prevention (RPR vs non-RPR)
+--    A4. Window dedup prevention (same PATTERN, different DEFINE)
+--    A5. Unused window removal prevention
+--    A6. Inverse transition bypass
+--    A7. Cost estimation RPR awareness
+--    A8. Subquery flattening prevention
+--    A9. DEFINE expression non-propagation
+--    A10. RPR + LIMIT
+--
+-- B. Integration Scenario Tests
+--    B1. RPR + CTE
+--    B2. RPR + JOIN
+--    B3. RPR + Set operations
+--    B4. RPR + Prepared statements
+--    B5. RPR + Partitioned table
+--    B6. RPR + LATERAL
+--    B7. RPR + Recursive CTE
+--    B8. RPR + Incremental sort
+--    B9. RPR + Volatile function in DEFINE
+--    B10. RPR + Correlated subquery
+--
+
+CREATE TABLE rpr_integ (id INT, val INT);
+INSERT INTO rpr_integ VALUES
+    (1, 10), (2, 20), (3, 15), (4, 25), (5, 5),
+    (6, 30), (7, 35), (8, 20), (9, 40), (10, 45);
+
+-- ============================================================
+-- A1. Frame optimization bypass
+-- ============================================================
+-- optimize_window_clauses() must not apply frame optimization to RPR windows.
+-- Non-RPR case: frame can be optimized (RANGE -> ROWS conversion, etc.).
+-- RPR case: frame must stay as specified (ROWS BETWEEN CURRENT ROW AND
+-- UNBOUNDED FOLLOWING).
+
+-- Non-RPR window with default frame -> frame optimization applied
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w FROM rpr_integ
+WINDOW w AS (ORDER BY id);
+
+-- RPR window -> frame optimization must NOT change the frame
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val));
+
+-- ============================================================
+-- A2. Run condition pushdown bypass
+-- ============================================================
+-- Verify that find_window_run_conditions() does not push a monotonic
+-- filter down as a Run Condition on RPR windows.  RPR match counts are
+-- determined by pattern matching rather than by a monotonic
+-- accumulation over the frame, so a filter such as "cnt > 0" cannot be
+-- used to stop evaluating the window function early.
+
+-- Non-RPR baseline: the filter is expected to appear as a Run Condition.
+EXPLAIN (COSTS OFF)
+SELECT * FROM (
+    SELECT count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+) t WHERE cnt > 0;
+
+-- RPR case: the filter must appear as a Filter above the WindowAgg,
+-- not as a Run Condition.
+EXPLAIN (COSTS OFF)
+SELECT * FROM (
+    SELECT count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0;
+
+-- Verify that the RPR query still returns every row whose match count is
+-- greater than zero, confirming the filter is evaluated above the
+-- WindowAgg rather than cutting off pattern matching prematurely.
+SELECT * FROM (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0
+ORDER BY id;
+
+-- ============================================================
+-- A3. Window dedup prevention (RPR vs non-RPR)
+-- ============================================================
+-- Verify that PostgreSQL does not merge an RPR window with a non-RPR
+-- window even when both share the same ORDER BY and frame
+-- specification.  RPR pattern matching produces results that are
+-- semantically different from a plain frame-based aggregate, so the
+-- two windows must remain as separate WindowAgg nodes.  Inline window
+-- specs are used throughout this section because only inline windows
+-- are subject to the dedup path; distinct named windows are always
+-- kept separate regardless of equivalence.
+
+-- Non-RPR baseline: two inline windows with identical spec are
+-- deduped by the planner into a single WindowAgg node, confirming
+-- that the dedup path is active for non-RPR windows.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS cnt,
+    sum(val)  OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS total
+FROM rpr_integ;
+
+-- An inline RPR window and an inline non-RPR window share the same
+-- ORDER BY and frame but must remain as distinct WindowAgg nodes.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS rpr_cnt,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS normal_cnt
+FROM rpr_integ;
+
+-- Verify that the two windows return independent counts per row,
+-- confirming they were not merged into a single WindowAgg.
+SELECT
+    id, val,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS rpr_cnt,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS normal_cnt
+FROM rpr_integ
+ORDER BY id;
+
+-- ============================================================
+-- A4. Window dedup prevention (same PATTERN, different DEFINE)
+-- ============================================================
+-- Verify that inline-window dedup does not merge two RPR windows
+-- that share the same PATTERN structure but have different DEFINE
+-- conditions.  Even though the ORDER BY, frame, and PATTERN coincide,
+-- the differing DEFINE expressions classify rows differently and
+-- must therefore yield two separate WindowAgg nodes.  Inline specs
+-- are used here because dedup only applies to inline windows.
+
+-- Baseline: two inline RPR windows that are structurally identical
+-- (same ORDER BY, frame, PATTERN, and DEFINE) are deduped by the
+-- parser into a single WindowAgg node, confirming that parser-level
+-- dedup is active for RPR windows whose DEFINE matches.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS cnt,
+    sum(val)  OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS total
+FROM rpr_integ;
+
+-- Two inline RPR windows with the same PATTERN but opposite DEFINE
+-- conditions must remain as separate WindowAgg nodes.
+EXPLAIN (COSTS OFF)
+SELECT
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS cnt_up,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val < PREV(val)) AS cnt_down
+FROM rpr_integ;
+
+-- Verify that the two windows return different counts per row,
+-- confirming the DEFINE conditions were not collapsed by dedup.
+SELECT
+    id, val,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)) AS cnt_up,
+    count(*) OVER (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val < PREV(val)) AS cnt_down
+FROM rpr_integ
+ORDER BY id;
+
+-- ============================================================
+-- A5. Unused window removal prevention
+-- ============================================================
+-- Verify that remove_unused_subquery_outputs() does not drop an RPR
+-- window function even when the outer query does not reference its
+-- result.  The RPR WindowAgg node is responsible for performing pattern
+-- matching, so removing the window function would silently skip the
+-- pattern match even though the surrounding query still depends on
+-- RPR semantics.
+
+-- The outer query ignores the per-row window result, yet pattern
+-- matching must still execute.  The plan must still contain a
+-- WindowAgg node below the outer Aggregate; if the window were
+-- removed, only Aggregate + Seq Scan would appear.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM (
+    SELECT count(*) OVER w FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+
+SELECT count(*) FROM (
+    SELECT count(*) OVER w FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+
+-- The DEFINE expression references PREV(val), so the window must be
+-- preserved even if the outer query only aggregates over the count.
+-- The plan must still contain a WindowAgg with the PATTERN/DEFINE
+-- intact.
+EXPLAIN (COSTS OFF)
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS val > PREV(val))
+) t;
+
+-- The DEFINE expression contains no navigation, but the RPR window
+-- must still be preserved because the match structure itself affects
+-- the count.  The plan must retain the WindowAgg.
+EXPLAIN (COSTS OFF)
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS TRUE)
+) t;
+
+SELECT count(*), sum(c) FROM (
+    SELECT count(*) OVER w AS c FROM rpr_integ
+    WINDOW w AS (
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A+)
+        DEFINE A AS TRUE)
+) t;
+
+-- XXX: "val" is non-resjunk in the subquery output and is not
+-- referenced by the outer query.  Without a guard,
+-- remove_unused_subquery_outputs() would replace it with NULL in
+-- the subquery output, and that replacement propagates to the
+-- scan's targetlist -- DEFINE would then evaluate with NULL
+-- inputs.  The targetlist has no way to distinguish "exposed to
+-- the outer query" from "referenced only by DEFINE", so the
+-- optimization cannot be applied selectively.  The column guard
+-- in allpaths.c blocks this replacement for any column referenced
+-- by an RPR DEFINE clause, keeping the WindowAgg with DEFINE
+-- active in the plan.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM (
+    SELECT val, count(*) OVER w FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t;
+
+SELECT count(*) FROM (
+    SELECT val, count(*) OVER w FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t;
+
+-- ============================================================
+-- A6. Inverse transition bypass
+-- ============================================================
+-- Verify that RPR windows do not use the moving aggregate (inverse
+-- transition) optimization.  Moving aggregates maintain state by
+-- adding arriving rows and subtracting leaving rows, but an RPR
+-- reduced frame is not a sliding window; the set of rows included in
+-- the frame is determined by pattern matching and cannot be derived
+-- incrementally from the previous frame.
+
+-- sum() would normally be eligible for the moving aggregate
+-- optimization; under RPR it must be computed from scratch over each
+-- reduced frame, and the returned values must match the pattern.
+-- Note: inverse-transition selection is not exposed in the plan, so
+-- there is no direct EXPLAIN assertion for it.  The structural
+-- guarantee is that RPR uses its own navigation mark, distinct from
+-- the moving-aggregate mark, so the inverse-transition path is
+-- never reached on the RPR side.  This test verifies that
+-- separation indirectly: if inverse transition leaked into the RPR
+-- path, state would mix across match boundaries and pattern_sum
+-- would diverge from the expected output, failing the regression.
+SELECT id, val,
+    sum(val) OVER w AS pattern_sum
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+
+-- ============================================================
+-- A7. Cost estimation RPR awareness
+-- ============================================================
+-- cost_windowagg() must account for DEFINE expression evaluation cost.
+-- Verify RPR WindowAgg cost > non-RPR WindowAgg cost.
+
+CREATE FUNCTION get_windowagg_cost(query text) RETURNS numeric AS $$
+DECLARE
+    plan json;
+    cost numeric;
+BEGIN
+    EXECUTE 'EXPLAIN (FORMAT JSON) ' || query INTO plan;
+    cost := (plan->0->'Plan'->>'Total Cost')::numeric;
+    RETURN cost;
+END;
+$$ LANGUAGE plpgsql;
+
+SELECT get_windowagg_cost(
+    'SELECT count(*) OVER w FROM rpr_integ
+     WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+                  PATTERN (A B+ C+) DEFINE B AS val > PREV(val), C AS val < PREV(val))')
+    >
+    get_windowagg_cost(
+    'SELECT count(*) OVER w FROM rpr_integ
+     WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)')
+    AS rpr_cost_is_higher;
+
+DROP FUNCTION get_windowagg_cost(text);
+
+-- ============================================================
+-- A8. Subquery flattening prevention
+-- ============================================================
+-- Verify that a subquery containing an RPR window is not flattened
+-- into the outer query.  is_simple_subquery() already blocks pullup
+-- for subqueries with window functions in general; this test confirms
+-- the rule continues to apply to RPR windows, so EXPLAIN must still
+-- show a Subquery Scan above the RPR WindowAgg.
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) sub
+WHERE cnt > 0;
+
+-- ============================================================
+-- A9. DEFINE expression non-propagation
+-- ============================================================
+-- Verify that DEFINE expressions are not propagated into the
+-- targetlist of any upper WindowAgg node.  Only the column references
+-- consumed by DEFINE should be passed up; the full DEFINE expression
+-- is meaningful only inside the RPR WindowAgg that owns it.
+-- EXPLAIN VERBOSE is therefore expected to show a clean targetlist on
+-- the outer WindowAgg, with no DEFINE-derived expression leaking in.
+-- Note: columns referenced by DEFINE (e.g., "val") may appear as
+-- resjunk entries in upper WindowAgg targetlists -- that is a
+-- harmless byproduct of the column guard's broad scope and does not
+-- affect client output.  The claim here is limited to the full
+-- DEFINE boolean expression.
+
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT
+    count(*) OVER w_rpr AS rpr_cnt,
+    count(*) OVER w_normal AS normal_cnt
+FROM rpr_integ
+WINDOW
+    w_rpr AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)),
+    w_normal AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING);
+
+-- Executing the same query shows the client result is limited to
+-- the two projected columns; "id" and "val" that appeared in the
+-- upper WindowAgg Output line are resjunk-only and do not reach
+-- the client.
+SELECT
+    count(*) OVER w_rpr AS rpr_cnt,
+    count(*) OVER w_normal AS normal_cnt
+FROM rpr_integ
+WINDOW
+    w_rpr AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val)),
+    w_normal AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+ORDER BY rpr_cnt DESC, normal_cnt DESC;
+
+-- ============================================================
+-- A10. RPR + LIMIT
+-- ============================================================
+-- LIMIT must not interfere with RPR pattern matching.  The Limit
+-- node must sit above the WindowAgg so that pattern matching runs
+-- on the full partition first; the result is then a prefix of the
+-- un-LIMITed output.  Pushing Limit below the WindowAgg would
+-- truncate input before matching and silently drop valid matches.
+EXPLAIN (COSTS OFF)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+LIMIT 5;
+
+-- Reference: un-LIMITed result against which the LIMIT 5 result is
+-- compared.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+
+-- LIMIT 5 case; the first five rows must match the reference above.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+LIMIT 5;
+
+-- ============================================================
+-- B1. RPR + CTE
+-- ============================================================
+-- Verify that an RPR window embedded inside a CTE behaves the same as
+-- a direct RPR query:
+--   (1) A single-reference CTE is inlined by the planner and yields
+--       per-row results identical to the direct RPR query.
+--   (2) A multi-reference CTE is materialized (CTE Scan appears in
+--       the plan) so pattern matching runs once, and every reference
+--       observes the same match results.
+
+-- Baseline: direct RPR produces the per-row reference output.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+
+-- Single-reference CTE: plan has no "CTE rpr_result" scope, showing
+-- the CTE was inlined into the surrounding query.
+EXPLAIN (COSTS OFF)
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT id, val, cnt FROM rpr_result ORDER BY id;
+
+-- Result must match the baseline row-for-row.
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT id, val, cnt FROM rpr_result ORDER BY id;
+
+-- Multi-reference CTE (self-join): plan has a "CTE rpr_result" scope
+-- and CTE Scan nodes on both sides, showing the CTE was materialized
+-- and pattern matching ran only once.
+EXPLAIN (COSTS OFF)
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT r1.id, r1.cnt
+FROM rpr_result r1
+JOIN rpr_result r2 ON r1.id = r2.id AND r1.cnt = r2.cnt
+WHERE r1.cnt > 0
+ORDER BY r1.id;
+
+-- Result: both references see the same match counts, so the self-join
+-- preserves all matched rows from the baseline.
+WITH rpr_result AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+)
+SELECT r1.id, r1.cnt
+FROM rpr_result r1
+JOIN rpr_result r2 ON r1.id = r2.id AND r1.cnt = r2.cnt
+WHERE r1.cnt > 0
+ORDER BY r1.id;
+
+-- ============================================================
+-- B2. RPR + JOIN
+-- ============================================================
+-- Verify that an RPR subquery can be joined with another relation.
+-- Two aspects are checked against a non-RPR baseline:
+--   (1) Flattening: a non-RPR subquery is pulled up by the planner
+--       (no Subquery Scan in the plan); an RPR subquery is kept
+--       un-flattened (Subquery Scan above WindowAgg).
+--   (2) Join correctness: the join aligns each RPR match row with
+--       the dimension-table row on the same key.
+
+CREATE TABLE rpr_integ2 (id INT, label TEXT);
+INSERT INTO rpr_integ2 VALUES
+    (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'),
+    (6, 'f'), (7, 'g'), (8, 'h'), (9, 'i'), (10, 'j');
+
+-- Baseline: a non-RPR subquery is flattened by the planner.  No
+-- Subquery Scan node appears; the inner SELECT is merged into the
+-- outer join.
+EXPLAIN (COSTS OFF)
+SELECT r.id, r.val, j.label
+FROM (SELECT id, val FROM rpr_integ) r
+JOIN rpr_integ2 j ON r.id = j.id
+ORDER BY r.id;
+
+-- RPR subquery JOIN: the Subquery Scan is preserved above the
+-- WindowAgg, confirming the RPR subquery is not flattened.
+EXPLAIN (COSTS OFF)
+SELECT r.id, r.cnt, j.label
+FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+JOIN rpr_integ2 j ON r.id = j.id
+WHERE r.cnt > 0
+ORDER BY r.id;
+
+-- Result: matched RPR rows align with dimension rows on id, showing
+-- the join correctly pairs per-row match counts with their labels.
+SELECT r.id, r.cnt, j.label
+FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+JOIN rpr_integ2 j ON r.id = j.id
+WHERE r.cnt > 0
+ORDER BY r.id;
+
+-- ============================================================
+-- B3. RPR + Set operations
+-- ============================================================
+-- Verify that RPR results combine correctly with non-RPR results
+-- under a UNION ALL.  The plan must show an Append node with two
+-- independent child plans: the RPR branch with Pattern/DEFINE active,
+-- and the non-RPR branch with a plain WindowAgg.  Each child scans
+-- the base relation on its own and contributes its rows to the
+-- unioned output.
+
+-- Plan: Append with two independent children.  The RPR branch has a
+-- WindowAgg carrying Pattern/Nav Mark Lookback; the non-RPR branch
+-- has a plain WindowAgg with no pattern metadata.
+EXPLAIN (COSTS OFF)
+SELECT id, cnt, 'rpr' AS source FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0
+UNION ALL
+SELECT id, count(*) OVER (ORDER BY id) AS cnt, 'normal' AS source
+FROM rpr_integ
+ORDER BY source, id;
+
+-- Result: rows from both branches are present in the unioned output.
+-- The RPR branch emits only matched rows (cnt > 0), while the
+-- non-RPR branch emits all rows with its own count values.
+SELECT id, cnt, 'rpr' AS source FROM (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) t WHERE cnt > 0
+UNION ALL
+SELECT id, count(*) OVER (ORDER BY id) AS cnt, 'normal' AS source
+FROM rpr_integ
+ORDER BY source, id;
+
+-- ============================================================
+-- B4. RPR + Prepared statements
+-- ============================================================
+-- Verify that RPR queries survive the prepared-statement path by
+-- exercising both plancache modes with a parameter that feeds into
+-- RPR's navigation offset (PREV(val, $1)).  The parameter surfaces
+-- the RPR-specific plancache difference:
+--   - custom plan: "Nav Mark Lookback" is resolved to the literal
+--     parameter value at plan time (e.g., "Nav Mark Lookback: 1").
+--   - generic plan: "Nav Mark Lookback" is deferred to execution and
+--     appears as "Nav Mark Lookback: runtime" in the plan.
+-- The result must be identical under both modes.
+
+-- Register the prepared statement; DEFINE uses PREV(val, $1) so the
+-- parameter reaches RPR's navigation machinery.
+PREPARE rpr_prev(int) AS
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val, $1))
+ORDER BY id;
+
+-- Custom plan: Nav Mark Lookback resolved to the literal 1.
+SET plan_cache_mode = force_custom_plan;
+EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
+EXECUTE rpr_prev(1);
+
+-- Generic plan: Nav Mark Lookback deferred to execution, shown as
+-- "runtime" in the plan.  Result must match the custom-plan result
+-- exactly.
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
+EXECUTE rpr_prev(1);
+
+RESET plan_cache_mode;
+DEALLOCATE rpr_prev;
+
+-- ============================================================
+-- B5. RPR + Partitioned table
+-- ============================================================
+-- Verify that RPR pattern matching works correctly when the source
+-- relation is partitioned.  The planner must gather rows from every
+-- partition into a single ordered stream before RPR can see them,
+-- because pattern matching is sequential across the entire
+-- partition-by group and cannot be performed independently on each
+-- table partition.
+
+CREATE TABLE rpr_part (id INT, val INT) PARTITION BY RANGE (id);
+CREATE TABLE rpr_part_1 PARTITION OF rpr_part FOR VALUES FROM (1) TO (6);
+CREATE TABLE rpr_part_2 PARTITION OF rpr_part FOR VALUES FROM (6) TO (11);
+INSERT INTO rpr_part SELECT id, val FROM rpr_integ;
+
+-- Plan: partition scans are combined with Append (or Merge Append),
+-- sorted into a single ordered stream, and fed into one WindowAgg
+-- that performs RPR pattern matching across the combined stream.
+EXPLAIN (COSTS OFF)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_part
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+
+-- Baseline: the same query against the non-partitioned rpr_integ
+-- produces the per-row reference output.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+
+-- Result against the partitioned table must match the baseline
+-- row-for-row.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_part
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id;
+
+DROP TABLE rpr_part;
+
+-- ============================================================
+-- B6. RPR + LATERAL
+-- ============================================================
+-- RPR inside a LATERAL subquery.  Qualified column references from
+-- the outer query are not yet supported in DEFINE, so this tests
+-- the basic case where LATERAL provides the correlation filter
+-- (WHERE id <= o.id) and DEFINE uses only local columns.  The plan
+-- must show a Nested Loop driving the outer relation into the inner
+-- subquery scan, with the RPR WindowAgg re-executed for each outer
+-- row and the correlation surfacing as a scan-level Filter on
+-- "id <= o.id".
+
+-- Plan: Nested Loop with the RPR WindowAgg in the inner leg, driven
+-- by the filtered outer rows (o.id IN (5, 10)).
+EXPLAIN (COSTS OFF)
+SELECT o.id AS outer_id, r.id, r.cnt
+FROM rpr_integ o,
+LATERAL (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WHERE id <= o.id
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+WHERE r.cnt > 0 AND o.id IN (5, 10)
+ORDER BY o.id, r.id;
+
+-- Result: for each of the two outer ids (5 and 10), the LATERAL
+-- subquery produces RPR match counts over the restricted input.
+SELECT o.id AS outer_id, r.id, r.cnt
+FROM rpr_integ o,
+LATERAL (
+    SELECT id, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WHERE id <= o.id
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+) r
+WHERE r.cnt > 0 AND o.id IN (5, 10)
+ORDER BY o.id, r.id;
+
+-- ============================================================
+-- B7. RPR + Recursive CTE
+-- ============================================================
+-- Verify that an RPR window can appear inside the non-recursive
+-- (base) leg of a recursive CTE.  The plan must show the RPR
+-- WindowAgg sitting under the Recursive Union as the base-leg
+-- child, with the WorkTable Scan feeding the recursive leg above
+-- it.  This confirms that RPR output can seed a recursive CTE
+-- (window functions cannot appear in the recursive leg itself, a
+-- PostgreSQL restriction, so this is the natural place to exercise
+-- "RPR under Recursive Union").
+--
+-- XXX: Whether this case falls under the ISO/IEC 9075-2 4.18.5 /
+-- 6.17.5 prohibition is not something I can judge.  If this case
+-- is not prohibited, the open question is whether a query that
+-- does trigger the prohibition can be constructed at all.
+-- Whether to prohibit this case is left to the community.
+
+-- Plan: Recursive Union with the RPR WindowAgg on the base leg and
+-- the WorkTable Scan on the recursive leg.
+EXPLAIN (COSTS OFF)
+WITH RECURSIVE seq AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+    UNION ALL
+    SELECT id + 100, val, cnt FROM seq WHERE id < 3
+)
+SELECT id, val, cnt FROM seq ORDER BY id;
+
+-- Result: the base leg contributes the RPR match counts; the
+-- recursive leg propagates those counts with shifted ids.
+WITH RECURSIVE seq AS (
+    SELECT id, val, count(*) OVER w AS cnt
+    FROM rpr_integ
+    WINDOW w AS (ORDER BY id
+        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+        PATTERN (A B+)
+        DEFINE B AS val > PREV(val))
+    UNION ALL
+    SELECT id + 100, val, cnt FROM seq WHERE id < 3
+)
+SELECT id, val, cnt FROM seq ORDER BY id;
+
+-- ============================================================
+-- B8. RPR + Incremental sort
+-- ============================================================
+-- Verify that RPR pattern matching works correctly when the input
+-- to WindowAgg arrives via an incremental sort.  The index on (id)
+-- provides presorted input for the first ORDER BY key, so
+-- "ORDER BY id, val" lets the planner use Incremental Sort to order
+-- only on the second key.  The plan must show Incremental Sort
+-- below the RPR WindowAgg, and RPR must produce the same per-row
+-- match counts as it would with a plain Sort.
+
+CREATE INDEX rpr_integ_id_idx ON rpr_integ (id);
+SET enable_seqscan = off;
+
+-- Plan: RPR WindowAgg above an Incremental Sort above an Index Scan.
+-- The Incremental Sort declares "Presorted Key: id" and sorts only
+-- on val within each id group.
+EXPLAIN (COSTS OFF)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id, val
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val));
+
+-- Result: RPR over the incrementally sorted stream produces match
+-- counts per row.
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id, val
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val))
+ORDER BY id, val;
+
+RESET enable_seqscan;
+DROP INDEX rpr_integ_id_idx;
+
+-- ============================================================
+-- B9. RPR + Volatile function in DEFINE
+-- ============================================================
+-- Records the current behaviour: DEFINE today accepts volatile
+-- functions such as random() and the query runs to completion.
+-- To keep the expected output deterministic the predicate uses
+-- "random() >= 0.0", which is structurally equivalent to TRUE and
+-- therefore does not perturb the match result.  The interesting
+-- property is that volatile invocation does not crash or short-
+-- circuit pattern matching.
+--
+-- XXX: volatile functions in DEFINE are slated to be rejected at
+-- parse time.  Under RPR's NFA engine the same row's DEFINE
+-- predicate may be evaluated multiple times (backtracking,
+-- PREV/NEXT navigation), so a truly volatile result would make
+-- pattern matching non-deterministic.  When the prohibition lands,
+-- this test must be replaced with an error-case test that expects
+-- random() in DEFINE to be rejected.
+
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_integ
+WINDOW w AS (ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE B AS val > PREV(val) AND random() >= 0.0)
+ORDER BY id;
+
+-- ============================================================
+-- B10. RPR + Correlated subquery in WHERE
+-- ============================================================
+-- Verify that an RPR window placed inside a correlated scalar
+-- subquery is executed once per outer row.  DEFINE still references
+-- only local columns (qualified refs from the outer query are not
+-- supported in DEFINE); the correlation lives in the subquery's
+-- WHERE clause as "i.id <= o.id".  The plan must show a SubPlan
+-- attached to the outer scan, with the RPR WindowAgg driven by a
+-- per-row scan filter carrying the correlation predicate.
+
+-- Plan: SubPlan attached to the outer Seq Scan; the inner scan
+-- carries "Filter: (id <= o.id)", confirming the correlation is
+-- evaluated per outer row.
+EXPLAIN (COSTS OFF)
+SELECT o.id, o.val,
+    (SELECT count(*) OVER w
+     FROM rpr_integ i
+     WHERE i.id <= o.id
+     WINDOW w AS (ORDER BY id
+         ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+         PATTERN (A B+)
+         DEFINE B AS val > PREV(val))
+     ORDER BY id
+     LIMIT 1) AS first_cnt
+FROM rpr_integ o
+ORDER BY o.id;
+
+-- Result: each outer row receives the first_cnt from its own
+-- correlated RPR subquery.
+SELECT o.id, o.val,
+    (SELECT count(*) OVER w
+     FROM rpr_integ i
+     WHERE i.id <= o.id
+     WINDOW w AS (ORDER BY id
+         ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+         PATTERN (A B+)
+         DEFINE B AS val > PREV(val))
+     ORDER BY id
+     LIMIT 1) AS first_cnt
+FROM rpr_integ o
+ORDER BY o.id;
+
+-- Cleanup
+DROP TABLE rpr_integ;
+DROP TABLE rpr_integ2;
-- 
2.50.1 (Apple Git-155)


From ca171084b7c52e27a1f0bb2a17a8747f375e6a2f Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 14:09:12 +0900
Subject: [PATCH] Replace reduced frame map with single match result

The reduced frame map was a per-row byte array tracking match status.
Since rows are processed sequentially and only one match is active
at a time, replace it with four scalar fields: valid, matched,
start, and length.

Also distinguish empty matches (FIN reached with zero rows consumed)
from unmatched rows via RF_EMPTY_MATCH, counted as matched in NFA
statistics.

Widen row_is_in_reduced_frame() return type from int to int64,
since it returns rpr_match_length which is int64.
---
 src/backend/executor/execRPR.c            |  56 +++---
 src/backend/executor/nodeWindowAgg.c      | 233 +++++++++-------------
 src/include/nodes/execnodes.h             |  21 +-
 src/test/regress/expected/rpr_explain.out |   8 +-
 4 files changed, 132 insertions(+), 186 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 58f9da0b814..7d0f8fd401c 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -549,7 +549,7 @@
  *
  *   (1) Find or create a context for the target row
  *   (2) Enter the row processing loop
- *   (3) After the loop ends, record the result in reduced_frame_map
+ *   (3) After the loop ends, record the match result
  *
  * Pseudocode of the row processing loop:
  *
@@ -923,18 +923,19 @@
  * Chapter X  Match Result Processing
  * ============================================================================
  *
- * X-1. Reduced Frame Map
+ * X-1. Match Result
  *
- * RPR match results are recorded in a byte array called reduced_frame_map.
- * One byte is allocated per row, and the value is one of the following:
+ * RPR tracks the current match result as a single entry in WindowAggState
+ * with four fields: rpr_match_valid, rpr_match_matched, rpr_match_start,
+ * and rpr_match_length.  When rpr_match_valid is true, the entry describes
+ * the match result for the position at rpr_match_start: rpr_match_matched
+ * indicates success or failure, and rpr_match_length gives the number of
+ * rows consumed.  A match with rpr_match_length 0 represents an empty match
+ * (pattern matched but consumed no rows).  When rpr_match_valid is false,
+ * the position has not been evaluated yet (RF_NOT_DETERMINED).
  *
- *   RF_NOT_DETERMINED (0)  Not yet processed
- *   RF_FRAME_HEAD     (1)  Start row of the match
- *   RF_SKIPPED        (2)  Interior row of the match (skipped in frame)
- *   RF_UNMATCHED      (3)  Match failure
- *
- * The window function references this map to determine frame inclusion for
- * each row.
+ * A row's status against the current match result can be obtained by
+ * calling get_reduced_frame_status().
  *
  * X-2. AFTER MATCH SKIP
  *
@@ -1028,8 +1029,7 @@
  *     Phase 3 (Advance): skipped (no states)
  *
  *   C0.states is empty, so the loop terminates.
- *   matchEndRow < matchStartRow -> RF_UNMATCHED.
- *   register_reduced_frame_map(0, RF_UNMATCHED).
+ *   matchEndRow < matchStartRow -> unmatched.
  *
  * --- Row 1 (price=110) ---
  *
@@ -1113,9 +1113,7 @@
  *
  *   C1.states is empty and matchEndRow=3 >= matchStartRow=1 -> match succeeds.
  *
- *   register_reduced_frame_map(1, RF_FRAME_HEAD)
- *   register_reduced_frame_map(2, RF_SKIPPED)
- *   register_reduced_frame_map(3, RF_SKIPPED)
+ *   rpr_match_start = 1, rpr_match_length = 3
  *
  * --- Row 4 (price=130) ---
  *
@@ -1128,15 +1126,15 @@
  *     B: 130 < PREV(115) -> false
  *
  *   ... No subsequent rows, so ExecRPRFinalizeAllContexts() is called.
- *   Match incomplete -> RF_UNMATCHED.
+ *   Match incomplete -> unmatched.
  *
  * XI-5. Final Result
  *
- *   Row 0: RF_UNMATCHED  -> frame = the row itself
- *   Row 1: RF_FRAME_HEAD -> frame = rows 1 through 3
- *   Row 2: RF_SKIPPED    -> inside row 1's match
- *   Row 3: RF_SKIPPED    -> inside row 1's match
- *   Row 4: RF_UNMATCHED  -> frame = the row itself
+ *   Row 0: unmatched     -> frame = the row itself
+ *   Row 1: match head    -> frame = rows 1 through 3
+ *   Row 2: inside match  -> skipped
+ *   Row 3: inside match  -> skipped
+ *   Row 4: unmatched     -> frame = the row itself
  *
  * Chapter XII  Summary of Key Design Decisions
  * ============================================================================
@@ -1579,12 +1577,14 @@ static void nfa_advance(WindowAggState *winstate, RPRNFAContext *ctx,
  *
  * - Empty match handling: The initial advance uses currentPos =
  *   startPos - 1 (before any row is consumed). If FIN is reached via
- *   epsilon transitions alone, matchEndRow = startPos - 1 < matchStartRow,
- *   resulting in UNMATCHED. For reluctant min=0 patterns (A*?, A??),
- *   the skip path reaches FIN first and early termination prunes enter
- *   paths, yielding an immediate empty (unmatched) result. For
- *   greedy patterns (A*), the enter path adds VAR states first, then
- *   the skip FIN is recorded but VAR states survive for later matching.
+ *   epsilon transitions alone, matchEndRow = startPos - 1 < matchStartRow.
+ *   If matchedState is set (FIN was reached), this is an empty match
+ *   (RF_EMPTY_MATCH); otherwise it is unmatched (RF_UNMATCHED).
+ *   For reluctant min=0 patterns (A*?, A??), the skip path reaches
+ *   FIN first and early termination prunes enter paths, yielding an
+ *   immediate empty match result. For greedy patterns (A*), the enter
+ *   path adds VAR states first, then the skip FIN is recorded but VAR
+ *   states survive for later matching.
  *
  * Context Absorption Runtime:
  * ---------------------------
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index aed7cbef99a..dca2de570e8 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -247,13 +247,10 @@ static void attno_map(Node *node);
 static bool attno_map_walker(Node *node, void *context);
 
 static bool rpr_is_defined(WindowAggState *winstate);
-static int	row_is_in_reduced_frame(WindowObject winobj, int64 pos);
+static int64 row_is_in_reduced_frame(WindowObject winobj, int64 pos);
 
-static void create_reduced_frame_map(WindowAggState *winstate);
-static void clear_reduced_frame_map(WindowAggState *winstate);
-static int	get_reduced_frame_map(WindowAggState *winstate, int64 pos);
-static void register_reduced_frame_map(WindowAggState *winstate, int64 pos,
-									   int val);
+static void clear_reduced_frame(WindowAggState *winstate);
+static int	get_reduced_frame_status(WindowAggState *winstate, int64 pos);
 static void update_reduced_frame(WindowObject winobj, int64 pos);
 
 static void check_rpr_navigation(Node *node, bool is_prev);
@@ -1035,13 +1032,7 @@ eval_windowaggregates(WindowAggState *winstate)
 	 */
 	for (;;)
 	{
-		int			ret;
-
-#ifdef RPR_DEBUG
-		printf("===== loop in frame starts: aggregatedupto: " INT64_FORMAT " aggregatedbase: " INT64_FORMAT "\n",
-			   winstate->aggregatedupto,
-			   winstate->aggregatedbase);
-#endif
+		int64		ret;
 
 		/* Fetch next row if we didn't already */
 		if (TupIsNull(agg_row_slot))
@@ -1065,27 +1056,18 @@ eval_windowaggregates(WindowAggState *winstate)
 
 		if (rpr_is_defined(winstate))
 		{
-#ifdef RPR_DEBUG
-			printf("reduced_frame_map: %d aggregatedupto: " INT64_FORMAT " aggregatedbase: " INT64_FORMAT "\n",
-				   get_reduced_frame_map(winstate,
-										 winstate->aggregatedupto),
-				   winstate->aggregatedupto,
-				   winstate->aggregatedbase);
-#endif
-
 			/*
-			 * If the row status at currentpos is already decided and current
-			 * row status is not decided yet, it means we passed the last
-			 * reduced frame. Time to break the loop.
+			 * If currentpos is already decided but aggregatedupto is not yet
+			 * determined, we've passed the last reduced frame.
 			 */
-			if (get_reduced_frame_map(winstate, winstate->currentpos)
+			if (get_reduced_frame_status(winstate, winstate->currentpos)
 				!= RF_NOT_DETERMINED &&
-				get_reduced_frame_map(winstate, winstate->aggregatedupto)
+				get_reduced_frame_status(winstate, winstate->aggregatedupto)
 				== RF_NOT_DETERMINED)
 				break;
 
 			/*
-			 * Otherwise we need to calculate the reduced frame.
+			 * Calculate the reduced frame for aggregatedupto.
 			 */
 			ret = row_is_in_reduced_frame(winstate->agg_winobj,
 										  winstate->aggregatedupto);
@@ -1093,17 +1075,13 @@ eval_windowaggregates(WindowAggState *winstate)
 				break;
 
 			/*
-			 * Check if current row needs to be skipped due to no match.
+			 * 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_map(winstate,
-									  winstate->aggregatedupto) == RF_SKIPPED &&
+			if (get_reduced_frame_status(winstate,
+										 winstate->aggregatedupto) == RF_SKIPPED &&
 				winstate->aggregatedupto == winstate->aggregatedbase)
-			{
-#ifdef RPR_DEBUG
-				printf("skip current row for aggregation\n");
-#endif
 				break;
-			}
 		}
 
 		/* Set tuple context for evaluation of aggregate arguments */
@@ -1358,7 +1336,8 @@ begin_partition(WindowAggState *winstate)
 	winstate->framehead_valid = false;
 	winstate->frametail_valid = false;
 	winstate->grouptail_valid = false;
-	create_reduced_frame_map(winstate);
+	if (rpr_is_defined(winstate))
+		clear_reduced_frame(winstate);
 	winstate->spooled_rows = 0;
 	winstate->currentpos = 0;
 	winstate->frameheadpos = 0;
@@ -1581,9 +1560,8 @@ release_partition(WindowAggState *winstate)
 	winstate->partition_spooled = false;
 	winstate->next_partition = true;
 
-	/* Reset RPR reduced frame map */
-	winstate->reduced_frame_map = NULL;
-	winstate->alloc_sz = 0;
+	/* Reset RPR match results */
+	clear_reduced_frame(winstate);
 
 	/* Reset NFA state for new partition */
 	winstate->nfaContext = NULL;
@@ -2366,11 +2344,6 @@ ExecWindowAgg(PlanState *pstate)
 
 	CHECK_FOR_INTERRUPTS();
 
-#ifdef RPR_DEBUG
-	printf("ExecWindowAgg called. pos: " INT64_FORMAT "\n",
-		   winstate->currentpos);
-#endif
-
 	if (winstate->status == WINDOWAGG_DONE)
 		return NULL;
 
@@ -2480,14 +2453,13 @@ ExecWindowAgg(PlanState *pstate)
 		if (winstate->status == WINDOWAGG_RUN)
 		{
 			/*
-			 * If RPR is defined and skip mode is next row, we need to clear
-			 * existing reduced frame info so that we newly calculate the info
-			 * starting from current row.
+			 * 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_map(winstate);
+					clear_reduced_frame(winstate);
 			}
 
 			/*
@@ -2986,9 +2958,6 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 			name = te->resname;
 			expr = te->expr;
 
-#ifdef RPR_DEBUG
-			printf("defineVariable name: %s\n", name);
-#endif
 			winstate->defineVariableList =
 				lappend(winstate->defineVariableList,
 						makeString(pstrdup(name)));
@@ -3668,7 +3637,7 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 	int			notnull_offset;
 	int			notnull_relpos;
 	int			forward;
-	int			num_reduced_frame;
+	int64		num_reduced_frame;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
@@ -3968,14 +3937,12 @@ rpr_is_defined(WindowAggState *winstate)
  * AFTER MATCH SKIP PAST LAST ROW
  * -----------------
  */
-static int
+static int64
 row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 {
 	WindowAggState *winstate = winobj->winstate;
 	int			state;
-	int			rtn;
-	int64		i;
-	int			num_reduced_rows;
+	int64		rtn;
 
 	if (!rpr_is_defined(winstate))
 	{
@@ -3984,14 +3951,10 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 		 * window frame.
 		 */
 		rtn = 0;
-#ifdef RPR_DEBUG
-		printf("row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT "\n",
-			   rtn, pos);
-#endif
 		return rtn;
 	}
 
-	state = get_reduced_frame_map(winstate, pos);
+	state = get_reduced_frame_status(winstate, pos);
 
 	if (state == RF_NOT_DETERMINED)
 	{
@@ -3999,16 +3962,12 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 		update_reduced_frame(winobj, pos);
 	}
 
-	state = get_reduced_frame_map(winstate, pos);
+	state = get_reduced_frame_status(winstate, pos);
 
 	switch (state)
 	{
 		case RF_FRAME_HEAD:
-			num_reduced_rows = 1;
-			for (i = pos + 1;
-				 get_reduced_frame_map(winstate, i) == RF_SKIPPED; i++)
-				num_reduced_rows++;
-			rtn = num_reduced_rows;
+			rtn = winstate->rpr_match_length;
 			break;
 
 		case RF_SKIPPED:
@@ -4016,6 +3975,7 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 			break;
 
 		case RF_UNMATCHED:
+		case RF_EMPTY_MATCH:
 			rtn = -1;
 			break;
 
@@ -4025,91 +3985,56 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 			break;
 	}
 
-#ifdef RPR_DEBUG
-	printf("row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT "\n",
-		   rtn, pos);
-#endif
 	return rtn;
 }
 
-#define REDUCED_FRAME_MAP_INIT_SIZE	1024L
-
 /*
- * create_reduced_frame_map
- * Create reduced frame map
+ * clear_reduced_frame
+ * Clear reduced frame status
  */
 static void
-create_reduced_frame_map(WindowAggState *winstate)
+clear_reduced_frame(WindowAggState *winstate)
 {
-	winstate->reduced_frame_map =
-		MemoryContextAlloc(winstate->partcontext,
-						   REDUCED_FRAME_MAP_INIT_SIZE);
-	winstate->alloc_sz = REDUCED_FRAME_MAP_INIT_SIZE;
-	clear_reduced_frame_map(winstate);
+	winstate->rpr_match_valid = false;
+	winstate->rpr_match_matched = false;
+	winstate->rpr_match_start = -1;
+	winstate->rpr_match_length = 0;
 }
 
 /*
- * clear_reduced_frame_map
- * Clear reduced frame map
- */
-static void
-clear_reduced_frame_map(WindowAggState *winstate)
-{
-	Assert(winstate->reduced_frame_map != NULL);
-	MemSet(winstate->reduced_frame_map, RF_NOT_DETERMINED,
-		   winstate->alloc_sz);
-}
-
-/*
- * get_reduced_frame_map
- * Get reduced frame map specified by pos
+ * 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
  */
 static int
-get_reduced_frame_map(WindowAggState *winstate, int64 pos)
+get_reduced_frame_status(WindowAggState *winstate, int64 pos)
 {
-	Assert(winstate->reduced_frame_map != NULL);
-	Assert(pos >= 0);
+	int64		start = winstate->rpr_match_start;
+	int64		length = winstate->rpr_match_length;
 
-	/*
-	 * If pos is not in the reduced frame map, it means that any info
-	 * regarding the pos has not been registered yet. So we return
-	 * RF_NOT_DETERMINED.
-	 */
-	if (pos >= winstate->alloc_sz)
+	if (!winstate->rpr_match_valid)
 		return RF_NOT_DETERMINED;
 
-	return winstate->reduced_frame_map[pos];
-}
+	/* Empty match: covers only the start position */
+	if (pos == start && winstate->rpr_match_matched && length == 0)
+		return RF_EMPTY_MATCH;
 
-/*
- * register_reduced_frame_map
- * Add/replace reduced frame map member at pos.
- * If there's no enough space, expand the map.
- */
-static void
-register_reduced_frame_map(WindowAggState *winstate, int64 pos, int val)
-{
-	int64		realloc_sz;
-
-	Assert(winstate->reduced_frame_map != NULL);
-
-	if (pos < 0)
-		elog(ERROR, "wrong pos: " INT64_FORMAT, pos);
-
-	while (pos > winstate->alloc_sz - 1)
-	{
-		realloc_sz = winstate->alloc_sz * 2;
-
-		winstate->reduced_frame_map =
-			repalloc(winstate->reduced_frame_map, realloc_sz);
+	/* Outside the result range */
+	if (pos < start || pos >= start + length)
+		return RF_NOT_DETERMINED;
 
-		MemSet(winstate->reduced_frame_map + winstate->alloc_sz,
-			   RF_NOT_DETERMINED, realloc_sz - winstate->alloc_sz);
+	if (!winstate->rpr_match_matched)
+		return RF_UNMATCHED;
 
-		winstate->alloc_sz = realloc_sz;
-	}
+	if (pos == start)
+		return RF_FRAME_HEAD;
 
-	winstate->reduced_frame_map[pos] = val;
+	return RF_SKIPPED;
 }
 
 /*
@@ -4156,7 +4081,11 @@ update_reduced_frame(WindowObject winobj, int64 pos)
 	if (winstate->nfaContext != NULL &&
 		pos < winstate->nfaContext->matchStartRow)
 	{
-		register_reduced_frame_map(winstate, pos, RF_UNMATCHED);
+		/* already processed, unmatched */
+		winstate->rpr_match_valid = true;
+		winstate->rpr_match_matched = false;
+		winstate->rpr_match_start = pos;
+		winstate->rpr_match_length = 1;
 		return;
 	}
 
@@ -4173,7 +4102,11 @@ update_reduced_frame(WindowObject winobj, int64 pos)
 		 */
 		if (pos <= winstate->nfaLastProcessedRow)
 		{
-			register_reduced_frame_map(winstate, pos, RF_UNMATCHED);
+			/* already processed, unmatched */
+			winstate->rpr_match_valid = true;
+			winstate->rpr_match_matched = false;
+			winstate->rpr_match_start = pos;
+			winstate->rpr_match_length = 1;
 			return;
 		}
 		/* Not yet processed - create new context and start fresh */
@@ -4245,26 +4178,38 @@ register_result:
 	Assert(pos == targetCtx->matchStartRow);
 
 	/*
-	 * Register reduced frame map based on match result.
+	 * Record match result.
 	 */
+	winstate->rpr_match_valid = true;
+	winstate->rpr_match_start = targetCtx->matchStartRow;
+
 	if (targetCtx->matchEndRow < targetCtx->matchStartRow)
 	{
 		matchLen = targetCtx->lastProcessedRow - targetCtx->matchStartRow + 1;
 
-		register_reduced_frame_map(winstate, targetCtx->matchStartRow, RF_UNMATCHED);
-		ExecRPRRecordContextFailure(winstate, matchLen);
+		if (targetCtx->matchedState != NULL)
+		{
+			/* Empty match: FIN reached but 0 rows consumed */
+			winstate->rpr_match_matched = true;
+			winstate->rpr_match_length = 0;
+			ExecRPRRecordContextSuccess(winstate, 0);
+		}
+		else
+		{
+			/* No match */
+			winstate->rpr_match_matched = false;
+			winstate->rpr_match_length = 1;
+			ExecRPRRecordContextFailure(winstate, matchLen);
+		}
 		ExecRPRFreeContext(winstate, targetCtx);
 		return;
 	}
 
-	/* Match succeeded - register frame map and record statistics */
+	/* Match succeeded */
 	matchLen = targetCtx->matchEndRow - targetCtx->matchStartRow + 1;
 
-	register_reduced_frame_map(winstate, targetCtx->matchStartRow, RF_FRAME_HEAD);
-	for (int64 i = targetCtx->matchStartRow + 1; i <= targetCtx->matchEndRow; i++)
-	{
-		register_reduced_frame_map(winstate, i, RF_SKIPPED);
-	}
+	winstate->rpr_match_matched = true;
+	winstate->rpr_match_length = matchLen;
 	ExecRPRRecordContextSuccess(winstate, matchLen);
 
 	/* Remove the matched context */
@@ -4747,7 +4692,7 @@ WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
 	WindowAggState *winstate;
 	int64		abs_pos;
 	int64		mark_pos;
-	int			num_reduced_frame;
+	int64		num_reduced_frame;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 33028c3f10b..c672d29f35b 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2499,10 +2499,12 @@ typedef enum WindowAggStatus
 									 * tuples during spool */
 } WindowAggStatus;
 
-#define	RF_NOT_DETERMINED	0
-#define	RF_FRAME_HEAD		1
-#define	RF_SKIPPED			2
-#define	RF_UNMATCHED		3
+/* 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
@@ -2694,12 +2696,11 @@ typedef struct WindowAggState
 	TupleTableSlot *next_slot;	/* NEXT row navigation operator */
 	TupleTableSlot *null_slot;	/* all NULL slot */
 
-	/*
-	 * Each byte corresponds to a row positioned at absolute its pos in
-	 * partition.  See above definition for RF_*. Used for RPR.
-	 */
-	char	   *reduced_frame_map;
-	int64		alloc_sz;		/* size of the map */
+	/* RPR current match result */
+	bool		rpr_match_valid;	/* true if a match result is set */
+	bool		rpr_match_matched;	/* true if the result was a match */
+	int64		rpr_match_start;	/* start position of the match result */
+	int64		rpr_match_length;	/* number of rows matched (0 = empty) */
 } WindowAggState;
 
 /* ----------------
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index bd345906133..79cbc246039 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -3348,8 +3348,8 @@ WINDOW w AS (
    Pattern: ((a' b')+" c)*
    Storage: Memory  Maximum Storage: NkB
    NFA States: 9 peak, 178 total, 0 merged
-   NFA Contexts: 4 peak, 61 total, 22 pruned
-   NFA: 1 matched (len 57/57/57.0), 0 mismatched
+   NFA Contexts: 4 peak, 61 total, 20 pruned
+   NFA: 3 matched (len 0/57/19.0), 0 mismatched
    NFA: 0 absorbed, 37 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
@@ -3385,8 +3385,8 @@ WINDOW w AS (
    Pattern: (a (b c)+)*
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 160 total, 0 merged
-   NFA Contexts: 4 peak, 61 total, 22 pruned
-   NFA: 1 matched (len 57/57/57.0), 0 mismatched
+   NFA Contexts: 4 peak, 61 total, 20 pruned
+   NFA: 3 matched (len 0/57/19.0), 0 mismatched
    NFA: 0 absorbed, 37 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
-- 
2.50.1 (Apple Git-155)


From 5d9be742c6266d8fbff887a0577c37743d429690 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Sat, 4 Apr 2026 21:47:01 +0900
Subject: [PATCH] Add fixed-length group absorption for RPR

Extend context absorption to unbounded groups with fixed-length
children (min == max, recursively).  Patterns like (A B{2})+ or
((A (B C){2}){2})+ are now absorbable, equivalent to unrolling to
{1,1} VARs at compile time without actually unrolling.

isFixedLengthChildren() recursively verifies min == max for all
children including nested subgroups, extending the existing Case 2
in isUnboundedStart().

Absorption comparison in nfa_states_covered requires states to be at
an ABSORBABLE judgment point, where count-dominance is guaranteed.
The inline advance in nfa_match is generalized to advance bounded
VARs within the absorbable region through END chains to reach the
judgment point.

Fix isAbsorbable propagation in nfa_advance_var and nfa_advance_end
exit paths, where reusing a state object skipped recomputation.

Mark VAR elements in the DFS visited bitmap at nfa_add_state_unique
instead of at nfa_advance_state entry, so that loop-back through ALT
to the same VAR is not incorrectly blocked by cycle detection.
---
 src/backend/executor/execRPR.c            | 110 ++++++--
 src/backend/optimizer/plan/rpr.c          | 136 +++++++--
 src/test/regress/expected/rpr_base.out    |  83 +++++-
 src/test/regress/expected/rpr_explain.out | 206 +++++++++++++-
 src/test/regress/expected/rpr_nfa.out     | 321 ++++++++++++++++++++++
 src/test/regress/sql/rpr_base.sql         |  36 +++
 src/test/regress/sql/rpr_explain.sql      | 114 ++++++++
 src/test/regress/sql/rpr_nfa.sql          | 168 +++++++++++
 8 files changed, 1111 insertions(+), 63 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 7d0f8fd401c..aec1057e1b2 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -1760,6 +1760,10 @@ nfa_add_state_unique(WindowAggState *winstate, RPRNFAContext *ctx, RPRNFAState *
 	RPRNFAState *s;
 	RPRNFAState *tail = NULL;
 
+	/* Mark VAR in visited before duplicate check to prevent DFS loops */
+	winstate->nfaVisitedElems[WORDNUM(state->elemIdx)] |=
+		((bitmapword) 1 << BITNUM(state->elemIdx));
+
 	/* Check for duplicate and find tail */
 	for (s = ctx->states; s != NULL; s = s->next)
 	{
@@ -2033,6 +2037,14 @@ nfa_states_covered(RPRPattern *pattern, RPRNFAContext *older, RPRNFAContext *new
 		elem = &pattern->elements[newerState->elemIdx];
 		depth = elem->depth;
 
+		/*
+		 * Only compare at absorption judgment points (RPR_ELEM_ABSORBABLE).
+		 * Judgment 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();
@@ -2175,9 +2187,10 @@ nfa_eval_var_match(WindowAggState *winstate, RPRPatternElement *elem,
  *   - not matched: remove state (exit alternatives already exist from
  *     previous advance when count >= min was satisfied)
  *
- * For simple VARs (min=max=1) followed by END:
- *   - Advance to END and update group count before absorb phase
- *   - This ensures absorption can compare states by group completion
+ * For VARs that reached max count followed by END:
+ *   - Advance through END chain to reach absorption judgment point
+ *   - Only deterministic exits (count >= max, max != INF) are handled
+ *   - Chains through END elements while count >= max (must-exit path)
  *
  * Non-VAR elements (ALT, END, FIN) are kept as-is for advance phase.
  */
@@ -2191,9 +2204,9 @@ nfa_match(WindowAggState *winstate, RPRNFAContext *ctx, bool *varMatched)
 	RPRNFAState *nextState;
 
 	/*
-	 * Evaluate VAR elements against current row. For simple VARs with END
-	 * next, advance to END and update group count inline so absorb phase can
-	 * compare states properly.
+	 * Evaluate VAR elements against current row. For VARs that reach max
+	 * count with END next, advance through END chain inline so absorb phase
+	 * can compare states at judgment points.
 	 */
 	for (state = ctx->states; state != NULL; state = nextState)
 	{
@@ -2223,34 +2236,61 @@ nfa_match(WindowAggState *winstate, RPRNFAContext *ctx, bool *varMatched)
 				state->counts[depth] = count;
 
 				/*
-				 * For simple VAR (min=max=1) with END next, advance to END
-				 * and update group count inline. This keeps state in place,
-				 * preserving lexical order.
+				 * For VAR at max count with END next, advance through END
+				 * chain to reach the absorption judgment point. Only
+				 * deterministic exits (count >= max, max finite) are handled;
+				 * unbounded VARs stay for advance phase.
 				 */
-				if (elem->min == 1 && elem->max == 1 &&
+				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];
 
-					Assert(count == 1);
-
-					/* Increment group count with overflow protection */
+					/* Increment group count */
 					if (endCount < RPR_COUNT_MAX)
 						endCount++;
-
-					/*
-					 * END's max can never be exceeded here because
-					 * nfa_advance_end only loops when count < max, so
-					 * endCount entering inline advance is at most max-1, and
-					 * incrementing yields at most max.
-					 */
 					Assert(endElem->max == RPR_QUANTITY_INF ||
 						   endCount <= endElem->max);
 
 					state->elemIdx = elem->next;
 					state->counts[endDepth] = endCount;
+
+					/*
+					 * Chain through END elements within the absorbable region
+					 * (ABSORBABLE_BRANCH) until reaching the judgment 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];
+
+						/* Reset exited group's count */
+						state->counts[endDepth] = 0;
+
+						/* Increment outer group count */
+						if (outerCount < RPR_COUNT_MAX)
+							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 */
 			}
@@ -2468,6 +2508,10 @@ nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
 		state->elemIdx = elem->next;
 		nextElem = &elements[state->elemIdx];
 
+		/* Update isAbsorbable for target element (monotonic) */
+		state->isAbsorbable = state->isAbsorbable &&
+			RPRElemIsAbsorbableBranch(nextElem);
+
 		/* END->END: increment outer END's count */
 		if (RPRElemIsEnd(nextElem) && state->counts[nextElem->depth] < RPR_COUNT_MAX)
 			state->counts[nextElem->depth]++;
@@ -2621,6 +2665,13 @@ nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
 			state->elemIdx = elem->next;
 			nextElem = &elements[state->elemIdx];
 
+			/*
+			 * Update isAbsorbable for target element (monotonic: AND
+			 * preserves false)
+			 */
+			state->isAbsorbable = state->isAbsorbable &&
+				RPRElemIsAbsorbableBranch(nextElem);
+
 			/*
 			 * When exiting directly to an outer END, increment its iteration
 			 * count.  Simple VARs (min=max=1) handle this via inline advance
@@ -2650,6 +2701,13 @@ nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
 		state->elemIdx = elem->next;
 		nextElem = &elements[state->elemIdx];
 
+		/*
+		 * Update isAbsorbable for target element (monotonic: AND preserves
+		 * false)
+		 */
+		state->isAbsorbable = state->isAbsorbable &&
+			RPRElemIsAbsorbableBranch(nextElem);
+
 		/* See comment above: increment outer END count for quantified VARs */
 		if (RPRElemIsEnd(nextElem))
 		{
@@ -2686,11 +2744,19 @@ nfa_advance_state(WindowAggState *winstate, RPRNFAContext *ctx,
 		nfa_state_free(winstate, state);
 		return;
 	}
-	winstate->nfaVisitedElems[WORDNUM(state->elemIdx)] |=
-		((bitmapword) 1 << BITNUM(state->elemIdx));
 
 	elem = &pattern->elements[state->elemIdx];
 
+	/*
+	 * Mark epsilon elements (END, ALT, BEGIN, FIN) in visited to prevent
+	 * infinite epsilon cycles.  VAR elements are marked later when added to
+	 * the state list (nfa_add_state_unique), allowing legitimate loop-back to
+	 * the same VAR in a new iteration.
+	 */
+	if (!RPRElemIsVar(elem))
+		winstate->nfaVisitedElems[WORDNUM(state->elemIdx)] |=
+			((bitmapword) 1 << BITNUM(state->elemIdx));
+
 	switch (elem->varId)
 	{
 		case RPR_VARID_FIN:
diff --git a/src/backend/optimizer/plan/rpr.c b/src/backend/optimizer/plan/rpr.c
index 2728a0b9fca..c0e9d134aa9 100644
--- a/src/backend/optimizer/plan/rpr.c
+++ b/src/backend/optimizer/plan/rpr.c
@@ -92,6 +92,8 @@ static bool fillRPRPattern(RPRPatternNode *node, RPRPattern *pat,
 static void finalizeRPRPattern(RPRPattern *result);
 
 /* Forward declarations - context absorption */
+static bool isFixedLengthChildren(RPRPattern *pattern, RPRElemIdx idx,
+								  RPRDepth scopeDepth);
 static bool isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx);
 static void computeAbsorbabilityRecursive(RPRPattern *pattern,
 										  RPRElemIdx startIdx,
@@ -1524,6 +1526,70 @@ finalizeRPRPattern(RPRPattern *result)
  *-------------------------------------------------------------------------
  */
 
+/*
+ * isFixedLengthChildren
+ *		Check if all children at scopeDepth have fixed-length quantifiers
+ *		(min == max), recursively for nested subgroups.
+ *
+ * A fixed-length group is semantically equivalent to unrolling each child
+ * to {1,1} copies, which is the existing Case 2 already proven correct
+ * for absorption.  This check recognizes fixed-length groups at compile
+ * time without actually unrolling them.
+ *
+ * Traverses the flat element array starting at idx.  For VAR elements,
+ * checks min == max.  For BEGIN elements (nested subgroups), recurses
+ * into the subgroup and also checks the subgroup's END quantifier.
+ * ALT elements are rejected (alternation inside absorbable group is
+ * not supported).
+ *
+ * Returns true if all children are fixed-length, stopping at the END
+ * element at scopeDepth - 1.
+ */
+static bool
+isFixedLengthChildren(RPRPattern *pattern, RPRElemIdx idx, RPRDepth scopeDepth)
+{
+	RPRPatternElement *e = &pattern->elements[idx];
+
+	check_stack_depth();
+
+	while (e->depth == scopeDepth)
+	{
+		if (RPRElemIsVar(e))
+		{
+			if (e->min != e->max)
+				return false;
+		}
+		else if (RPRElemIsBegin(e))
+		{
+			RPRElemIdx	childIdx = e->next;
+
+			/* Recurse into subgroup children at scopeDepth + 1 */
+			if (!isFixedLengthChildren(pattern, childIdx, scopeDepth + 1))
+				return false;
+
+			/* Advance past the subgroup to its END element */
+			e = &pattern->elements[e->next];
+			while (e->depth > scopeDepth)
+				e = &pattern->elements[e->next];
+
+			/* e is now the END at scopeDepth; check its quantifier */
+			Assert(RPRElemIsEnd(e) && e->depth == scopeDepth);
+			if (e->min != e->max)
+				return false;
+		}
+		else
+		{
+			/* ALT inside group: not supported for absorption */
+			return false;
+		}
+
+		Assert(e->next != RPR_ELEMIDX_INVALID);
+		e = &pattern->elements[e->next];
+	}
+
+	return true;
+}
+
 /*
  * isUnboundedStart
  *		Check if the element at idx starts an unbounded greedy sequence.
@@ -1533,29 +1599,31 @@ finalizeRPRPattern(RPRPattern *result)
  *   - Greedy (not reluctant)
  *   - At the start of current scope
  *
- * Algorithm:
- *   - Traverse elements within current scope (parentDepth to startDepth)
- *   - For GROUP: must be unbounded greedy AND contain only simple {1,1} VARs
- *   - Sets ABSORBABLE and ABSORBABLE_BRANCH flags on matching elements
- *
  * Two cases are handled:
  *   1. Simple VAR: A+ B C - A has max=INF, gets both flags
- *   2. Group: (A B)+ C - END has max=INF, all children are {1,1} VARs
- *      A,B,END get ABSORBABLE_BRANCH, only END gets ABSORBABLE
+ *   2. Unbounded GROUP with fixed-length children: (A B{2})+ C
+ *      All children must have min == max (recursively for nested subgroups).
+ *      This is equivalent to unrolling to {1,1} VARs, e.g., (A B B)+ C.
+ *      All elements within the group get ABSORBABLE_BRANCH.
+ *      Only the unbounded END gets ABSORBABLE (judgment point).
+ *      Examples:
+ *        (A B{2})+ C          - B{2} has min==max, step=3
+ *        (A (B C){2} D)+ E    - nested {2} subgroup, step=6
+ *        ((A (B C){2}){2})+   - doubly nested {2}, step=10
+ *        (A ((B C{3}){2} D){2} E)+ F  - deep nesting, step=20
  *
  * Returns false for patterns where absorption cannot work:
  *   - A B+ (unbounded not at start)
  *   - A+? B (reluctant quantifier)
  *   - (A | B)+ (ALT inside group)
- *   - (A B+)+ (unbounded element inside group)
- *   - ((A B)+ C)+ (nested unbounded groups)
+ *   - (A B+)+ (variable-length element inside group)
+ *   - (A B{2,5})+ (min != max inside group)
  */
 static bool
 isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx)
 {
 	RPRPatternElement *elem = &pattern->elements[idx];
 	RPRDepth	startDepth = elem->depth;
-	RPRPatternElement *nextElem;
 	RPRPatternElement *e;
 
 	/* Case 1: Simple unbounded VAR at start (greedy only) */
@@ -1568,21 +1636,19 @@ isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx)
 	}
 
 	/*
-	 * Case 2: Unbounded GROUP - traverse siblings at startDepth and check if
-	 * they're all simple {1,1} VARs, then check if END at startDepth - 1 is
-	 * unbounded greedy.
+	 * Case 2: Unbounded GROUP with fixed-length children.  Each child must
+	 * have min == max (recursively for nested subgroups), ensuring a fixed
+	 * step size per iteration so that count-dominance holds.
 	 */
-	for (e = elem; e->depth == startDepth; e = nextElem)
-	{
-		/* Must be simple {1,1} VAR */
-		if (!RPRElemIsVar(e) || e->min != 1 || e->max != 1)
-			return false;
+	if (!isFixedLengthChildren(pattern, idx, startDepth))
+		return false;
 
-		Assert(e->next != RPR_ELEMIDX_INVALID);
-		nextElem = &pattern->elements[e->next];
-	}
+	/* Find the END element at startDepth - 1 */
+	e = &pattern->elements[idx];
+	while (e->depth >= startDepth)
+		e = &pattern->elements[e->next];
 
-	/* Now e should be END at startDepth - 1 */
+	/* END must be unbounded greedy */
 	if (e->depth == startDepth - 1 &&
 		RPRElemIsEnd(e) && e->max == RPR_QUANTITY_INF &&
 		!RPRElemIsReluctant(e))
@@ -1590,7 +1656,8 @@ isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx)
 		Assert(e->jump == idx); /* END points back to first child */
 
 		/* Set ABSORBABLE_BRANCH on all children, ABSORBABLE on END only */
-		for (e = elem; !RPRElemIsEnd(e); e = &pattern->elements[e->next])
+		for (e = elem; !RPRElemIsEnd(e) || e->depth >= startDepth;
+			 e = &pattern->elements[e->next])
 			e->flags |= RPR_ELEM_ABSORBABLE_BRANCH;
 		e->flags |= RPR_ELEM_ABSORBABLE_BRANCH | RPR_ELEM_ABSORBABLE;
 		return true;
@@ -1654,12 +1721,25 @@ computeAbsorbabilityRecursive(RPRPattern *pattern, RPRElemIdx startIdx,
 	}
 	else if (RPRElemIsBegin(elem))
 	{
-		/* BEGIN: skip to first child and check that */
-		computeAbsorbabilityRecursive(pattern, elem->next, hasAbsorbable);
-
-		/* Mark BEGIN element if contents are absorbable */
-		if (*hasAbsorbable)
+		/*
+		 * BEGIN: first try to treat this BEGIN's children as an unbounded
+		 * group directly (handles nested fixed-length groups like ((A{2}
+		 * B{3}){2})+).  If that fails, skip to first child and recurse as
+		 * before.
+		 */
+		if (isUnboundedStart(pattern, elem->next))
+		{
+			*hasAbsorbable = true;
 			elem->flags |= RPR_ELEM_ABSORBABLE_BRANCH;
+		}
+		else
+		{
+			computeAbsorbabilityRecursive(pattern, elem->next, hasAbsorbable);
+
+			/* Mark BEGIN element if contents are absorbable */
+			if (*hasAbsorbable)
+				elem->flags |= RPR_ELEM_ABSORBABLE_BRANCH;
+		}
 	}
 	else
 	{
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 3168468d0ae..7452cf1b3ab 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -3312,7 +3312,7 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
 -------------------------------------------------------------------------------
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
-   Pattern: (a{2}){2,}
+   Pattern: (a{2}'){2,}"
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
@@ -4095,6 +4095,87 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
          ->  Seq Scan on rpr_plan
 (6 rows)
 
+-- Fixed-length group absorbable: (A{2} B{3})+ -> (a{2}' b{3}'){2,}"
+-- All children have min == max, equivalent to unrolling to {1,1}
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A{2} B{3})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a{2}' b{3}')+"
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Nested fixed-length group: (A (B C){2} D)+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A (B C){2} D)+)
+             DEFINE A AS val <= 20, B AS val <= 40, C AS val <= 60, D AS val > 60);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' (b' c'){2}' d')+"
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Nested fixed-length with inner quantifier: ((A{2} B{3}){2})+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN (((A{2} B{3}){2})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: ((a{2}' b{3}'){2}')+"
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Non-absorbable fixed-length: (A B{2,5})+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B{2,5})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a b{2,5})+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Non-absorbable fixed-length: (A B?)+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B?)+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a b?)+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
 -- Non-absorbable (unbounded not at start): A B+ -> a b+ (no markers)
 EXPLAIN (COSTS OFF)
 SELECT COUNT(*) OVER w FROM rpr_plan
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index 79cbc246039..560f21f44c2 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -462,10 +462,10 @@ WINDOW w AS (
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+"
    Storage: Memory  Maximum Storage: NkB
-   NFA States: 3 peak, 91 total, 0 merged
-   NFA Contexts: 2 peak, 61 total, 0 pruned
+   NFA States: 4 peak, 91 total, 0 merged
+   NFA Contexts: 3 peak, 61 total, 0 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
-   NFA: 29 absorbed (len 1/1/1.0), 30 skipped (len 1/1/1.0)
+   NFA: 29 absorbed (len 2/2/2.0), 30 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
 
@@ -904,6 +904,188 @@ WINDOW w AS (
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
 (9 rows)
 
+-- Fixed-length group absorption: (A B B)+ C
+-- B B merged to B{2}; absorbable with fixed-length check
+-- step_size=3 (A + B + B); v % 7 cycle gives 2 iterations per match
+CREATE VIEW rpr_ev20b AS
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20b'), E'\n')) AS line WHERE line ~ 'PATTERN';
+          line           
+-------------------------
+   PATTERN ((a b b)+ c) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=70.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' b{2}')+" c
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 3 peak, 91 total, 0 merged
+   NFA Contexts: 4 peak, 71 total, 40 pruned
+   NFA: 10 matched (len 7/7/7.0), 0 mismatched
+   NFA: 10 absorbed (len 3/3/3.0), 10 skipped (len 1/1/1.0)
+   ->  Function Scan on generate_series s (actual rows=70.00 loops=1)
+(9 rows)
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- step_size = 1 + (1+1)*2 + 1 = 6; v % 13 cycle gives 2 iterations + E
+CREATE VIEW rpr_ev20c AS
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20c'), E'\n')) AS line WHERE line ~ 'PATTERN';
+              line              
+--------------------------------
+   PATTERN ((a (b c){2} d)+ e) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=65.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' (b' c'){2}' d')+" e
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 3 peak, 76 total, 0 merged
+   NFA Contexts: 4 peak, 66 total, 50 pruned
+   NFA: 5 matched (len 13/13/13.0), 0 mismatched
+   NFA: 5 absorbed (len 6/6/6.0), 5 skipped (len 1/1/1.0)
+   ->  Function Scan on generate_series s (actual rows=65.00 loops=1)
+(9 rows)
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; v % 41 cycle gives 2 iterations + F
+CREATE VIEW rpr_ev20d AS
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20d'), E'\n')) AS line WHERE line ~ 'PATTERN';
+                   line                    
+-------------------------------------------
+   PATTERN ((a ((b c c c){2} d){2} e)+ f) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=82.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' ((b' c{3}'){2}' d'){2}' e')+" f
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 3 peak, 87 total, 0 merged
+   NFA Contexts: 4 peak, 83 total, 76 pruned
+   NFA: 2 matched (len 41/41/41.0), 0 mismatched
+   NFA: 2 absorbed (len 20/20/20.0), 2 skipped (len 1/1/1.0)
+   ->  Function Scan on generate_series s (actual rows=82.00 loops=1)
+(9 rows)
+
+-- 3-level END chain absorption: ((A (B C){2}){2})+
+-- step_size = (1 + (1+1)*2) * 2 = 10; v % 21 cycle gives 2 iterations
+-- END chain: END(BC{2}) -> END(A..{2}) -> END(+, ABSORBABLE)
+CREATE VIEW rpr_ev20e AS
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20e'), E'\n')) AS line WHERE line ~ 'PATTERN';
+              line               
+---------------------------------
+   PATTERN (((a (b c){2}){2})+) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=42.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: ((a' (b' c'){2}'){2}')+"
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 5 peak, 47 total, 0 merged
+   NFA Contexts: 5 peak, 43 total, 30 pruned
+   NFA: 2 matched (len 20/20/20.0), 0 mismatched
+   NFA: 2 absorbed (len 10/10/10.0), 8 skipped (len 1/5/3.0)
+   ->  Function Scan on generate_series s (actual rows=42.00 loops=1)
+(9 rows)
+
 -- ============================================================
 -- Match Length Statistics Tests
 -- ============================================================
@@ -1894,10 +2076,10 @@ WINDOW w AS (
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b' c')+"
    Storage: Memory  Maximum Storage: NkB
-   NFA States: 3 peak, 81 total, 0 merged
-   NFA Contexts: 3 peak, 61 total, 20 pruned
+   NFA States: 4 peak, 81 total, 0 merged
+   NFA Contexts: 4 peak, 61 total, 20 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
-   NFA: 19 absorbed (len 1/1/1.0), 20 skipped (len 1/1/1.0)
+   NFA: 19 absorbed (len 3/3/3.0), 20 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
 
@@ -2461,7 +2643,7 @@ WINDOW w AS (
    NFA States: 4 peak, 102 total, 0 merged
    NFA Contexts: 2 peak, 41 total, 10 pruned
    NFA: 10 matched (len 3/3/3.0), 0 mismatched
-   NFA: 20 absorbed (len 1/1/1.0), 0 skipped
+   NFA: 10 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
 (9 rows)
 
@@ -3158,10 +3340,10 @@ WINDOW w AS (
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+"
    Storage: Memory  Maximum Storage: NkB
-   NFA States: 3 peak, 61 total, 0 merged
-   NFA Contexts: 2 peak, 41 total, 0 pruned
+   NFA States: 4 peak, 61 total, 0 merged
+   NFA Contexts: 3 peak, 41 total, 0 pruned
    NFA: 1 matched (len 40/40/40.0), 0 mismatched
-   NFA: 19 absorbed (len 1/1/1.0), 20 skipped (len 1/1/1.0)
+   NFA: 19 absorbed (len 2/2/2.0), 20 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
 (9 rows)
 
@@ -3234,12 +3416,12 @@ WINDOW w AS (
 ----------------------------------------------------------------------
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
-   Pattern: ((a b){2})+
+   Pattern: ((a' b'){2}')+"
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 76 total, 0 merged
    NFA Contexts: 4 peak, 61 total, 15 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
-   NFA: 0 absorbed, 44 skipped (len 1/4/2.3)
+   NFA: 14 absorbed (len 4/4/4.0), 30 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
 
diff --git a/src/test/regress/expected/rpr_nfa.out b/src/test/regress/expected/rpr_nfa.out
index 7b5a17fb671..250f7f131b1 100644
--- a/src/test/regress/expected/rpr_nfa.out
+++ b/src/test/regress/expected/rpr_nfa.out
@@ -447,6 +447,327 @@ WINDOW w AS (
   7 | {X}   |             |          
 (7 rows)
 
+-- Fixed-length group absorption: (A B{2})+ C
+-- B{2} has min == max, equivalent to unrolling to (A B B)+ C
+WITH test_absorb_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),
+        (11, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B{2})+ C)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        10
+  2 | {B}   |             |          
+  3 | {B}   |             |          
+  4 | {A}   |             |          
+  5 | {B}   |             |          
+  6 | {B}   |             |          
+  7 | {A}   |             |          
+  8 | {B}   |             |          
+  9 | {B}   |             |          
+ 10 | {C}   |             |          
+ 11 | {X}   |             |          
+(11 rows)
+
+-- Consecutive vars merged to fixed-length: (A B B)+ -> (A B{2})+
+-- mergeConsecutiveVars produces B{2}; now absorbable with fixed-length check
+WITH test_absorb_consecutive AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_consecutive
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |         9
+  2 | {B}   |             |          
+  3 | {B}   |             |          
+  4 | {A}   |             |          
+  5 | {B}   |             |          
+  6 | {B}   |             |          
+  7 | {A}   |             |          
+  8 | {B}   |             |          
+  9 | {B}   |             |          
+ 10 | {X}   |             |          
+(10 rows)
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- Inner group {2} has min == max; absorbable via recursive check
+-- step_size = 1 + (1+1)*2 + 1 = 6
+WITH test_absorb_nested_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),
+        (6,  ARRAY['D']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['C']),
+        (10, ARRAY['B']),
+        (11, ARRAY['C']),
+        (12, ARRAY['D']),
+        (13, ARRAY['E']),
+        (14, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_nested_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        13
+  2 | {B}   |             |          
+  3 | {C}   |             |          
+  4 | {B}   |             |          
+  5 | {C}   |             |          
+  6 | {D}   |             |          
+  7 | {A}   |             |          
+  8 | {B}   |             |          
+  9 | {C}   |             |          
+ 10 | {B}   |             |          
+ 11 | {C}   |             |          
+ 12 | {D}   |             |          
+ 13 | {E}   |             |          
+ 14 | {X}   |             |          
+(14 rows)
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; 2 iterations + F = 41 rows
+WITH test_absorb_doubly_nested AS (
+    SELECT v AS id, ARRAY[
+        CASE
+            WHEN v % 41 IN (1, 21)  THEN 'A'
+            WHEN v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35) THEN 'B'
+            WHEN v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                            23,24,25, 27,28,29, 32,33,34, 36,37,38) THEN 'C'
+            WHEN v % 41 IN (10, 19, 30, 39) THEN 'D'
+            WHEN v % 41 IN (20, 40) THEN 'E'
+            WHEN v % 41 = 0 THEN 'F'
+            ELSE 'X'
+        END
+    ] AS flags
+    FROM generate_series(1, 82) AS s(v)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_doubly_nested
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags),
+        F AS 'F' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        41
+  2 | {B}   |             |          
+  3 | {C}   |             |          
+  4 | {C}   |             |          
+  5 | {C}   |             |          
+  6 | {B}   |             |          
+  7 | {C}   |             |          
+  8 | {C}   |             |          
+  9 | {C}   |             |          
+ 10 | {D}   |             |          
+ 11 | {B}   |             |          
+ 12 | {C}   |             |          
+ 13 | {C}   |             |          
+ 14 | {C}   |             |          
+ 15 | {B}   |             |          
+ 16 | {C}   |             |          
+ 17 | {C}   |             |          
+ 18 | {C}   |             |          
+ 19 | {D}   |             |          
+ 20 | {E}   |             |          
+ 21 | {A}   |             |          
+ 22 | {B}   |             |          
+ 23 | {C}   |             |          
+ 24 | {C}   |             |          
+ 25 | {C}   |             |          
+ 26 | {B}   |             |          
+ 27 | {C}   |             |          
+ 28 | {C}   |             |          
+ 29 | {C}   |             |          
+ 30 | {D}   |             |          
+ 31 | {B}   |             |          
+ 32 | {C}   |             |          
+ 33 | {C}   |             |          
+ 34 | {C}   |             |          
+ 35 | {B}   |             |          
+ 36 | {C}   |             |          
+ 37 | {C}   |             |          
+ 38 | {C}   |             |          
+ 39 | {D}   |             |          
+ 40 | {E}   |             |          
+ 41 | {F}   |             |          
+ 42 | {A}   |          42 |        82
+ 43 | {B}   |             |          
+ 44 | {C}   |             |          
+ 45 | {C}   |             |          
+ 46 | {C}   |             |          
+ 47 | {B}   |             |          
+ 48 | {C}   |             |          
+ 49 | {C}   |             |          
+ 50 | {C}   |             |          
+ 51 | {D}   |             |          
+ 52 | {B}   |             |          
+ 53 | {C}   |             |          
+ 54 | {C}   |             |          
+ 55 | {C}   |             |          
+ 56 | {B}   |             |          
+ 57 | {C}   |             |          
+ 58 | {C}   |             |          
+ 59 | {C}   |             |          
+ 60 | {D}   |             |          
+ 61 | {E}   |             |          
+ 62 | {A}   |             |          
+ 63 | {B}   |             |          
+ 64 | {C}   |             |          
+ 65 | {C}   |             |          
+ 66 | {C}   |             |          
+ 67 | {B}   |             |          
+ 68 | {C}   |             |          
+ 69 | {C}   |             |          
+ 70 | {C}   |             |          
+ 71 | {D}   |             |          
+ 72 | {B}   |             |          
+ 73 | {C}   |             |          
+ 74 | {C}   |             |          
+ 75 | {C}   |             |          
+ 76 | {B}   |             |          
+ 77 | {C}   |             |          
+ 78 | {C}   |             |          
+ 79 | {C}   |             |          
+ 80 | {D}   |             |          
+ 81 | {E}   |             |          
+ 82 | {F}   |             |          
+(82 rows)
+
+-- 3-level END chain: ((A (B C){2}){2})+
+-- Tests END(BC{2}) -> END(A..{2}) -> END(+) chaining
+-- 2 iterations of +, each 10 rows: (A B C B C)(A B C B C)
+WITH test_absorb_3level_end AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),  -- 1st + iter, 1st {2}, A
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),  -- 1st (BC){2} done
+        (6,  ARRAY['A']),  -- 1st + iter, 2nd {2}, A
+        (7,  ARRAY['B']),
+        (8,  ARRAY['C']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),  -- 2nd (BC){2} done, 1st {2} done, 1st + iter done
+        (11, ARRAY['A']),  -- 2nd + iter, 1st {2}, A
+        (12, ARRAY['B']),
+        (13, ARRAY['C']),
+        (14, ARRAY['B']),
+        (15, ARRAY['C']),
+        (16, ARRAY['A']),  -- 2nd + iter, 2nd {2}, A
+        (17, ARRAY['B']),
+        (18, ARRAY['C']),
+        (19, ARRAY['B']),
+        (20, ARRAY['C']),  -- 2nd + iter done
+        (21, ARRAY['X'])   -- no match, + ends
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_3level_end
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        20
+  2 | {B}   |             |          
+  3 | {C}   |             |          
+  4 | {B}   |             |          
+  5 | {C}   |             |          
+  6 | {A}   |             |          
+  7 | {B}   |             |          
+  8 | {C}   |             |          
+  9 | {B}   |             |          
+ 10 | {C}   |             |          
+ 11 | {A}   |             |          
+ 12 | {B}   |             |          
+ 13 | {C}   |             |          
+ 14 | {B}   |             |          
+ 15 | {C}   |             |          
+ 16 | {A}   |             |          
+ 17 | {B}   |             |          
+ 18 | {C}   |             |          
+ 19 | {B}   |             |          
+ 20 | {C}   |             |          
+ 21 | {X}   |             |          
+(21 rows)
+
 -- Multiple unbounded: A+ B+ (first element unbounded enables absorption)
 WITH test_multi_unbounded AS (
     SELECT * FROM (VALUES
diff --git a/src/test/regress/sql/rpr_base.sql b/src/test/regress/sql/rpr_base.sql
index cf6c062ae85..8c23c7598a3 100644
--- a/src/test/regress/sql/rpr_base.sql
+++ b/src/test/regress/sql/rpr_base.sql
@@ -2600,6 +2600,42 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
              AFTER MATCH SKIP PAST LAST ROW PATTERN ((A+ B | A B)*)
              DEFINE A AS val <= 50, B AS val > 50);
 
+-- Fixed-length group absorbable: (A{2} B{3})+ -> (a{2}' b{3}'){2,}"
+-- All children have min == max, equivalent to unrolling to {1,1}
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A{2} B{3})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
+-- Nested fixed-length group: (A (B C){2} D)+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A (B C){2} D)+)
+             DEFINE A AS val <= 20, B AS val <= 40, C AS val <= 60, D AS val > 60);
+
+-- Nested fixed-length with inner quantifier: ((A{2} B{3}){2})+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN (((A{2} B{3}){2})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
+-- Non-absorbable fixed-length: (A B{2,5})+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B{2,5})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
+-- Non-absorbable fixed-length: (A B?)+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B?)+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
 -- Non-absorbable (unbounded not at start): A B+ -> a b+ (no markers)
 EXPLAIN (COSTS OFF)
 SELECT COUNT(*) OVER w FROM rpr_plan
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index 93e06b0cbdf..237f0366631 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -578,6 +578,120 @@ WINDOW w AS (
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );');
 
+-- Fixed-length group absorption: (A B B)+ C
+-- B B merged to B{2}; absorbable with fixed-length check
+-- step_size=3 (A + B + B); v % 7 cycle gives 2 iterations per match
+CREATE VIEW rpr_ev20b AS
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20b'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);');
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- step_size = 1 + (1+1)*2 + 1 = 6; v % 13 cycle gives 2 iterations + E
+CREATE VIEW rpr_ev20c AS
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20c'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);');
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; v % 41 cycle gives 2 iterations + F
+CREATE VIEW rpr_ev20d AS
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20d'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);');
+
+-- 3-level END chain absorption: ((A (B C){2}){2})+
+-- step_size = (1 + (1+1)*2) * 2 = 10; v % 21 cycle gives 2 iterations
+-- END chain: END(BC{2}) -> END(A..{2}) -> END(+, ABSORBABLE)
+CREATE VIEW rpr_ev20e AS
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20e'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);');
+
 -- ============================================================
 -- Match Length Statistics Tests
 -- ============================================================
diff --git a/src/test/regress/sql/rpr_nfa.sql b/src/test/regress/sql/rpr_nfa.sql
index 5edcb3357e6..aaa7b44f789 100644
--- a/src/test/regress/sql/rpr_nfa.sql
+++ b/src/test/regress/sql/rpr_nfa.sql
@@ -346,6 +346,174 @@ WINDOW w AS (
         B AS 'B' = ANY(flags)
 );
 
+-- Fixed-length group absorption: (A B{2})+ C
+-- B{2} has min == max, equivalent to unrolling to (A B B)+ C
+WITH test_absorb_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),
+        (11, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B{2})+ C)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+
+-- Consecutive vars merged to fixed-length: (A B B)+ -> (A B{2})+
+-- mergeConsecutiveVars produces B{2}; now absorbable with fixed-length check
+WITH test_absorb_consecutive AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_consecutive
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags)
+);
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- Inner group {2} has min == max; absorbable via recursive check
+-- step_size = 1 + (1+1)*2 + 1 = 6
+WITH test_absorb_nested_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),
+        (6,  ARRAY['D']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['C']),
+        (10, ARRAY['B']),
+        (11, ARRAY['C']),
+        (12, ARRAY['D']),
+        (13, ARRAY['E']),
+        (14, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_nested_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags)
+);
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; 2 iterations + F = 41 rows
+WITH test_absorb_doubly_nested AS (
+    SELECT v AS id, ARRAY[
+        CASE
+            WHEN v % 41 IN (1, 21)  THEN 'A'
+            WHEN v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35) THEN 'B'
+            WHEN v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                            23,24,25, 27,28,29, 32,33,34, 36,37,38) THEN 'C'
+            WHEN v % 41 IN (10, 19, 30, 39) THEN 'D'
+            WHEN v % 41 IN (20, 40) THEN 'E'
+            WHEN v % 41 = 0 THEN 'F'
+            ELSE 'X'
+        END
+    ] AS flags
+    FROM generate_series(1, 82) AS s(v)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_doubly_nested
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags),
+        F AS 'F' = ANY(flags)
+);
+
+-- 3-level END chain: ((A (B C){2}){2})+
+-- Tests END(BC{2}) -> END(A..{2}) -> END(+) chaining
+-- 2 iterations of +, each 10 rows: (A B C B C)(A B C B C)
+WITH test_absorb_3level_end AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),  -- 1st + iter, 1st {2}, A
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),  -- 1st (BC){2} done
+        (6,  ARRAY['A']),  -- 1st + iter, 2nd {2}, A
+        (7,  ARRAY['B']),
+        (8,  ARRAY['C']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),  -- 2nd (BC){2} done, 1st {2} done, 1st + iter done
+        (11, ARRAY['A']),  -- 2nd + iter, 1st {2}, A
+        (12, ARRAY['B']),
+        (13, ARRAY['C']),
+        (14, ARRAY['B']),
+        (15, ARRAY['C']),
+        (16, ARRAY['A']),  -- 2nd + iter, 2nd {2}, A
+        (17, ARRAY['B']),
+        (18, ARRAY['C']),
+        (19, ARRAY['B']),
+        (20, ARRAY['C']),  -- 2nd + iter done
+        (21, ARRAY['X'])   -- no match, + ends
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_3level_end
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+
 -- Multiple unbounded: A+ B+ (first element unbounded enables absorption)
 WITH test_multi_unbounded AS (
     SELECT * FROM (VALUES
-- 
2.50.1 (Apple Git-155)


From 7851d85ef7450c20b55035547ecaf8c977c68207 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Sat, 4 Apr 2026 22:00:50 +0900
Subject: [PATCH] Rename rpr_explain test views from sequential numbers to
 descriptive names

Replace rpr_ev01..rpr_ev89 with section-based names like
rpr_ev_ctx_absorb_group, rpr_ev_alt_nested_start, etc.
This avoids numbering conflicts when inserting new tests.
---
 src/test/regress/expected/rpr_explain.out | 378 +++++++++++-----------
 src/test/regress/sql/rpr_explain.sql      | 372 ++++++++++-----------
 2 files changed, 375 insertions(+), 375 deletions(-)

diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index 560f21f44c2..f66caf8908e 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -111,7 +111,7 @@ VALUES
 -- Basic NFA Statistics Tests
 -- ============================================================
 -- Simple pattern - should show basic statistics
-CREATE VIEW rpr_ev01 AS
+CREATE VIEW rpr_ev_basic_simple AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -120,7 +120,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS cat = 'A', B AS cat = 'B'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev01'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line       
 ------------------
    PATTERN (a b) 
@@ -150,7 +150,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Pattern with no matches - 0 matched
-CREATE VIEW rpr_ev02 AS
+CREATE VIEW rpr_ev_basic_nomatch AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -159,7 +159,7 @@ WINDOW w AS (
     PATTERN (X Y Z)
     DEFINE X AS cat = 'X', Y AS cat = 'Y', Z AS cat = 'Z'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev02'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_nomatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line        
 --------------------
    PATTERN (x y z) 
@@ -188,7 +188,7 @@ WINDOW w AS (
 (8 rows)
 
 -- Pattern matching every row - high match count
-CREATE VIEW rpr_ev03 AS
+CREATE VIEW rpr_ev_basic_allrows AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -197,7 +197,7 @@ WINDOW w AS (
     PATTERN (R)
     DEFINE R AS TRUE
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev03'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_allrows'), E'\n')) AS line WHERE line ~ 'PATTERN';
       line      
 ----------------
    PATTERN (r) 
@@ -227,7 +227,7 @@ WINDOW w AS (
 
 -- Regression test: Space before parenthesis in pattern deparse
 -- Verifies that "A (B | C)" correctly outputs as "a (b | c)" with space
-CREATE VIEW rpr_ev04 AS
+CREATE VIEW rpr_ev_basic_deparse_space AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -235,7 +235,7 @@ WINDOW w AS (
     PATTERN (A (B | C))
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev04'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_deparse_space'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line          
 ------------------------
    PATTERN (a (b | c)) 
@@ -266,7 +266,7 @@ WINDOW w AS (
 -- Regression test: Sequential alternations at same depth
 -- Verifies that "((B | C) (D | E))" correctly outputs as "(b | c) (d | e)"
 -- Previously failed due to missing parentheses on ALT depth decrease
-CREATE VIEW rpr_ev05 AS
+CREATE VIEW rpr_ev_basic_deparse_seqalt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -274,7 +274,7 @@ WINDOW w AS (
     PATTERN (A ((B | C) (D | E))*)
     DEFINE A AS v % 5 = 1, B AS v % 5 = 2, C AS v % 5 = 3, D AS v % 5 = 4, E AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev05'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_deparse_seqalt'), E'\n')) AS line WHERE line ~ 'PATTERN';
                line                
 -----------------------------------
    PATTERN (a ((b | c) (d | e))*) 
@@ -305,7 +305,7 @@ WINDOW w AS (
 -- State Statistics Tests (peak, total, merged)
 -- ============================================================
 -- Simple quantifier pattern - A+ with short matches (no merging)
-CREATE VIEW rpr_ev06 AS
+CREATE VIEW rpr_ev_state_simple_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -314,7 +314,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS v % 2 = 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev06'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_simple_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
       line       
 -----------------
    PATTERN (a+) 
@@ -343,7 +343,7 @@ WINDOW w AS (
 (8 rows)
 
 -- Alternation pattern - multiple state branches
-CREATE VIEW rpr_ev07 AS
+CREATE VIEW rpr_ev_state_alt AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -354,7 +354,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev07'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
                line               
 ----------------------------------
    PATTERN ((a | b | c) (d | e)) 
@@ -386,7 +386,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Complex pattern with high state count
-CREATE VIEW rpr_ev08 AS
+CREATE VIEW rpr_ev_state_complex AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -398,7 +398,7 @@ WINDOW w AS (
         B AS v % 3 = 2,
         C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev08'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_complex'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line          
 -----------------------
    PATTERN (a+ b* c+) 
@@ -431,7 +431,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Grouped pattern with quantifier - state count with grouping
-CREATE VIEW rpr_ev09 AS
+CREATE VIEW rpr_ev_state_group_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -440,7 +440,7 @@ WINDOW w AS (
     PATTERN ((A B)+)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev09'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_group_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN ((a b)+) 
@@ -471,7 +471,7 @@ WINDOW w AS (
 
 -- State explosion pattern - many alternations
 -- Pattern (A|B)(A|B)(A|B)(A|B) can create many parallel states
-CREATE VIEW rpr_ev10 AS
+CREATE VIEW rpr_ev_state_explosion AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -480,7 +480,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B))
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev10'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_explosion'), E'\n')) AS line WHERE line ~ 'PATTERN';
                                      line                                     
 ------------------------------------------------------------------------------
    PATTERN ((a | b) (a | b) (a | b) (a | b) (a | b) (a | b) (a | b) (a | b)) 
@@ -511,7 +511,7 @@ WINDOW w AS (
 
 -- Consecutive ALT merge followed by different ALT
 -- Tests mergeConsecutiveAlts flush on ALT change: (A|B){2} (C|D)
-CREATE VIEW rpr_ev11 AS
+CREATE VIEW rpr_ev_state_alt_merge_alt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -520,7 +520,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) (C | D))
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev11'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_merge_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
                  line                 
 --------------------------------------
    PATTERN ((a | b) (a | b) (c | d)) 
@@ -551,7 +551,7 @@ WINDOW w AS (
 
 -- Consecutive ALT merge followed by non-ALT element
 -- Tests mergeConsecutiveAlts flush on non-ALT: (A|B){2} c
-CREATE VIEW rpr_ev12 AS
+CREATE VIEW rpr_ev_state_alt_merge_nonalt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -560,7 +560,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) C)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev12'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_merge_nonalt'), E'\n')) AS line WHERE line ~ 'PATTERN';
               line              
 --------------------------------
    PATTERN ((a | b) (a | b) c) 
@@ -590,7 +590,7 @@ WINDOW w AS (
 (9 rows)
 
 -- ALT prefix/suffix absorbed into GROUP: (A|B) (A|B)+ (A|B) -> (A|B){3,}
-CREATE VIEW rpr_ev13 AS
+CREATE VIEW rpr_ev_state_alt_absorb_group AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -599,7 +599,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B)+ (A | B))
     DEFINE A AS v % 2 = 0, B AS v % 2 = 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev13'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_absorb_group'), E'\n')) AS line WHERE line ~ 'PATTERN';
                  line                  
 ---------------------------------------
    PATTERN ((a | b) (a | b)+ (a | b)) 
@@ -629,7 +629,7 @@ WINDOW w AS (
 (9 rows)
 
 -- High state count - alternation with plus quantifier
-CREATE VIEW rpr_ev14 AS
+CREATE VIEW rpr_ev_state_alt_plus AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -638,7 +638,7 @@ WINDOW w AS (
     PATTERN ((A | B | C)+ D)
     DEFINE A AS v % 4 = 1, B AS v % 4 = 2, C AS v % 4 = 3, D AS v % 4 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev14'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_plus'), E'\n')) AS line WHERE line ~ 'PATTERN';
             line             
 -----------------------------
    PATTERN ((a | b | c)+ d) 
@@ -669,7 +669,7 @@ WINDOW w AS (
 
 -- Early termination: first ALT branch (A) reaches FIN immediately,
 -- pruning second branch (A B+) before it can accumulate B repetitions.
-CREATE VIEW rpr_ev15 AS
+CREATE VIEW rpr_ev_state_alt_prune AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -678,7 +678,7 @@ WINDOW w AS (
     PATTERN ((A | A B)+)
     DEFINE A AS v = 1, B AS v > 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev15'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_prune'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line           
 -------------------------
    PATTERN ((a | a b)+) 
@@ -707,7 +707,7 @@ WINDOW w AS (
 (8 rows)
 
 -- Nested quantifiers causing state growth
-CREATE VIEW rpr_ev16 AS
+CREATE VIEW rpr_ev_state_nested_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1000) AS s(v)
 WINDOW w AS (
@@ -716,7 +716,7 @@ WINDOW w AS (
     PATTERN (((A | B)+)+)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev16'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_nested_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
            line           
 --------------------------
    PATTERN (((a | b)+)+) 
@@ -749,7 +749,7 @@ WINDOW w AS (
 -- Context Statistics Tests (peak, total, pruned + absorbed/skipped)
 -- ============================================================
 -- Context absorption with unbounded quantifier at start
-CREATE VIEW rpr_ev17 AS
+CREATE VIEW rpr_ev_ctx_absorb_unbounded AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -758,7 +758,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev17'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_unbounded'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -788,7 +788,7 @@ WINDOW w AS (
 (9 rows)
 
 -- No absorption - bounded quantifier
-CREATE VIEW rpr_ev18 AS
+CREATE VIEW rpr_ev_ctx_no_absorb AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -797,7 +797,7 @@ WINDOW w AS (
     PATTERN (A{2,4} B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev18'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_no_absorb'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line          
 -----------------------
    PATTERN (a{2,4} b) 
@@ -827,7 +827,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Contexts skipped by SKIP PAST LAST ROW
-CREATE VIEW rpr_ev19 AS
+CREATE VIEW rpr_ev_ctx_skip AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -836,7 +836,7 @@ WINDOW w AS (
     PATTERN (A B C)
     DEFINE A AS v % 10 = 1, B AS v % 10 = 2, C AS v % 10 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev19'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_skip'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line        
 --------------------
    PATTERN (a b c) 
@@ -866,7 +866,7 @@ WINDOW w AS (
 (9 rows)
 
 -- High context absorption - unbounded group
-CREATE VIEW rpr_ev20 AS
+CREATE VIEW rpr_ev_ctx_absorb_group AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -875,7 +875,7 @@ WINDOW w AS (
     PATTERN ((A B)+ C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_group'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line          
 -----------------------
    PATTERN ((a b)+ c) 
@@ -907,7 +907,7 @@ WINDOW w AS (
 -- Fixed-length group absorption: (A B B)+ C
 -- B B merged to B{2}; absorbable with fixed-length check
 -- step_size=3 (A + B + B); v % 7 cycle gives 2 iterations per match
-CREATE VIEW rpr_ev20b AS
+CREATE VIEW rpr_ev_ctx_absorb_fixedvar AS
 SELECT count(*) OVER w
 FROM generate_series(1, 70) AS s(v)
 WINDOW w AS (
@@ -916,7 +916,7 @@ WINDOW w AS (
     PATTERN ((A B B)+ C)
     DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20b'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_fixedvar'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line           
 -------------------------
    PATTERN ((a b b)+ c) 
@@ -947,7 +947,7 @@ WINDOW w AS (
 
 -- Nested fixed-length group absorption: (A (B C){2} D)+ E
 -- step_size = 1 + (1+1)*2 + 1 = 6; v % 13 cycle gives 2 iterations + E
-CREATE VIEW rpr_ev20c AS
+CREATE VIEW rpr_ev_ctx_absorb_nested AS
 SELECT count(*) OVER w
 FROM generate_series(1, 65) AS s(v)
 WINDOW w AS (
@@ -958,7 +958,7 @@ WINDOW w AS (
            C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
            E AS v % 13 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20c'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_nested'), E'\n')) AS line WHERE line ~ 'PATTERN';
               line              
 --------------------------------
    PATTERN ((a (b c){2} d)+ e) 
@@ -991,7 +991,7 @@ WINDOW w AS (
 
 -- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
 -- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; v % 41 cycle gives 2 iterations + F
-CREATE VIEW rpr_ev20d AS
+CREATE VIEW rpr_ev_ctx_absorb_deep AS
 SELECT count(*) OVER w
 FROM generate_series(1, 82) AS s(v)
 WINDOW w AS (
@@ -1006,7 +1006,7 @@ WINDOW w AS (
            E AS v % 41 IN (20, 40),
            F AS v % 41 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20d'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_deep'), E'\n')) AS line WHERE line ~ 'PATTERN';
                    line                    
 -------------------------------------------
    PATTERN ((a ((b c c c){2} d){2} e)+ f) 
@@ -1044,7 +1044,7 @@ WINDOW w AS (
 -- 3-level END chain absorption: ((A (B C){2}){2})+
 -- step_size = (1 + (1+1)*2) * 2 = 10; v % 21 cycle gives 2 iterations
 -- END chain: END(BC{2}) -> END(A..{2}) -> END(+, ABSORBABLE)
-CREATE VIEW rpr_ev20e AS
+CREATE VIEW rpr_ev_ctx_absorb_endchain AS
 SELECT count(*) OVER w
 FROM generate_series(1, 42) AS s(v)
 WINDOW w AS (
@@ -1055,7 +1055,7 @@ WINDOW w AS (
            B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
            C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20e'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_endchain'), E'\n')) AS line WHERE line ~ 'PATTERN';
               line               
 ---------------------------------
    PATTERN (((a (b c){2}){2})+) 
@@ -1090,7 +1090,7 @@ WINDOW w AS (
 -- Match Length Statistics Tests
 -- ============================================================
 -- Fixed length matches - all same length
-CREATE VIEW rpr_ev21 AS
+CREATE VIEW rpr_ev_mlen_fixed AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -1101,7 +1101,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev21'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_fixed'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line          
 ------------------------
    PATTERN (a b c d e) 
@@ -1133,7 +1133,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Variable length matches - min/max/avg differ
-CREATE VIEW rpr_ev22 AS
+CREATE VIEW rpr_ev_mlen_variable AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -1142,7 +1142,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev22'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_variable'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -1172,7 +1172,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Very long matches
-CREATE VIEW rpr_ev23 AS
+CREATE VIEW rpr_ev_mlen_long AS
 SELECT count(*) OVER w
 FROM generate_series(1, 200) AS s(v)
 WINDOW w AS (
@@ -1181,7 +1181,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v <= 195, B AS v > 195
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev23'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_long'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -1211,7 +1211,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Uniform match length with mismatches from gap rows (v%20 = 11..15)
-CREATE VIEW rpr_ev24 AS
+CREATE VIEW rpr_ev_mlen_with_mismatch AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -1222,7 +1222,7 @@ WINDOW w AS (
         A AS (v % 20 <> 0) AND (v % 20 <= 10 OR v % 20 > 15),
         B AS v % 20 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev24'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_with_mismatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -1258,7 +1258,7 @@ WINDOW w AS (
 -- ============================================================
 -- Pattern with complete match every cycle: 0 mismatched
 -- A(1,2,3) B(4,5) C(6) repeats perfectly; X rows are pruned, not mismatched
-CREATE VIEW rpr_ev25 AS
+CREATE VIEW rpr_ev_mlen_no_mismatch AS
 SELECT count(*) OVER w
 FROM (
     SELECT v,
@@ -1274,7 +1274,7 @@ WINDOW w AS (
     PATTERN (A+ B+ C)
     DEFINE A AS cat = 'A', B AS cat = 'B', C AS cat = 'C'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev25'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_no_mismatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line         
 ----------------------
    PATTERN (a+ b+ c) 
@@ -1311,7 +1311,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Long partial matches that fail
-CREATE VIEW rpr_ev26 AS
+CREATE VIEW rpr_ev_mlen_long_partial AS
 SELECT count(*) OVER w
 FROM (
     SELECT i AS v,
@@ -1332,7 +1332,7 @@ WINDOW w AS (
     PATTERN (A+ B+ C)
     DEFINE A AS cat = 'A', B AS cat = 'B', C AS cat = 'C'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev26'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_long_partial'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line         
 ----------------------
    PATTERN (a+ b+ c) 
@@ -1377,7 +1377,7 @@ WINDOW w AS (
 -- JSON Format Tests
 -- ============================================================
 -- JSON format output with all statistics
-CREATE VIEW rpr_ev27 AS
+CREATE VIEW rpr_ev_json_basic AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1386,7 +1386,7 @@ WINDOW w AS (
     PATTERN (A+ B+)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev27'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_basic'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line        
 --------------------
    PATTERN (a+ b+) 
@@ -1454,7 +1454,7 @@ WINDOW w AS (
 (1 row)
 
 -- JSON format with match length statistics
-CREATE VIEW rpr_ev28 AS
+CREATE VIEW rpr_ev_json_matchlen AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -1463,7 +1463,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev28'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_matchlen'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -1535,7 +1535,7 @@ WINDOW w AS (
 
 -- JSON format with mismatch statistics
 -- Pattern A B C expects 1,2,3 but gets 1,2,4 twice causing mismatches
-CREATE VIEW rpr_ev29 AS
+CREATE VIEW rpr_ev_json_mismatch AS
 SELECT count(*) OVER w
 FROM (VALUES (1),(2),(4), (1),(2),(4), (1),(2),(3)) AS t(v)
 WINDOW w AS (
@@ -1544,7 +1544,7 @@ WINDOW w AS (
     PATTERN (A B C)
     DEFINE A AS v = 1, B AS v = 2, C AS v = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev29'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_mismatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line        
 --------------------
    PATTERN (a b c) 
@@ -1615,7 +1615,7 @@ WINDOW w AS (
 
 -- JSON format with skipped context statistics
 -- Alternation pattern with SKIP PAST LAST ROW causes many contexts to be skipped
-CREATE VIEW rpr_ev30 AS
+CREATE VIEW rpr_ev_json_skip AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -1624,7 +1624,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B))
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev30'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_skip'), E'\n')) AS line WHERE line ~ 'PATTERN';
                                      line                                     
 ------------------------------------------------------------------------------
    PATTERN ((a | b) (a | b) (a | b) (a | b) (a | b) (a | b) (a | b) (a | b)) 
@@ -1698,7 +1698,7 @@ WINDOW w AS (
 -- XML Format Tests
 -- ============================================================
 -- XML format output
-CREATE VIEW rpr_ev31 AS
+CREATE VIEW rpr_ev_xml_basic AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -1707,7 +1707,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev31'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_xml_basic'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line       
 ------------------
    PATTERN (a b) 
@@ -1778,7 +1778,7 @@ WINDOW w AS (
 -- Multiple Partitions Tests
 -- ============================================================
 -- Statistics across multiple partitions
-CREATE VIEW rpr_ev32 AS
+CREATE VIEW rpr_ev_part_multi AS
 SELECT count(*) OVER w
 FROM (
     SELECT p, v
@@ -1792,7 +1792,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev32'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_part_multi'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -1832,7 +1832,7 @@ WINDOW w AS (
 (14 rows)
 
 -- Different pattern behavior per partition
-CREATE VIEW rpr_ev33 AS
+CREATE VIEW rpr_ev_part_diff AS
 SELECT count(*) OVER w
 FROM (
     SELECT
@@ -1847,7 +1847,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS val < 5, B AS val >= 5
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev33'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_part_diff'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -1889,7 +1889,7 @@ WINDOW w AS (
 -- Edge Cases
 -- ============================================================
 -- Empty result set
-CREATE VIEW rpr_ev34 AS
+CREATE VIEW rpr_ev_edge_empty AS
 SELECT count(*) OVER w
 FROM generate_series(1, 0) AS s(v)
 WINDOW w AS (
@@ -1898,7 +1898,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v = 1, B AS v = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev34'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_empty'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line       
 ------------------
    PATTERN (a b) 
@@ -1923,7 +1923,7 @@ WINDOW w AS (
 (4 rows)
 
 -- Single row
-CREATE VIEW rpr_ev35 AS
+CREATE VIEW rpr_ev_edge_single_row AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1) AS s(v)
 WINDOW w AS (
@@ -1932,7 +1932,7 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS TRUE
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev35'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_single_row'), E'\n')) AS line WHERE line ~ 'PATTERN';
       line      
 ----------------
    PATTERN (a) 
@@ -1961,7 +1961,7 @@ WINDOW w AS (
 (8 rows)
 
 -- Pattern longer than data
-CREATE VIEW rpr_ev36 AS
+CREATE VIEW rpr_ev_edge_pattern_longer AS
 SELECT count(*) OVER w
 FROM generate_series(1, 5) AS s(v)
 WINDOW w AS (
@@ -1972,7 +1972,7 @@ WINDOW w AS (
         A AS v = 1, B AS v = 2, C AS v = 3, D AS v = 4, E AS v = 5,
         F AS v = 6, G AS v = 7, H AS v = 8, I AS v = 9, J AS v = 10
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev36'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_pattern_longer'), E'\n')) AS line WHERE line ~ 'PATTERN';
                line               
 ----------------------------------
    PATTERN (a b c d e f g h i j) 
@@ -2003,7 +2003,7 @@ WINDOW w AS (
 (8 rows)
 
 -- All rows match as single match
-CREATE VIEW rpr_ev37 AS
+CREATE VIEW rpr_ev_edge_single_match AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2012,7 +2012,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS TRUE
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev37'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_single_match'), E'\n')) AS line WHERE line ~ 'PATTERN';
       line       
 -----------------
    PATTERN (a+) 
@@ -2045,7 +2045,7 @@ WINDOW w AS (
 -- Complex Pattern Tests
 -- ============================================================
 -- Nested groups
-CREATE VIEW rpr_ev38 AS
+CREATE VIEW rpr_ev_cpx_nested AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -2054,7 +2054,7 @@ WINDOW w AS (
     PATTERN (((A B) C)+)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev38'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_nested'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line           
 -------------------------
    PATTERN (((a b) c)+) 
@@ -2084,7 +2084,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Multiple alternations
-CREATE VIEW rpr_ev39 AS
+CREATE VIEW rpr_ev_cpx_multi_alt AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -2095,7 +2095,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev39'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_multi_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
                line               
 ----------------------------------
    PATTERN ((a | b) (c | d | e)) 
@@ -2127,7 +2127,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Optional elements
-CREATE VIEW rpr_ev40 AS
+CREATE VIEW rpr_ev_cpx_optional AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2136,7 +2136,7 @@ WINDOW w AS (
     PATTERN (A B? C)
     DEFINE A AS v % 4 = 1, B AS v % 4 = 2, C AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev40'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_optional'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN (a b? c) 
@@ -2166,7 +2166,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Bounded quantifiers
-CREATE VIEW rpr_ev41 AS
+CREATE VIEW rpr_ev_cpx_bounded AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -2175,7 +2175,7 @@ WINDOW w AS (
     PATTERN (A{2,5} B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev41'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_bounded'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line          
 -----------------------
    PATTERN (a{2,5} b) 
@@ -2205,7 +2205,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Star quantifier
-CREATE VIEW rpr_ev42 AS
+CREATE VIEW rpr_ev_cpx_star AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2214,7 +2214,7 @@ WINDOW w AS (
     PATTERN (A B* C)
     DEFINE A AS v % 10 = 1, B AS v % 10 IN (2,3,4,5,6,7,8), C AS v % 10 = 9
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev42'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_star'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN (a b* c) 
@@ -2247,7 +2247,7 @@ WINDOW w AS (
 -- Real-world Pattern Examples
 -- ============================================================
 -- Stock price pattern - V-shape (down then up)
-CREATE VIEW rpr_ev43 AS
+CREATE VIEW rpr_ev_real_vshape AS
 SELECT count(*) OVER w
 FROM rpr_nfa_complex
 WINDOW w AS (
@@ -2256,7 +2256,7 @@ WINDOW w AS (
     PATTERN (D+ U+)
     DEFINE D AS trend = 'D', U AS trend = 'U'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev43'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_real_vshape'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line        
 --------------------
    PATTERN (d+ u+) 
@@ -2286,7 +2286,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Stock price pattern - peak (up, stable, down)
-CREATE VIEW rpr_ev44 AS
+CREATE VIEW rpr_ev_real_peak AS
 SELECT count(*) OVER w
 FROM rpr_nfa_complex
 WINDOW w AS (
@@ -2295,7 +2295,7 @@ WINDOW w AS (
     PATTERN (U+ S* D+)
     DEFINE U AS trend = 'U', S AS trend = 'S', D AS trend = 'D'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev44'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_real_peak'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line          
 -----------------------
    PATTERN (u+ s* d+) 
@@ -2325,7 +2325,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Consecutive increasing values (using PREV)
-CREATE VIEW rpr_ev45 AS
+CREATE VIEW rpr_ev_real_increasing AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2334,7 +2334,7 @@ WINDOW w AS (
     PATTERN (A{3,})
     DEFINE A AS v > PREV(v) OR PREV(v) IS NULL
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev45'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_real_increasing'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line        
 --------------------
    PATTERN (a{3,}) 
@@ -2367,7 +2367,7 @@ WINDOW w AS (
 -- Performance-oriented Tests
 -- ============================================================
 -- Large dataset with simple pattern
-CREATE VIEW rpr_ev46 AS
+CREATE VIEW rpr_ev_perf_large_simple AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1000) AS s(v)
 WINDOW w AS (
@@ -2376,7 +2376,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev46'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_perf_large_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line       
 ------------------
    PATTERN (a b) 
@@ -2406,7 +2406,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Large dataset with absorption
-CREATE VIEW rpr_ev47 AS
+CREATE VIEW rpr_ev_perf_large_absorb AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1000) AS s(v)
 WINDOW w AS (
@@ -2415,7 +2415,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 100 <> 0, B AS v % 100 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev47'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_perf_large_absorb'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -2445,7 +2445,7 @@ WINDOW w AS (
 (9 rows)
 
 -- High state merge ratio
-CREATE VIEW rpr_ev48 AS
+CREATE VIEW rpr_ev_perf_high_merge AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -2454,7 +2454,7 @@ WINDOW w AS (
     PATTERN ((A | B)+ C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev48'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_perf_high_merge'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line           
 -------------------------
    PATTERN ((a | b)+ c) 
@@ -2487,7 +2487,7 @@ WINDOW w AS (
 -- INITIAL vs no INITIAL comparison
 -- ============================================================
 -- With INITIAL keyword
-CREATE VIEW rpr_ev49 AS
+CREATE VIEW rpr_ev_initial_with AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2497,7 +2497,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev49'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_initial_with'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -2528,7 +2528,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Without INITIAL keyword (same behavior currently)
-CREATE VIEW rpr_ev50 AS
+CREATE VIEW rpr_ev_initial_without AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2537,7 +2537,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev50'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_initial_without'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -2570,7 +2570,7 @@ WINDOW w AS (
 -- Quantifier Variations
 -- ============================================================
 -- Plus quantifier
-CREATE VIEW rpr_ev51 AS
+CREATE VIEW rpr_ev_quant_plus AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -2579,7 +2579,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS v % 4 <> 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev51'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_plus'), E'\n')) AS line WHERE line ~ 'PATTERN';
       line       
 -----------------
    PATTERN (a+) 
@@ -2609,7 +2609,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Star quantifier (zero or more)
-CREATE VIEW rpr_ev52 AS
+CREATE VIEW rpr_ev_quant_star AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -2618,7 +2618,7 @@ WINDOW w AS (
     PATTERN (A* B)
     DEFINE A AS v % 4 IN (1, 2), B AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev52'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_star'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a* b) 
@@ -2648,7 +2648,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Question mark (zero or one)
-CREATE VIEW rpr_ev53 AS
+CREATE VIEW rpr_ev_quant_question AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -2657,7 +2657,7 @@ WINDOW w AS (
     PATTERN (A? B C)
     DEFINE A AS v % 4 = 1, B AS v % 4 = 2, C AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev53'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_question'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN (a? b c) 
@@ -2687,7 +2687,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Exact count {n}
-CREATE VIEW rpr_ev54 AS
+CREATE VIEW rpr_ev_quant_exact AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2696,7 +2696,7 @@ WINDOW w AS (
     PATTERN (A{3} B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev54'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_exact'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN (a{3} b) 
@@ -2726,7 +2726,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Range {n,m}
-CREATE VIEW rpr_ev55 AS
+CREATE VIEW rpr_ev_quant_range AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2735,7 +2735,7 @@ WINDOW w AS (
     PATTERN (A{2,4} B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev55'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_range'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line          
 -----------------------
    PATTERN (a{2,4} b) 
@@ -2765,7 +2765,7 @@ WINDOW w AS (
 (9 rows)
 
 -- At least {n,}
-CREATE VIEW rpr_ev56 AS
+CREATE VIEW rpr_ev_quant_atleast AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2774,7 +2774,7 @@ WINDOW w AS (
     PATTERN (A{3,} B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev56'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_atleast'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line         
 ----------------------
    PATTERN (a{3,} b) 
@@ -2808,7 +2808,7 @@ WINDOW w AS (
 -- ============================================================
 -- Verify state count accuracy
 -- Pattern A+ B with 20 rows should show predictable state behavior
-CREATE VIEW rpr_ev57 AS
+CREATE VIEW rpr_ev_reg_state_count AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -2817,7 +2817,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev57'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_reg_state_count'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -2847,7 +2847,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Verify context count with known absorption
-CREATE VIEW rpr_ev58 AS
+CREATE VIEW rpr_ev_reg_ctx_absorb AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -2856,7 +2856,7 @@ WINDOW w AS (
     PATTERN (A+ B C)
     DEFINE A AS v % 10 IN (1,2,3,4,5,6,7), B AS v % 10 = 8, C AS v % 10 = 9
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev58'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_reg_ctx_absorb'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN (a+ b c) 
@@ -2886,7 +2886,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Verify match length with fixed-length pattern
-CREATE VIEW rpr_ev59 AS
+CREATE VIEW rpr_ev_reg_matchlen AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -2895,7 +2895,7 @@ WINDOW w AS (
     PATTERN (A B C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev59'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_reg_matchlen'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line        
 --------------------
    PATTERN (a b c) 
@@ -2928,7 +2928,7 @@ WINDOW w AS (
 -- Alternation Pattern Tests
 -- ============================================================
 -- Simple alternation
-CREATE VIEW rpr_ev60 AS
+CREATE VIEW rpr_ev_alt_simple AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -2937,7 +2937,7 @@ WINDOW w AS (
     PATTERN ((A | B) C)
     DEFINE A AS cat = 'A', B AS cat = 'B', C AS cat = 'C'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev60'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line          
 ------------------------
    PATTERN ((a | b) c) 
@@ -2967,7 +2967,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Multiple items in alternation
-CREATE VIEW rpr_ev61 AS
+CREATE VIEW rpr_ev_alt_multi_item AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -2978,7 +2978,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev61'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_multi_item'), E'\n')) AS line WHERE line ~ 'PATTERN';
               line              
 --------------------------------
    PATTERN ((a | b | c | d) e) 
@@ -3010,7 +3010,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Alternation with quantifiers
-CREATE VIEW rpr_ev62 AS
+CREATE VIEW rpr_ev_alt_with_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -3019,7 +3019,7 @@ WINDOW w AS (
     PATTERN ((A | B)+ C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev62'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_with_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line           
 -------------------------
    PATTERN ((a | b)+ c) 
@@ -3049,7 +3049,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Multiple alternatives (4+)
-CREATE VIEW rpr_ev63 AS
+CREATE VIEW rpr_ev_alt_four_plus AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -3057,7 +3057,7 @@ WINDOW w AS (
     PATTERN (A | B | C | D | E)
     DEFINE A AS v % 5 = 0, B AS v % 5 = 1, C AS v % 5 = 2, D AS v % 5 = 3, E AS v % 5 = 4
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev63'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_four_plus'), E'\n')) AS line WHERE line ~ 'PATTERN';
               line              
 --------------------------------
    PATTERN (a | b | c | d | e) 
@@ -3085,7 +3085,7 @@ WINDOW w AS (
 (8 rows)
 
 -- Alternation at start
-CREATE VIEW rpr_ev64 AS
+CREATE VIEW rpr_ev_alt_at_start AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -3093,7 +3093,7 @@ WINDOW w AS (
     PATTERN ((A | B) C D)
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev64'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_at_start'), E'\n')) AS line WHERE line ~ 'PATTERN';
            line           
 --------------------------
    PATTERN ((a | b) c d) 
@@ -3122,7 +3122,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Multiple sequential alternations
-CREATE VIEW rpr_ev65 AS
+CREATE VIEW rpr_ev_alt_sequential AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -3130,7 +3130,7 @@ WINDOW w AS (
     PATTERN ((A | B) C (D | E) F)
     DEFINE A AS v % 6 = 0, B AS v % 6 = 1, C AS v % 6 = 2, D AS v % 6 = 3, E AS v % 6 = 4, F AS v % 6 = 5
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev65'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_sequential'), E'\n')) AS line WHERE line ~ 'PATTERN';
                line               
 ----------------------------------
    PATTERN ((a | b) c (d | e) f) 
@@ -3158,7 +3158,7 @@ WINDOW w AS (
 (8 rows)
 
 -- Quantified alternatives
-CREATE VIEW rpr_ev66 AS
+CREATE VIEW rpr_ev_alt_quantified AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -3166,7 +3166,7 @@ WINDOW w AS (
     PATTERN ((A+ | B+) C)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev66'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_quantified'), E'\n')) AS line WHERE line ~ 'PATTERN';
            line           
 --------------------------
    PATTERN ((a+ | b+) c) 
@@ -3195,7 +3195,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Alternation at end
-CREATE VIEW rpr_ev67 AS
+CREATE VIEW rpr_ev_alt_at_end AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -3203,7 +3203,7 @@ WINDOW w AS (
     PATTERN (A B (C | D))
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev67'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_at_end'), E'\n')) AS line WHERE line ~ 'PATTERN';
            line           
 --------------------------
    PATTERN (a b (c | d)) 
@@ -3233,7 +3233,7 @@ WINDOW w AS (
 
 -- Nested ALT at start of branch inside outer ALT
 -- Pattern: (A ((B | C) D | E)) - preceding VAR + inner ALT as first branch element
-CREATE VIEW rpr_ev68 AS
+CREATE VIEW rpr_ev_alt_nested_start AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -3241,7 +3241,7 @@ WINDOW w AS (
     PATTERN (A ((B | C) D | E))
     DEFINE A AS v % 5 = 0, B AS v % 5 = 1, C AS v % 5 = 2, D AS v % 5 = 3, E AS v % 5 = 4
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev68'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_nested_start'), E'\n')) AS line WHERE line ~ 'PATTERN';
               line              
 --------------------------------
    PATTERN (a ((b | c) d | e)) 
@@ -3270,7 +3270,7 @@ WINDOW w AS (
 
 -- Nested ALT at end of branch inside outer ALT
 -- Pattern: (C (A | B) | D) - inner ALT is last element in outer branch
-CREATE VIEW rpr_ev69 AS
+CREATE VIEW rpr_ev_alt_nested_end AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -3278,7 +3278,7 @@ WINDOW w AS (
     PATTERN (C (A | B) | D)
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev69'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_nested_end'), E'\n')) AS line WHERE line ~ 'PATTERN';
             line            
 ----------------------------
    PATTERN (c (a | b) | d) 
@@ -3309,7 +3309,7 @@ WINDOW w AS (
 -- Group Pattern Tests
 -- ============================================================
 -- Simple group
-CREATE VIEW rpr_ev70 AS
+CREATE VIEW rpr_ev_grp_simple AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -3318,7 +3318,7 @@ WINDOW w AS (
     PATTERN ((A B)+)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev70'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN ((a b)+) 
@@ -3348,7 +3348,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Group with bounded quantifier
-CREATE VIEW rpr_ev71 AS
+CREATE VIEW rpr_ev_grp_bounded AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -3357,7 +3357,7 @@ WINDOW w AS (
     PATTERN ((A B){2,4})
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev71'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_bounded'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line           
 -------------------------
    PATTERN ((a b){2,4}) 
@@ -3387,7 +3387,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Nested groups
-CREATE VIEW rpr_ev72 AS
+CREATE VIEW rpr_ev_grp_nested AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -3396,7 +3396,7 @@ WINDOW w AS (
     PATTERN (((A B){2})+)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev72'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_nested'), E'\n')) AS line WHERE line ~ 'PATTERN';
            line           
 --------------------------
    PATTERN (((a b){2})+) 
@@ -3426,7 +3426,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Deep nesting (3+ levels)
-CREATE VIEW rpr_ev73 AS
+CREATE VIEW rpr_ev_grp_deep AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -3434,7 +3434,7 @@ WINDOW w AS (
     PATTERN ((((A | B)+)+)+)
     DEFINE A AS v % 2 = 0, B AS v % 2 = 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev73'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_deep'), E'\n')) AS line WHERE line ~ 'PATTERN';
             line             
 -----------------------------
    PATTERN ((((a | b)+)+)+) 
@@ -3463,7 +3463,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Bounded quantifier on alternation
-CREATE VIEW rpr_ev74 AS
+CREATE VIEW rpr_ev_grp_bounded_alt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -3471,7 +3471,7 @@ WINDOW w AS (
     PATTERN ((A | B){2,3} C)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev74'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_bounded_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
             line             
 -----------------------------
    PATTERN ((a | b){2,3} c) 
@@ -3500,7 +3500,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Nested groups with quantifiers
-CREATE VIEW rpr_ev75 AS
+CREATE VIEW rpr_ev_grp_nested_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -3508,7 +3508,7 @@ WINDOW w AS (
     PATTERN (((A B)+ C)*)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev75'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_nested_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
            line           
 --------------------------
    PATTERN (((a b)+ c)*) 
@@ -3537,7 +3537,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Partial nested quantification
-CREATE VIEW rpr_ev76 AS
+CREATE VIEW rpr_ev_grp_partial_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -3545,7 +3545,7 @@ WINDOW w AS (
     PATTERN ((A (B C)+)*)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev76'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_partial_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
            line           
 --------------------------
    PATTERN ((a (b c)+)*) 
@@ -3577,7 +3577,7 @@ WINDOW w AS (
 -- Window Function Combinations
 -- ============================================================
 -- count(*) with pattern
-CREATE VIEW rpr_ev77 AS
+CREATE VIEW rpr_ev_wfn_count AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -3586,7 +3586,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev77'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_count'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -3616,7 +3616,7 @@ WINDOW w AS (
 (9 rows)
 
 -- first_value with pattern
-CREATE VIEW rpr_ev78 AS
+CREATE VIEW rpr_ev_wfn_first_value AS
 SELECT first_value(v) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -3625,7 +3625,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev78'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_first_value'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -3655,7 +3655,7 @@ WINDOW w AS (
 (9 rows)
 
 -- last_value with pattern
-CREATE VIEW rpr_ev79 AS
+CREATE VIEW rpr_ev_wfn_last_value AS
 SELECT last_value(v) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -3664,7 +3664,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev79'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_last_value'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -3694,7 +3694,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Multiple window functions
-CREATE VIEW rpr_ev80 AS
+CREATE VIEW rpr_ev_wfn_multi AS
 SELECT
     count(*) OVER w,
     first_value(v) OVER w,
@@ -3706,7 +3706,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev80'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_multi'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -3742,7 +3742,7 @@ WINDOW w AS (
 -- DEFINE Expression Variations
 -- ============================================================
 -- Complex boolean expressions
-CREATE VIEW rpr_ev81 AS
+CREATE VIEW rpr_ev_def_complex_bool AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -3753,7 +3753,7 @@ WINDOW w AS (
         A AS (v % 5 <> 0) AND (v % 3 <> 0),
         B AS (v % 5 = 0) OR (v % 3 = 0)
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev81'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_def_complex_bool'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -3785,7 +3785,7 @@ WINDOW w AS (
 (9 rows)
 
 -- Using PREV function
-CREATE VIEW rpr_ev82 AS
+CREATE VIEW rpr_ev_def_prev AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -3797,7 +3797,7 @@ WINDOW w AS (
         U AS v > PREV(v),
         D AS v < PREV(v)
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev82'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_def_prev'), E'\n')) AS line WHERE line ~ 'PATTERN';
          line         
 ----------------------
    PATTERN (s u+ d+) 
@@ -3829,7 +3829,7 @@ WINDOW w AS (
 (8 rows)
 
 -- Using NULL comparisons
-CREATE VIEW rpr_ev83 AS
+CREATE VIEW rpr_ev_def_null AS
 SELECT count(*) OVER w
 FROM (
     SELECT CASE WHEN v % 5 = 0 THEN NULL ELSE v END AS v
@@ -3841,7 +3841,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v IS NOT NULL, B AS v IS NULL
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev83'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_def_null'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line        
 -------------------
    PATTERN (a+ b) 
@@ -3877,7 +3877,7 @@ WINDOW w AS (
 -- Large Scale Statistics Verification
 -- ============================================================
 -- 500 rows - verify statistics scale correctly
-CREATE VIEW rpr_ev84 AS
+CREATE VIEW rpr_ev_scale_500rows AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -3886,7 +3886,7 @@ WINDOW w AS (
     PATTERN (A+ B C)
     DEFINE A AS v % 10 < 7, B AS v % 10 = 7, C AS v % 10 = 8
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev84'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_scale_500rows'), E'\n')) AS line WHERE line ~ 'PATTERN';
         line         
 ---------------------
    PATTERN (a+ b c) 
@@ -3916,7 +3916,7 @@ WINDOW w AS (
 (9 rows)
 
 -- High match count scenario
-CREATE VIEW rpr_ev85 AS
+CREATE VIEW rpr_ev_scale_high_match AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -3925,7 +3925,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev85'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_scale_high_match'), E'\n')) AS line WHERE line ~ 'PATTERN';
        line       
 ------------------
    PATTERN (a b) 
@@ -3955,7 +3955,7 @@ WINDOW w AS (
 (9 rows)
 
 -- High skip count scenario
-CREATE VIEW rpr_ev86 AS
+CREATE VIEW rpr_ev_scale_high_skip AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -3969,7 +3969,7 @@ WINDOW w AS (
         D AS v % 100 = 4,
         E AS v % 100 = 5
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev86'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_scale_high_skip'), E'\n')) AS line WHERE line ~ 'PATTERN';
           line          
 ------------------------
    PATTERN (a b c d e) 
@@ -4013,17 +4013,17 @@ WINDOW w AS (
 -- Test with row_number() as representative case.
 --
 -- Without RPR: row_number() frame is optimized to ROWS UNBOUNDED PRECEDING
-CREATE VIEW rpr_ev87 AS
+CREATE VIEW rpr_ev_opt_no_rpr AS
 SELECT row_number() OVER w
 FROM generate_series(1, 10) AS s(v)
 WINDOW w AS (
     ORDER BY v
     ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
 );
-EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev87;
+EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_no_rpr;
                           QUERY PLAN                          
 --------------------------------------------------------------
- Subquery Scan on rpr_ev87
+ Subquery Scan on rpr_ev_opt_no_rpr
    ->  WindowAgg
          Window: w AS (ORDER BY s.v ROWS UNBOUNDED PRECEDING)
          ->  Sort
@@ -4032,7 +4032,7 @@ EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev87;
 (6 rows)
 
 -- With RPR: frame must remain ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
-CREATE VIEW rpr_ev88 AS
+CREATE VIEW rpr_ev_opt_with_rpr AS
 SELECT row_number() OVER w
 FROM generate_series(1, 10) AS s(v)
 WINDOW w AS (
@@ -4043,10 +4043,10 @@ WINDOW w AS (
     DEFINE
         B AS v > PREV(v)
 );
-EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev88;
+EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_with_rpr;
                                       QUERY PLAN                                      
 --------------------------------------------------------------------------------------
- Subquery Scan on rpr_ev88
+ Subquery Scan on rpr_ev_opt_with_rpr
    ->  WindowAgg
          Window: w AS (ORDER BY s.v ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
@@ -4059,7 +4059,7 @@ EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev88;
 -- Planner optimization: non-RPR and RPR windows that share the same base frame
 -- after frame optimization are kept as separate WindowAgg nodes.
 --
-CREATE VIEW rpr_ev89 AS
+CREATE VIEW rpr_ev_opt_mixed AS
 SELECT
     row_number() OVER w_normal AS rn_normal,
     row_number() OVER w_rpr AS rn_rpr
@@ -4072,10 +4072,10 @@ WINDOW
         PATTERN (A+)
         DEFINE A AS v > 1
     );
-EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev89;
+EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_mixed;
                                         QUERY PLAN                                        
 ------------------------------------------------------------------------------------------
- Subquery Scan on rpr_ev89
+ Subquery Scan on rpr_ev_opt_mixed
    ->  WindowAgg
          Window: w_rpr AS (ORDER BY s.v ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a+"
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index 237f0366631..65a775fdad9 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -118,7 +118,7 @@ VALUES
 -- ============================================================
 
 -- Simple pattern - should show basic statistics
-CREATE VIEW rpr_ev01 AS
+CREATE VIEW rpr_ev_basic_simple AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -127,7 +127,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS cat = 'A', B AS cat = 'B'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev01'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -140,7 +140,7 @@ WINDOW w AS (
 )');
 
 -- Pattern with no matches - 0 matched
-CREATE VIEW rpr_ev02 AS
+CREATE VIEW rpr_ev_basic_nomatch AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -149,7 +149,7 @@ WINDOW w AS (
     PATTERN (X Y Z)
     DEFINE X AS cat = 'X', Y AS cat = 'Y', Z AS cat = 'Z'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev02'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_nomatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -162,7 +162,7 @@ WINDOW w AS (
 );');
 
 -- Pattern matching every row - high match count
-CREATE VIEW rpr_ev03 AS
+CREATE VIEW rpr_ev_basic_allrows AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -171,7 +171,7 @@ WINDOW w AS (
     PATTERN (R)
     DEFINE R AS TRUE
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev03'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_allrows'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -185,7 +185,7 @@ WINDOW w AS (
 
 -- Regression test: Space before parenthesis in pattern deparse
 -- Verifies that "A (B | C)" correctly outputs as "a (b | c)" with space
-CREATE VIEW rpr_ev04 AS
+CREATE VIEW rpr_ev_basic_deparse_space AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -193,7 +193,7 @@ WINDOW w AS (
     PATTERN (A (B | C))
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev04'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_deparse_space'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -207,7 +207,7 @@ WINDOW w AS (
 -- Regression test: Sequential alternations at same depth
 -- Verifies that "((B | C) (D | E))" correctly outputs as "(b | c) (d | e)"
 -- Previously failed due to missing parentheses on ALT depth decrease
-CREATE VIEW rpr_ev05 AS
+CREATE VIEW rpr_ev_basic_deparse_seqalt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -215,7 +215,7 @@ WINDOW w AS (
     PATTERN (A ((B | C) (D | E))*)
     DEFINE A AS v % 5 = 1, B AS v % 5 = 2, C AS v % 5 = 3, D AS v % 5 = 4, E AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev05'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_basic_deparse_seqalt'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -231,7 +231,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Simple quantifier pattern - A+ with short matches (no merging)
-CREATE VIEW rpr_ev06 AS
+CREATE VIEW rpr_ev_state_simple_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -240,7 +240,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS v % 2 = 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev06'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_simple_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -253,7 +253,7 @@ WINDOW w AS (
 );');
 
 -- Alternation pattern - multiple state branches
-CREATE VIEW rpr_ev07 AS
+CREATE VIEW rpr_ev_state_alt AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -264,7 +264,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev07'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -279,7 +279,7 @@ WINDOW w AS (
 );');
 
 -- Complex pattern with high state count
-CREATE VIEW rpr_ev08 AS
+CREATE VIEW rpr_ev_state_complex AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -291,7 +291,7 @@ WINDOW w AS (
         B AS v % 3 = 2,
         C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev08'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_complex'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -307,7 +307,7 @@ WINDOW w AS (
 );');
 
 -- Grouped pattern with quantifier - state count with grouping
-CREATE VIEW rpr_ev09 AS
+CREATE VIEW rpr_ev_state_group_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -316,7 +316,7 @@ WINDOW w AS (
     PATTERN ((A B)+)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev09'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_group_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -330,7 +330,7 @@ WINDOW w AS (
 
 -- State explosion pattern - many alternations
 -- Pattern (A|B)(A|B)(A|B)(A|B) can create many parallel states
-CREATE VIEW rpr_ev10 AS
+CREATE VIEW rpr_ev_state_explosion AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -339,7 +339,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B))
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev10'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_explosion'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -353,7 +353,7 @@ WINDOW w AS (
 
 -- Consecutive ALT merge followed by different ALT
 -- Tests mergeConsecutiveAlts flush on ALT change: (A|B){2} (C|D)
-CREATE VIEW rpr_ev11 AS
+CREATE VIEW rpr_ev_state_alt_merge_alt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -362,7 +362,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) (C | D))
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev11'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_merge_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -376,7 +376,7 @@ WINDOW w AS (
 
 -- Consecutive ALT merge followed by non-ALT element
 -- Tests mergeConsecutiveAlts flush on non-ALT: (A|B){2} c
-CREATE VIEW rpr_ev12 AS
+CREATE VIEW rpr_ev_state_alt_merge_nonalt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -385,7 +385,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) C)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev12'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_merge_nonalt'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -398,7 +398,7 @@ WINDOW w AS (
 );');
 
 -- ALT prefix/suffix absorbed into GROUP: (A|B) (A|B)+ (A|B) -> (A|B){3,}
-CREATE VIEW rpr_ev13 AS
+CREATE VIEW rpr_ev_state_alt_absorb_group AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -407,7 +407,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B)+ (A | B))
     DEFINE A AS v % 2 = 0, B AS v % 2 = 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev13'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_absorb_group'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -420,7 +420,7 @@ WINDOW w AS (
 );');
 
 -- High state count - alternation with plus quantifier
-CREATE VIEW rpr_ev14 AS
+CREATE VIEW rpr_ev_state_alt_plus AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -429,7 +429,7 @@ WINDOW w AS (
     PATTERN ((A | B | C)+ D)
     DEFINE A AS v % 4 = 1, B AS v % 4 = 2, C AS v % 4 = 3, D AS v % 4 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev14'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_plus'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -443,7 +443,7 @@ WINDOW w AS (
 
 -- Early termination: first ALT branch (A) reaches FIN immediately,
 -- pruning second branch (A B+) before it can accumulate B repetitions.
-CREATE VIEW rpr_ev15 AS
+CREATE VIEW rpr_ev_state_alt_prune AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -452,7 +452,7 @@ WINDOW w AS (
     PATTERN ((A | A B)+)
     DEFINE A AS v = 1, B AS v > 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev15'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_alt_prune'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -465,7 +465,7 @@ WINDOW w AS (
 );');
 
 -- Nested quantifiers causing state growth
-CREATE VIEW rpr_ev16 AS
+CREATE VIEW rpr_ev_state_nested_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1000) AS s(v)
 WINDOW w AS (
@@ -474,7 +474,7 @@ WINDOW w AS (
     PATTERN (((A | B)+)+)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev16'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_state_nested_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -491,7 +491,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Context absorption with unbounded quantifier at start
-CREATE VIEW rpr_ev17 AS
+CREATE VIEW rpr_ev_ctx_absorb_unbounded AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -500,7 +500,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev17'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_unbounded'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -513,7 +513,7 @@ WINDOW w AS (
 );');
 
 -- No absorption - bounded quantifier
-CREATE VIEW rpr_ev18 AS
+CREATE VIEW rpr_ev_ctx_no_absorb AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -522,7 +522,7 @@ WINDOW w AS (
     PATTERN (A{2,4} B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev18'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_no_absorb'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -535,7 +535,7 @@ WINDOW w AS (
 );');
 
 -- Contexts skipped by SKIP PAST LAST ROW
-CREATE VIEW rpr_ev19 AS
+CREATE VIEW rpr_ev_ctx_skip AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -544,7 +544,7 @@ WINDOW w AS (
     PATTERN (A B C)
     DEFINE A AS v % 10 = 1, B AS v % 10 = 2, C AS v % 10 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev19'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_skip'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -557,7 +557,7 @@ WINDOW w AS (
 );');
 
 -- High context absorption - unbounded group
-CREATE VIEW rpr_ev20 AS
+CREATE VIEW rpr_ev_ctx_absorb_group AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -566,7 +566,7 @@ WINDOW w AS (
     PATTERN ((A B)+ C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_group'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -581,7 +581,7 @@ WINDOW w AS (
 -- Fixed-length group absorption: (A B B)+ C
 -- B B merged to B{2}; absorbable with fixed-length check
 -- step_size=3 (A + B + B); v % 7 cycle gives 2 iterations per match
-CREATE VIEW rpr_ev20b AS
+CREATE VIEW rpr_ev_ctx_absorb_fixedvar AS
 SELECT count(*) OVER w
 FROM generate_series(1, 70) AS s(v)
 WINDOW w AS (
@@ -590,7 +590,7 @@ WINDOW w AS (
     PATTERN ((A B B)+ C)
     DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20b'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_fixedvar'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -604,7 +604,7 @@ WINDOW w AS (
 
 -- Nested fixed-length group absorption: (A (B C){2} D)+ E
 -- step_size = 1 + (1+1)*2 + 1 = 6; v % 13 cycle gives 2 iterations + E
-CREATE VIEW rpr_ev20c AS
+CREATE VIEW rpr_ev_ctx_absorb_nested AS
 SELECT count(*) OVER w
 FROM generate_series(1, 65) AS s(v)
 WINDOW w AS (
@@ -615,7 +615,7 @@ WINDOW w AS (
            C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
            E AS v % 13 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20c'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_nested'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -631,7 +631,7 @@ WINDOW w AS (
 
 -- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
 -- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; v % 41 cycle gives 2 iterations + F
-CREATE VIEW rpr_ev20d AS
+CREATE VIEW rpr_ev_ctx_absorb_deep AS
 SELECT count(*) OVER w
 FROM generate_series(1, 82) AS s(v)
 WINDOW w AS (
@@ -646,7 +646,7 @@ WINDOW w AS (
            E AS v % 41 IN (20, 40),
            F AS v % 41 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20d'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_deep'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -667,7 +667,7 @@ WINDOW w AS (
 -- 3-level END chain absorption: ((A (B C){2}){2})+
 -- step_size = (1 + (1+1)*2) * 2 = 10; v % 21 cycle gives 2 iterations
 -- END chain: END(BC{2}) -> END(A..{2}) -> END(+, ABSORBABLE)
-CREATE VIEW rpr_ev20e AS
+CREATE VIEW rpr_ev_ctx_absorb_endchain AS
 SELECT count(*) OVER w
 FROM generate_series(1, 42) AS s(v)
 WINDOW w AS (
@@ -678,7 +678,7 @@ WINDOW w AS (
            B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
            C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20e'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_endchain'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -697,7 +697,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Fixed length matches - all same length
-CREATE VIEW rpr_ev21 AS
+CREATE VIEW rpr_ev_mlen_fixed AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -708,7 +708,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev21'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_fixed'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -723,7 +723,7 @@ WINDOW w AS (
 );');
 
 -- Variable length matches - min/max/avg differ
-CREATE VIEW rpr_ev22 AS
+CREATE VIEW rpr_ev_mlen_variable AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -732,7 +732,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev22'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_variable'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -745,7 +745,7 @@ WINDOW w AS (
 );');
 
 -- Very long matches
-CREATE VIEW rpr_ev23 AS
+CREATE VIEW rpr_ev_mlen_long AS
 SELECT count(*) OVER w
 FROM generate_series(1, 200) AS s(v)
 WINDOW w AS (
@@ -754,7 +754,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v <= 195, B AS v > 195
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev23'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_long'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -767,7 +767,7 @@ WINDOW w AS (
 );');
 
 -- Uniform match length with mismatches from gap rows (v%20 = 11..15)
-CREATE VIEW rpr_ev24 AS
+CREATE VIEW rpr_ev_mlen_with_mismatch AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -778,7 +778,7 @@ WINDOW w AS (
         A AS (v % 20 <> 0) AND (v % 20 <= 10 OR v % 20 > 15),
         B AS v % 20 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev24'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_with_mismatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -798,7 +798,7 @@ WINDOW w AS (
 
 -- Pattern with complete match every cycle: 0 mismatched
 -- A(1,2,3) B(4,5) C(6) repeats perfectly; X rows are pruned, not mismatched
-CREATE VIEW rpr_ev25 AS
+CREATE VIEW rpr_ev_mlen_no_mismatch AS
 SELECT count(*) OVER w
 FROM (
     SELECT v,
@@ -814,7 +814,7 @@ WINDOW w AS (
     PATTERN (A+ B+ C)
     DEFINE A AS cat = 'A', B AS cat = 'B', C AS cat = 'C'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev25'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_no_mismatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -834,7 +834,7 @@ WINDOW w AS (
 );');
 
 -- Long partial matches that fail
-CREATE VIEW rpr_ev26 AS
+CREATE VIEW rpr_ev_mlen_long_partial AS
 SELECT count(*) OVER w
 FROM (
     SELECT i AS v,
@@ -855,7 +855,7 @@ WINDOW w AS (
     PATTERN (A+ B+ C)
     DEFINE A AS cat = 'A', B AS cat = 'B', C AS cat = 'C'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev26'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_mlen_long_partial'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -884,7 +884,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- JSON format output with all statistics
-CREATE VIEW rpr_ev27 AS
+CREATE VIEW rpr_ev_json_basic AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -893,7 +893,7 @@ WINDOW w AS (
     PATTERN (A+ B+)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev27'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_basic'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF, FORMAT JSON)
 SELECT count(*) OVER w
@@ -906,7 +906,7 @@ WINDOW w AS (
 )');
 
 -- JSON format with match length statistics
-CREATE VIEW rpr_ev28 AS
+CREATE VIEW rpr_ev_json_matchlen AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -915,7 +915,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev28'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_matchlen'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF, FORMAT JSON)
 SELECT count(*) OVER w
@@ -929,7 +929,7 @@ WINDOW w AS (
 
 -- JSON format with mismatch statistics
 -- Pattern A B C expects 1,2,3 but gets 1,2,4 twice causing mismatches
-CREATE VIEW rpr_ev29 AS
+CREATE VIEW rpr_ev_json_mismatch AS
 SELECT count(*) OVER w
 FROM (VALUES (1),(2),(4), (1),(2),(4), (1),(2),(3)) AS t(v)
 WINDOW w AS (
@@ -938,7 +938,7 @@ WINDOW w AS (
     PATTERN (A B C)
     DEFINE A AS v = 1, B AS v = 2, C AS v = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev29'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_mismatch'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF, FORMAT JSON)
 SELECT count(*) OVER w
@@ -952,7 +952,7 @@ WINDOW w AS (
 
 -- JSON format with skipped context statistics
 -- Alternation pattern with SKIP PAST LAST ROW causes many contexts to be skipped
-CREATE VIEW rpr_ev30 AS
+CREATE VIEW rpr_ev_json_skip AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -961,7 +961,7 @@ WINDOW w AS (
     PATTERN ((A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B) (A | B))
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev30'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_json_skip'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF, FORMAT JSON)
 SELECT count(*) OVER w
@@ -978,7 +978,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- XML format output
-CREATE VIEW rpr_ev31 AS
+CREATE VIEW rpr_ev_xml_basic AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -987,7 +987,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev31'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_xml_basic'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF, FORMAT XML)
 SELECT count(*) OVER w
@@ -1004,7 +1004,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Statistics across multiple partitions
-CREATE VIEW rpr_ev32 AS
+CREATE VIEW rpr_ev_part_multi AS
 SELECT count(*) OVER w
 FROM (
     SELECT p, v
@@ -1018,7 +1018,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev32'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_part_multi'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1036,7 +1036,7 @@ WINDOW w AS (
 );');
 
 -- Different pattern behavior per partition
-CREATE VIEW rpr_ev33 AS
+CREATE VIEW rpr_ev_part_diff AS
 SELECT count(*) OVER w
 FROM (
     SELECT
@@ -1051,7 +1051,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS val < 5, B AS val >= 5
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev33'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_part_diff'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1074,7 +1074,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Empty result set
-CREATE VIEW rpr_ev34 AS
+CREATE VIEW rpr_ev_edge_empty AS
 SELECT count(*) OVER w
 FROM generate_series(1, 0) AS s(v)
 WINDOW w AS (
@@ -1083,7 +1083,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v = 1, B AS v = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev34'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_empty'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1096,7 +1096,7 @@ WINDOW w AS (
 );');
 
 -- Single row
-CREATE VIEW rpr_ev35 AS
+CREATE VIEW rpr_ev_edge_single_row AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1) AS s(v)
 WINDOW w AS (
@@ -1105,7 +1105,7 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS TRUE
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev35'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_single_row'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1118,7 +1118,7 @@ WINDOW w AS (
 );');
 
 -- Pattern longer than data
-CREATE VIEW rpr_ev36 AS
+CREATE VIEW rpr_ev_edge_pattern_longer AS
 SELECT count(*) OVER w
 FROM generate_series(1, 5) AS s(v)
 WINDOW w AS (
@@ -1129,7 +1129,7 @@ WINDOW w AS (
         A AS v = 1, B AS v = 2, C AS v = 3, D AS v = 4, E AS v = 5,
         F AS v = 6, G AS v = 7, H AS v = 8, I AS v = 9, J AS v = 10
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev36'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_pattern_longer'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1144,7 +1144,7 @@ WINDOW w AS (
 );');
 
 -- All rows match as single match
-CREATE VIEW rpr_ev37 AS
+CREATE VIEW rpr_ev_edge_single_match AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1153,7 +1153,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS TRUE
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev37'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_edge_single_match'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1170,7 +1170,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Nested groups
-CREATE VIEW rpr_ev38 AS
+CREATE VIEW rpr_ev_cpx_nested AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -1179,7 +1179,7 @@ WINDOW w AS (
     PATTERN (((A B) C)+)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev38'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_nested'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1192,7 +1192,7 @@ WINDOW w AS (
 );');
 
 -- Multiple alternations
-CREATE VIEW rpr_ev39 AS
+CREATE VIEW rpr_ev_cpx_multi_alt AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -1203,7 +1203,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev39'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_multi_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1218,7 +1218,7 @@ WINDOW w AS (
 );');
 
 -- Optional elements
-CREATE VIEW rpr_ev40 AS
+CREATE VIEW rpr_ev_cpx_optional AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1227,7 +1227,7 @@ WINDOW w AS (
     PATTERN (A B? C)
     DEFINE A AS v % 4 = 1, B AS v % 4 = 2, C AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev40'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_optional'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1240,7 +1240,7 @@ WINDOW w AS (
 );');
 
 -- Bounded quantifiers
-CREATE VIEW rpr_ev41 AS
+CREATE VIEW rpr_ev_cpx_bounded AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -1249,7 +1249,7 @@ WINDOW w AS (
     PATTERN (A{2,5} B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev41'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_bounded'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1262,7 +1262,7 @@ WINDOW w AS (
 );');
 
 -- Star quantifier
-CREATE VIEW rpr_ev42 AS
+CREATE VIEW rpr_ev_cpx_star AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1271,7 +1271,7 @@ WINDOW w AS (
     PATTERN (A B* C)
     DEFINE A AS v % 10 = 1, B AS v % 10 IN (2,3,4,5,6,7,8), C AS v % 10 = 9
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev42'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_cpx_star'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1288,7 +1288,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Stock price pattern - V-shape (down then up)
-CREATE VIEW rpr_ev43 AS
+CREATE VIEW rpr_ev_real_vshape AS
 SELECT count(*) OVER w
 FROM rpr_nfa_complex
 WINDOW w AS (
@@ -1297,7 +1297,7 @@ WINDOW w AS (
     PATTERN (D+ U+)
     DEFINE D AS trend = 'D', U AS trend = 'U'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev43'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_real_vshape'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1310,7 +1310,7 @@ WINDOW w AS (
 );');
 
 -- Stock price pattern - peak (up, stable, down)
-CREATE VIEW rpr_ev44 AS
+CREATE VIEW rpr_ev_real_peak AS
 SELECT count(*) OVER w
 FROM rpr_nfa_complex
 WINDOW w AS (
@@ -1319,7 +1319,7 @@ WINDOW w AS (
     PATTERN (U+ S* D+)
     DEFINE U AS trend = 'U', S AS trend = 'S', D AS trend = 'D'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev44'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_real_peak'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1332,7 +1332,7 @@ WINDOW w AS (
 );');
 
 -- Consecutive increasing values (using PREV)
-CREATE VIEW rpr_ev45 AS
+CREATE VIEW rpr_ev_real_increasing AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1341,7 +1341,7 @@ WINDOW w AS (
     PATTERN (A{3,})
     DEFINE A AS v > PREV(v) OR PREV(v) IS NULL
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev45'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_real_increasing'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1358,7 +1358,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Large dataset with simple pattern
-CREATE VIEW rpr_ev46 AS
+CREATE VIEW rpr_ev_perf_large_simple AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1000) AS s(v)
 WINDOW w AS (
@@ -1367,7 +1367,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev46'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_perf_large_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1380,7 +1380,7 @@ WINDOW w AS (
 );');
 
 -- Large dataset with absorption
-CREATE VIEW rpr_ev47 AS
+CREATE VIEW rpr_ev_perf_large_absorb AS
 SELECT count(*) OVER w
 FROM generate_series(1, 1000) AS s(v)
 WINDOW w AS (
@@ -1389,7 +1389,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 100 <> 0, B AS v % 100 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev47'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_perf_large_absorb'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1402,7 +1402,7 @@ WINDOW w AS (
 );');
 
 -- High state merge ratio
-CREATE VIEW rpr_ev48 AS
+CREATE VIEW rpr_ev_perf_high_merge AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -1411,7 +1411,7 @@ WINDOW w AS (
     PATTERN ((A | B)+ C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev48'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_perf_high_merge'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1428,7 +1428,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- With INITIAL keyword
-CREATE VIEW rpr_ev49 AS
+CREATE VIEW rpr_ev_initial_with AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1438,7 +1438,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev49'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_initial_with'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1452,7 +1452,7 @@ WINDOW w AS (
 );');
 
 -- Without INITIAL keyword (same behavior currently)
-CREATE VIEW rpr_ev50 AS
+CREATE VIEW rpr_ev_initial_without AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1461,7 +1461,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev50'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_initial_without'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1478,7 +1478,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Plus quantifier
-CREATE VIEW rpr_ev51 AS
+CREATE VIEW rpr_ev_quant_plus AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -1487,7 +1487,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS v % 4 <> 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev51'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_plus'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1500,7 +1500,7 @@ WINDOW w AS (
 );');
 
 -- Star quantifier (zero or more)
-CREATE VIEW rpr_ev52 AS
+CREATE VIEW rpr_ev_quant_star AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -1509,7 +1509,7 @@ WINDOW w AS (
     PATTERN (A* B)
     DEFINE A AS v % 4 IN (1, 2), B AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev52'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_star'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1522,7 +1522,7 @@ WINDOW w AS (
 );');
 
 -- Question mark (zero or one)
-CREATE VIEW rpr_ev53 AS
+CREATE VIEW rpr_ev_quant_question AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -1531,7 +1531,7 @@ WINDOW w AS (
     PATTERN (A? B C)
     DEFINE A AS v % 4 = 1, B AS v % 4 = 2, C AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev53'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_question'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1544,7 +1544,7 @@ WINDOW w AS (
 );');
 
 -- Exact count {n}
-CREATE VIEW rpr_ev54 AS
+CREATE VIEW rpr_ev_quant_exact AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1553,7 +1553,7 @@ WINDOW w AS (
     PATTERN (A{3} B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev54'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_exact'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1566,7 +1566,7 @@ WINDOW w AS (
 );');
 
 -- Range {n,m}
-CREATE VIEW rpr_ev55 AS
+CREATE VIEW rpr_ev_quant_range AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1575,7 +1575,7 @@ WINDOW w AS (
     PATTERN (A{2,4} B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev55'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_range'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1588,7 +1588,7 @@ WINDOW w AS (
 );');
 
 -- At least {n,}
-CREATE VIEW rpr_ev56 AS
+CREATE VIEW rpr_ev_quant_atleast AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1597,7 +1597,7 @@ WINDOW w AS (
     PATTERN (A{3,} B)
     DEFINE A AS v % 10 <> 0, B AS v % 10 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev56'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_quant_atleast'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1615,7 +1615,7 @@ WINDOW w AS (
 
 -- Verify state count accuracy
 -- Pattern A+ B with 20 rows should show predictable state behavior
-CREATE VIEW rpr_ev57 AS
+CREATE VIEW rpr_ev_reg_state_count AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -1624,7 +1624,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev57'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_reg_state_count'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1637,7 +1637,7 @@ WINDOW w AS (
 );');
 
 -- Verify context count with known absorption
-CREATE VIEW rpr_ev58 AS
+CREATE VIEW rpr_ev_reg_ctx_absorb AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -1646,7 +1646,7 @@ WINDOW w AS (
     PATTERN (A+ B C)
     DEFINE A AS v % 10 IN (1,2,3,4,5,6,7), B AS v % 10 = 8, C AS v % 10 = 9
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev58'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_reg_ctx_absorb'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1659,7 +1659,7 @@ WINDOW w AS (
 );');
 
 -- Verify match length with fixed-length pattern
-CREATE VIEW rpr_ev59 AS
+CREATE VIEW rpr_ev_reg_matchlen AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -1668,7 +1668,7 @@ WINDOW w AS (
     PATTERN (A B C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev59'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_reg_matchlen'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1685,7 +1685,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Simple alternation
-CREATE VIEW rpr_ev60 AS
+CREATE VIEW rpr_ev_alt_simple AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -1694,7 +1694,7 @@ WINDOW w AS (
     PATTERN ((A | B) C)
     DEFINE A AS cat = 'A', B AS cat = 'B', C AS cat = 'C'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev60'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1707,7 +1707,7 @@ WINDOW w AS (
 );');
 
 -- Multiple items in alternation
-CREATE VIEW rpr_ev61 AS
+CREATE VIEW rpr_ev_alt_multi_item AS
 SELECT count(*) OVER w
 FROM rpr_nfa_test
 WINDOW w AS (
@@ -1718,7 +1718,7 @@ WINDOW w AS (
         A AS cat = 'A', B AS cat = 'B', C AS cat = 'C',
         D AS cat = 'D', E AS cat = 'E'
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev61'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_multi_item'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1733,7 +1733,7 @@ WINDOW w AS (
 );');
 
 -- Alternation with quantifiers
-CREATE VIEW rpr_ev62 AS
+CREATE VIEW rpr_ev_alt_with_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -1742,7 +1742,7 @@ WINDOW w AS (
     PATTERN ((A | B)+ C)
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev62'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_with_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1755,7 +1755,7 @@ WINDOW w AS (
 );');
 
 -- Multiple alternatives (4+)
-CREATE VIEW rpr_ev63 AS
+CREATE VIEW rpr_ev_alt_four_plus AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -1763,7 +1763,7 @@ WINDOW w AS (
     PATTERN (A | B | C | D | E)
     DEFINE A AS v % 5 = 0, B AS v % 5 = 1, C AS v % 5 = 2, D AS v % 5 = 3, E AS v % 5 = 4
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev63'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_four_plus'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1775,7 +1775,7 @@ WINDOW w AS (
 );');
 
 -- Alternation at start
-CREATE VIEW rpr_ev64 AS
+CREATE VIEW rpr_ev_alt_at_start AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -1783,7 +1783,7 @@ WINDOW w AS (
     PATTERN ((A | B) C D)
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev64'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_at_start'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1795,7 +1795,7 @@ WINDOW w AS (
 );');
 
 -- Multiple sequential alternations
-CREATE VIEW rpr_ev65 AS
+CREATE VIEW rpr_ev_alt_sequential AS
 SELECT count(*) OVER w
 FROM generate_series(1, 100) AS s(v)
 WINDOW w AS (
@@ -1803,7 +1803,7 @@ WINDOW w AS (
     PATTERN ((A | B) C (D | E) F)
     DEFINE A AS v % 6 = 0, B AS v % 6 = 1, C AS v % 6 = 2, D AS v % 6 = 3, E AS v % 6 = 4, F AS v % 6 = 5
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev65'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_sequential'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1815,7 +1815,7 @@ WINDOW w AS (
 );');
 
 -- Quantified alternatives
-CREATE VIEW rpr_ev66 AS
+CREATE VIEW rpr_ev_alt_quantified AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -1823,7 +1823,7 @@ WINDOW w AS (
     PATTERN ((A+ | B+) C)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev66'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_quantified'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1835,7 +1835,7 @@ WINDOW w AS (
 );');
 
 -- Alternation at end
-CREATE VIEW rpr_ev67 AS
+CREATE VIEW rpr_ev_alt_at_end AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -1843,7 +1843,7 @@ WINDOW w AS (
     PATTERN (A B (C | D))
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev67'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_at_end'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1856,7 +1856,7 @@ WINDOW w AS (
 
 -- Nested ALT at start of branch inside outer ALT
 -- Pattern: (A ((B | C) D | E)) - preceding VAR + inner ALT as first branch element
-CREATE VIEW rpr_ev68 AS
+CREATE VIEW rpr_ev_alt_nested_start AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -1864,7 +1864,7 @@ WINDOW w AS (
     PATTERN (A ((B | C) D | E))
     DEFINE A AS v % 5 = 0, B AS v % 5 = 1, C AS v % 5 = 2, D AS v % 5 = 3, E AS v % 5 = 4
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev68'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_nested_start'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1877,7 +1877,7 @@ WINDOW w AS (
 
 -- Nested ALT at end of branch inside outer ALT
 -- Pattern: (C (A | B) | D) - inner ALT is last element in outer branch
-CREATE VIEW rpr_ev69 AS
+CREATE VIEW rpr_ev_alt_nested_end AS
 SELECT count(*) OVER w
 FROM generate_series(1, 20) AS s(v)
 WINDOW w AS (
@@ -1885,7 +1885,7 @@ WINDOW w AS (
     PATTERN (C (A | B) | D)
     DEFINE A AS v % 4 = 0, B AS v % 4 = 1, C AS v % 4 = 2, D AS v % 4 = 3
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev69'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_alt_nested_end'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1901,7 +1901,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Simple group
-CREATE VIEW rpr_ev70 AS
+CREATE VIEW rpr_ev_grp_simple AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -1910,7 +1910,7 @@ WINDOW w AS (
     PATTERN ((A B)+)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev70'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_simple'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1923,7 +1923,7 @@ WINDOW w AS (
 );');
 
 -- Group with bounded quantifier
-CREATE VIEW rpr_ev71 AS
+CREATE VIEW rpr_ev_grp_bounded AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -1932,7 +1932,7 @@ WINDOW w AS (
     PATTERN ((A B){2,4})
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev71'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_bounded'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1945,7 +1945,7 @@ WINDOW w AS (
 );');
 
 -- Nested groups
-CREATE VIEW rpr_ev72 AS
+CREATE VIEW rpr_ev_grp_nested AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -1954,7 +1954,7 @@ WINDOW w AS (
     PATTERN (((A B){2})+)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev72'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_nested'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1967,7 +1967,7 @@ WINDOW w AS (
 );');
 
 -- Deep nesting (3+ levels)
-CREATE VIEW rpr_ev73 AS
+CREATE VIEW rpr_ev_grp_deep AS
 SELECT count(*) OVER w
 FROM generate_series(1, 40) AS s(v)
 WINDOW w AS (
@@ -1975,7 +1975,7 @@ WINDOW w AS (
     PATTERN ((((A | B)+)+)+)
     DEFINE A AS v % 2 = 0, B AS v % 2 = 1
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev73'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_deep'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -1987,7 +1987,7 @@ WINDOW w AS (
 );');
 
 -- Bounded quantifier on alternation
-CREATE VIEW rpr_ev74 AS
+CREATE VIEW rpr_ev_grp_bounded_alt AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -1995,7 +1995,7 @@ WINDOW w AS (
     PATTERN ((A | B){2,3} C)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev74'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_bounded_alt'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2007,7 +2007,7 @@ WINDOW w AS (
 );');
 
 -- Nested groups with quantifiers
-CREATE VIEW rpr_ev75 AS
+CREATE VIEW rpr_ev_grp_nested_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -2015,7 +2015,7 @@ WINDOW w AS (
     PATTERN (((A B)+ C)*)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev75'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_nested_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2027,7 +2027,7 @@ WINDOW w AS (
 );');
 
 -- Partial nested quantification
-CREATE VIEW rpr_ev76 AS
+CREATE VIEW rpr_ev_grp_partial_quant AS
 SELECT count(*) OVER w
 FROM generate_series(1, 60) AS s(v)
 WINDOW w AS (
@@ -2035,7 +2035,7 @@ WINDOW w AS (
     PATTERN ((A (B C)+)*)
     DEFINE A AS v % 3 = 0, B AS v % 3 = 1, C AS v % 3 = 2
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev76'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_grp_partial_quant'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2051,7 +2051,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- count(*) with pattern
-CREATE VIEW rpr_ev77 AS
+CREATE VIEW rpr_ev_wfn_count AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -2060,7 +2060,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev77'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_count'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2073,7 +2073,7 @@ WINDOW w AS (
 );');
 
 -- first_value with pattern
-CREATE VIEW rpr_ev78 AS
+CREATE VIEW rpr_ev_wfn_first_value AS
 SELECT first_value(v) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -2082,7 +2082,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev78'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_first_value'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT first_value(v) OVER w
@@ -2095,7 +2095,7 @@ WINDOW w AS (
 );');
 
 -- last_value with pattern
-CREATE VIEW rpr_ev79 AS
+CREATE VIEW rpr_ev_wfn_last_value AS
 SELECT last_value(v) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -2104,7 +2104,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev79'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_last_value'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT last_value(v) OVER w
@@ -2117,7 +2117,7 @@ WINDOW w AS (
 );');
 
 -- Multiple window functions
-CREATE VIEW rpr_ev80 AS
+CREATE VIEW rpr_ev_wfn_multi AS
 SELECT
     count(*) OVER w,
     first_value(v) OVER w,
@@ -2129,7 +2129,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v % 5 <> 0, B AS v % 5 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev80'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_wfn_multi'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT
@@ -2149,7 +2149,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- Complex boolean expressions
-CREATE VIEW rpr_ev81 AS
+CREATE VIEW rpr_ev_def_complex_bool AS
 SELECT count(*) OVER w
 FROM generate_series(1, 50) AS s(v)
 WINDOW w AS (
@@ -2160,7 +2160,7 @@ WINDOW w AS (
         A AS (v % 5 <> 0) AND (v % 3 <> 0),
         B AS (v % 5 = 0) OR (v % 3 = 0)
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev81'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_def_complex_bool'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2175,7 +2175,7 @@ WINDOW w AS (
 );');
 
 -- Using PREV function
-CREATE VIEW rpr_ev82 AS
+CREATE VIEW rpr_ev_def_prev AS
 SELECT count(*) OVER w
 FROM generate_series(1, 30) AS s(v)
 WINDOW w AS (
@@ -2187,7 +2187,7 @@ WINDOW w AS (
         U AS v > PREV(v),
         D AS v < PREV(v)
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev82'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_def_prev'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2203,7 +2203,7 @@ WINDOW w AS (
 );');
 
 -- Using NULL comparisons
-CREATE VIEW rpr_ev83 AS
+CREATE VIEW rpr_ev_def_null AS
 SELECT count(*) OVER w
 FROM (
     SELECT CASE WHEN v % 5 = 0 THEN NULL ELSE v END AS v
@@ -2215,7 +2215,7 @@ WINDOW w AS (
     PATTERN (A+ B)
     DEFINE A AS v IS NOT NULL, B AS v IS NULL
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev83'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_def_null'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2235,7 +2235,7 @@ WINDOW w AS (
 -- ============================================================
 
 -- 500 rows - verify statistics scale correctly
-CREATE VIEW rpr_ev84 AS
+CREATE VIEW rpr_ev_scale_500rows AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -2244,7 +2244,7 @@ WINDOW w AS (
     PATTERN (A+ B C)
     DEFINE A AS v % 10 < 7, B AS v % 10 = 7, C AS v % 10 = 8
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev84'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_scale_500rows'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2257,7 +2257,7 @@ WINDOW w AS (
 );');
 
 -- High match count scenario
-CREATE VIEW rpr_ev85 AS
+CREATE VIEW rpr_ev_scale_high_match AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -2266,7 +2266,7 @@ WINDOW w AS (
     PATTERN (A B)
     DEFINE A AS v % 2 = 1, B AS v % 2 = 0
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev85'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_scale_high_match'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2279,7 +2279,7 @@ WINDOW w AS (
 );');
 
 -- High skip count scenario
-CREATE VIEW rpr_ev86 AS
+CREATE VIEW rpr_ev_scale_high_skip AS
 SELECT count(*) OVER w
 FROM generate_series(1, 500) AS s(v)
 WINDOW w AS (
@@ -2293,7 +2293,7 @@ WINDOW w AS (
         D AS v % 100 = 4,
         E AS v % 100 = 5
 );
-SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev86'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_scale_high_skip'), E'\n')) AS line WHERE line ~ 'PATTERN';
 SELECT rpr_explain_filter('
 EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
 SELECT count(*) OVER w
@@ -2321,7 +2321,7 @@ WINDOW w AS (
 --
 
 -- Without RPR: row_number() frame is optimized to ROWS UNBOUNDED PRECEDING
-CREATE VIEW rpr_ev87 AS
+CREATE VIEW rpr_ev_opt_no_rpr AS
 SELECT row_number() OVER w
 FROM generate_series(1, 10) AS s(v)
 WINDOW w AS (
@@ -2329,10 +2329,10 @@ WINDOW w AS (
     ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
 );
 
-EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev87;
+EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_no_rpr;
 
 -- With RPR: frame must remain ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
-CREATE VIEW rpr_ev88 AS
+CREATE VIEW rpr_ev_opt_with_rpr AS
 SELECT row_number() OVER w
 FROM generate_series(1, 10) AS s(v)
 WINDOW w AS (
@@ -2344,13 +2344,13 @@ WINDOW w AS (
         B AS v > PREV(v)
 );
 
-EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev88;
+EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_with_rpr;
 
 --
 -- Planner optimization: non-RPR and RPR windows that share the same base frame
 -- after frame optimization are kept as separate WindowAgg nodes.
 --
-CREATE VIEW rpr_ev89 AS
+CREATE VIEW rpr_ev_opt_mixed AS
 SELECT
     row_number() OVER w_normal AS rn_normal,
     row_number() OVER w_rpr AS rn_rpr
@@ -2364,7 +2364,7 @@ WINDOW
         DEFINE A AS v > 1
     );
 
-EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev89;
+EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_mixed;
 
 --
 -- Planner optimization: find_window_run_conditions must not push down
-- 
2.50.1 (Apple Git-155)


From 5a5e04eb3becfc5cd185b67243fe15eff5ef6f56 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 08:53:46 +0900
Subject: [PATCH] Fix quote_identifier() for RPR pattern variable name deparse

Add quote_identifier() to PATTERN and DEFINE variable name output
in ruleutils.c and explain.c.  Without quoting, mixed-case or
reserved-word variable names (e.g., "Start", "Up") lose their
case or conflict with keywords in pg_get_viewdef() output,
breaking pg_dump/pg_restore round-trips.

Add regression test with quoted identifiers ("Start", "Up") to
verify correct deparse in both pg_get_viewdef and EXPLAIN output.
---
 src/backend/commands/explain.c            |  2 +-
 src/backend/utils/adt/ruleutils.c         |  4 ++--
 src/test/regress/expected/rpr_base.out    | 24 +++++++++++++++++++++++
 src/test/regress/expected/rpr_explain.out | 19 ++++++++++++++++++
 src/test/regress/sql/rpr_base.sql         | 10 ++++++++++
 src/test/regress/sql/rpr_explain.sql      | 12 ++++++++++++
 6 files changed, 68 insertions(+), 3 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 7f0367ce546..933eadab71e 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3176,7 +3176,7 @@ deparse_rpr_var(RPRPattern *pattern, int *idx, StringInfoData *buf,
 		appendStringInfoChar(buf, ' ');
 
 	Assert(elem->varId < pattern->numVars);
-	appendStringInfoString(buf, pattern->varNames[elem->varId]);
+	appendStringInfoString(buf, quote_identifier(pattern->varNames[elem->varId]));
 	append_rpr_quantifier(buf, elem);
 	*needSpace = true;
 
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index cfe24de43cf..c755a42efd6 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -7160,7 +7160,7 @@ get_rule_pattern_node(RPRPatternNode *node, deparse_context *context)
 	switch (node->nodeType)
 	{
 		case RPR_PATTERN_VAR:
-			appendStringInfoString(buf, node->varName);
+			appendStringInfoString(buf, quote_identifier(node->varName));
 			append_pattern_quantifier(buf, node);
 			break;
 
@@ -7229,7 +7229,7 @@ get_rule_define(List *defineClause, bool force_colno, deparse_context *context)
 	{
 		TargetEntry *te = (TargetEntry *) lfirst(lc_def);
 
-		appendStringInfo(buf, "%s%s AS ", sep, te->resname);
+		appendStringInfo(buf, "%s%s AS ", sep, quote_identifier(te->resname));
 		get_rule_expr((Node *) te->expr, context, false);
 		sep = ",\n  ";
 	}
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 7452cf1b3ab..6526365dd6a 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -2252,6 +2252,30 @@ SELECT pg_get_viewdef('rpr_quant_reluctant_v'::regclass);
    b AS (val > 0) );
 (1 row)
 
+-- Quoted identifier round-trip: mixed case and reserved words need quoting
+CREATE VIEW rpr_serial_quoted AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN ("Start" "Up"+)
+             DEFINE "Start" AS TRUE, "Up" AS val > PREV(val));
+SELECT pg_get_viewdef('rpr_serial_quoted'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN ("Start" "Up"+)                                                   +
+   DEFINE                                                                    +
+   "Start" AS true,                                                          +
+   "Up" AS (val > prev(val)) );
+(1 row)
+
 -- Materialized view (if supported)
 CREATE TABLE rpr_mview (id INT, val INT);
 INSERT INTO rpr_mview VALUES (1, 10), (2, 20), (3, 30);
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index f66caf8908e..a68ec61e10f 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -301,6 +301,25 @@ WINDOW w AS (
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
 (8 rows)
 
+-- Regression test: Quoted identifiers in EXPLAIN pattern deparse
+-- Mixed case names must be quoted to preserve round-trip safety
+SELECT rpr_explain_filter('
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 10) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN ("Start" "Up"+)
+    DEFINE "Start" AS TRUE, "Up" AS v > PREV(v)
+);');
+                        rpr_explain_filter                         
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: "Start" "Up"+
+   ->  Function Scan on generate_series s
+(4 rows)
+
 -- ============================================================
 -- State Statistics Tests (peak, total, merged)
 -- ============================================================
diff --git a/src/test/regress/sql/rpr_base.sql b/src/test/regress/sql/rpr_base.sql
index 8c23c7598a3..3accecb73ba 100644
--- a/src/test/regress/sql/rpr_base.sql
+++ b/src/test/regress/sql/rpr_base.sql
@@ -1559,6 +1559,16 @@ WINDOW w AS (ORDER BY id
              DEFINE A AS val > 0, B AS val > 0);
 SELECT pg_get_viewdef('rpr_quant_reluctant_v'::regclass);
 
+-- Quoted identifier round-trip: mixed case and reserved words need quoting
+CREATE VIEW rpr_serial_quoted AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN ("Start" "Up"+)
+             DEFINE "Start" AS TRUE, "Up" AS val > PREV(val));
+SELECT pg_get_viewdef('rpr_serial_quoted'::regclass);
+
 -- Materialized view (if supported)
 
 CREATE TABLE rpr_mview (id INT, val INT);
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index 65a775fdad9..703ecd3b23b 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -226,6 +226,18 @@ WINDOW w AS (
     DEFINE A AS v % 5 = 1, B AS v % 5 = 2, C AS v % 5 = 3, D AS v % 5 = 4, E AS v % 5 = 0
 );');
 
+-- Regression test: Quoted identifiers in EXPLAIN pattern deparse
+-- Mixed case names must be quoted to preserve round-trip safety
+SELECT rpr_explain_filter('
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 10) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN ("Start" "Up"+)
+    DEFINE "Start" AS TRUE, "Up" AS v > PREV(v)
+);');
+
 -- ============================================================
 -- State Statistics Tests (peak, total, merged)
 -- ============================================================
-- 
2.50.1 (Apple Git-155)


From b49e64adde991d23799fdfd309e6d996c8d2e5c6 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:14:59 +0900
Subject: [PATCH] Fix execRPR.o ordering in executor Makefile to match
 meson.build

---
 src/backend/executor/Makefile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/backend/executor/Makefile b/src/backend/executor/Makefile
index eeed9a904e5..2b257427795 100644
--- a/src/backend/executor/Makefile
+++ b/src/backend/executor/Makefile
@@ -25,8 +25,8 @@ OBJS = \
 	execParallel.o \
 	execPartition.o \
 	execProcnode.o \
-	execReplication.o \
 	execRPR.o \
+	execReplication.o \
 	execSRF.o \
 	execScan.o \
 	execTuples.o \
-- 
2.50.1 (Apple Git-155)


From 80726d6151aa00ae6edf73fc49498aac75f9fb28 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:40:53 +0900
Subject: [PATCH] Remove unused force_colno parameter from RPR deparse
 functions

---
 src/backend/utils/adt/ruleutils.c | 15 ++++++---------
 1 file changed, 6 insertions(+), 9 deletions(-)

diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index c755a42efd6..e93c03a351c 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -449,10 +449,8 @@ static void get_rule_orderby(List *orderList, List *targetList,
 							 bool force_colno, deparse_context *context);
 static void append_pattern_quantifier(StringInfo buf, RPRPatternNode *node);
 static void get_rule_pattern_node(RPRPatternNode *node, deparse_context *context);
-static void get_rule_pattern(RPRPatternNode *rpPattern, bool force_colno,
-							 deparse_context *context);
-static void get_rule_define(List *defineClause, bool force_colno,
-							deparse_context *context);
+static void get_rule_pattern(RPRPatternNode *rpPattern, deparse_context *context);
+static void get_rule_define(List *defineClause, deparse_context *context);
 static void get_rule_windowclause(Query *query, deparse_context *context);
 static void get_rule_windowspec(WindowClause *wc, List *targetList,
 								deparse_context *context);
@@ -7203,8 +7201,7 @@ get_rule_pattern_node(RPRPatternNode *node, deparse_context *context)
  * Display a PATTERN clause.
  */
 static void
-get_rule_pattern(RPRPatternNode *rpPattern, bool force_colno,
-				 deparse_context *context)
+get_rule_pattern(RPRPatternNode *rpPattern, deparse_context *context)
 {
 	StringInfo	buf = context->buf;
 
@@ -7217,7 +7214,7 @@ get_rule_pattern(RPRPatternNode *rpPattern, bool force_colno,
  * Display a DEFINE clause.
  */
 static void
-get_rule_define(List *defineClause, bool force_colno, deparse_context *context)
+get_rule_define(List *defineClause, deparse_context *context)
 {
 	StringInfo	buf = context->buf;
 	const char *sep;
@@ -7356,7 +7353,7 @@ get_rule_windowspec(WindowClause *wc, List *targetList,
 		if (needspace)
 			appendStringInfoChar(buf, ' ');
 		appendStringInfoString(buf, "\n  PATTERN ");
-		get_rule_pattern(wc->rpPattern, false, context);
+		get_rule_pattern(wc->rpPattern, context);
 		needspace = true;
 	}
 
@@ -7365,7 +7362,7 @@ get_rule_windowspec(WindowClause *wc, List *targetList,
 		if (needspace)
 			appendStringInfoChar(buf, ' ');
 		appendStringInfoString(buf, "\n  DEFINE\n");
-		get_rule_define(wc->defineClause, false, context);
+		get_rule_define(wc->defineClause, context);
 		appendStringInfoChar(buf, ' ');
 	}
 
-- 
2.50.1 (Apple Git-155)


From 5e4a1d039508c7872adc2506706a7481b3d3755b Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:43:07 +0900
Subject: [PATCH] Add CHECK_FOR_INTERRUPTS to RPR context cleanup and finalize
 loops

---
 src/backend/executor/execRPR.c | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index aec1057e1b2..4c429528b04 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -3068,6 +3068,8 @@ ExecRPRCleanupDeadContexts(WindowAggState *winstate, RPRNFAContext *excludeCtx)
 
 	for (ctx = winstate->nfaContext; ctx != NULL; ctx = next)
 	{
+		CHECK_FOR_INTERRUPTS();
+
 		next = ctx->next;
 
 		/* Skip the target context and contexts still processing */
@@ -3108,6 +3110,8 @@ ExecRPRFinalizeAllContexts(WindowAggState *winstate, int64 lastPos)
 
 	for (ctx = winstate->nfaContext; ctx != NULL; ctx = ctx->next)
 	{
+		CHECK_FOR_INTERRUPTS();
+
 		if (ctx->states != NULL)
 		{
 			nfa_match(winstate, ctx, NULL);
-- 
2.50.1 (Apple Git-155)


From e54dd341aef218be3dcce088ee46c4d35f24d482 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:48:55 +0900
Subject: [PATCH] Narrow variable scope in ExecInitWindowAgg DEFINE clause loop

---
 src/backend/executor/nodeWindowAgg.c | 11 +++--------
 1 file changed, 3 insertions(+), 8 deletions(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index dca2de570e8..0202c508323 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -2628,9 +2628,6 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	TupleDesc	scanDesc;
 	ListCell   *l;
 
-	TargetEntry *te;
-	Expr	   *expr;
-
 	/* check for unsupported flags */
 	Assert(!(eflags & (EXEC_FLAG_BACKWARD | EXEC_FLAG_MARK)));
 
@@ -2951,13 +2948,11 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 		 */
 		foreach(l, node->defineClause)
 		{
-			char	   *name;
+			TargetEntry *te = lfirst(l);
+			char	   *name = te->resname;
+			Expr	   *expr = te->expr;
 			ExprState  *exps;
 
-			te = lfirst(l);
-			name = te->resname;
-			expr = te->expr;
-
 			winstate->defineVariableList =
 				lappend(winstate->defineVariableList,
 						makeString(pstrdup(name)));
-- 
2.50.1 (Apple Git-155)


From 747b855b0bb586e0d0b6164065376079dc5473c5 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 11:32:56 +0900
Subject: [PATCH] Normalize RPR element flag macros to return bool

---
 src/include/optimizer/rpr.h | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/include/optimizer/rpr.h b/src/include/optimizer/rpr.h
index e78092678bb..360e1bb777f 100644
--- a/src/include/optimizer/rpr.h
+++ b/src/include/optimizer/rpr.h
@@ -44,10 +44,10 @@
 #define RPR_ELEM_ABSORBABLE			0x08	/* absorption judgment point */
 
 /* Accessor macros for RPRPatternElement */
-#define RPRElemIsReluctant(e)			((e)->flags & RPR_ELEM_RELUCTANT)
-#define RPRElemCanEmptyLoop(e)			((e)->flags & RPR_ELEM_EMPTY_LOOP)
-#define RPRElemIsAbsorbableBranch(e)	((e)->flags & RPR_ELEM_ABSORBABLE_BRANCH)
-#define RPRElemIsAbsorbable(e)			((e)->flags & RPR_ELEM_ABSORBABLE)
+#define RPRElemIsReluctant(e)			(((e)->flags & RPR_ELEM_RELUCTANT) != 0)
+#define RPRElemCanEmptyLoop(e)			(((e)->flags & RPR_ELEM_EMPTY_LOOP) != 0)
+#define RPRElemIsAbsorbableBranch(e)	(((e)->flags & RPR_ELEM_ABSORBABLE_BRANCH) != 0)
+#define RPRElemIsAbsorbable(e)			(((e)->flags & RPR_ELEM_ABSORBABLE) != 0)
 #define RPRElemIsVar(e)			((e)->varId <= RPR_VARID_MAX)
 #define RPRElemIsBegin(e)		((e)->varId == RPR_VARID_BEGIN)
 #define RPRElemIsEnd(e)			((e)->varId == RPR_VARID_END)
-- 
2.50.1 (Apple Git-155)


From 79855c1edcf7c411c63ddeab09ac238f2b7d9986 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Fri, 20 Mar 2026 23:52:42 +0900
Subject: [PATCH] Implement 1-slot PREV/NEXT navigation for RPR

Add PREV(value [, offset]) and NEXT(value [, offset]) navigation
functions for use in the DEFINE clause of row pattern recognition.

These functions return the column value at a row offset rows
before/after the current row within the partition, returning NULL
if the target row is outside the partition. The offset defaults
to 1 if omitted; offset=0 refers to the current row itself;
NULL or negative offset raises an error.

Key design: instead of the previous 3-slot model (outer/scan/inner),
a single-slot swap model is used. EEOP_RPR_NAV_SET temporarily
replaces ecxt_outertuple with the target row, the argument
expression evaluates against it, and EEOP_RPR_NAV_RESTORE restores
the original slot. This eliminates varno rewriting and naturally
supports arbitrary offsets.

A dedicated nav_winobj with its own tuplestore read pointer avoids
interference with aggregate processing. A mark pointer pinned at
position 0 prevents tuplestore truncation so that PREV(expr, N)
can reach any prior row.

RPRNavExpr is a new expression node that replaces the previous
approach of identifying PREV/NEXT by funcid. The parser transforms
PREV/NEXT function calls into RPRNavExpr nodes in ParseFuncOrColumn().
Validation in parse_rpr.c rejects nested PREV/NEXT, requires at
least one column reference in the first argument, and ensures
the offset is a run-time constant.

RPRNavKind uses plain enum values (not -1/+1) so that FIRST/LAST
can be added later without arithmetic tricks.

LLVM JIT falls back to the interpreter for expressions containing
RPR navigation opcodes, because JIT code caches the outertuple's
tts_values/tts_isnull pointers in the entry block and the
mid-expression slot swap leaves them stale. Only DEFINE clause
expressions with PREV/NEXT are affected; other expressions in
the same query are still JIT-compiled normally.
---
 doc/src/sgml/func/func-window.sgml            |  22 +-
 src/backend/executor/execExpr.c               |  56 ++
 src/backend/executor/execExprInterp.c         | 110 ++++
 src/backend/executor/nodeWindowAgg.c          | 243 ++++----
 src/backend/jit/llvm/llvmjit_expr.c           |  40 ++
 src/backend/jit/llvm/llvmjit_types.c          |   2 +
 src/backend/nodes/nodeFuncs.c                 |  33 ++
 src/backend/parser/parse_func.c               |  30 +-
 src/backend/parser/parse_rpr.c                |  86 +++
 src/backend/utils/adt/ruleutils.c             |  16 +
 src/backend/utils/adt/windowfuncs.c           |  52 +-
 src/include/catalog/pg_proc.dat               |   6 +
 src/include/executor/execExpr.h               |  18 +
 src/include/executor/nodeWindowAgg.h          |   3 +
 src/include/nodes/execnodes.h                 |  10 +-
 src/include/nodes/primnodes.h                 |  31 +
 src/test/regress/expected/rpr.out             | 551 +++++++++++++++++-
 src/test/regress/expected/rpr_base.out        |   2 +-
 src/test/regress/expected/rpr_explain.out     |  80 +++
 src/test/regress/expected/rpr_integration.out |  57 +-
 src/test/regress/sql/rpr.sql                  | 322 +++++++++-
 src/test/regress/sql/rpr_explain.sql          |  56 ++
 src/tools/pgindent/typedefs.list              |   3 +-
 23 files changed, 1655 insertions(+), 174 deletions(-)

diff --git a/doc/src/sgml/func/func-window.sgml b/doc/src/sgml/func/func-window.sgml
index ae36e0f3135..1b9b993a817 100644
--- a/doc/src/sgml/func/func-window.sgml
+++ b/doc/src/sgml/func/func-window.sgml
@@ -304,12 +304,17 @@
         <indexterm>
          <primary>prev</primary>
         </indexterm>
-        <function>prev</function> ( <parameter>value</parameter> <type>anyelement</type> )
+        <function>prev</function> ( <parameter>value</parameter> <type>anyelement</type> [, <parameter>offset</parameter> <type>bigint</type> ] )
         <returnvalue>anyelement</returnvalue>
        </para>
        <para>
-        Returns the column value at the previous row;
-        returns NULL if there is no previous row in the window frame.
+        Returns the column value at the row <parameter>offset</parameter>
+        rows before the current row within the window frame;
+        returns NULL if the target row is outside the window frame.
+        <parameter>offset</parameter> defaults to 1 if omitted.
+        <parameter>offset</parameter> must be a non-negative integer;
+        an offset of 0 refers to the current row itself.
+        <parameter>offset</parameter> must not be NULL.
        </para></entry>
       </row>
 
@@ -318,12 +323,17 @@
         <indexterm>
          <primary>next</primary>
         </indexterm>
-        <function>next</function> ( <parameter>value</parameter> <type>anyelement</type> )
+        <function>next</function> ( <parameter>value</parameter> <type>anyelement</type> [, <parameter>offset</parameter> <type>bigint</type> ] )
         <returnvalue>anyelement</returnvalue>
        </para>
        <para>
-        Returns the column value at the next row;
-        returns NULL if there is no next row in the window frame.
+        Returns the column value at the row <parameter>offset</parameter>
+        rows after the current row within the window frame;
+        returns NULL if the target row is outside the window frame.
+        <parameter>offset</parameter> defaults to 1 if omitted.
+        <parameter>offset</parameter> must be a non-negative integer;
+        an offset of 0 refers to the current row itself.
+        <parameter>offset</parameter> must not be NULL.
        </para></entry>
       </row>
 
diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 77229141b38..dbed4f48a0f 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -1222,6 +1222,62 @@ ExecInitExprRec(Expr *node, ExprState *state,
 				break;
 			}
 
+		case T_RPRNavExpr:
+			{
+				/*
+				 * RPR navigation functions (PREV/NEXT) 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.
+				 */
+				RPRNavExpr *nav = (RPRNavExpr *) node;
+				WindowAggState *winstate;
+
+				Assert(state->parent && IsA(state->parent, WindowAggState));
+				winstate = (WindowAggState *) state->parent;
+
+				/* Emit SET opcode: swap slot to target row */
+				scratch.opcode = EEOP_RPR_NAV_SET;
+				scratch.d.rpr_nav.winstate = winstate;
+				scratch.d.rpr_nav.kind = nav->kind;
+
+				if (nav->offset_arg != NULL)
+				{
+					/*
+					 * Allocate storage for the runtime offset value.  The
+					 * offset expression is compiled below so it runs before
+					 * EEOP_RPR_NAV_SET.
+					 */
+					Datum	   *offset_value = palloc_object(Datum);
+					bool	   *offset_isnull = palloc_object(bool);
+
+					/* Compile the offset expression into the temp storage */
+					ExecInitExprRec(nav->offset_arg, state,
+									offset_value, offset_isnull);
+
+					scratch.d.rpr_nav.offset_value = offset_value;
+					scratch.d.rpr_nav.offset_isnull = offset_isnull;
+				}
+				else
+				{
+					scratch.d.rpr_nav.offset_value = NULL;
+					scratch.d.rpr_nav.offset_isnull = NULL;
+				}
+
+				ExprEvalPushStep(state, &scratch);
+
+				/* Compile the argument expression normally */
+				ExecInitExprRec(nav->arg, state, resv, resnull);
+
+				/* Emit RESTORE opcode: restore original slot */
+				scratch.opcode = EEOP_RPR_NAV_RESTORE;
+				scratch.d.rpr_nav.winstate = winstate;
+				ExprEvalPushStep(state, &scratch);
+				break;
+			}
+
 		case T_FuncExpr:
 			{
 				FuncExpr   *func = (FuncExpr *) node;
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 3c4843cde86..e41faa95be3 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -56,12 +56,14 @@
  */
 #include "postgres.h"
 
+#include "common/int.h"
 #include "access/heaptoast.h"
 #include "access/tupconvert.h"
 #include "catalog/pg_type.h"
 #include "commands/sequence.h"
 #include "executor/execExpr.h"
 #include "executor/nodeSubplan.h"
+#include "executor/nodeWindowAgg.h"
 #include "funcapi.h"
 #include "miscadmin.h"
 #include "nodes/miscnodes.h"
@@ -578,6 +580,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,
@@ -2005,6 +2009,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)
 		{
@@ -5918,3 +5940,91 @@ ExecAggPlainTransByRef(AggState *aggstate, AggStatePerTrans pertrans,
 
 	MemoryContextSwitchTo(oldContext);
 }
+
+/*
+ * Evaluate RPR PREV/NEXT navigation: 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 = op->d.rpr_nav.winstate;
+	int64		offset;
+	int64		target_pos;
+	TupleTableSlot *target_slot;
+
+	/* Save current slot for later restore */
+	winstate->nav_saved_outertuple = econtext->ecxt_outertuple;
+
+	/*
+	 * Determine the unsigned offset.  For 2-arg PREV/NEXT the offset
+	 * expression has already been evaluated into offset_value.  NULL or
+	 * negative offsets are errors per the SQL standard (ISO/IEC 9075-2,
+	 * Subclause 5.6.2).
+	 */
+	if (op->d.rpr_nav.offset_value != NULL)
+	{
+		if (*op->d.rpr_nav.offset_isnull)
+			ereport(ERROR,
+					(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+					 errmsg("PREV/NEXT offset must not be null")));
+
+		offset = DatumGetInt64(*op->d.rpr_nav.offset_value);
+
+		if (offset < 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("PREV/NEXT offset must not be negative")));
+	}
+	else
+		offset = 1;
+
+	/*
+	 * Calculate target position based on navigation direction.  On overflow,
+	 * use -1 so that ExecRPRNavGetSlot treats it as out of range.
+	 */
+	switch (op->d.rpr_nav.kind)
+	{
+		case RPR_NAV_PREV:
+			if (pg_sub_s64_overflow(winstate->currentpos, offset, &target_pos))
+				target_pos = -1;
+			break;
+		case RPR_NAV_NEXT:
+			if (pg_add_s64_overflow(winstate->currentpos, offset, &target_pos))
+				target_pos = -1;
+			break;
+	}
+
+	/* Fetch target row slot (returns nav_null_slot if out of range) */
+	target_slot = ExecRPRNavGetSlot(winstate, target_pos);
+
+	/*
+	 * 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 PREV/NEXT 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.
+ */
+void
+ExecEvalRPRNavRestore(ExprState *state, ExprEvalStep *op,
+					  ExprContext *econtext)
+{
+	WindowAggState *winstate = op->d.rpr_nav.winstate;
+
+	econtext->ecxt_outertuple = winstate->nav_saved_outertuple;
+}
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 0202c508323..4e643df94cf 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -53,7 +53,6 @@
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/datum.h"
-#include "utils/fmgroids.h"
 #include "utils/expandeddatum.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -177,14 +176,6 @@ typedef struct WindowStatePerAggData
 	bool		restart;		/* need to restart this agg in this cycle? */
 } WindowStatePerAggData;
 
-/*
- * Structure used by check_rpr_navigation() and rpr_navigation_walker().
- */
-typedef struct NavigationInfo
-{
-	bool		is_prev;		/* true if PREV */
-	int			num_vars;		/* number of var nodes */
-} NavigationInfo;
 
 static void initialize_windowaggregate(WindowAggState *winstate,
 									   WindowStatePerFunc perfuncstate,
@@ -243,9 +234,6 @@ 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 void attno_map(Node *node);
-static bool attno_map_walker(Node *node, void *context);
-
 static bool rpr_is_defined(WindowAggState *winstate);
 static int64 row_is_in_reduced_frame(WindowObject winobj, int64 pos);
 
@@ -253,9 +241,6 @@ static void clear_reduced_frame(WindowAggState *winstate);
 static int	get_reduced_frame_status(WindowAggState *winstate, int64 pos);
 static void update_reduced_frame(WindowObject winobj, int64 pos);
 
-static void check_rpr_navigation(Node *node, bool is_prev);
-static bool rpr_navigation_walker(Node *node, void *context);
-
 /* Forward declarations - NFA row evaluation */
 static bool nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched);
 
@@ -1280,6 +1265,25 @@ prepare_tuplestore(WindowAggState *winstate)
 		}
 	}
 
+	/* Create read/mark pointers for RPR navigation if needed */
+	if (winstate->nav_winobj)
+	{
+		/*
+		 * Allocate a mark pointer pinned at position 0 so that the tuplestore
+		 * never truncates rows that a PREV(expr, N) might need.
+		 *
+		 * XXX This retains the entire partition in the tuplestore.  If the
+		 * DEFINE clause only uses PREV/NEXT with small constant offsets, we
+		 * could advance the mark to (currentpos - max_offset) instead.
+		 */
+		winstate->nav_winobj->markptr =
+			tuplestore_alloc_read_pointer(winstate->buffer, 0);
+		winstate->nav_winobj->readptr =
+			tuplestore_alloc_read_pointer(winstate->buffer,
+										  EXEC_FLAG_BACKWARD);
+		winstate->nav_winobj->markpos = 0;
+	}
+
 	/*
 	 * If we are in RANGE or GROUPS mode, then determining frame boundaries
 	 * requires physical access to the frame endpoint rows, except in certain
@@ -1391,6 +1395,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++)
 	{
@@ -2726,15 +2737,18 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->temp_slot_2 = ExecInitExtraTupleSlot(estate, scanDesc,
 												   &TTSOpsMinimalTuple);
 
-	winstate->prev_slot = ExecInitExtraTupleSlot(estate, scanDesc,
-												 &TTSOpsMinimalTuple);
+	if (node->rpPattern != NULL)
+	{
+		winstate->nav_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+													&TTSOpsMinimalTuple);
+		winstate->nav_slot_pos = -1;
 
-	winstate->next_slot = ExecInitExtraTupleSlot(estate, scanDesc,
-												 &TTSOpsMinimalTuple);
+		winstate->nav_null_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+														 &TTSOpsMinimalTuple);
+		winstate->nav_null_slot = ExecStoreAllNullTuple(winstate->nav_null_slot);
 
-	winstate->null_slot = ExecInitExtraTupleSlot(estate, scanDesc,
-												 &TTSOpsMinimalTuple);
-	winstate->null_slot = ExecStoreAllNullTuple(winstate->null_slot);
+		winstate->nav_saved_outertuple = NULL;
+	}
 
 	/*
 	 * create frame head and tail slots only if needed (must create slots in
@@ -2904,6 +2918,23 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 		winstate->agg_winobj = agg_winobj;
 	}
 
+	/*
+	 * 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.
+	 */
+	if (node->rpPattern != NULL)
+	{
+		WindowObject 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;
+	}
+
 	/* Set the status to running */
 	winstate->status = WINDOWAGG_RUN;
 
@@ -2944,7 +2975,9 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	if (node->defineClause != NIL)
 	{
 		/*
-		 * Tweak arg var of PREV/NEXT so that it refers to scan/inner slot.
+		 * 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.
 		 */
 		foreach(l, node->defineClause)
 		{
@@ -2956,7 +2989,6 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 			winstate->defineVariableList =
 				lappend(winstate->defineVariableList,
 						makeString(pstrdup(name)));
-			attno_map((Node *) expr);
 			exps = ExecInitExpr(expr, (PlanState *) winstate);
 			winstate->defineClauseList =
 				lappend(winstate->defineClauseList, exps);
@@ -2991,107 +3023,38 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 }
 
 /*
- * Rewrite varno of Var nodes that are the argument of PREV/NET so that they
- * see scan tuple (PREV) or inner tuple (NEXT).  Also we check the arguments
- * of PREV/NEXT include at least 1 column reference. This is required by the
- * SQL standard.
+ * ExecRPRNavGetSlot
+ *
+ * Fetch tuple at given position for RPR navigation opcodes.
+ * Returns nav_slot with the tuple loaded, or nav_null_slot if out of range.
  */
-static void
-attno_map(Node *node)
+TupleTableSlot *
+ExecRPRNavGetSlot(WindowAggState *winstate, int64 pos)
 {
-	(void) expression_tree_walker(node, attno_map_walker, NULL);
-}
+	WindowObject winobj = winstate->nav_winobj;
+	TupleTableSlot *slot = winstate->nav_slot;
 
-static bool
-attno_map_walker(Node *node, void *context)
-{
-	FuncExpr   *func;
-	int			nargs;
-	bool		is_prev;
+	if (pos < 0)
+		return winstate->nav_null_slot;
 
-	if (node == NULL)
-		return false;
+	/*
+	 * 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 (IsA(node, FuncExpr))
+	if (!window_gettupleslot(winobj, pos, slot))
 	{
-		func = (FuncExpr *) node;
-
-		if (func->funcid == F_PREV || func->funcid == F_NEXT)
-		{
-			/*
-			 * The SQL standard allows to have two more arguments form of
-			 * PREV/NEXT.  But currently we allow only 1 argument form.
-			 */
-			nargs = list_length(func->args);
-			if (list_length(func->args) != 1)
-				elog(ERROR, "PREV/NEXT must have 1 argument but function %d has %d args",
-					 func->funcid, nargs);
-
-			/*
-			 * Check expr of PREV/NEXT aruguments and replace varno.
-			 */
-			is_prev = (func->funcid == F_PREV) ? true : false;
-			check_rpr_navigation(node, is_prev);
-		}
+		winstate->nav_slot_pos = -1;
+		return winstate->nav_null_slot;
 	}
-	return expression_tree_walker(node, attno_map_walker, NULL);
-}
 
-/*
- * Rewrite varno of Var of RPR navigation operations (PREV/NEXT).
- * If is_prev is true, we take care PREV, otherwise NEXT.
- */
-static void
-check_rpr_navigation(Node *node, bool is_prev)
-{
-	NavigationInfo context;
-
-	context.is_prev = is_prev;
-	context.num_vars = 0;
-	(void) expression_tree_walker(node, rpr_navigation_walker, &context);
-	if (context.num_vars < 1)
-		ereport(ERROR,
-				errmsg("row pattern navigation operation's argument must include at least one column reference"));
-}
-
-static bool
-rpr_navigation_walker(Node *node, void *context)
-{
-	NavigationInfo *nav = (NavigationInfo *) context;
-
-	if (node == NULL)
-		return false;
-
-	switch (nodeTag(node))
-	{
-		case T_Var:
-			{
-				Var		   *var = (Var *) node;
-
-				nav->num_vars++;
-
-				if (nav->is_prev)
-				{
-					/*
-					 * Rewrite varno from OUTER_VAR to regular var no so that
-					 * the var references scan tuple.
-					 */
-					var->varno = var->varnosyn;
-				}
-				else
-					var->varno = INNER_VAR;
-			}
-			break;
-		case T_Const:
-		case T_FuncExpr:
-		case T_OpExpr:
-			break;
-
-		default:
-			ereport(ERROR,
-					errmsg("row pattern navigation operation's argument includes unsupported expression"));
-	}
-	return expression_tree_walker(node, rpr_navigation_walker, context);
+	winstate->nav_slot_pos = pos;
+	return slot;
 }
 
 
@@ -3152,8 +3115,8 @@ ExecReScanWindowAgg(WindowAggState *node)
 	ExecClearTuple(node->agg_row_slot);
 	ExecClearTuple(node->temp_slot_1);
 	ExecClearTuple(node->temp_slot_2);
-	ExecClearTuple(node->prev_slot);
-	ExecClearTuple(node->next_slot);
+	if (node->nav_slot)
+		ExecClearTuple(node->nav_slot);
 	if (node->framehead_slot)
 		ExecClearTuple(node->framehead_slot);
 	if (node->frametail_slot)
@@ -4218,6 +4181,10 @@ register_result:
  * Returns true if the row exists, false if out of partition.
  * If row exists, fills varMatched array.
  * varMatched[i] = true if variable i matched at current row.
+ *
+ * Uses 1-slot model: only ecxt_outertuple is set to the current row.
+ * PREV/NEXT navigation is handled by EEOP_RPR_NAV_SET/RESTORE opcodes
+ * during expression evaluation, which temporarily swap the slot.
  */
 static bool
 nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched)
@@ -4228,37 +4195,25 @@ nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched)
 	ListCell   *lc;
 	int			varIdx = 0;
 	TupleTableSlot *slot;
+	int64		saved_pos;
 
-	/*
-	 * Set up slots for current, previous, and next rows. We don't call
-	 * get_slots() here to avoid recursion through row_is_in_frame ->
-	 * update_reduced_frame -> ExecRPRProcessRow.
-	 */
-
-	/* Current row -> ecxt_outertuple */
+	/* 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;
 
-	/* Previous row -> ecxt_scantuple (for PREV) */
-	if (pos > 0)
-	{
-		slot = winstate->prev_slot;
-		if (!window_gettupleslot(winobj, pos - 1, slot))
-			econtext->ecxt_scantuple = winstate->null_slot;
-		else
-			econtext->ecxt_scantuple = slot;
-	}
-	else
-		econtext->ecxt_scantuple = winstate->null_slot;
+	/*
+	 * Save and set currentpos so that EEOP_RPR_NAV_SET opcodes can calculate
+	 * target positions (currentpos +/- offset).
+	 */
+	saved_pos = winstate->currentpos;
+	winstate->currentpos = pos;
 
-	/* Next row -> ecxt_innertuple (for NEXT) */
-	slot = winstate->next_slot;
-	if (!window_gettupleslot(winobj, pos + 1, slot))
-		econtext->ecxt_innertuple = winstate->null_slot;
-	else
-		econtext->ecxt_innertuple = slot;
+	/* Invalidate nav_slot cache so PREV/NEXT re-fetch for new row */
+	winstate->nav_slot_pos = -1;
 
 	foreach(lc, winstate->defineClauseList)
 	{
@@ -4276,6 +4231,8 @@ nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched)
 			break;
 	}
 
+	winstate->currentpos = saved_pos;
+
 	return true;				/* Row exists */
 }
 
diff --git a/src/backend/jit/llvm/llvmjit_expr.c b/src/backend/jit/llvm/llvmjit_expr.c
index 650f1d42a93..d158e37e7b5 100644
--- a/src/backend/jit/llvm/llvmjit_expr.c
+++ b/src/backend/jit/llvm/llvmjit_expr.c
@@ -296,6 +296,40 @@ 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 pointers become stale because the new slot has
+	 * its own tts_values/tts_isnull arrays.  Fall back to the interpreter for
+	 * these expressions.
+	 *
+	 * XXX To JIT-compile these expressions properly, the NAV_SET and
+	 * NAV_RESTORE handlers would need to reload the tts_values and tts_isnull
+	 * pointers from the new slot.  However, LLVM uses SSA (Static Single
+	 * Assignment) form where each value is defined exactly once.  When
+	 * different basic blocks produce different values for the same pointer,
+	 * LLVM requires PHI nodes at the merge point to select the correct one.
+	 * Without that plumbing, OUTER_VAR steps after a slot swap would read
+	 * from the wrong pointer.
+	 */
+	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)
+			{
+				LLVMDeleteFunction(eval_fn);
+				LLVMDisposeBuilder(b);
+				return false;
+			}
+		}
+	}
+
 	/* 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++)
@@ -2432,6 +2466,12 @@ llvm_compile_expr(ExprState *state)
 				LLVMBuildBr(b, opblocks[opno + 1]);
 				break;
 
+			case EEOP_RPR_NAV_SET:
+			case EEOP_RPR_NAV_RESTORE:
+				/* unreachable: filtered out by the pre-scan above */
+				Assert(false);
+				return false;
+
 			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/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c
index 1adda7c5d84..d2f19584070 100644
--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -69,6 +69,9 @@ exprType(const Node *expr)
 		case T_MergeSupportFunc:
 			type = ((const MergeSupportFunc *) expr)->msftype;
 			break;
+		case T_RPRNavExpr:
+			type = ((const RPRNavExpr *) expr)->resulttype;
+			break;
 		case T_SubscriptingRef:
 			type = ((const SubscriptingRef *) expr)->refrestype;
 			break;
@@ -853,6 +856,9 @@ exprCollation(const Node *expr)
 		case T_MergeSupportFunc:
 			coll = ((const MergeSupportFunc *) expr)->msfcollid;
 			break;
+		case T_RPRNavExpr:
+			coll = ((const RPRNavExpr *) expr)->resultcollid;
+			break;
 		case T_SubscriptingRef:
 			coll = ((const SubscriptingRef *) expr)->refcollid;
 			break;
@@ -1154,6 +1160,9 @@ exprSetCollation(Node *expr, Oid collation)
 		case T_MergeSupportFunc:
 			((MergeSupportFunc *) expr)->msfcollid = collation;
 			break;
+		case T_RPRNavExpr:
+			((RPRNavExpr *) expr)->resultcollid = collation;
+			break;
 		case T_SubscriptingRef:
 			((SubscriptingRef *) expr)->refcollid = collation;
 			break;
@@ -1426,6 +1435,9 @@ exprLocation(const Node *expr)
 		case T_MergeSupportFunc:
 			loc = ((const MergeSupportFunc *) expr)->location;
 			break;
+		case T_RPRNavExpr:
+			loc = ((const RPRNavExpr *) expr)->location;
+			break;
 		case T_SubscriptingRef:
 			/* just use container argument's location */
 			loc = exprLocation((Node *) ((const SubscriptingRef *) expr)->refexpr);
@@ -2187,6 +2199,16 @@ expression_tree_walker_impl(Node *node,
 					return true;
 			}
 			break;
+		case T_RPRNavExpr:
+			{
+				RPRNavExpr *expr = (RPRNavExpr *) node;
+
+				if (WALK(expr->arg))
+					return true;
+				if (expr->offset_arg && WALK(expr->offset_arg))
+					return true;
+			}
+			break;
 		case T_SubscriptingRef:
 			{
 				SubscriptingRef *sbsref = (SubscriptingRef *) node;
@@ -3116,6 +3138,17 @@ expression_tree_mutator_impl(Node *node,
 				return (Node *) newnode;
 			}
 			break;
+		case T_RPRNavExpr:
+			{
+				RPRNavExpr *nav = (RPRNavExpr *) node;
+				RPRNavExpr *newnode;
+
+				FLATCOPY(newnode, nav, RPRNavExpr);
+				MUTATE(newnode->arg, nav->arg, Expr *);
+				MUTATE(newnode->offset_arg, nav->offset_arg, Expr *);
+				return (Node *) newnode;
+			}
+			break;
 		case T_SubscriptingRef:
 			{
 				SubscriptingRef *sbsref = (SubscriptingRef *) node;
diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c
index 23b02fb3bc0..e14ff4dc494 100644
--- a/src/backend/parser/parse_func.c
+++ b/src/backend/parser/parse_func.c
@@ -760,7 +760,8 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs,
 	/* next() and prev() are only allowed in a WINDOW DEFINE clause */
 	if (fdresult == FUNCDETAIL_NORMAL &&
 		pstate->p_expr_kind != EXPR_KIND_RPR_DEFINE &&
-		(funcid == F_PREV || funcid == F_NEXT))
+		(funcid == F_PREV_ANYELEMENT || funcid == F_NEXT_ANYELEMENT ||
+		 funcid == F_PREV_ANYELEMENT_INT8 || funcid == F_NEXT_ANYELEMENT_INT8))
 		ereport(ERROR,
 				(errcode(ERRCODE_SYNTAX_ERROR),
 				 errmsg("%s can only be used in a DEFINE clause",
@@ -768,7 +769,32 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs,
 				 parser_errposition(pstate, location)));
 
 	/* build the appropriate output structure */
-	if (fdresult == FUNCDETAIL_NORMAL || fdresult == FUNCDETAIL_PROCEDURE)
+	if (fdresult == FUNCDETAIL_NORMAL &&
+		(funcid == F_PREV_ANYELEMENT || funcid == F_NEXT_ANYELEMENT ||
+		 funcid == F_PREV_ANYELEMENT_INT8 || funcid == F_NEXT_ANYELEMENT_INT8))
+	{
+		/*
+		 * PREV() and NEXT() are compiled into EEOP_RPR_NAV_SET /
+		 * EEOP_RPR_NAV_RESTORE opcodes instead of a normal function call.
+		 * Represent them as RPRNavExpr nodes so that later stages can
+		 * identify them without relying on funcid comparisons.
+		 */
+		bool		is_next = (funcid == F_NEXT_ANYELEMENT ||
+							   funcid == F_NEXT_ANYELEMENT_INT8);
+		bool		has_offset = (funcid == F_PREV_ANYELEMENT_INT8 ||
+								  funcid == F_NEXT_ANYELEMENT_INT8);
+		RPRNavExpr *navexpr = makeNode(RPRNavExpr);
+
+		navexpr->kind = is_next ? RPR_NAV_NEXT : RPR_NAV_PREV;
+		navexpr->arg = (Expr *) linitial(fargs);
+		navexpr->offset_arg = has_offset ? (Expr *) lsecond(fargs) : NULL;
+		navexpr->resulttype = rettype;
+		/* resultcollid will be set by parse_collate.c */
+		navexpr->location = location;
+
+		retval = (Node *) navexpr;
+	}
+	else if (fdresult == FUNCDETAIL_NORMAL || fdresult == FUNCDETAIL_PROCEDURE)
 	{
 		FuncExpr   *funcexpr = makeNode(FuncExpr);
 
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index db1309ca311..3fb5d94abe9 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -42,6 +42,8 @@ static void validateRPRPatternVarCount(ParseState *pstate, RPRPatternNode *node,
 									   List *rpDefs, List **varNames);
 static List *transformDefineClause(ParseState *pstate, WindowClause *wc,
 								   WindowDef *windef, List **targetlist);
+static void check_rpr_nav_expr(RPRNavExpr *nav, ParseState *pstate);
+static bool check_rpr_nav_nesting_walker(Node *node, void *context);
 
 /*
  * transformRPR
@@ -410,6 +412,10 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 	foreach_ptr(TargetEntry, te, defineClause)
 		te->expr = (Expr *) coerce_to_boolean(pstate, (Node *) te->expr, "DEFINE");
 
+	/* check for nested PREV/NEXT and missing column references */
+	foreach_ptr(TargetEntry, te, defineClause)
+		(void) check_rpr_nav_nesting_walker((Node *) te->expr, pstate);
+
 	/* mark column origins */
 	markTargetListOrigins(pstate, defineClause);
 
@@ -418,3 +424,83 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 
 	return defineClause;
 }
+
+/*
+ * check_rpr_nav_expr
+ *		Validate a single RPRNavExpr node by walking its arg and offset_arg
+ *		subtrees in a single pass each.  Checks for nested PREV/NEXT, missing
+ *		column references, and non-constant offset expressions.
+ */
+typedef struct
+{
+	bool		has_nav;		/* RPRNavExpr found (nesting) */
+	bool		has_column_ref; /* Var found */
+}			NavCheckResult;
+
+static bool
+nav_check_walker(Node *node, void *context)
+{
+	NavCheckResult *result = (NavCheckResult *) context;
+
+	if (node == NULL)
+		return false;
+	if (IsA(node, RPRNavExpr))
+		result->has_nav = true;
+	if (IsA(node, Var))
+		result->has_column_ref = true;
+
+	return expression_tree_walker(node, nav_check_walker, context);
+}
+
+static void
+check_rpr_nav_expr(RPRNavExpr *nav, ParseState *pstate)
+{
+	NavCheckResult result;
+
+	/* Check arg subtree: nesting + column reference in one walk */
+	memset(&result, 0, sizeof(result));
+	(void) nav_check_walker((Node *) nav->arg, &result);
+
+	if (result.has_nav)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("PREV and NEXT cannot be nested"),
+				 parser_errposition(pstate, nav->location)));
+	if (!result.has_column_ref)
+		ereport(ERROR,
+				(errcode(ERRCODE_SYNTAX_ERROR),
+				 errmsg("argument of row pattern navigation operation must include at least one column reference"),
+				 parser_errposition(pstate, nav->location)));
+
+	/* Check offset_arg: column ref + volatile in one walk */
+	if (nav->offset_arg != NULL)
+	{
+		memset(&result, 0, sizeof(result));
+		(void) nav_check_walker((Node *) nav->offset_arg, &result);
+
+		if (result.has_column_ref ||
+			contain_volatile_functions((Node *) nav->offset_arg))
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("PREV/NEXT offset must be a run-time constant"),
+					 parser_errposition(pstate, nav->location)));
+	}
+}
+
+/*
+ * check_rpr_nav_nesting_walker
+ *		Walk the DEFINE clause expression tree and validate each RPRNavExpr.
+ */
+static bool
+check_rpr_nav_nesting_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+	if (IsA(node, RPRNavExpr))
+	{
+		check_rpr_nav_expr((RPRNavExpr *) node, (ParseState *) context);
+		/* don't recurse into arg; nesting already checked above */
+		return false;
+	}
+	return expression_tree_walker(node, check_rpr_nav_nesting_walker, context);
+}
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index e93c03a351c..a4fe725646c 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -10103,6 +10103,22 @@ get_rule_expr(Node *node, deparse_context *context,
 			get_func_expr((FuncExpr *) node, context, showimplicit);
 			break;
 
+		case T_RPRNavExpr:
+			{
+				RPRNavExpr *nav = (RPRNavExpr *) node;
+
+				appendStringInfoString(buf,
+									   nav->kind == RPR_NAV_PREV ? "PREV(" : "NEXT(");
+				get_rule_expr((Node *) nav->arg, context, showimplicit);
+				if (nav->offset_arg != NULL)
+				{
+					appendStringInfoString(buf, ", ");
+					get_rule_expr((Node *) nav->offset_arg, context, showimplicit);
+				}
+				appendStringInfoChar(buf, ')');
+			}
+			break;
+
 		case T_NamedArgExpr:
 			{
 				NamedArgExpr *na = (NamedArgExpr *) node;
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index 74ef109f72e..091260d2cce 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -726,22 +726,62 @@ window_nth_value(PG_FUNCTION_ARGS)
 
 /*
  * prev
- * Dummy function to invoke RPR's navigation operator "PREV".
- * This is *not* a window function.
+ * Catalog placeholder for RPR's PREV navigation operator.
+ *
+ * The parser transforms prev() calls inside DEFINE into RPRNavExpr nodes,
+ * so this function is never reached during normal RPR execution.  It exists
+ * only so that the parser can resolve the function name from pg_proc.
+ * Calls outside DEFINE are rejected by parse_func.c (EXPR_KIND_RPR_DEFINE
+ * check).  The error below is a defensive measure in case that check is
+ * bypassed (e.g., direct C-level function invocation).
  */
 Datum
 window_prev(PG_FUNCTION_ARGS)
 {
-	PG_RETURN_DATUM(PG_GETARG_DATUM(0));
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("prev() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
 }
 
 /*
  * next
- * Dummy function to invoke RPR's navigation operation "NEXT".
- * This is *not* a window function.
+ * Catalog placeholder for RPR's NEXT navigation operator.
+ * See window_prev() for details.
  */
 Datum
 window_next(PG_FUNCTION_ARGS)
 {
-	PG_RETURN_DATUM(PG_GETARG_DATUM(0));
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("next() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
+}
+
+/*
+ * prev(value, offset)
+ * Catalog placeholder for RPR's PREV navigation operator with offset.
+ * See window_prev() for details.
+ */
+Datum
+window_prev_offset(PG_FUNCTION_ARGS)
+{
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("prev() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
+}
+
+/*
+ * next(value, offset)
+ * Catalog placeholder for RPR's NEXT navigation operator with offset.
+ * See window_prev() for details.
+ */
+Datum
+window_next_offset(PG_FUNCTION_ARGS)
+{
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("next() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
 }
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index ddf922d16d7..8e95169b7b0 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -10975,9 +10975,15 @@
 { oid => '8126', descr => 'previous value',
   proname => 'prev', provolatile => 's', prorettype => 'anyelement',
   proargtypes => 'anyelement', prosrc => 'window_prev' },
+{ oid => '8128', descr => 'previous value at offset',
+  proname => 'prev', provolatile => 's', proisstrict => 'f', prorettype => 'anyelement',
+  proargtypes => 'anyelement int8', prosrc => 'window_prev_offset' },
 { oid => '8127', descr => 'next value',
   proname => 'next', provolatile => 's', prorettype => 'anyelement',
   proargtypes => 'anyelement', prosrc => 'window_next' },
+{ oid => '8129', descr => 'next value at offset',
+  proname => 'next', provolatile => 's', proisstrict => 'f', prorettype => 'anyelement',
+  proargtypes => 'anyelement int8', prosrc => 'window_next_offset' },
 
 # functions for range types
 { oid => '3832', descr => 'I/O',
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index aa9b361fa31..fac37c96896 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 (RPR PREV/NEXT) */
+	EEOP_RPR_NAV_SET,
+	EEOP_RPR_NAV_RESTORE,
+
 	/* aggregation related nodes */
 	EEOP_AGG_STRICT_DESERIALIZE,
 	EEOP_AGG_DESERIALIZE,
@@ -691,6 +695,16 @@ typedef struct ExprEvalStep
 			SubPlanState *sstate;
 		}			subplan;
 
+		/* for EEOP_RPR_NAV_SET / EEOP_RPR_NAV_RESTORE */
+		struct
+		{
+			WindowAggState *winstate;
+			RPRNavKind	kind;	/* PREV or NEXT */
+			Datum	   *offset_value;	/* 2-arg: runtime offset value, or
+										 * NULL */
+			bool	   *offset_isnull;	/* 2-arg: runtime offset null flag */
+		}			rpr_nav;
+
 		/* for EEOP_AGG_*DESERIALIZE */
 		struct
 		{
@@ -898,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/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 c672d29f35b..74a6b682132 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2691,10 +2691,12 @@ typedef struct WindowAggState
 	TupleTableSlot *temp_slot_1;
 	TupleTableSlot *temp_slot_2;
 
-	/* temporary slots for RPR */
-	TupleTableSlot *prev_slot;	/* PREV row navigation operator */
-	TupleTableSlot *next_slot;	/* NEXT row navigation operator */
-	TupleTableSlot *null_slot;	/* all NULL slot */
+	/* RPR navigation */
+	struct WindowObjectData *nav_winobj;	/* winobj for RPR nav fetch */
+	int64		nav_slot_pos;	/* position cached in nav_slot, or -1 */
+	TupleTableSlot *nav_slot;	/* slot for PREV/NEXT target row */
+	TupleTableSlot *nav_saved_outertuple;	/* saved slot during nav swap */
+	TupleTableSlot *nav_null_slot;	/* all NULL slot */
 
 	/* RPR current match result */
 	bool		rpr_match_valid;	/* true if a match result is set */
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index f5b6b45664a..94723a3b909 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -648,6 +648,37 @@ typedef struct WindowFuncRunCondition
 	Expr	   *arg;
 } WindowFuncRunCondition;
 
+/*
+ * RPRNavExpr
+ *
+ * Represents a PREV() or NEXT() navigation call in an RPR DEFINE clause.
+ * At expression compile time this is translated into EEOP_RPR_NAV_SET /
+ * EEOP_RPR_NAV_RESTORE opcodes rather than a normal function call.
+ *
+ * kind:       RPR_NAV_PREV or RPR_NAV_NEXT
+ * arg:        the expression to evaluate against the target row
+ * offset_arg: optional explicit offset expression (2-arg form); NULL for
+ *             the 1-arg form which uses an implicit offset of 1
+ */
+typedef enum RPRNavKind
+{
+	RPR_NAV_PREV,
+	RPR_NAV_NEXT,
+} RPRNavKind;
+
+typedef struct RPRNavExpr
+{
+	Expr		xpr;
+	RPRNavKind	kind;			/* PREV or NEXT */
+	Expr	   *arg;			/* argument expression */
+	Expr	   *offset_arg;		/* offset expression, or NULL for 1-arg form */
+	Oid			resulttype;		/* result type (same as arg's type) */
+	/* OID of collation of result */
+	Oid			resultcollid pg_node_attr(query_jumble_ignore);
+	/* token location, or -1 if unknown */
+	ParseLoc	location;
+} RPRNavExpr;
+
 /*
  * MergeSupportFunc
  *
diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index e72171050c7..d586e17e0a1 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -1018,6 +1018,555 @@ WINDOW w AS (
   5 |     0.1 |     0
 (5 rows)
 
+--
+-- Error cases: PREV/NEXT usage restrictions
+--
+-- PREV outside DEFINE clause
+SELECT prev(price) FROM stock;
+ERROR:  prev can only be used in a DEFINE clause
+LINE 1: SELECT prev(price) FROM stock;
+               ^
+-- NEXT outside DEFINE clause
+SELECT next(price) FROM stock;
+ERROR:  next can only be used in a DEFINE clause
+LINE 1: SELECT next(price) FROM stock;
+               ^
+-- Nested PREV
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > PREV(PREV(price))
+);
+ERROR:  PREV and NEXT cannot be nested
+LINE 7:     DEFINE A AS price > PREV(PREV(price))
+                                ^
+-- Nested NEXT
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > NEXT(NEXT(price))
+);
+ERROR:  PREV and NEXT cannot be nested
+LINE 7:     DEFINE A AS price > NEXT(NEXT(price))
+                                ^
+-- PREV nested inside NEXT
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > NEXT(PREV(price))
+);
+ERROR:  PREV and NEXT cannot be nested
+LINE 7:     DEFINE A AS price > NEXT(PREV(price))
+                                ^
+-- PREV nested inside expression inside NEXT
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > NEXT(price * PREV(price))
+);
+ERROR:  PREV and NEXT cannot be nested
+LINE 7:     DEFINE A AS price > NEXT(price * PREV(price))
+                                ^
+-- Triple nesting: error reported at outermost PREV
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > PREV(PREV(PREV(price)))
+);
+ERROR:  PREV and NEXT cannot be nested
+LINE 7:     DEFINE A AS price > PREV(PREV(PREV(price)))
+                                ^
+-- No column reference in PREV/NEXT argument
+-- PREV(1): constant only, no column reference
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(1) > 0
+);
+ERROR:  argument of row pattern navigation operation must include at least one column reference
+LINE 7:     DEFINE A AS PREV(1) > 0
+                        ^
+-- NEXT(1 + 2): constant expression, no column reference
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS NEXT(1 + 2) > 0
+);
+ERROR:  argument of row pattern navigation operation must include at least one column reference
+LINE 7:     DEFINE A AS NEXT(1 + 2) > 0
+                        ^
+-- 2-arg form: PREV(1, 1): constant expression as first arg
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(1, 1) > 0
+);
+ERROR:  argument of row pattern navigation operation must include at least one column reference
+LINE 7:     DEFINE A AS PREV(1, 1) > 0
+                        ^
+-- Non-constant offset: column reference as offset
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price, price) > 0
+);
+ERROR:  PREV/NEXT offset must be a run-time constant
+LINE 7:     DEFINE A AS PREV(price, price) > 0
+                        ^
+-- Non-constant offset: volatile function as offset
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price, random()::int) > 0
+);
+ERROR:  PREV/NEXT offset must be a run-time constant
+LINE 7:     DEFINE A AS PREV(price, random()::int) > 0
+                        ^
+-- Non-constant offset: subquery as offset
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price, (SELECT 1)) > 0
+);
+ERROR:  cannot use subquery in DEFINE expression
+LINE 7:     DEFINE A AS PREV(price, (SELECT 1)) > 0
+                                    ^
+-- First arg: subquery (caught by DEFINE-level subquery restriction)
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price + (SELECT 1)) > 0
+);
+ERROR:  cannot use subquery in DEFINE expression
+LINE 7:     DEFINE A AS PREV(price + (SELECT 1)) > 0
+                                     ^
+-- First arg: volatile function is allowed (evaluated on target row)
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price + random() * 0) >= 0
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |             |            |     0
+ company1 | 07-02-2023 |   200 |         200 |        130 |     9
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |             |            |     0
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |             |            |     0
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |             |            |     0
+ company2 | 07-02-2023 |  2000 |        2000 |       1300 |     9
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |             |            |     0
+ company2 | 07-05-2023 |  1500 |             |            |     0
+ company2 | 07-06-2023 |    60 |             |            |     0
+ company2 | 07-07-2023 |  1100 |             |            |     0
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |             |            |     0
+(20 rows)
+
+--
+-- 2-arg PREV/NEXT: functional tests
+--
+-- PREV(price, 2): match rows where current price > price 2 rows back
+-- stock: 100, 90, 80, 95, 110
+-- Pattern (A B+): A=any, B where price > PREV(price, 2)
+-- At pos 2 (80): A matches. pos 3 (95): 95 > PREV(95,2)=90 TRUE.
+--                             pos 4 (110): 110 > PREV(110,2)=80 TRUE. Match!
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS price > PREV(price, 2)
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |             |            |     0
+ company1 | 07-02-2023 |   200 |         200 |        150 |     2
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |             |            |     0
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |         110 |        120 |     3
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |             |            |     0
+ company2 | 07-02-2023 |  2000 |        2000 |       1500 |     2
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |             |            |     0
+ company2 | 07-05-2023 |  1500 |             |            |     0
+ company2 | 07-06-2023 |    60 |             |            |     0
+ company2 | 07-07-2023 |  1100 |        1100 |       1200 |     3
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |             |            |     0
+(20 rows)
+
+-- NEXT(price, 2): match rows where current price > price 2 rows ahead
+-- pos 0 (100): NEXT(100,2)=80, 100>80 TRUE. pos 1 (90): NEXT(90,2)=95, 90>95 FALSE. Match ends.
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > NEXT(price, 2)
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |             |            |     0
+ company1 | 07-02-2023 |   200 |         200 |        200 |     1
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |         140 |        150 |     2
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |             |            |     0
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |             |            |     0
+ company2 | 07-02-2023 |  2000 |        2000 |       2000 |     1
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |        1400 |       1500 |     2
+ company2 | 07-05-2023 |  1500 |             |            |     0
+ company2 | 07-06-2023 |    60 |             |            |     0
+ company2 | 07-07-2023 |  1100 |             |            |     0
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |             |            |     0
+(20 rows)
+
+-- Expressions inside PREV/NEXT arg: expr is evaluated on target row
+-- PREV(price - 50, 1): fetches (price - 50) from 1 row back
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price - 50, 1)
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |             |            |     0
+ company1 | 07-02-2023 |   200 |         200 |        200 |     1
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |         140 |        150 |     2
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |         110 |        130 |     4
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |             |            |     0
+ company2 | 07-02-2023 |  2000 |        2000 |       2000 |     1
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |             |            |     0
+ company2 | 07-05-2023 |  1500 |        1500 |       1500 |     1
+ company2 | 07-06-2023 |    60 |             |            |     0
+ company2 | 07-07-2023 |  1100 |        1100 |       1300 |     2
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |        1300 |       1300 |     1
+(20 rows)
+
+-- NEXT(price * 2, 1): fetches (price * 2) from 1 row ahead
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price < NEXT(price * 2, 1)
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |         100 |        120 |     9
+ company1 | 07-02-2023 |   200 |             |            |     0
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |             |            |     0
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |             |            |     0
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |          50 |       1400 |     4
+ company2 | 07-02-2023 |  2000 |             |            |     0
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |             |            |     0
+ company2 | 07-05-2023 |  1500 |             |            |     0
+ company2 | 07-06-2023 |    60 |          60 |       1200 |     4
+ company2 | 07-07-2023 |  1100 |             |            |     0
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |             |            |     0
+(20 rows)
+
+-- Large offset: PREV(val, 999) on 1000-row series matches only last row
+-- NEXT(val, 999) matches only first row
+SELECT val, first_value(val) OVER w, last_value(val) OVER w, count(*) OVER w
+FROM generate_series(1, 1000) AS t(val)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(val, 999) = 1
+)
+ORDER BY val DESC LIMIT 3;
+ val  | first_value | last_value | count 
+------+-------------+------------+-------
+ 1000 |        1000 |       1000 |     1
+  999 |             |            |     0
+  998 |             |            |     0
+(3 rows)
+
+SELECT val, first_value(val) OVER w, last_value(val) OVER w, count(*) OVER w
+FROM generate_series(1, 1000) AS t(val)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(val, 999) = 1000
+)
+LIMIT 3;
+ val | first_value | last_value | count 
+-----+-------------+------------+-------
+   1 |           1 |          1 |     1
+   2 |             |            |     0
+   3 |             |            |     0
+(3 rows)
+
+-- PREV(price, 0): offset 0 means current row, always equal to price
+-- A+ matches entire partition as one group; count = partition size
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, 0) = price
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |         100 |        130 |    10
+ company1 | 07-02-2023 |   200 |             |            |     0
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |             |            |     0
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |             |            |     0
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |          50 |       1300 |    10
+ company2 | 07-02-2023 |  2000 |             |            |     0
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |             |            |     0
+ company2 | 07-05-2023 |  1500 |             |            |     0
+ company2 | 07-06-2023 |    60 |             |            |     0
+ company2 | 07-07-2023 |  1100 |             |            |     0
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |             |            |     0
+(20 rows)
+
+-- 2-arg PREV/NEXT outside DEFINE clause
+SELECT prev(price, 2) FROM stock;
+ERROR:  prev can only be used in a DEFINE clause
+LINE 1: SELECT prev(price, 2) FROM stock;
+               ^
+SELECT next(price, 2) FROM stock;
+ERROR:  next can only be used in a DEFINE clause
+LINE 1: SELECT next(price, 2) FROM stock;
+               ^
+-- 2-arg PREV/NEXT: negative offset
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, -1) IS NOT NULL
+);
+ERROR:  PREV/NEXT offset must not be negative
+-- 2-arg PREV/NEXT: NULL offset (typed)
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, NULL::int8) IS NOT NULL
+);
+ERROR:  PREV/NEXT offset must not be null
+-- 2-arg PREV/NEXT: NULL offset (untyped)
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, NULL) IS NOT NULL
+);
+ERROR:  PREV/NEXT offset must not be null
+-- 2-arg PREV/NEXT: host variable negative and NULL
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price, $1)
+);
+EXECUTE test_prev_offset(-1);
+ERROR:  PREV/NEXT offset must not be negative
+EXECUTE test_prev_offset(NULL);
+ERROR:  PREV/NEXT offset must not be null
+DEALLOCATE test_prev_offset;
+-- 2-arg PREV/NEXT: host variable with expression (0 + $1)
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price, 0 + $1)
+);
+EXECUTE test_prev_offset(-1);
+ERROR:  PREV/NEXT offset must not be negative
+EXECUTE test_prev_offset(NULL);
+ERROR:  PREV/NEXT offset must not be null
+DEALLOCATE test_prev_offset;
+-- 2-arg: two PREV with different offsets in same DEFINE clause
+-- B: price exceeds both 1-back and 2-back values
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS price > PREV(price, 1) AND price > PREV(price, 2)
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |             |            |     0
+ company1 | 07-02-2023 |   200 |             |            |     0
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |             |            |     0
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |         110 |        130 |     2
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |             |            |     0
+ company2 | 07-02-2023 |  2000 |             |            |     0
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |             |            |     0
+ company2 | 07-05-2023 |  1500 |             |            |     0
+ company2 | 07-06-2023 |    60 |             |            |     0
+ company2 | 07-07-2023 |  1100 |        1100 |       1300 |     2
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |             |            |     0
+(20 rows)
+
+-- 2-arg: PREV and NEXT with explicit offsets in same DEFINE clause
+-- A: price exceeds 1-back and is below 1-ahead (ascending interior point)
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price, 1) AND price < NEXT(price, 1)
+);
+ company  |   tdate    | price | first_value | last_value | count 
+----------+------------+-------+-------------+------------+-------
+ company1 | 07-01-2023 |   100 |             |            |     0
+ company1 | 07-02-2023 |   200 |             |            |     0
+ company1 | 07-03-2023 |   150 |             |            |     0
+ company1 | 07-04-2023 |   140 |             |            |     0
+ company1 | 07-05-2023 |   150 |             |            |     0
+ company1 | 07-06-2023 |    90 |             |            |     0
+ company1 | 07-07-2023 |   110 |         110 |        110 |     1
+ company1 | 07-08-2023 |   130 |             |            |     0
+ company1 | 07-09-2023 |   120 |             |            |     0
+ company1 | 07-10-2023 |   130 |             |            |     0
+ company2 | 07-01-2023 |    50 |             |            |     0
+ company2 | 07-02-2023 |  2000 |             |            |     0
+ company2 | 07-03-2023 |  1500 |             |            |     0
+ company2 | 07-04-2023 |  1400 |             |            |     0
+ company2 | 07-05-2023 |  1500 |             |            |     0
+ company2 | 07-06-2023 |    60 |             |            |     0
+ company2 | 07-07-2023 |  1100 |        1100 |       1100 |     1
+ company2 | 07-08-2023 |  1300 |             |            |     0
+ company2 | 07-09-2023 |  1200 |             |            |     0
+ company2 | 07-10-2023 |  1300 |             |            |     0
+(20 rows)
+
 --
 -- SKIP TO / Backtracking / Frame boundary
 --
@@ -1479,7 +2028,7 @@ count(*) OVER w
 (14 rows)
 
 -- ReScan test: LATERAL join forces WindowAgg rescan with RPR
--- Tests ExecReScanWindowAgg clearing prev_slot/next_slot
+-- Tests ExecReScanWindowAgg clearing nav_slot
 SELECT g.x, sub.*
 FROM generate_series(1, 2) g(x),
 LATERAL (
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 6526365dd6a..37aa81ebdea 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -2273,7 +2273,7 @@ SELECT pg_get_viewdef('rpr_serial_quoted'::regclass);
    PATTERN ("Start" "Up"+)                                                   +
    DEFINE                                                                    +
    "Start" AS true,                                                          +
-   "Up" AS (val > prev(val)) );
+   "Up" AS (val > PREV(val)) );
 (1 row)
 
 -- Materialized view (if supported)
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index a68ec61e10f..dc3075e6bd3 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -3847,6 +3847,86 @@ WINDOW w AS (
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
 (8 rows)
 
+-- Using 1-arg PREV (implicit offset 1)
+CREATE VIEW rpr_ev_nav_prev1 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v > PREV(v)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_prev1'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+          line           
+-------------------------
+   PATTERN (a b+) 
+   DEFINE
+   b AS (v > PREV(v)) );
+(3 rows)
+
+-- Using 1-arg NEXT (implicit offset 1)
+CREATE VIEW rpr_ev_nav_next1 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v < NEXT(v)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_next1'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+          line           
+-------------------------
+   PATTERN (a b+) 
+   DEFINE
+   b AS (v < NEXT(v)) );
+(3 rows)
+
+-- Using 2-arg PREV (explicit offset)
+CREATE VIEW rpr_ev_nav_prev2 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v > PREV(v, 2)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_prev2'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+                 line                 
+--------------------------------------
+   PATTERN (a b+) 
+   DEFINE
+   b AS (v > PREV(v, (2)::bigint)) );
+(3 rows)
+
+-- Using 2-arg NEXT (explicit offset)
+CREATE VIEW rpr_ev_nav_next2 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v < NEXT(v, 2)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_next2'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+                 line                 
+--------------------------------------
+   PATTERN (a b+) 
+   DEFINE
+   b AS (v < NEXT(v, (2)::bigint)) );
+(3 rows)
+
 -- Using NULL comparisons
 CREATE VIEW rpr_ev_def_null AS
 SELECT count(*) OVER w
diff --git a/src/test/regress/expected/rpr_integration.out b/src/test/regress/expected/rpr_integration.out
index 0ee612b74fb..f9f70a814ca 100644
--- a/src/test/regress/expected/rpr_integration.out
+++ b/src/test/regress/expected/rpr_integration.out
@@ -1026,27 +1026,66 @@ WINDOW w AS (ORDER BY id
     PATTERN (A B+)
     DEFINE B AS val > PREV(val, $1))
 ORDER BY id;
-ERROR:  function prev(integer, integer) does not exist
-LINE 7:     DEFINE B AS val > PREV(val, $1))
-                              ^
-DETAIL:  No function of that name accepts the given number of arguments.
 -- Custom plan: Nav Mark Lookback resolved to the literal 1.
 SET plan_cache_mode = force_custom_plan;
 EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
-ERROR:  prepared statement "rpr_prev" does not exist
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_integ
+(6 rows)
+
 EXECUTE rpr_prev(1);
-ERROR:  prepared statement "rpr_prev" does not exist
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
 -- Generic plan: Nav Mark Lookback deferred to execution, shown as
 -- "runtime" in the plan.  Result must match the custom-plan result
 -- exactly.
 SET plan_cache_mode = force_generic_plan;
 EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
-ERROR:  prepared statement "rpr_prev" does not exist
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a b+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_integ
+(6 rows)
+
 EXECUTE rpr_prev(1);
-ERROR:  prepared statement "rpr_prev" does not exist
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   2
+  4 |  25 |   0
+  5 |   5 |   3
+  6 |  30 |   0
+  7 |  35 |   0
+  8 |  20 |   3
+  9 |  40 |   0
+ 10 |  45 |   0
+(10 rows)
+
 RESET plan_cache_mode;
 DEALLOCATE rpr_prev;
-ERROR:  prepared statement "rpr_prev" does not exist
 -- ============================================================
 -- B5. RPR + Partitioned table
 -- ============================================================
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index 95794d409e1..504476a2b02 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -440,6 +440,326 @@ WINDOW w AS (
   B AS val > PREV(val) * 0.99
 );
 
+--
+-- Error cases: PREV/NEXT usage restrictions
+--
+
+-- PREV outside DEFINE clause
+SELECT prev(price) FROM stock;
+
+-- NEXT outside DEFINE clause
+SELECT next(price) FROM stock;
+
+-- Nested PREV
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > PREV(PREV(price))
+);
+
+-- Nested NEXT
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > NEXT(NEXT(price))
+);
+
+-- PREV nested inside NEXT
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > NEXT(PREV(price))
+);
+
+-- PREV nested inside expression inside NEXT
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > NEXT(price * PREV(price))
+);
+
+-- Triple nesting: error reported at outermost PREV
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS price > PREV(PREV(PREV(price)))
+);
+
+-- No column reference in PREV/NEXT argument
+-- PREV(1): constant only, no column reference
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(1) > 0
+);
+
+-- NEXT(1 + 2): constant expression, no column reference
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS NEXT(1 + 2) > 0
+);
+
+-- 2-arg form: PREV(1, 1): constant expression as first arg
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(1, 1) > 0
+);
+
+-- Non-constant offset: column reference as offset
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price, price) > 0
+);
+
+-- Non-constant offset: volatile function as offset
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price, random()::int) > 0
+);
+
+-- Non-constant offset: subquery as offset
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price, (SELECT 1)) > 0
+);
+
+-- First arg: subquery (caught by DEFINE-level subquery restriction)
+SELECT price FROM stock
+WINDOW w AS (
+    PARTITION BY company
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    INITIAL
+    PATTERN (A)
+    DEFINE A AS PREV(price + (SELECT 1)) > 0
+);
+
+-- First arg: volatile function is allowed (evaluated on target row)
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price + random() * 0) >= 0
+);
+
+--
+-- 2-arg PREV/NEXT: functional tests
+--
+
+-- PREV(price, 2): match rows where current price > price 2 rows back
+-- stock: 100, 90, 80, 95, 110
+-- Pattern (A B+): A=any, B where price > PREV(price, 2)
+-- At pos 2 (80): A matches. pos 3 (95): 95 > PREV(95,2)=90 TRUE.
+--                             pos 4 (110): 110 > PREV(110,2)=80 TRUE. Match!
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS price > PREV(price, 2)
+);
+
+-- NEXT(price, 2): match rows where current price > price 2 rows ahead
+-- pos 0 (100): NEXT(100,2)=80, 100>80 TRUE. pos 1 (90): NEXT(90,2)=95, 90>95 FALSE. Match ends.
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > NEXT(price, 2)
+);
+
+-- Expressions inside PREV/NEXT arg: expr is evaluated on target row
+-- PREV(price - 50, 1): fetches (price - 50) from 1 row back
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price - 50, 1)
+);
+
+-- NEXT(price * 2, 1): fetches (price * 2) from 1 row ahead
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price < NEXT(price * 2, 1)
+);
+
+-- Large offset: PREV(val, 999) on 1000-row series matches only last row
+-- NEXT(val, 999) matches only first row
+SELECT val, first_value(val) OVER w, last_value(val) OVER w, count(*) OVER w
+FROM generate_series(1, 1000) AS t(val)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(val, 999) = 1
+)
+ORDER BY val DESC LIMIT 3;
+
+SELECT val, first_value(val) OVER w, last_value(val) OVER w, count(*) OVER w
+FROM generate_series(1, 1000) AS t(val)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(val, 999) = 1000
+)
+LIMIT 3;
+
+-- PREV(price, 0): offset 0 means current row, always equal to price
+-- A+ matches entire partition as one group; count = partition size
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, 0) = price
+);
+
+-- 2-arg PREV/NEXT outside DEFINE clause
+SELECT prev(price, 2) FROM stock;
+SELECT next(price, 2) FROM stock;
+
+-- 2-arg PREV/NEXT: negative offset
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, -1) IS NOT NULL
+);
+
+-- 2-arg PREV/NEXT: NULL offset (typed)
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, NULL::int8) IS NOT NULL
+);
+
+-- 2-arg PREV/NEXT: NULL offset (untyped)
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(price, NULL) IS NOT NULL
+);
+
+-- 2-arg PREV/NEXT: host variable negative and NULL
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price, $1)
+);
+EXECUTE test_prev_offset(-1);
+EXECUTE test_prev_offset(NULL);
+DEALLOCATE test_prev_offset;
+
+-- 2-arg PREV/NEXT: host variable with expression (0 + $1)
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price, 0 + $1)
+);
+EXECUTE test_prev_offset(-1);
+EXECUTE test_prev_offset(NULL);
+DEALLOCATE test_prev_offset;
+
+-- 2-arg: two PREV with different offsets in same DEFINE clause
+-- B: price exceeds both 1-back and 2-back values
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS price > PREV(price, 1) AND price > PREV(price, 2)
+);
+
+-- 2-arg: PREV and NEXT with explicit offsets in same DEFINE clause
+-- A: price exceeds 1-back and is below 1-ahead (ascending interior point)
+SELECT company, tdate, price,
+       first_value(price) OVER w, last_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+)
+    DEFINE A AS price > PREV(price, 1) AND price < NEXT(price, 1)
+);
+
 --
 -- SKIP TO / Backtracking / Frame boundary
 --
@@ -671,7 +991,7 @@ count(*) OVER w
 );
 
 -- ReScan test: LATERAL join forces WindowAgg rescan with RPR
--- Tests ExecReScanWindowAgg clearing prev_slot/next_slot
+-- Tests ExecReScanWindowAgg clearing nav_slot
 SELECT g.x, sub.*
 FROM generate_series(1, 2) g(x),
 LATERAL (
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index 703ecd3b23b..e339edd7e91 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -2214,6 +2214,62 @@ WINDOW w AS (
         D AS v < PREV(v)
 );');
 
+-- Using 1-arg PREV (implicit offset 1)
+CREATE VIEW rpr_ev_nav_prev1 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v > PREV(v)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_prev1'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+
+-- Using 1-arg NEXT (implicit offset 1)
+CREATE VIEW rpr_ev_nav_next1 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v < NEXT(v)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_next1'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+
+-- Using 2-arg PREV (explicit offset)
+CREATE VIEW rpr_ev_nav_prev2 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v > PREV(v, 2)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_prev2'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+
+-- Using 2-arg NEXT (explicit offset)
+CREATE VIEW rpr_ev_nav_next2 AS
+SELECT count(*) OVER w
+FROM generate_series(1, 30) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS v < NEXT(v, 2)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_nav_next2'), E'\n')) AS line WHERE line ~ 'PATTERN|DEFINE|PREV|NEXT';
+
 -- Using NULL comparisons
 CREATE VIEW rpr_ev_def_null AS
 SELECT count(*) OVER w
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6090c2d8950..16de1421302 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1801,7 +1801,6 @@ NamedLWLockTrancheRequest
 NamedTuplestoreScan
 NamedTuplestoreScanState
 NamespaceInfo
-NavigationInfo
 NestLoop
 NestLoopParam
 NestLoopState
@@ -2478,6 +2477,8 @@ QuerySource
 QueueBackendStatus
 QueuePosition
 QuitSignalReason
+RPRNavExpr
+RPRNavKind
 RBTNode
 RBTOrderControl
 RBTree
-- 
2.50.1 (Apple Git-155)


From efb99428cfdb41363d49d4b7ca199f9212ba5a6e Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 10:54:30 +0900
Subject: [PATCH] Add JIT compilation support for RPR PREV/NEXT navigation

---
 src/backend/jit/llvm/llvmjit_expr.c | 72 +++++++++++++++++++++--------
 src/test/regress/expected/rpr.out   | 31 +++++++++++++
 src/test/regress/sql/rpr.sql        | 27 +++++++++++
 3 files changed, 111 insertions(+), 19 deletions(-)

diff --git a/src/backend/jit/llvm/llvmjit_expr.c b/src/backend/jit/llvm/llvmjit_expr.c
index d158e37e7b5..4901b2a7ff4 100644
--- a/src/backend/jit/llvm/llvmjit_expr.c
+++ b/src/backend/jit/llvm/llvmjit_expr.c
@@ -127,6 +127,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;
@@ -300,19 +303,16 @@ llvm_compile_expr(ExprState *state)
 	 * 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 pointers become stale because the new slot has
-	 * its own tts_values/tts_isnull arrays.  Fall back to the interpreter for
-	 * these expressions.
+	 * After a slot swap, these cached pointers become stale because the new
+	 * slot has its own tts_values/tts_isnull arrays.
 	 *
-	 * XXX To JIT-compile these expressions properly, the NAV_SET and
-	 * NAV_RESTORE handlers would need to reload the tts_values and tts_isnull
-	 * pointers from the new slot.  However, LLVM uses SSA (Static Single
-	 * Assignment) form where each value is defined exactly once.  When
-	 * different basic blocks produce different values for the same pointer,
-	 * LLVM requires PHI nodes at the merge point to select the correct one.
-	 * Without that plumbing, OUTER_VAR steps after a slot swap would read
-	 * from the wrong pointer.
+	 * 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)
 	{
@@ -323,9 +323,8 @@ llvm_compile_expr(ExprState *state)
 			if (opcode == EEOP_RPR_NAV_SET ||
 				opcode == EEOP_RPR_NAV_RESTORE)
 			{
-				LLVMDeleteFunction(eval_fn);
-				LLVMDisposeBuilder(b);
-				return false;
+				has_rpr_nav = true;
+				break;
 			}
 		}
 	}
@@ -492,8 +491,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)
 					{
@@ -2467,10 +2495,16 @@ llvm_compile_expr(ExprState *state)
 				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:
-				/* unreachable: filtered out by the pre-scan above */
-				Assert(false);
-				return false;
+				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/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index d586e17e0a1..de6ce4fba8a 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -2153,6 +2153,37 @@ SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
            0 |      99998 |     99999
 (1 row)
 
+-- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
+-- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
+-- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit_above_cost = 0;
+WITH data AS (
+ SELECT i, abs(50000 - i) AS price
+ FROM generate_series(1, 100000) i
+),
+result AS (
+ SELECT i, price,
+        count(*) OVER w AS match_len,
+        first_value(price) OVER w AS match_first
+ FROM data
+ WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  INITIAL
+  PATTERN (DOWN+ UP+)
+  DEFINE
+   DOWN AS price < PREV(price),
+   UP AS price > PREV(price)
+ )
+)
+SELECT count(*) AS matched_rows, max(match_len) AS longest_match
+FROM result WHERE match_len > 0;
+ matched_rows | longest_match 
+--------------+---------------
+            1 |         99999
+(1 row)
+
+RESET jit_above_cost;
 --
 -- IGNORE NULLS
 --
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index 504476a2b02..b3bbc8254c4 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -1084,6 +1084,33 @@ result AS (
 -- Should match: A (33333 rows) + B (33333 rows) + C (33333 rows) = 99999 rows
 SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
 
+-- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
+-- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
+-- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit_above_cost = 0;
+WITH data AS (
+ SELECT i, abs(50000 - i) AS price
+ FROM generate_series(1, 100000) i
+),
+result AS (
+ SELECT i, price,
+        count(*) OVER w AS match_len,
+        first_value(price) OVER w AS match_first
+ FROM data
+ WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  INITIAL
+  PATTERN (DOWN+ UP+)
+  DEFINE
+   DOWN AS price < PREV(price),
+   UP AS price > PREV(price)
+ )
+)
+SELECT count(*) AS matched_rows, max(match_len) AS longest_match
+FROM result WHERE match_len > 0;
+RESET jit_above_cost;
+
 --
 -- IGNORE NULLS
 --
-- 
2.50.1 (Apple Git-155)


From 7d9f1f094dbd685a03d982f9d62a86fb392c877f Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Sat, 4 Apr 2026 11:49:32 +0900
Subject: [PATCH] Add tuplestore trim optimization for RPR PREV navigation

Advance the tuplestore mark pointer based on the maximum PREV offset
found in DEFINE clause expressions, allowing tuplestore_trim() to
free rows that PREV can no longer reach.

The planner walks DEFINE expressions to find the maximum PREV offset.
If all offsets are constants, navMaxOffset is set directly. If any
offset is non-constant (parameter or expression), the planner sets
RPR_NAV_OFFSET_NEEDS_EVAL and the executor evaluates all PREV offsets
at init time. The executor then advances the mark to
(currentpos - navMaxOffset) each row.

NEXT offsets are ignored since they look forward and do not affect
trim. RPR_NAV_OFFSET_RETAIN_ALL is reserved for future navigation
functions (FIRST/LAST) that require the entire partition.
---
 src/backend/executor/nodeWindowAgg.c    | 136 ++++++++++++++++++++++--
 src/backend/optimizer/plan/createplan.c | 101 ++++++++++++++++++
 src/include/nodes/execnodes.h           |   1 +
 src/include/nodes/plannodes.h           |   9 ++
 src/include/optimizer/rpr.h             |   9 ++
 5 files changed, 246 insertions(+), 10 deletions(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 4e643df94cf..9787ef7756f 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -244,6 +244,10 @@ static void update_reduced_frame(WindowObject winobj, int64 pos);
 /* Forward declarations - NFA row evaluation */
 static bool nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched);
 
+/* Forward declarations - navigation offset evaluation */
+static bool collect_prev_offset_walker(Node *node, List **offsets);
+static void eval_nav_max_offset(WindowAggState *winstate, List *defineClause);
+
 /*
  * Not null info bit array consists of 2-bit items
  */
@@ -934,12 +938,18 @@ eval_windowaggregates(WindowAggState *winstate)
 		if (rpr_is_defined(winstate))
 		{
 			/*
-			 * If RPR is used, it is possible PREV wants to look at the
-			 * previous row.  So the mark pos should be frameheadpos - 1
-			 * unless it is below 0.
+			 * If RPR is used, PREV may need to look at rows before the frame
+			 * head.  Adjust mark by navMaxOffset if known, otherwise retain
+			 * from position 0.
 			 */
-			markpos -= 1;
-			if (markpos < 0)
+			if (winstate->navMaxOffset >= 0)
+			{
+				if (markpos > winstate->navMaxOffset)
+					markpos -= winstate->navMaxOffset;
+				else
+					markpos = 0;
+			}
+			else
 				markpos = 0;
 		}
 		WinSetMarkPosition(agg_winobj, markpos);
@@ -1269,12 +1279,15 @@ prepare_tuplestore(WindowAggState *winstate)
 	if (winstate->nav_winobj)
 	{
 		/*
-		 * Allocate a mark pointer pinned at position 0 so that the tuplestore
-		 * never truncates rows that a PREV(expr, N) might need.
+		 * Allocate mark and read pointers for PREV/NEXT navigation.
+		 *
+		 * If navMaxOffset >= 0, we advance the mark to (currentpos -
+		 * navMaxOffset) as rows are processed, allowing tuplestore_trim() to
+		 * free rows that are no longer reachable.
 		 *
-		 * XXX This retains the entire partition in the tuplestore.  If the
-		 * DEFINE clause only uses PREV/NEXT with small constant offsets, we
-		 * could advance the mark to (currentpos - max_offset) instead.
+		 * If navMaxOffset < 0 (RPR_NAV_OFFSET_NEEDS_EVAL or
+		 * RPR_NAV_OFFSET_RETAIN_ALL), the mark stays at 0, retaining the
+		 * entire partition in the tuplestore.
 		 */
 		winstate->nav_winobj->markptr =
 			tuplestore_alloc_read_pointer(winstate->buffer, 0);
@@ -2512,6 +2525,24 @@ ExecWindowAgg(PlanState *pstate)
 		if (winstate->grouptail_ptr >= 0)
 			update_grouptailpos(winstate);
 
+		/*
+		 * Advance RPR navigation mark pointer if possible, so that
+		 * tuplestore_trim() can free rows no longer reachable by PREV.
+		 */
+		if (winstate->nav_winobj &&
+			winstate->rpPattern != NULL &&
+			winstate->navMaxOffset >= 0)
+		{
+			int64		navmarkpos;
+
+			if (winstate->currentpos > winstate->navMaxOffset)
+				navmarkpos = winstate->currentpos - winstate->navMaxOffset;
+			else
+				navmarkpos = 0;
+			if (navmarkpos > winstate->nav_winobj->markpos)
+				WinSetMarkPosition(winstate->nav_winobj, navmarkpos);
+		}
+
 		/*
 		 * Truncate any no-longer-needed rows from the tuplestore.
 		 */
@@ -2957,6 +2988,10 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->rpSkipTo = node->rpSkipTo;
 	/* Set up row pattern recognition PATTERN clause (compiled NFA) */
 	winstate->rpPattern = node->rpPattern;
+	/* Set up max PREV offset for tuplestore trim */
+	winstate->navMaxOffset = node->navMaxOffset;
+	if (winstate->navMaxOffset == RPR_NAV_OFFSET_NEEDS_EVAL)
+		eval_nav_max_offset(winstate, node->defineClause);
 
 	/* Calculate NFA state size and allocate cycle detection bitmap */
 	if (node->rpPattern != NULL)
@@ -3867,6 +3902,87 @@ put_notnull_info(WindowObject winobj, int64 pos, int argno, bool isnull)
 	mbp[bpos] = mb;
 }
 
+/*
+ * collect_prev_offset_walker
+ *		Walk expression tree to collect PREV offset_arg expressions.
+ */
+static bool
+collect_prev_offset_walker(Node *node, List **offsets)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, RPRNavExpr))
+	{
+		RPRNavExpr *nav = (RPRNavExpr *) node;
+
+		if (nav->kind == RPR_NAV_PREV && nav->offset_arg != NULL)
+			*offsets = lappend(*offsets, nav->offset_arg);
+
+		/* Don't walk into RPRNavExpr children */
+		return false;
+	}
+
+	return expression_tree_walker(node, collect_prev_offset_walker, offsets);
+}
+
+/*
+ * eval_nav_max_offset
+ *		Evaluate non-constant PREV offsets at executor init time.
+ *
+ * Called when the planner set navMaxOffset to RPR_NAV_OFFSET_NEEDS_EVAL
+ * because some PREV offset contains a parameter or non-foldable expression.
+ * Walks the original defineClause expression trees, compiles and evaluates
+ * each PREV offset_arg, and stores the maximum in winstate->navMaxOffset.
+ */
+static void
+eval_nav_max_offset(WindowAggState *winstate, List *defineClause)
+{
+	ExprContext *econtext = winstate->ss.ps.ps_ExprContext;
+	List	   *offsets = NIL;
+	ListCell   *lc;
+	int64		maxOffset = 0;
+
+	/* Collect all PREV offset expressions from DEFINE clause */
+	foreach(lc, defineClause)
+	{
+		TargetEntry *te = (TargetEntry *) lfirst(lc);
+
+		collect_prev_offset_walker((Node *) te->expr, &offsets);
+	}
+
+	/* Evaluate each offset and find maximum */
+	foreach(lc, offsets)
+	{
+		Expr	   *offset_expr = (Expr *) lfirst(lc);
+		ExprState  *estate;
+		Datum		val;
+		bool		isnull;
+		int64		offset;
+
+		estate = ExecInitExpr(offset_expr, (PlanState *) winstate);
+		val = ExecEvalExprSwitchContext(estate, econtext, &isnull);
+
+		/*
+		 * NULL or negative offsets will cause a runtime error when PREV is
+		 * actually evaluated.  For trim purposes, treat them as 0.
+		 */
+		if (isnull)
+			continue;
+
+		offset = DatumGetInt64(val);
+		if (offset < 0)
+			continue;
+
+		if (offset > maxOffset)
+			maxOffset = offset;
+	}
+
+	winstate->navMaxOffset = maxOffset;
+
+	list_free(offsets);
+}
+
 /*
  * rpr_is_defined
  * return true if Row pattern recognition is defined.
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 9ac24cc222d..ee2d53b5924 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -2461,6 +2461,104 @@ create_minmaxagg_plan(PlannerInfo *root, MinMaxAggPath *best_path)
 	return plan;
 }
 
+/*
+ * nav_max_offset_walker
+ *		Walk expression tree to find the maximum PREV offset.
+ *
+ * Only PREV is relevant for tuplestore trim since it looks backward;
+ * NEXT looks forward and never references already-trimmed rows.
+ *
+ * Returns true (to stop walking) if a non-constant PREV offset is found,
+ * in which case *maxOffset is set to -1.  Otherwise accumulates the
+ * maximum constant offset value.
+ */
+static bool
+nav_max_offset_walker(Node *node, int64 *maxOffset)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, RPRNavExpr))
+	{
+		RPRNavExpr *nav = (RPRNavExpr *) node;
+
+		/* Only PREV looks backward; NEXT is irrelevant for trim */
+		if (nav->kind == RPR_NAV_PREV)
+		{
+			int64		offset;
+
+			if (nav->offset_arg == NULL)
+			{
+				/* 1-arg form: implicit offset of 1 */
+				offset = 1;
+			}
+			else if (IsA(nav->offset_arg, Const))
+			{
+				Const	   *c = (Const *) nav->offset_arg;
+
+				if (c->constisnull)
+				{
+					/*
+					 * NULL offset causes a runtime error, so this path is
+					 * never actually reached during execution.  Use 0 as a
+					 * safe placeholder for planning purposes.
+					 */
+					offset = 0;
+				}
+				else
+				{
+					offset = DatumGetInt64(c->constvalue);
+					if (offset < 0)
+						offset = 0; /* negative offset causes runtime error */
+				}
+			}
+			else
+			{
+				/*
+				 * Non-constant offset (Param, stable function, etc.). The
+				 * parser guarantees offset is a runtime constant, so it can
+				 * be evaluated at executor init time.
+				 */
+				*maxOffset = RPR_NAV_OFFSET_NEEDS_EVAL;
+				return true;	/* stop walking */
+			}
+
+			if (offset > *maxOffset)
+				*maxOffset = offset;
+		}
+
+		/* Don't walk into RPRNavExpr children - offset_arg already handled */
+		return false;
+	}
+
+	return expression_tree_walker(node, nav_max_offset_walker, maxOffset);
+}
+
+/*
+ * compute_nav_max_offset
+ *		Compute the maximum PREV offset from DEFINE clause expressions.
+ *
+ * Returns the maximum constant offset found, or -1 if any PREV offset
+ * cannot be determined statically.  NEXT offsets are ignored since they
+ * look forward and don't affect tuplestore trim.
+ */
+static int64
+compute_nav_max_offset(List *defineClause)
+{
+	int64		maxOffset = 0;
+	ListCell   *lc;
+
+	foreach(lc, defineClause)
+	{
+		TargetEntry *te = (TargetEntry *) lfirst(lc);
+
+		if (nav_max_offset_walker((Node *) te->expr, &maxOffset))
+			return RPR_NAV_OFFSET_NEEDS_EVAL;
+	}
+
+	return maxOffset;
+}
+
 /*
  * create_windowagg_plan
  *
@@ -6678,6 +6776,9 @@ make_windowagg(List *tlist, WindowClause *wc,
 
 	node->defineClause = defineClause;
 
+	/* Compute max PREV offset for tuplestore trim optimization */
+	node->navMaxOffset = compute_nav_max_offset(defineClause);
+
 	plan->targetlist = tlist;
 	plan->lefttree = lefttree;
 	plan->righttree = NULL;
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 74a6b682132..ff6d7d70a60 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2692,6 +2692,7 @@ typedef struct WindowAggState
 	TupleTableSlot *temp_slot_2;
 
 	/* RPR navigation */
+	int64		navMaxOffset;	/* max PREV offset; see RPR_NAV_OFFSET_* */
 	struct WindowObjectData *nav_winobj;	/* winobj for RPR nav fetch */
 	int64		nav_slot_pos;	/* position cached in nav_slot, or -1 */
 	TupleTableSlot *nav_slot;	/* slot for PREV/NEXT target row */
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index ceaab4d97b0..27a2e7b48c7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -1386,6 +1386,15 @@ typedef struct WindowAgg
 	/* Row Pattern DEFINE clause (list of TargetEntry) */
 	List	   *defineClause;
 
+	/*
+	 * Maximum PREV offset for tuplestore mark optimization. >= 0: statically
+	 * determined max offset (mark = currentpos - offset).
+	 * RPR_NAV_OFFSET_NEEDS_EVAL: has non-constant offset; evaluate at
+	 * executor init.  RPR_NAV_OFFSET_RETAIN_ALL: must retain entire partition
+	 * (no trim possible).
+	 */
+	int64		navMaxOffset;
+
 	/*
 	 * false for all apart from the WindowAgg that's closest to the root of
 	 * the plan
diff --git a/src/include/optimizer/rpr.h b/src/include/optimizer/rpr.h
index 360e1bb777f..00a28abe2b4 100644
--- a/src/include/optimizer/rpr.h
+++ b/src/include/optimizer/rpr.h
@@ -55,6 +55,15 @@
 #define RPRElemIsFin(e)			((e)->varId == RPR_VARID_FIN)
 #define RPRElemCanSkip(e)		((e)->min == 0)
 
+/*
+ * navMaxOffset sentinel values.
+ * Non-negative values represent a statically determined maximum PREV offset.
+ */
+#define RPR_NAV_OFFSET_NEEDS_EVAL	(-1)	/* has non-constant PREV offset;
+											 * evaluate at executor init */
+#define RPR_NAV_OFFSET_RETAIN_ALL	(-2)	/* must retain entire partition
+											 * (e.g., future FIRST/LAST) */
+
 extern List *collectPatternVariables(RPRPatternNode *pattern);
 extern void buildDefineVariableList(List *defineClause,
 									List **defineVariableList);
-- 
2.50.1 (Apple Git-155)


From fea456180111bfdb10d4dc580fc4062fe0ae6b15 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 16:06:06 +0900
Subject: [PATCH] Update RPR code comments to reflect 1-slot navigation model

---
 src/backend/executor/execRPR.c | 45 ++++++++++++++++++++++------------
 src/backend/parser/parse_rpr.c |  3 ++-
 2 files changed, 32 insertions(+), 16 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 4c429528b04..5428d0e8fc4 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -191,10 +191,15 @@
  * transformDefineClause() processes each DEFINE variable as follows:
  *
  *   (1) Checks for duplicate variable names
- *   (2) Transforms the expression into a standard SQL expression
- *   (3) Coerces to Boolean type (coerce_to_boolean)
+ *   (2) Transforms the expression via transformExpr()
+ *   (3) Extracts Var nodes via pull_var_clause() and ensures each is
+ *       present in the query targetlist, so the planner propagates the
+ *       referenced columns through the plan tree
  *   (4) Wraps in a TargetEntry with the variable name set in resname
  *
+ * After all variables are processed:
+ *   (5) Coerces each expression to Boolean type (coerce_to_boolean)
+ *
  * Variables that are used in PATTERN but not defined in DEFINE are implicitly
  * evaluated as TRUE (matching all rows).
  *
@@ -431,8 +436,9 @@
  *
  *   Case 1: Simple VAR+ (e.g., A+)
  *           -> ABSORBABLE | ABSORBABLE_BRANCH set on the VAR
- *   Case 2: GROUP+ whose body consists only of {1,1} VARs (e.g., (A B)+)
- *           -> ABSORBABLE_BRANCH on children,
+ *   Case 2: GROUP+ with fixed-length children (min == max, recursively)
+ *           e.g., (A B)+, (A B{2})+, ((A (B C){2}){2})+
+ *           -> ABSORBABLE_BRANCH on all elements within the group,
  *             ABSORBABLE | ABSORBABLE_BRANCH on END
  *   Case 3: GROUP+ whose body starts with VAR+ (e.g., (A+ B)+)
  *           -> Recurses from BEGIN into the body, applying Case 1.
@@ -604,11 +610,17 @@
  *       varMatched[i] = (not null and true)
  *
  * To support row navigation operators such as PREV() and NEXT(),
- * the previous row, current row, and next row are set in separate slots:
+ * a 1-slot model is used: only ecxt_outertuple is set to the current
+ * row.  PREV/NEXT navigation is handled by EEOP_RPR_NAV_SET/RESTORE
+ * opcodes emitted during DEFINE expression compilation:
+ *
+ *   NAV_SET:     save ecxt_outertuple, swap in target row via nav_slot
+ *   (evaluate):  argument expression reads from swapped slot
+ *   NAV_RESTORE: restore original ecxt_outertuple
  *
- *   ecxt_scantuple  = previous row (for PREV reference)
- *   ecxt_outertuple = current row  (default reference)
- *   ecxt_innertuple = next row     (for NEXT reference)
+ * nav_slot caches the last fetched position (nav_slot_pos) to avoid
+ * redundant tuplestore lookups when multiple PREV/NEXT calls target
+ * the same row.
  *
  * The varMatched array is referenced later in Phase 1 (Match).
  *
@@ -908,8 +920,11 @@
  *
  *   (2) At runtime: initialize the nfaVisitedElems bitmap immediately before
  *       DFS expansion of each state within advance (once per state).
- *       During DFS, set the corresponding elemIdx bit when visiting each
- *       element.
+ *       During DFS, epsilon elements (END, ALT, BEGIN) are marked in the
+ *       bitmap at nfa_advance_state entry.  VAR elements are marked later
+ *       when added to the state list (nfa_add_state_unique), so that
+ *       legitimate loop-back to the same VAR in a new group iteration
+ *       (e.g., END -> ALT -> same VAR) is not blocked.
  *       If a previously visited elemIdx is revisited, that path is terminated.
  *
  *   Note: the bitmap tracks only elemIdx and does not consider counts.
@@ -1216,11 +1231,11 @@
  *   (3) State Deduplication (IX-5)
  *
  *     During advance, DFS may generate states with the same (elemIdx,
- *     counts) combination through multiple paths. Additionally, unlike
- *     VAR repetition, group repetition cannot perform absorption
- *     comparison using VAR states, so inline advance is performed from
- *     after Phase 1 match through to END; this process can also produce
- *     duplicate states reaching the same END.
+ *     counts) combination through multiple paths. Additionally, for
+ *     group absorption, nfa_match performs inline advance from bounded
+ *     VARs (count >= max) within the absorbable region (ABSORBABLE_BRANCH)
+ *     through END chains to reach the judgment point (ABSORBABLE END).
+ *     This process can also produce duplicate states reaching the same END.
  *     nfa_add_state_unique() blocks duplicate addition of identical states
  *     in both cases.
  *
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 3fb5d94abe9..d1e02e52e53 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -265,7 +265,8 @@ validateRPRPatternVarCount(ParseState *pstate, RPRPatternNode *node,
  *
  * Then for each DEFINE variable:
  *   2. Checks for duplicate variable names in DEFINE clause
- *   3. Transforms expressions and adds to targetlist via findTargetlistEntrySQL99
+ *   3. Transforms expression via transformExpr() and ensures referenced
+ *      Var nodes are present in the query targetlist (via pull_var_clause)
  *   4. Creates defineClause entry with proper resname (pattern variable name)
  *   5. Coerces expressions to boolean type
  *   6. Marks column origins and assigns collation information
-- 
2.50.1 (Apple Git-155)


From 79f32ee6991e3cbf5bea0e51099e9613c13f5ec0 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Mon, 6 Apr 2026 09:48:54 +0900
Subject: [PATCH] Enable JIT compilation for PREV/NEXT navigation tests in RPR

---
 src/test/regress/expected/rpr.out | 2 ++
 src/test/regress/sql/rpr.sql      | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index de6ce4fba8a..5a460e9bd52 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -2156,6 +2156,7 @@ SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
 -- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
 -- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
 -- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit = on;
 SET jit_above_cost = 0;
 WITH data AS (
  SELECT i, abs(50000 - i) AS price
@@ -2184,6 +2185,7 @@ FROM result WHERE match_len > 0;
 (1 row)
 
 RESET jit_above_cost;
+RESET jit;
 --
 -- IGNORE NULLS
 --
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index b3bbc8254c4..e417789eb2b 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -1087,6 +1087,7 @@ SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
 -- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
 -- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
 -- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit = on;
 SET jit_above_cost = 0;
 WITH data AS (
  SELECT i, abs(50000 - i) AS price
@@ -1110,6 +1111,7 @@ result AS (
 SELECT count(*) AS matched_rows, max(match_len) AS longest_match
 FROM result WHERE match_len > 0;
 RESET jit_above_cost;
+RESET jit;
 
 --
 -- IGNORE NULLS
-- 
2.50.1 (Apple Git-155)


From 081f70847766b2aa312b667e5b7c8e2a41088378 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Mon, 6 Apr 2026 09:53:14 +0900
Subject: [PATCH] Add 2-arg PREV/NEXT test for row pattern navigation with host
 variable

---
 src/test/regress/expected/rpr.out | 63 +++++++++++++++++++++++++++++++
 src/test/regress/sql/rpr.sql      | 16 ++++++++
 2 files changed, 79 insertions(+)

diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index 5a460e9bd52..c02dbd4c08d 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -1492,6 +1492,69 @@ EXECUTE test_prev_offset(-1);
 ERROR:  PREV/NEXT offset must not be negative
 EXECUTE test_prev_offset(NULL);
 ERROR:  PREV/NEXT offset must not be null
+DEALLOCATE test_prev_offset;
+-- 2-arg PREV/NEXT: host variable with positive value
+-- Exercises RPR_NAV_OFFSET_NEEDS_EVAL -> eval_nav_max_offset() path
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS price > PREV(price, $1)
+);
+EXECUTE test_prev_offset(1);
+ company  |   tdate    | price | first_value | count 
+----------+------------+-------+-------------+-------
+ company1 | 07-01-2023 |   100 |         100 |     2
+ company1 | 07-02-2023 |   200 |             |     0
+ company1 | 07-03-2023 |   150 |             |     0
+ company1 | 07-04-2023 |   140 |         140 |     2
+ company1 | 07-05-2023 |   150 |             |     0
+ company1 | 07-06-2023 |    90 |          90 |     3
+ company1 | 07-07-2023 |   110 |             |     0
+ company1 | 07-08-2023 |   130 |             |     0
+ company1 | 07-09-2023 |   120 |         120 |     2
+ company1 | 07-10-2023 |   130 |             |     0
+ company2 | 07-01-2023 |    50 |          50 |     2
+ company2 | 07-02-2023 |  2000 |             |     0
+ company2 | 07-03-2023 |  1500 |             |     0
+ company2 | 07-04-2023 |  1400 |        1400 |     2
+ company2 | 07-05-2023 |  1500 |             |     0
+ company2 | 07-06-2023 |    60 |          60 |     3
+ company2 | 07-07-2023 |  1100 |             |     0
+ company2 | 07-08-2023 |  1300 |             |     0
+ company2 | 07-09-2023 |  1200 |        1200 |     2
+ company2 | 07-10-2023 |  1300 |             |     0
+(20 rows)
+
+EXECUTE test_prev_offset(2);
+ company  |   tdate    | price | first_value | count 
+----------+------------+-------+-------------+-------
+ company1 | 07-01-2023 |   100 |             |     0
+ company1 | 07-02-2023 |   200 |         200 |     2
+ company1 | 07-03-2023 |   150 |             |     0
+ company1 | 07-04-2023 |   140 |             |     0
+ company1 | 07-05-2023 |   150 |             |     0
+ company1 | 07-06-2023 |    90 |             |     0
+ company1 | 07-07-2023 |   110 |         110 |     3
+ company1 | 07-08-2023 |   130 |             |     0
+ company1 | 07-09-2023 |   120 |             |     0
+ company1 | 07-10-2023 |   130 |             |     0
+ company2 | 07-01-2023 |    50 |             |     0
+ company2 | 07-02-2023 |  2000 |        2000 |     2
+ company2 | 07-03-2023 |  1500 |             |     0
+ company2 | 07-04-2023 |  1400 |             |     0
+ company2 | 07-05-2023 |  1500 |             |     0
+ company2 | 07-06-2023 |    60 |             |     0
+ company2 | 07-07-2023 |  1100 |        1100 |     3
+ company2 | 07-08-2023 |  1300 |             |     0
+ company2 | 07-09-2023 |  1200 |             |     0
+ company2 | 07-10-2023 |  1300 |             |     0
+(20 rows)
+
 DEALLOCATE test_prev_offset;
 -- 2-arg: two PREV with different offsets in same DEFINE clause
 -- B: price exceeds both 1-back and 2-back values
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index e417789eb2b..47f33904690 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -732,6 +732,22 @@ EXECUTE test_prev_offset(-1);
 EXECUTE test_prev_offset(NULL);
 DEALLOCATE test_prev_offset;
 
+-- 2-arg PREV/NEXT: host variable with positive value
+-- Exercises RPR_NAV_OFFSET_NEEDS_EVAL -> eval_nav_max_offset() path
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS price > PREV(price, $1)
+);
+EXECUTE test_prev_offset(1);
+EXECUTE test_prev_offset(2);
+DEALLOCATE test_prev_offset;
+
 -- 2-arg: two PREV with different offsets in same DEFINE clause
 -- B: price exceeds both 1-back and 2-back values
 SELECT company, tdate, price,
-- 
2.50.1 (Apple Git-155)


From a4f260651422e7bc364789d493784d5e7f9e7c52 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Mon, 6 Apr 2026 10:46:38 +0900
Subject: [PATCH] Add Nav Mark Lookback to EXPLAIN and fix
 compute_nav_max_offset()

Show the planner-computed navMaxOffset in EXPLAIN output as
"Nav Mark Lookback" for RPR windows, so that tuplestore trim
behavior is visible in query plans.  Displayed as an integer for
constant offsets, "runtime" for non-constant (Param) offsets, and
"retain all" when the entire partition must be preserved.

Fix compute_nav_max_offset() to return the walker-set value instead
of unconditionally returning RPR_NAV_OFFSET_NEEDS_EVAL when the
walker stops early.

Add regression tests covering constant offsets, NEXT-only, multiple
PREV offsets, and host variable offsets under both custom and generic
plans.
---
 src/backend/commands/explain.c                |   9 +
 src/backend/optimizer/plan/createplan.c       |   2 +-
 src/test/regress/expected/rpr_base.out        | 231 ++++++----
 src/test/regress/expected/rpr_explain.out     | 397 ++++++++++++++----
 src/test/regress/expected/rpr_integration.out |  70 ++-
 src/test/regress/sql/rpr_explain.sql          |  71 ++++
 6 files changed, 590 insertions(+), 190 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 933eadab71e..1848de9de7a 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3254,6 +3254,15 @@ show_window_def(WindowAggState *planstate, List *ancestors, ExplainState *es)
 			ExplainPropertyText("Pattern", patternStr, es);
 			pfree(patternStr);
 		}
+
+		/* Show navigation offsets for tuplestore trim */
+		if (wagg->navMaxOffset == RPR_NAV_OFFSET_RETAIN_ALL)
+			ExplainPropertyText("Nav Mark Lookback", "retain all", es);
+		else if (wagg->navMaxOffset == RPR_NAV_OFFSET_NEEDS_EVAL)
+			ExplainPropertyText("Nav Mark Lookback", "runtime", es);
+		else
+			ExplainPropertyInteger("Nav Mark Lookback", NULL,
+								   wagg->navMaxOffset, es);
 	}
 }
 
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index ee2d53b5924..8ee3ccf6d0d 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -2553,7 +2553,7 @@ compute_nav_max_offset(List *defineClause)
 		TargetEntry *te = (TargetEntry *) lfirst(lc);
 
 		if (nav_max_offset_walker((Node *) te->expr, &maxOffset))
-			return RPR_NAV_OFFSET_NEEDS_EVAL;
+			return maxOffset;	/* NEEDS_EVAL or RETAIN_ALL */
 	}
 
 	return maxOffset;
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 37aa81ebdea..1e450a07ced 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -3065,10 +3065,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{3}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive VAR merge: A{2} A{3} -> a{5}
 EXPLAIN (COSTS OFF)
@@ -3080,10 +3081,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{5}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive VAR merge: A+ A* -> a+
 EXPLAIN (COSTS OFF)
@@ -3095,10 +3097,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive VAR merge: A A+ -> a{2,}
 -- Tests line 251: child->max == RPR_QUANTITY_INF branch in mergeConsecutiveVars
@@ -3112,10 +3115,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive GROUP merge with finite quantifiers: ((A B){5}) ((A B){10}) -> merged
 EXPLAIN (COSTS OFF)
@@ -3127,10 +3131,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b){15}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive GROUP merge with unbounded: (A B)+ (A B)+ -> (a b){2,}
 EXPLAIN (COSTS OFF)
@@ -3142,10 +3147,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive GROUP merge: (A B){2} (A B)+ -> (a b){3,}
 -- Tests line 325: child->max == RPR_QUANTITY_INF branch in mergeConsecutiveGroups
@@ -3159,10 +3165,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){3,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- PREFIX merge: A B (A B)+ -> (a b){2,}
 EXPLAIN (COSTS OFF)
@@ -3174,10 +3181,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- PREFIX and SUFFIX merge: A B (A B)+ A B -> (a b){3,}
 EXPLAIN (COSTS OFF)
@@ -3189,10 +3197,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){3,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Flatten nested: A ((B) (C)) -> a b c
 EXPLAIN (COSTS OFF)
@@ -3204,10 +3213,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b c
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Data execution: SEQ flatten produces correct results
 SELECT id, val, count(*) OVER w AS cnt
@@ -3239,10 +3249,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b | c)+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- ALT deduplicate: (A | B | A) -> (a | b)
 EXPLAIN (COSTS OFF)
@@ -3254,10 +3265,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b)+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Data execution: ALT dedup produces correct results
 SELECT id, val, count(*) OVER w AS cnt
@@ -3289,10 +3301,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{6}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Quantifier multiply with child range: (A{2,3}){3} -> a{6,9}
 -- outer exact, child range - optimization applies
@@ -3305,10 +3318,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{6,9}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Quantifier NO multiply: (A{2}){2,3} stays as (a{2}){2,3}
 -- outer range - gaps would occur (4,6 not 4,5,6), no optimization
@@ -3321,10 +3335,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2}){2,3}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Quantifier NO multiply: (A{2}){2,} stays as (a{2}){2,}
 -- outer unbounded - gaps would occur (4,6,8,... not 4,5,6,...), no optimization
@@ -3337,10 +3352,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2}'){2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Quantifier multiply: (A){2,} -> a{2,}
 -- child exact 1 - no gaps, optimization applies
@@ -3353,10 +3369,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Quantifier multiply: (A)+ -> a+
 -- child exact 1 - no gaps, optimization applies
@@ -3369,10 +3386,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Quantifier NO multiply: (A{2}){3,5} stays as (a{2}){3,5}
 -- outer range, child exact > 1 - gaps would occur (6,8,10 not 6,7,8,9,10)
@@ -3385,10 +3403,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2}){3,5}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Quantifier NO multiply: (A{2,3}){2,3} stays as (a{2,3}){2,3}
 -- outer range, child range - gaps possible (e.g., (A{4,5}){2,3} misses 11)
@@ -3401,10 +3420,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2,3}){2,3}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested unbounded: (A*)* -> a*
 EXPLAIN (COSTS OFF)
@@ -3416,10 +3436,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a*"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested unbounded: (A+)* -> a*
 EXPLAIN (COSTS OFF)
@@ -3431,10 +3452,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a*"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested unbounded: (A+)+ -> a+
 EXPLAIN (COSTS OFF)
@@ -3446,10 +3468,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Unwrap GROUP{1,1}: (A) -> a
 EXPLAIN (COSTS OFF)
@@ -3461,10 +3484,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Unwrap GROUP{1,1}: (A B) -> a b
 EXPLAIN (COSTS OFF)
@@ -3476,10 +3500,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Combined optimization: A A (B B)+ B B C C C -> a{2} (b{2}){2,} c{3}
 EXPLAIN (COSTS OFF)
@@ -3492,10 +3517,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{2} (b{2}){2,} c{3}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive GROUP merge with unbounded: (A+) (A+) -> a{2,}
 -- Tests mergeConsecutiveGroups with child->max == INF
@@ -3508,10 +3534,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive GROUP merge finite: (A{10}){20} -> a{200}
 -- Tests mergeConsecutiveGroups with both finite
@@ -3524,10 +3551,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{200}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Different GROUP prevents merge: (A B){2} (C D){3}
 -- Tests mergeConsecutiveGroups flush previous
@@ -3542,10 +3570,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b){2} (c d){3}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Different children count prevents merge: (A B)+ (A B C)+
 -- Tests rprPatternChildrenEqual length check
@@ -3559,10 +3588,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+" (a b c)+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- PREFIX only merge: A B (A B)+ -> (a b){2,}
 -- Tests mergeGroupPrefixSuffix: absorb preceding elements into GROUP min
@@ -3575,10 +3605,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- SUFFIX only merge: (A B)+ A B -> (a b){2,}
 -- Tests mergeGroupPrefixSuffix: absorb following elements into GROUP min
@@ -3591,10 +3622,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){2,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Multiple SUFFIX absorption with skipUntil: (A B)+ A B A B C
 -- Tests mergeGroupPrefixSuffix: skip absorbed suffix elements
@@ -3608,10 +3640,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){3,}" c
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- PREFIX merge with remaining prefix: A B C D (C D)+
 -- Tests mergeGroupPrefixSuffix: trimmed list reconstruction
@@ -3626,10 +3659,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b (c d){2,}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- PREFIX merge with quantifiers: A B* (A B*)+ -> (a b*){2,}
 -- Tests mergeGroupPrefixSuffix: quantifier comparison in rprPatternEqual
@@ -3643,10 +3677,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b*){2,}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- PREFIX merge with multiple quantifiers: A+ B* C? (A+ B* C?)+ -> (a+ b* c?){2,}
 -- Tests mergeGroupPrefixSuffix: complex quantifier patterns
@@ -3660,10 +3695,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a+" b* c?){2,}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- SUFFIX merge with quantifiers: (A B*)+ A B* -> (a b*){2,}
 -- Tests mergeGroupPrefixSuffix: suffix with quantifiers
@@ -3677,10 +3713,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b*){2,}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Unwrap GROUP{1,1}: ((A | B | C)) -> (a | b | c)
 -- Tests tryUnwrapGroup removing redundant outer GROUP
@@ -3693,10 +3730,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b | c)
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Data execution: GROUP unwrap produces correct results
 SELECT id, val, count(*) OVER w AS cnt
@@ -3729,10 +3767,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+? a
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant optimization bypass: GROUP merge
 -- (A B)+? (A B) stays separate (greedy merges to (a b){2,})
@@ -3745,10 +3784,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b)+? a b
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant optimization bypass: quantifier multiply (outer reluctant)
 -- (A{2}){3}? stays as (a{2}){3}? (greedy merges to a{6})
@@ -3761,10 +3801,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2}){3}?
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant optimization bypass: quantifier multiply (inner reluctant)
 -- (A{2}?){3} stays as (a{2}?){3} (greedy merges to a{6})
@@ -3777,10 +3818,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2}?){3}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant optimization bypass: PREFIX merge
 -- A B (A B)+? stays separate (greedy merges to (a b){2,})
@@ -3793,10 +3835,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b (a b)+?
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant optimization bypass: SUFFIX merge
 -- (A B)+? A B stays separate (greedy merges to (a b){2,})
@@ -3809,10 +3852,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b)+? a b
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- GROUP unwrap with quantifier propagation: (A)?? B -> a?? b
 -- Single VAR child {1,1} receives GROUP's quantifier and reluctant
@@ -3825,10 +3869,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a?? b
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant preserved through ALT flatten
 -- (A | (B | C))+? flattens to (a | b | c)+? - inner ALT flattened, reluctant kept
@@ -3841,10 +3886,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b | c)+?
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant optimization bypass: absorption flags
 -- A+? with SKIP PAST LAST ROW - no absorption markers (greedy A+ gets a+")
@@ -3857,10 +3903,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+?
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Duplicate GROUP removal: ((A | B)+ | (A | B)+) -> (a | b)+
 EXPLAIN (COSTS OFF)
@@ -3872,10 +3919,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b)+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive VAR merge with zero-min: A* A+ -> a+
 EXPLAIN (COSTS OFF)
@@ -3887,10 +3935,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Consecutive VAR merge (4-element): A A{2} A+ A{3} -> a{7,}
 EXPLAIN (COSTS OFF)
@@ -3902,10 +3951,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{7,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- PREFIX+SUFFIX merge (5-way): A B A B (A B)+ A B A B -> (a b){5,}
 EXPLAIN (COSTS OFF)
@@ -3918,10 +3968,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b'){5,}"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Unwrap single-item ALT after dedup: (A | A)+ -> a+
 -- ALT dedup reduces to single-item, then GROUP unwrap
@@ -3934,10 +3985,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- GROUP{1,1} to SEQ with flatten: ((A B)(C D)) -> a b c d
 EXPLAIN (COSTS OFF)
@@ -3951,10 +4003,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b c d
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested ALT pattern: ((A B) | C) D | A B C
 EXPLAIN (COSTS OFF)
@@ -3968,10 +4021,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: ((a b | c) d | a b c)
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested ALT with unbounded: ((A+ B) | C) D | A B C
 EXPLAIN (COSTS OFF)
@@ -3985,10 +4039,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: ((a+" b | c) d | a b c)
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- ============================================================
 -- Absorption Flag Display Tests
@@ -4006,10 +4061,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- GROUP unbounded: (A B)+ -> (a' b')+" (branch + judgment)
 EXPLAIN (COSTS OFF)
@@ -4021,10 +4077,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- ALT both absorbable: A+ | B+ -> (a+" | b+")
 EXPLAIN (COSTS OFF)
@@ -4036,10 +4093,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a+" | b+")
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- ALT one absorbable: A+ | B -> (a+" | b)
 EXPLAIN (COSTS OFF)
@@ -4051,10 +4109,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a+" | b)
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Sequence with absorbable start: A+ B -> a+" b
 EXPLAIN (COSTS OFF)
@@ -4066,10 +4125,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Complex nested: ((A+ B) | C) D | A B C - deeply nested ALT
 EXPLAIN (COSTS OFF)
@@ -4082,10 +4142,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: ((a+" b | c) d | a b c)
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested unbounded: (A+ | B)+ -> (a+" | b)+ (first iteration absorbable)
 EXPLAIN (COSTS OFF)
@@ -4098,10 +4159,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a+" | b)+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- ALT inside unbounded GROUP: (A+ B | A B)* -> (a+" b | a b)* (first iteration absorbable)
 EXPLAIN (COSTS OFF)
@@ -4114,10 +4176,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a+" b | a b)*
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Fixed-length group absorbable: (A{2} B{3})+ -> (a{2}' b{3}'){2,}"
 -- All children have min == max, equivalent to unrolling to {1,1}
@@ -4131,10 +4194,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2}' b{3}')+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested fixed-length group: (A (B C){2} D)+ -> absorbable
 EXPLAIN (COSTS OFF)
@@ -4147,10 +4211,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' (b' c'){2}' d')+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Nested fixed-length with inner quantifier: ((A{2} B{3}){2})+ -> absorbable
 EXPLAIN (COSTS OFF)
@@ -4163,10 +4228,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: ((a{2}' b{3}'){2}')+"
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Non-absorbable fixed-length: (A B{2,5})+ -> no markers (min != max)
 EXPLAIN (COSTS OFF)
@@ -4179,10 +4245,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b{2,5})+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Non-absorbable fixed-length: (A B?)+ -> no markers (min != max)
 EXPLAIN (COSTS OFF)
@@ -4195,10 +4262,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b?)+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Non-absorbable (unbounded not at start): A B+ -> a b+ (no markers)
 EXPLAIN (COSTS OFF)
@@ -4210,10 +4278,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Non-absorbable (no unbounded branch): (A | B){2,} -> (a | b){2,} (no markers)
 EXPLAIN (COSTS OFF)
@@ -4225,10 +4294,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b){2,}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Non-absorbable (SKIP TO NEXT ROW): A+ -> a+ (no markers)
 EXPLAIN (COSTS OFF)
@@ -4240,10 +4310,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Non-absorbable (limited frame): A+ -> a+ (no markers)
 EXPLAIN (COSTS OFF)
@@ -4255,10 +4326,11 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND 10 FOLLOWING
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND '10'::bigint FOLLOWING)
    Pattern: a+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- Reluctant {1}? quantifier deparse
 -- A{1}? is a reluctant {1,1} quantifier.  The deparse code must
@@ -4277,10 +4349,11 @@ WINDOW w AS (
  WindowAgg
    Window: w AS (ORDER BY val ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{1}? b
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: val
          ->  Seq Scan on rpr_plan
-(6 rows)
+(7 rows)
 
 -- ============================================================
 -- Absorption Analysis Tests
@@ -4775,10 +4848,11 @@ WINDOW w AS (
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2000000000}){2}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_fallback
-(6 rows)
+(7 rows)
 
 -- Expected: Fallback - pattern not merged due to min overflow (4000000000 > INT32_MAX)
 -- Test: max-only quantifier overflow causes optimization fallback
@@ -4795,10 +4869,11 @@ WINDOW w AS (
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{1,2000000000}){2}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_fallback
-(6 rows)
+(7 rows)
 
 -- Expected: Fallback - min OK (2*1=2), but max overflow (2*2000000000 > INT32_MAX)
 -- Test: max quantifier exceeds valid range (2147483647 = INT_MAX, limit is 2147483646)
@@ -4828,10 +4903,11 @@ WINDOW w AS (
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a{2000000000,}"){2000000000,}
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_fallback
-(6 rows)
+(7 rows)
 
 -- Expected: Fallback - min overflow (2000000000 * 2000000000 > INT32_MAX)
 -- Test: prefix mismatch causes optimization fallback
@@ -4848,10 +4924,11 @@ WINDOW w AS (
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b (c d)+
+   Nav Mark Lookback: 0
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_fallback
-(6 rows)
+(7 rows)
 
 -- Expected: Fallback - prefix elements don't match GROUP content
 DROP TABLE rpr_fallback;
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index dc3075e6bd3..77ab25a2289 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -36,6 +36,7 @@
 --   Window Function Combinations
 --   DEFINE Expression Variations
 --   Large Scale Statistics Verification
+--   Nav Mark Lookback (tuplestore trim)
 -- ============================================================
 -- Filter function to normalize platform-dependent memory values (not NFA statistics).
 -- NFA statistics should not change between platforms; if they do, it could
@@ -141,13 +142,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 101 total, 0 merged
    NFA Contexts: 2 peak, 101 total, 60 pruned
    NFA: 20 matched (len 2/2/2.0), 0 mismatched
    NFA: 0 absorbed, 20 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Pattern with no matches - 0 matched
 CREATE VIEW rpr_ev_basic_nomatch AS
@@ -180,12 +182,13 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: x y z
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 1 peak, 101 total, 0 merged
    NFA Contexts: 2 peak, 101 total, 100 pruned
    NFA: 0 matched, 0 mismatched
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Pattern matching every row - high match count
 CREATE VIEW rpr_ev_basic_allrows AS
@@ -218,12 +221,13 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: r
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 101 total, 0 merged
    NFA Contexts: 2 peak, 101 total, 0 pruned
    NFA: 100 matched (len 1/1/1.0), 0 mismatched
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Regression test: Space before parenthesis in pattern deparse
 -- Verifies that "A (B | C)" correctly outputs as "a (b | c)" with space
@@ -255,13 +259,14 @@ WINDOW w AS (
  WindowAgg (actual rows=20.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a (b | c)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 35 total, 0 merged
    NFA Contexts: 2 peak, 21 total, 6 pruned
    NFA: 7 matched (len 2/2/2.0), 0 mismatched
    NFA: 0 absorbed, 7 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=20.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Regression test: Sequential alternations at same depth
 -- Verifies that "((B | C) (D | E))" correctly outputs as "(b | c) (d | e)"
@@ -294,12 +299,13 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a ((b | c) (d | e))*
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 61 total, 0 merged
    NFA Contexts: 3 peak, 31 total, 24 pruned
    NFA: 6 matched (len 1/1/1.0), 0 mismatched
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Regression test: Quoted identifiers in EXPLAIN pattern deparse
 -- Mixed case names must be quoted to preserve round-trip safety
@@ -317,8 +323,9 @@ WINDOW w AS (
  WindowAgg
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: "Start" "Up"+
+   Nav Mark Lookback: 1
    ->  Function Scan on generate_series s
-(4 rows)
+(5 rows)
 
 -- ============================================================
 -- State Statistics Tests (peak, total, merged)
@@ -354,12 +361,13 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 76 total, 0 merged
    NFA Contexts: 3 peak, 51 total, 25 pruned
    NFA: 25 matched (len 1/1/1.0), 0 mismatched
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Alternation pattern - multiple state branches
 CREATE VIEW rpr_ev_state_alt AS
@@ -396,13 +404,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b | c) (d | e)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 6 peak, 524 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 20 pruned
    NFA: 20 matched (len 2/2/2.0), 40 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 20 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Complex pattern with high state count
 CREATE VIEW rpr_ev_state_complex AS
@@ -441,13 +450,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b* c+
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 235 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 34 pruned
    NFA: 33 matched (len 3/3/3.0), 0 mismatched
    NFA: 0 absorbed, 33 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Grouped pattern with quantifier - state count with grouping
 CREATE VIEW rpr_ev_state_group_quant AS
@@ -480,13 +490,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 91 total, 0 merged
    NFA Contexts: 3 peak, 61 total, 0 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
    NFA: 29 absorbed (len 2/2/2.0), 30 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- State explosion pattern - many alternations
 -- Pattern (A|B)(A|B)(A|B)(A|B) can create many parallel states
@@ -520,13 +531,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b){8}
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 17 peak, 995 total, 0 merged
    NFA Contexts: 8 peak, 101 total, 1 pruned
    NFA: 12 matched (len 8/8/8.0), 3 mismatched (len 2/4/3.0)
    NFA: 0 absorbed, 84 skipped (len 1/7/4.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Consecutive ALT merge followed by different ALT
 -- Tests mergeConsecutiveAlts flush on ALT change: (A|B){2} (C|D)
@@ -560,13 +572,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b){2} (c | d)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 181 total, 0 merged
    NFA Contexts: 3 peak, 41 total, 12 pruned
    NFA: 9 matched (len 3/3/3.0), 1 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 18 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Consecutive ALT merge followed by non-ALT element
 -- Tests mergeConsecutiveAlts flush on non-ALT: (A|B){2} c
@@ -600,13 +613,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b){2} c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 6 peak, 177 total, 0 merged
    NFA Contexts: 3 peak, 41 total, 2 pruned
    NFA: 12 matched (len 3/3/3.0), 2 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 24 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ALT prefix/suffix absorbed into GROUP: (A|B) (A|B)+ (A|B) -> (A|B){3,}
 CREATE VIEW rpr_ev_state_alt_absorb_group AS
@@ -639,13 +653,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b){3,}
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 243 total, 0 merged
    NFA Contexts: 3 peak, 41 total, 0 pruned
    NFA: 1 matched (len 40/40/40.0), 0 mismatched
    NFA: 0 absorbed, 39 skipped (len 1/2/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- High state count - alternation with plus quantifier
 CREATE VIEW rpr_ev_state_alt_plus AS
@@ -678,13 +693,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b | c)+ d
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 16 peak, 1004 total, 0 merged
    NFA Contexts: 4 peak, 101 total, 0 pruned
    NFA: 25 matched (len 4/4/4.0), 0 mismatched
    NFA: 0 absorbed, 75 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Early termination: first ALT branch (A) reaches FIN immediately,
 -- pruning second branch (A B+) before it can accumulate B repetitions.
@@ -718,12 +734,13 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | a b)+
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 6 peak, 306 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 99 pruned
    NFA: 1 matched (len 1/1/1.0), 0 mismatched
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Nested quantifiers causing state growth
 CREATE VIEW rpr_ev_state_nested_quant AS
@@ -756,13 +773,14 @@ WINDOW w AS (
  WindowAgg (actual rows=1000.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b)+
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 6 peak, 5004 total, 0 merged
    NFA Contexts: 3 peak, 1001 total, 333 pruned
    NFA: 334 matched (len 1/2/2.0), 0 mismatched
    NFA: 0 absorbed, 333 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=1000.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Context Statistics Tests (peak, total, pruned + absorbed/skipped)
@@ -798,13 +816,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 91 total, 0 merged
    NFA Contexts: 2 peak, 51 total, 0 pruned
    NFA: 10 matched (len 5/5/5.0), 0 mismatched
    NFA: 30 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- No absorption - bounded quantifier
 CREATE VIEW rpr_ev_ctx_no_absorb AS
@@ -837,13 +856,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{2,4} b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 101 total, 0 merged
    NFA Contexts: 5 peak, 51 total, 0 pruned
    NFA: 10 matched (len 5/5/5.0), 0 mismatched
    NFA: 0 absorbed, 40 skipped (len 1/4/2.5)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Contexts skipped by SKIP PAST LAST ROW
 CREATE VIEW rpr_ev_ctx_skip AS
@@ -876,13 +896,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 101 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 80 pruned
    NFA: 10 matched (len 3/3/3.0), 0 mismatched
    NFA: 0 absorbed, 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- High context absorption - unbounded group
 CREATE VIEW rpr_ev_ctx_absorb_group AS
@@ -915,13 +936,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+" c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 134 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 34 pruned
    NFA: 33 matched (len 3/3/3.0), 0 mismatched
    NFA: 0 absorbed, 33 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Fixed-length group absorption: (A B B)+ C
 -- B B merged to B{2}; absorbable with fixed-length check
@@ -956,13 +978,14 @@ WINDOW w AS (
  WindowAgg (actual rows=70.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b{2}')+" c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 91 total, 0 merged
    NFA Contexts: 4 peak, 71 total, 40 pruned
    NFA: 10 matched (len 7/7/7.0), 0 mismatched
    NFA: 10 absorbed (len 3/3/3.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=70.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Nested fixed-length group absorption: (A (B C){2} D)+ E
 -- step_size = 1 + (1+1)*2 + 1 = 6; v % 13 cycle gives 2 iterations + E
@@ -1000,13 +1023,14 @@ WINDOW w AS (
  WindowAgg (actual rows=65.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' (b' c'){2}' d')+" e
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 76 total, 0 merged
    NFA Contexts: 4 peak, 66 total, 50 pruned
    NFA: 5 matched (len 13/13/13.0), 0 mismatched
    NFA: 5 absorbed (len 6/6/6.0), 5 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=65.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
 -- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; v % 41 cycle gives 2 iterations + F
@@ -1052,13 +1076,14 @@ WINDOW w AS (
  WindowAgg (actual rows=82.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' ((b' c{3}'){2}' d'){2}' e')+" f
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 87 total, 0 merged
    NFA Contexts: 4 peak, 83 total, 76 pruned
    NFA: 2 matched (len 41/41/41.0), 0 mismatched
    NFA: 2 absorbed (len 20/20/20.0), 2 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=82.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- 3-level END chain absorption: ((A (B C){2}){2})+
 -- step_size = (1 + (1+1)*2) * 2 = 10; v % 21 cycle gives 2 iterations
@@ -1097,13 +1122,14 @@ WINDOW w AS (
  WindowAgg (actual rows=42.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: ((a' (b' c'){2}'){2}')+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 47 total, 0 merged
    NFA Contexts: 5 peak, 43 total, 30 pruned
    NFA: 2 matched (len 20/20/20.0), 0 mismatched
    NFA: 2 absorbed (len 10/10/10.0), 8 skipped (len 1/5/3.0)
    ->  Function Scan on generate_series s (actual rows=42.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Match Length Statistics Tests
@@ -1143,13 +1169,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b c d e
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 101 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 60 pruned
    NFA: 20 matched (len 5/5/5.0), 0 mismatched
    NFA: 0 absorbed, 20 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Variable length matches - min/max/avg differ
 CREATE VIEW rpr_ev_mlen_variable AS
@@ -1182,13 +1209,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 191 total, 0 merged
    NFA Contexts: 2 peak, 101 total, 0 pruned
    NFA: 10 matched (len 10/10/10.0), 0 mismatched
    NFA: 80 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Very long matches
 CREATE VIEW rpr_ev_mlen_long AS
@@ -1221,13 +1249,14 @@ WINDOW w AS (
  WindowAgg (actual rows=200.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 396 total, 0 merged
    NFA Contexts: 2 peak, 201 total, 4 pruned
    NFA: 1 matched (len 196/196/196.0), 0 mismatched
    NFA: 194 absorbed (len 1/1/1.0), 1 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=200.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Uniform match length with mismatches from gap rows (v%20 = 11..15)
 CREATE VIEW rpr_ev_mlen_with_mismatch AS
@@ -1264,13 +1293,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 171 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 25 pruned
    NFA: 5 matched (len 5/5/5.0), 5 mismatched (len 11/11/11.0)
    NFA: 60 absorbed (len 1/1/1.0), 5 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Mismatch Length Statistics Tests
@@ -1321,13 +1351,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b+ c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 151 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 60 pruned
    NFA: 10 matched (len 6/6/6.0), 0 mismatched
    NFA: 20 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Long partial matches that fail
 CREATE VIEW rpr_ev_mlen_long_partial AS
@@ -1384,13 +1415,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b+ c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 115 total, 0 merged
    NFA Contexts: 3 peak, 61 total, 15 pruned
    NFA: 1 matched (len 30/30/30.0), 1 mismatched (len 26/26/26.0)
    NFA: 42 absorbed (len 1/1/1.0), 1 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series i (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- JSON Format Tests
@@ -1434,6 +1466,7 @@ WINDOW w AS (
        "Disabled": false,                                                  +
        "Window": "w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)",+
        "Pattern": "a+\" b+",                                               +
+       "Nav Mark Lookback": 0,                                             +
        "Storage": "Memory",                                                +
        "Maximum Storage": 0,                                               +
        "NFA States Peak": 3,                                               +
@@ -1511,6 +1544,7 @@ WINDOW w AS (
        "Disabled": false,                                                  +
        "Window": "w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)",+
        "Pattern": "a+\" b",                                                +
+       "Nav Mark Lookback": 0,                                             +
        "Storage": "Memory",                                                +
        "Maximum Storage": 0,                                               +
        "NFA States Peak": 3,                                               +
@@ -1592,6 +1626,7 @@ WINDOW w AS (
        "Disabled": false,                                                  +
        "Window": "w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)",+
        "Pattern": "a b c",                                                 +
+       "Nav Mark Lookback": 0,                                             +
        "Storage": "Memory",                                                +
        "Maximum Storage": 0,                                               +
        "NFA States Peak": 2,                                               +
@@ -1672,6 +1707,7 @@ WINDOW w AS (
        "Disabled": false,                                                  +
        "Window": "w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)",+
        "Pattern": "(a | b){8}",                                            +
+       "Nav Mark Lookback": 0,                                             +
        "Storage": "Memory",                                                +
        "Maximum Storage": 0,                                               +
        "NFA States Peak": 17,                                              +
@@ -1755,6 +1791,7 @@ WINDOW w AS (
        <Disabled>false</Disabled>                                              +
        <Window>w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)</Window>+
        <Pattern>a b</Pattern>                                                  +
+       <Nav-Mark-Lookback>0</Nav-Mark-Lookback>                                +
        <Storage>Memory</Storage>                                               +
        <Maximum-Storage>0</Maximum-Storage>                                    +
        <NFA-States-Peak>2</NFA-States-Peak>                                    +
@@ -1837,6 +1874,7 @@ WINDOW w AS (
  WindowAgg (actual rows=90.00 loops=1)
    Window: w AS (PARTITION BY p.p ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 165 total, 0 merged
    NFA Contexts: 2 peak, 93 total, 0 pruned
@@ -1848,7 +1886,7 @@ WINDOW w AS (
          ->  Nested Loop (actual rows=90.00 loops=1)
                ->  Function Scan on generate_series p (actual rows=3.00 loops=1)
                ->  Function Scan on generate_series v (actual rows=30.00 loops=3)
-(14 rows)
+(15 rows)
 
 -- Different pattern behavior per partition
 CREATE VIEW rpr_ev_part_diff AS
@@ -1893,6 +1931,7 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (PARTITION BY (CASE WHEN (v.v <= 25) THEN 1 ELSE 2 END) ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 77 total, 0 merged
    NFA Contexts: 2 peak, 52 total, 21 pruned
@@ -1902,7 +1941,7 @@ WINDOW w AS (
          Sort Key: (CASE WHEN (v.v <= 25) THEN 1 ELSE 2 END)
          Sort Method: quicksort  Memory: NkB
          ->  Function Scan on generate_series v (actual rows=50.00 loops=1)
-(12 rows)
+(13 rows)
 
 -- ============================================================
 -- Edge Cases
@@ -1938,8 +1977,9 @@ WINDOW w AS (
  WindowAgg (actual rows=0.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b
+   Nav Mark Lookback: 0
    ->  Function Scan on generate_series s (actual rows=0.00 loops=1)
-(4 rows)
+(5 rows)
 
 -- Single row
 CREATE VIEW rpr_ev_edge_single_row AS
@@ -1972,12 +2012,13 @@ WINDOW w AS (
  WindowAgg (actual rows=1.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 2 total, 0 merged
    NFA Contexts: 2 peak, 2 total, 0 pruned
    NFA: 1 matched (len 1/1/1.0), 0 mismatched
    ->  Function Scan on generate_series s (actual rows=1.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Pattern longer than data
 CREATE VIEW rpr_ev_edge_pattern_longer AS
@@ -2014,12 +2055,13 @@ WINDOW w AS (
  WindowAgg (actual rows=5.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b c d e f g h i j
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 6 total, 0 merged
    NFA Contexts: 3 peak, 6 total, 4 pruned
    NFA: 0 matched, 1 mismatched (len 5/5/5.0)
    ->  Function Scan on generate_series s (actual rows=5.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- All rows match as single match
 CREATE VIEW rpr_ev_edge_single_match AS
@@ -2052,13 +2094,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 101 total, 0 merged
    NFA Contexts: 2 peak, 51 total, 0 pruned
    NFA: 1 matched (len 50/50/50.0), 0 mismatched
    NFA: 49 absorbed (len 1/1/1.0), 0 skipped
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Complex Pattern Tests
@@ -2094,13 +2137,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b' c')+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 81 total, 0 merged
    NFA Contexts: 4 peak, 61 total, 20 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
    NFA: 19 absorbed (len 3/3/3.0), 20 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Multiple alternations
 CREATE VIEW rpr_ev_cpx_multi_alt AS
@@ -2137,13 +2181,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b) (c | d | e)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 6 peak, 423 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 40 pruned
    NFA: 20 matched (len 2/2/2.0), 20 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 20 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Optional elements
 CREATE VIEW rpr_ev_cpx_optional AS
@@ -2176,13 +2221,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b? c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 64 total, 0 merged
    NFA Contexts: 3 peak, 51 total, 25 pruned
    NFA: 12 matched (len 3/3/3.0), 1 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 12 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Bounded quantifiers
 CREATE VIEW rpr_ev_cpx_bounded AS
@@ -2215,13 +2261,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{2,5} b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 9 peak, 311 total, 0 merged
    NFA Contexts: 7 peak, 101 total, 0 pruned
    NFA: 10 matched (len 6/6/6.0), 40 mismatched (len 6/6/6.0)
    NFA: 0 absorbed, 50 skipped (len 1/5/3.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Star quantifier
 CREATE VIEW rpr_ev_cpx_star AS
@@ -2254,13 +2301,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b* c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 91 total, 0 merged
    NFA Contexts: 3 peak, 51 total, 40 pruned
    NFA: 5 matched (len 9/9/9.0), 0 mismatched
    NFA: 0 absorbed, 5 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Real-world Pattern Examples
@@ -2296,13 +2344,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: d+" u+
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 58 total, 0 merged
    NFA Contexts: 3 peak, 31 total, 3 pruned
    NFA: 3 matched (len 3/14/8.0), 1 mismatched (len 3/3/3.0)
    NFA: 9 absorbed (len 1/1/1.0), 14 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_complex (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Stock price pattern - peak (up, stable, down)
 CREATE VIEW rpr_ev_real_peak AS
@@ -2335,13 +2384,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: u+" s* d+
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 76 total, 0 merged
    NFA Contexts: 3 peak, 31 total, 1 pruned
    NFA: 4 matched (len 3/11/7.2), 0 mismatched
    NFA: 12 absorbed (len 1/1/1.0), 13 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_complex (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Consecutive increasing values (using PREV)
 CREATE VIEW rpr_ev_real_increasing AS
@@ -2374,13 +2424,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{3,}"
+   Nav Mark Lookback: 1
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 99 total, 0 merged
    NFA Contexts: 2 peak, 51 total, 0 pruned
    NFA: 1 matched (len 50/50/50.0), 0 mismatched
    NFA: 49 absorbed (len 1/1/1.0), 0 skipped
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Performance-oriented Tests
@@ -2416,13 +2467,14 @@ WINDOW w AS (
  WindowAgg (actual rows=1000.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 1001 total, 0 merged
    NFA Contexts: 2 peak, 1001 total, 0 pruned
    NFA: 500 matched (len 2/2/2.0), 0 mismatched
    NFA: 0 absorbed, 500 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=1000.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Large dataset with absorption
 CREATE VIEW rpr_ev_perf_large_absorb AS
@@ -2455,13 +2507,14 @@ WINDOW w AS (
  WindowAgg (actual rows=1000.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 1991 total, 0 merged
    NFA Contexts: 2 peak, 1001 total, 0 pruned
    NFA: 10 matched (len 100/100/100.0), 0 mismatched
    NFA: 980 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=1000.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- High state merge ratio
 CREATE VIEW rpr_ev_perf_high_merge AS
@@ -2494,13 +2547,14 @@ WINDOW w AS (
  WindowAgg (actual rows=500.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b)+ c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 9 peak, 3006 total, 0 merged
    NFA Contexts: 3 peak, 501 total, 1 pruned
    NFA: 166 matched (len 3/3/3.0), 1 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 332 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=500.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- INITIAL vs no INITIAL comparison
@@ -2538,13 +2592,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 91 total, 0 merged
    NFA Contexts: 2 peak, 51 total, 0 pruned
    NFA: 10 matched (len 5/5/5.0), 0 mismatched
    NFA: 30 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Without INITIAL keyword (same behavior currently)
 CREATE VIEW rpr_ev_initial_without AS
@@ -2577,13 +2632,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 91 total, 0 merged
    NFA Contexts: 2 peak, 51 total, 0 pruned
    NFA: 10 matched (len 5/5/5.0), 0 mismatched
    NFA: 30 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Quantifier Variations
@@ -2619,13 +2675,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 71 total, 0 merged
    NFA Contexts: 3 peak, 41 total, 10 pruned
    NFA: 10 matched (len 3/3/3.0), 0 mismatched
    NFA: 20 absorbed (len 1/1/1.0), 0 skipped
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Star quantifier (zero or more)
 CREATE VIEW rpr_ev_quant_star AS
@@ -2658,13 +2715,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a*" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 102 total, 0 merged
    NFA Contexts: 2 peak, 41 total, 10 pruned
    NFA: 10 matched (len 3/3/3.0), 0 mismatched
    NFA: 10 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Question mark (zero or one)
 CREATE VIEW rpr_ev_quant_question AS
@@ -2697,13 +2755,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a? b c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 82 total, 0 merged
    NFA Contexts: 3 peak, 41 total, 10 pruned
    NFA: 10 matched (len 3/3/3.0), 0 mismatched
    NFA: 0 absorbed, 20 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Exact count {n}
 CREATE VIEW rpr_ev_quant_exact AS
@@ -2736,13 +2795,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{3} b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 51 total, 0 merged
    NFA Contexts: 5 peak, 51 total, 0 pruned
    NFA: 10 matched (len 4/4/4.0), 10 mismatched (len 4/4/4.0)
    NFA: 0 absorbed, 30 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Range {n,m}
 CREATE VIEW rpr_ev_quant_range AS
@@ -2775,13 +2835,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{2,4} b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 101 total, 0 merged
    NFA Contexts: 5 peak, 51 total, 0 pruned
    NFA: 10 matched (len 5/5/5.0), 0 mismatched
    NFA: 0 absorbed, 40 skipped (len 1/4/2.5)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- At least {n,}
 CREATE VIEW rpr_ev_quant_atleast AS
@@ -2814,13 +2875,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a{3,}" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 86 total, 0 merged
    NFA Contexts: 2 peak, 51 total, 0 pruned
    NFA: 5 matched (len 10/10/10.0), 0 mismatched
    NFA: 40 absorbed (len 1/1/1.0), 5 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Regression Tests for Statistics Accuracy
@@ -2857,13 +2919,14 @@ WINDOW w AS (
  WindowAgg (actual rows=20.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 37 total, 0 merged
    NFA Contexts: 2 peak, 21 total, 0 pruned
    NFA: 4 matched (len 5/5/5.0), 0 mismatched
    NFA: 12 absorbed (len 1/1/1.0), 4 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=20.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Verify context count with known absorption
 CREATE VIEW rpr_ev_reg_ctx_absorb AS
@@ -2896,13 +2959,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 52 total, 0 merged
    NFA Contexts: 3 peak, 31 total, 6 pruned
    NFA: 3 matched (len 9/9/9.0), 0 mismatched
    NFA: 18 absorbed (len 1/1/1.0), 3 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Verify match length with fixed-length pattern
 CREATE VIEW rpr_ev_reg_matchlen AS
@@ -2935,13 +2999,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 31 total, 0 merged
    NFA Contexts: 3 peak, 31 total, 10 pruned
    NFA: 10 matched (len 3/3/3.0), 0 mismatched
    NFA: 0 absorbed, 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Alternation Pattern Tests
@@ -2977,13 +3042,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b) c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 303 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 40 pruned
    NFA: 20 matched (len 2/2/2.0), 20 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 20 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Multiple items in alternation
 CREATE VIEW rpr_ev_alt_multi_item AS
@@ -3020,13 +3086,14 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b | c | d) e
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 6 peak, 505 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 0 pruned
    NFA: 20 matched (len 2/2/2.0), 60 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 20 skipped (len 1/1/1.0)
    ->  Seq Scan on rpr_nfa_test (actual rows=100.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Alternation with quantifiers
 CREATE VIEW rpr_ev_alt_with_quant AS
@@ -3059,13 +3126,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b)+ c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 9 peak, 306 total, 0 merged
    NFA Contexts: 3 peak, 51 total, 1 pruned
    NFA: 16 matched (len 3/3/3.0), 1 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 32 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Multiple alternatives (4+)
 CREATE VIEW rpr_ev_alt_four_plus AS
@@ -3096,12 +3164,13 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b | c | d | e)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 606 total, 0 merged
    NFA Contexts: 2 peak, 101 total, 0 pruned
    NFA: 100 matched (len 1/1/1.0), 0 mismatched
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Alternation at start
 CREATE VIEW rpr_ev_alt_at_start AS
@@ -3132,13 +3201,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b) c d
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 183 total, 0 merged
    NFA Contexts: 3 peak, 61 total, 16 pruned
    NFA: 15 matched (len 3/3/3.0), 14 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 15 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Multiple sequential alternations
 CREATE VIEW rpr_ev_alt_sequential AS
@@ -3169,12 +3239,13 @@ WINDOW w AS (
  WindowAgg (actual rows=100.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b) c (d | e) f
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 337 total, 0 merged
    NFA Contexts: 3 peak, 101 total, 67 pruned
    NFA: 0 matched, 33 mismatched (len 2/4/3.0)
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Quantified alternatives
 CREATE VIEW rpr_ev_alt_quantified AS
@@ -3205,13 +3276,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a+" | b+") c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 223 total, 0 merged
    NFA Contexts: 3 peak, 61 total, 1 pruned
    NFA: 20 matched (len 2/2/2.0), 19 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 20 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Alternation at end
 CREATE VIEW rpr_ev_alt_at_end AS
@@ -3242,13 +3314,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b (c | d)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 89 total, 0 merged
    NFA Contexts: 3 peak, 61 total, 32 pruned
    NFA: 14 matched (len 3/3/3.0), 0 mismatched
    NFA: 0 absorbed, 14 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Nested ALT at start of branch inside outer ALT
 -- Pattern: (A ((B | C) D | E)) - preceding VAR + inner ALT as first branch element
@@ -3280,12 +3353,13 @@ WINDOW w AS (
  WindowAgg (actual rows=20.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a ((b | c) d | e)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 37 total, 0 merged
    NFA Contexts: 3 peak, 21 total, 17 pruned
    NFA: 0 matched, 3 mismatched (len 3/3/3.0)
    ->  Function Scan on generate_series s (actual rows=20.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Nested ALT at end of branch inside outer ALT
 -- Pattern: (C (A | B) | D) - inner ALT is last element in outer branch
@@ -3317,12 +3391,13 @@ WINDOW w AS (
  WindowAgg (actual rows=20.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (c (a | b) | d)
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 73 total, 0 merged
    NFA Contexts: 3 peak, 21 total, 10 pruned
    NFA: 5 matched (len 1/1/1.0), 5 mismatched (len 2/2/2.0)
    ->  Function Scan on generate_series s (actual rows=20.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- ============================================================
 -- Group Pattern Tests
@@ -3358,13 +3433,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 61 total, 0 merged
    NFA Contexts: 3 peak, 41 total, 0 pruned
    NFA: 1 matched (len 40/40/40.0), 0 mismatched
    NFA: 19 absorbed (len 2/2/2.0), 20 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Group with bounded quantifier
 CREATE VIEW rpr_ev_grp_bounded AS
@@ -3397,13 +3473,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a b){2,4}
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 4 peak, 51 total, 0 merged
    NFA Contexts: 3 peak, 41 total, 5 pruned
    NFA: 5 matched (len 8/8/8.0), 0 mismatched
    NFA: 0 absorbed, 30 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Nested groups
 CREATE VIEW rpr_ev_grp_nested AS
@@ -3436,13 +3513,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: ((a' b'){2}')+"
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 76 total, 0 merged
    NFA Contexts: 4 peak, 61 total, 15 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
    NFA: 14 absorbed (len 4/4/4.0), 30 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Deep nesting (3+ levels)
 CREATE VIEW rpr_ev_grp_deep AS
@@ -3473,13 +3551,14 @@ WINDOW w AS (
  WindowAgg (actual rows=40.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b)+
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 6 peak, 243 total, 0 merged
    NFA Contexts: 2 peak, 41 total, 0 pruned
    NFA: 1 matched (len 40/40/40.0), 0 mismatched
    NFA: 0 absorbed, 39 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Bounded quantifier on alternation
 CREATE VIEW rpr_ev_grp_bounded_alt AS
@@ -3510,13 +3589,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a | b){2,3} c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 8 peak, 320 total, 0 merged
    NFA Contexts: 3 peak, 61 total, 2 pruned
    NFA: 19 matched (len 3/3/3.0), 1 mismatched (len 2/2/2.0)
    NFA: 0 absorbed, 38 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Nested groups with quantifiers
 CREATE VIEW rpr_ev_grp_nested_quant AS
@@ -3547,13 +3627,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: ((a' b')+" c)*
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 9 peak, 178 total, 0 merged
    NFA Contexts: 4 peak, 61 total, 20 pruned
    NFA: 3 matched (len 0/57/19.0), 0 mismatched
    NFA: 0 absorbed, 37 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Partial nested quantification
 CREATE VIEW rpr_ev_grp_partial_quant AS
@@ -3584,13 +3665,14 @@ WINDOW w AS (
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a (b c)+)*
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 160 total, 0 merged
    NFA Contexts: 4 peak, 61 total, 20 pruned
    NFA: 3 matched (len 0/57/19.0), 0 mismatched
    NFA: 0 absorbed, 37 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Window Function Combinations
@@ -3626,13 +3708,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 55 total, 0 merged
    NFA Contexts: 2 peak, 31 total, 0 pruned
    NFA: 6 matched (len 5/5/5.0), 0 mismatched
    NFA: 18 absorbed (len 1/1/1.0), 6 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- first_value with pattern
 CREATE VIEW rpr_ev_wfn_first_value AS
@@ -3665,13 +3748,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 55 total, 0 merged
    NFA Contexts: 2 peak, 31 total, 0 pruned
    NFA: 6 matched (len 5/5/5.0), 0 mismatched
    NFA: 18 absorbed (len 1/1/1.0), 6 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- last_value with pattern
 CREATE VIEW rpr_ev_wfn_last_value AS
@@ -3704,13 +3788,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 55 total, 0 merged
    NFA Contexts: 2 peak, 31 total, 0 pruned
    NFA: 6 matched (len 5/5/5.0), 0 mismatched
    NFA: 18 absorbed (len 1/1/1.0), 6 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Multiple window functions
 CREATE VIEW rpr_ev_wfn_multi AS
@@ -3749,13 +3834,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 55 total, 0 merged
    NFA Contexts: 2 peak, 31 total, 0 pruned
    NFA: 6 matched (len 5/5/5.0), 0 mismatched
    NFA: 18 absorbed (len 1/1/1.0), 6 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- DEFINE Expression Variations
@@ -3795,13 +3881,14 @@ WINDOW w AS (
  WindowAgg (actual rows=50.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 78 total, 0 merged
    NFA Contexts: 2 peak, 51 total, 6 pruned
    NFA: 17 matched (len 2/3/2.6), 0 mismatched
    NFA: 10 absorbed (len 1/1/1.0), 17 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- Using PREV function
 CREATE VIEW rpr_ev_def_prev AS
@@ -3840,12 +3927,13 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: s u+ d+
+   Nav Mark Lookback: 1
    Storage: Memory  Maximum Storage: NkB
    NFA States: 60 peak, 466 total, 0 merged
    NFA Contexts: 31 peak, 31 total, 1 pruned
    NFA: 0 matched, 29 mismatched (len 2/30/16.0)
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
-(8 rows)
+(9 rows)
 
 -- Using 1-arg PREV (implicit offset 1)
 CREATE VIEW rpr_ev_nav_prev1 AS
@@ -3964,13 +4052,14 @@ WINDOW w AS (
  WindowAgg (actual rows=30.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 55 total, 0 merged
    NFA Contexts: 2 peak, 31 total, 0 pruned
    NFA: 6 matched (len 5/5/5.0), 0 mismatched
    NFA: 18 absorbed (len 1/1/1.0), 6 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series v (actual rows=30.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- ============================================================
 -- Large Scale Statistics Verification
@@ -4006,13 +4095,14 @@ WINDOW w AS (
  WindowAgg (actual rows=500.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a+" b c
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 3 peak, 851 total, 0 merged
    NFA Contexts: 3 peak, 501 total, 101 pruned
    NFA: 50 matched (len 8/9/9.0), 0 mismatched
    NFA: 299 absorbed (len 1/1/1.0), 50 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=500.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- High match count scenario
 CREATE VIEW rpr_ev_scale_high_match AS
@@ -4045,13 +4135,14 @@ WINDOW w AS (
  WindowAgg (actual rows=500.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 501 total, 0 merged
    NFA Contexts: 2 peak, 501 total, 0 pruned
    NFA: 250 matched (len 2/2/2.0), 0 mismatched
    NFA: 0 absorbed, 250 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=500.00 loops=1)
-(9 rows)
+(10 rows)
 
 -- High skip count scenario
 CREATE VIEW rpr_ev_scale_high_skip AS
@@ -4094,13 +4185,14 @@ WINDOW w AS (
  WindowAgg (actual rows=500.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b c d e
+   Nav Mark Lookback: 0
    Storage: Memory  Maximum Storage: NkB
    NFA States: 2 peak, 501 total, 0 merged
    NFA Contexts: 3 peak, 501 total, 490 pruned
    NFA: 5 matched (len 5/5/5.0), 0 mismatched
    NFA: 0 absorbed, 5 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=500.00 loops=1)
-(9 rows)
+(10 rows)
 
 --
 -- Planner optimization: optimize_window_clauses must not alter RPR frame
@@ -4149,10 +4241,11 @@ EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_with_rpr;
    ->  WindowAgg
          Window: w AS (ORDER BY s.v ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: s.v
                ->  Function Scan on generate_series s
-(7 rows)
+(8 rows)
 
 --
 -- Planner optimization: non-RPR and RPR windows that share the same base frame
@@ -4178,12 +4271,13 @@ EXPLAIN (COSTS OFF) SELECT * FROM rpr_ev_opt_mixed;
    ->  WindowAgg
          Window: w_rpr AS (ORDER BY s.v ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a+"
+         Nav Mark Lookback: 0
          ->  WindowAgg
                Window: w_normal AS (ORDER BY s.v ROWS UNBOUNDED PRECEDING)
                ->  Sort
                      Sort Key: s.v
                      ->  Function Scan on generate_series s
-(9 rows)
+(10 rows)
 
 --
 -- Planner optimization: find_window_run_conditions must not push down
@@ -4242,8 +4336,133 @@ SELECT * FROM (
    ->  WindowAgg
          Window: w AS (ORDER BY s.v ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: s.v
                ->  Function Scan on generate_series s
-(8 rows)
+(9 rows)
+
+-- ============================================================
+-- Nav Mark Lookback Tests
+-- Verifies planner-computed navigation offset for tuplestore trim.
+-- Lookback: how far back from currentpos (PREV/LAST).
+-- ============================================================
+-- Prepare statement for host variable offset test below
+PREPARE rpr_nav_offset_prep(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > PREV(v, $1)
+);
+-- No navigation function: offset 0
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > 0
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 0
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- NEXT only: no backward navigation, offset 0
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v < NEXT(v)
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 0
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- PREV(v): implicit offset 1
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > PREV(v)
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 1
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- PREV(v, 3): explicit constant offset 3
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > PREV(v, 3)
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 3
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Two PREV with different offsets: max(1, 5) = 5
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v, 1) < v AND PREV(v, 5) < v
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 5
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Host variable offset: custom plan resolves $1=2 to constant 2
+EXPLAIN (COSTS OFF) EXECUTE rpr_nav_offset_prep(2);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 2
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Force generic plan: offset becomes "runtime" (Param node)
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF) EXECUTE rpr_nav_offset_prep(2);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: runtime
+   ->  Function Scan on generate_series s
+(5 rows)
 
+RESET plan_cache_mode;
+DEALLOCATE rpr_nav_offset_prep;
diff --git a/src/test/regress/expected/rpr_integration.out b/src/test/regress/expected/rpr_integration.out
index f9f70a814ca..9dbf0f43c8f 100644
--- a/src/test/regress/expected/rpr_integration.out
+++ b/src/test/regress/expected/rpr_integration.out
@@ -67,10 +67,11 @@ WINDOW w AS (ORDER BY id
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 1
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_integ
-(6 rows)
+(7 rows)
 
 -- ============================================================
 -- A2. Run condition pushdown bypass
@@ -117,10 +118,11 @@ SELECT * FROM (
    ->  WindowAgg
          Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: rpr_integ.id
                ->  Seq Scan on rpr_integ
-(8 rows)
+(9 rows)
 
 -- Verify that the RPR query still returns every row whose match count is
 -- greater than zero, confirming the filter is evaluated above the
@@ -190,10 +192,11 @@ FROM rpr_integ;
    ->  WindowAgg
          Window: w1 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: id
                ->  Seq Scan on rpr_integ
-(8 rows)
+(9 rows)
 
 -- Verify that the two windows return independent counts per row,
 -- confirming they were not merged into a single WindowAgg.
@@ -250,10 +253,11 @@ FROM rpr_integ;
  WindowAgg
    Window: w1 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 1
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_integ
-(6 rows)
+(7 rows)
 
 -- Two inline RPR windows with the same PATTERN but opposite DEFINE
 -- conditions must remain as separate WindowAgg nodes.
@@ -273,13 +277,15 @@ FROM rpr_integ;
  WindowAgg
    Window: w2 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 1
    ->  WindowAgg
          Window: w1 AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: id
                ->  Seq Scan on rpr_integ
-(9 rows)
+(11 rows)
 
 -- Verify that the two windows return different counts per row,
 -- confirming the DEFINE conditions were not collapsed by dedup.
@@ -336,8 +342,9 @@ SELECT count(*) FROM (
    ->  WindowAgg
          Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a+"
+         Nav Mark Lookback: 1
          ->  Seq Scan on rpr_integ
-(5 rows)
+(6 rows)
 
 SELECT count(*) FROM (
     SELECT count(*) OVER w FROM rpr_integ
@@ -369,8 +376,9 @@ SELECT count(*), sum(c) FROM (
    ->  WindowAgg
          Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a+"
+         Nav Mark Lookback: 1
          ->  Seq Scan on rpr_integ
-(5 rows)
+(6 rows)
 
 SELECT count(*), sum(c) FROM (
     SELECT count(*) OVER w AS c FROM rpr_integ
@@ -401,8 +409,9 @@ SELECT count(*), sum(c) FROM (
    ->  WindowAgg
          Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a+"
+         Nav Mark Lookback: 0
          ->  Seq Scan on rpr_integ
-(5 rows)
+(6 rows)
 
 SELECT count(*), sum(c) FROM (
     SELECT count(*) OVER w AS c FROM rpr_integ
@@ -441,10 +450,11 @@ SELECT count(*) FROM (
    ->  WindowAgg
          Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: rpr_integ.id
                ->  Seq Scan on rpr_integ
-(7 rows)
+(8 rows)
 
 SELECT count(*) FROM (
     SELECT val, count(*) OVER w FROM rpr_integ
@@ -555,10 +565,11 @@ WHERE cnt > 0;
    ->  WindowAgg
          Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: rpr_integ.id
                ->  Seq Scan on rpr_integ
-(8 rows)
+(9 rows)
 
 -- ============================================================
 -- A9. DEFINE expression non-propagation
@@ -595,12 +606,13 @@ WINDOW
          Output: id, val, count(*) OVER w_rpr
          Window: w_rpr AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Output: id, val
                Sort Key: rpr_integ.id
                ->  Seq Scan on public.rpr_integ
                      Output: id, val
-(12 rows)
+(13 rows)
 
 -- Executing the same query shows the client result is limited to
 -- the two projected columns; "id" and "val" that appeared in the
@@ -654,10 +666,11 @@ LIMIT 5;
    ->  WindowAgg
          Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
          Pattern: a b+
+         Nav Mark Lookback: 1
          ->  Sort
                Sort Key: id
                ->  Seq Scan on rpr_integ
-(7 rows)
+(8 rows)
 
 -- Reference: un-LIMITed result against which the LIMIT 5 result is
 -- compared.
@@ -748,10 +761,11 @@ SELECT id, val, cnt FROM rpr_result ORDER BY id;
  WindowAgg
    Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 1
    ->  Sort
          Sort Key: rpr_integ.id
          ->  Seq Scan on rpr_integ
-(6 rows)
+(7 rows)
 
 -- Result must match the baseline row-for-row.
 WITH rpr_result AS (
@@ -802,6 +816,7 @@ ORDER BY r1.id;
      ->  WindowAgg
            Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
            Pattern: a b+
+           Nav Mark Lookback: 1
            ->  Sort
                  Sort Key: rpr_integ.id
                  ->  Seq Scan on rpr_integ
@@ -813,7 +828,7 @@ ORDER BY r1.id;
          Sort Key: r1.id, r1.cnt
          ->  CTE Scan on rpr_result r1
                Filter: (cnt > 0)
-(17 rows)
+(18 rows)
 
 -- Result: both references see the same match counts, so the self-join
 -- preserves all matched rows from the baseline.
@@ -896,13 +911,14 @@ ORDER BY r.id;
          ->  WindowAgg
                Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
                Pattern: a b+
+               Nav Mark Lookback: 1
                ->  Sort
                      Sort Key: rpr_integ.id
                      ->  Seq Scan on rpr_integ
    ->  Sort
          Sort Key: j.id
          ->  Seq Scan on rpr_integ2 j
-(13 rows)
+(14 rows)
 
 -- Result: matched RPR rows align with dimension rows on id, showing
 -- the join correctly pairs per-row match counts with their labels.
@@ -961,6 +977,7 @@ ORDER BY source, id;
                ->  WindowAgg
                      Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
                      Pattern: a b+
+                     Nav Mark Lookback: 1
                      ->  Sort
                            Sort Key: rpr_integ.id
                            ->  Seq Scan on rpr_integ
@@ -969,7 +986,7 @@ ORDER BY source, id;
                ->  Sort
                      Sort Key: rpr_integ_1.id
                      ->  Seq Scan on rpr_integ rpr_integ_1
-(16 rows)
+(17 rows)
 
 -- Result: rows from both branches are present in the unioned output.
 -- The RPR branch emits only matched rows (cnt > 0), while the
@@ -1034,10 +1051,11 @@ EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 1
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_integ
-(6 rows)
+(7 rows)
 
 EXECUTE rpr_prev(1);
  id | val | cnt 
@@ -1064,10 +1082,11 @@ EXPLAIN (COSTS OFF) EXECUTE rpr_prev(1);
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: runtime
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_integ
-(6 rows)
+(7 rows)
 
 EXECUTE rpr_prev(1);
  id | val | cnt 
@@ -1115,12 +1134,13 @@ ORDER BY id;
  WindowAgg
    Window: w AS (ORDER BY rpr_part.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 1
    ->  Sort
          Sort Key: rpr_part.id
          ->  Append
                ->  Seq Scan on rpr_part_1
                ->  Seq Scan on rpr_part_2
-(8 rows)
+(9 rows)
 
 -- Baseline: the same query against the non-partitioned rpr_integ
 -- produces the per-row reference output.
@@ -1208,11 +1228,12 @@ ORDER BY o.id, r.id;
                ->  WindowAgg
                      Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
                      Pattern: a b+
+                     Nav Mark Lookback: 1
                      ->  Sort
                            Sort Key: rpr_integ.id
                            ->  Seq Scan on rpr_integ
                                  Filter: (id <= o.id)
-(14 rows)
+(15 rows)
 
 -- Result: for each of the two outer ids (5 and 10), the LATERAL
 -- subquery produces RPR match counts over the restricted input.
@@ -1279,13 +1300,14 @@ SELECT id, val, cnt FROM seq ORDER BY id;
            ->  WindowAgg
                  Window: w AS (ORDER BY rpr_integ.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
                  Pattern: a b+
+                 Nav Mark Lookback: 1
                  ->  Sort
                        Sort Key: rpr_integ.id
                        ->  Seq Scan on rpr_integ
            ->  WorkTable Scan on seq seq_1
                  Filter: (id < 3)
    ->  CTE Scan on seq
-(13 rows)
+(14 rows)
 
 -- Result: the base leg contributes the RPR match counts; the
 -- recursive leg propagates those counts with shifted ids.
@@ -1343,11 +1365,12 @@ WINDOW w AS (ORDER BY id, val
  WindowAgg
    Window: w AS (ORDER BY id, val ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: a b+
+   Nav Mark Lookback: 1
    ->  Incremental Sort
          Sort Key: id, val
          Presorted Key: id
          ->  Index Scan using rpr_integ_id_idx on rpr_integ
-(7 rows)
+(8 rows)
 
 -- Result: RPR over the incrementally sorted stream produces match
 -- counts per row.
@@ -1449,11 +1472,12 @@ ORDER BY o.id;
                  ->  WindowAgg
                        Window: w AS (ORDER BY i.id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
                        Pattern: a b+
+                       Nav Mark Lookback: 1
                        ->  Sort
                              Sort Key: i.id
                              ->  Seq Scan on rpr_integ i
                                    Filter: (id <= o.id)
-(12 rows)
+(13 rows)
 
 -- Result: each outer row receives the first_cnt from its own
 -- correlated RPR subquery.
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index e339edd7e91..5082cc2b5de 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -36,6 +36,7 @@
 --   Window Function Combinations
 --   DEFINE Expression Variations
 --   Large Scale Statistics Verification
+--   Nav Mark Lookback (tuplestore trim)
 -- ============================================================
 
 -- Filter function to normalize platform-dependent memory values (not NFA statistics).
@@ -2476,3 +2477,73 @@ SELECT * FROM (
     )
 ) t WHERE cnt > 0;
 
+-- ============================================================
+-- Nav Mark Lookback Tests
+-- Verifies planner-computed navigation offset for tuplestore trim.
+-- Lookback: how far back from currentpos (PREV/LAST).
+-- ============================================================
+
+-- Prepare statement for host variable offset test below
+PREPARE rpr_nav_offset_prep(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > PREV(v, $1)
+);
+
+-- No navigation function: offset 0
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > 0
+);
+
+-- NEXT only: no backward navigation, offset 0
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v < NEXT(v)
+);
+
+-- PREV(v): implicit offset 1
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > PREV(v)
+);
+
+-- PREV(v, 3): explicit constant offset 3
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > PREV(v, 3)
+);
+
+-- Two PREV with different offsets: max(1, 5) = 5
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v, 1) < v AND PREV(v, 5) < v
+);
+
+-- Host variable offset: custom plan resolves $1=2 to constant 2
+EXPLAIN (COSTS OFF) EXECUTE rpr_nav_offset_prep(2);
+
+-- Force generic plan: offset becomes "runtime" (Param node)
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF) EXECUTE rpr_nav_offset_prep(2);
+RESET plan_cache_mode;
+DEALLOCATE rpr_nav_offset_prep;
+
-- 
2.50.1 (Apple Git-155)


From 61571e745b76c864158c7c574136e13c1913c516 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Sun, 5 Apr 2026 00:56:20 +0900
Subject: [PATCH] Implement FIRST/LAST and compound navigation for RPR

Add FIRST(expr [, offset]) and LAST(expr [, offset]) navigation
functions for DEFINE clauses.  FIRST references the match start row,
LAST references the current row.  Includes slot swap elision,
per-context DEFINE re-evaluation for match_start-dependent variables,
and planner-level absorption disabling when FIRST or LAST-with-offset
is present.

Add compound navigation: PREV(FIRST()), NEXT(FIRST()),
PREV(LAST()), NEXT(LAST()) per SQL standard 5.6.4.  The parser
flattens nested RPRNavExpr into a single compound node with two
offsets.  The executor computes the target position in two steps
with range validation at each step.  ruleutils restores the nested
syntax for deparsing.

Extend tuplestore trim to handle FIRST navigation via
hasFirstNav/navFirstOffset, allowing mark advance based on
oldest active context's matchStartRow.  Compound offsets are
computed by the unified nav_offset_walker in a single expression
tree walk.  Add "Nav Mark Lookahead" to EXPLAIN for FIRST-based
navigation.
---
 doc/src/sgml/func/func-window.sgml        |  53 ++
 src/backend/commands/explain.c            |  68 ++-
 src/backend/executor/execExpr.c           |  55 +-
 src/backend/executor/execExprInterp.c     | 155 ++++-
 src/backend/executor/execRPR.c            | 152 ++++-
 src/backend/executor/nodeWindowAgg.c      | 343 +++++++++---
 src/backend/nodes/nodeFuncs.c             |   3 +
 src/backend/optimizer/plan/createplan.c   | 387 +++++++++++--
 src/backend/optimizer/plan/rpr.c          |   6 +-
 src/backend/parser/parse_func.c           |  70 ++-
 src/backend/parser/parse_rpr.c            | 112 +++-
 src/backend/utils/adt/ruleutils.c         |  81 ++-
 src/backend/utils/adt/windowfuncs.c       |  56 ++
 src/include/catalog/pg_proc.dat           |  12 +
 src/include/executor/execExpr.h           |   8 +-
 src/include/nodes/execnodes.h             |  12 +-
 src/include/nodes/parsenodes.h            |  17 +-
 src/include/nodes/plannodes.h             |  30 +-
 src/include/nodes/primnodes.h             |  37 +-
 src/include/optimizer/rpr.h               |  12 +-
 src/test/regress/expected/rpr.out         | 652 +++++++++++++++++++++-
 src/test/regress/expected/rpr_base.out    | 257 ++++++++-
 src/test/regress/expected/rpr_explain.out | 425 +++++++++++++-
 src/test/regress/sql/rpr.sql              | 374 +++++++++++++
 src/test/regress/sql/rpr_base.sql         | 136 ++++-
 src/test/regress/sql/rpr_explain.sql      | 242 +++++++-
 src/tools/pgindent/typedefs.list          |   5 +
 27 files changed, 3509 insertions(+), 251 deletions(-)

diff --git a/doc/src/sgml/func/func-window.sgml b/doc/src/sgml/func/func-window.sgml
index 1b9b993a817..ab80690f7be 100644
--- a/doc/src/sgml/func/func-window.sgml
+++ b/doc/src/sgml/func/func-window.sgml
@@ -337,10 +337,63 @@
        </para></entry>
       </row>
 
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>first</primary>
+        </indexterm>
+        <function>first</function> ( <parameter>value</parameter> <type>anyelement</type> [, <parameter>offset</parameter> <type>bigint</type> ] )
+        <returnvalue>anyelement</returnvalue>
+       </para>
+       <para>
+        Returns the column value at the row <parameter>offset</parameter>
+        rows after the match start row;
+        returns NULL if the target row is beyond the current row.
+        <parameter>offset</parameter> defaults to 0 if omitted, referring to the
+        match start row itself.
+        <parameter>offset</parameter> must be a non-negative integer.
+        <parameter>offset</parameter> must not be NULL.
+        Can only be used in a <literal>DEFINE</literal> clause.
+       </para></entry>
+      </row>
+
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>last</primary>
+        </indexterm>
+        <function>last</function> ( <parameter>value</parameter> <type>anyelement</type> [, <parameter>offset</parameter> <type>bigint</type> ] )
+        <returnvalue>anyelement</returnvalue>
+       </para>
+       <para>
+        Returns the column value at the row <parameter>offset</parameter>
+        rows before the current row within the match;
+        returns NULL if the target row is before the match start row.
+        <parameter>offset</parameter> defaults to 0 if omitted, referring to the
+        current row itself.
+        <parameter>offset</parameter> must be a non-negative integer.
+        <parameter>offset</parameter> must not be NULL.
+        Can only be used in a <literal>DEFINE</literal> clause.
+       </para></entry>
+      </row>
+
      </tbody>
     </tgroup>
    </table>
 
+   <para>
+    <function>PREV</function> and <function>NEXT</function> may wrap
+    <function>FIRST</function> or <function>LAST</function> for compound
+    navigation. For example,
+    <literal>PREV(FIRST(val, 2), 3)</literal> fetches the value at
+    3 rows before the row that is 2 rows after the match start.
+    The reverse nesting (<function>FIRST</function>/<function>LAST</function>
+    wrapping <function>PREV</function>/<function>NEXT</function>) is not
+    permitted. Same-category nesting (e.g.,
+    <function>PREV</function> inside <function>PREV</function>) is also
+    prohibited.
+   </para>
+
   <note>
    <para>
     The SQL standard defines a <literal>FROM FIRST</literal> or <literal>FROM LAST</literal>
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 1848de9de7a..221d9a49e0d 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3255,14 +3255,66 @@ show_window_def(WindowAggState *planstate, List *ancestors, ExplainState *es)
 			pfree(patternStr);
 		}
 
-		/* Show navigation offsets for tuplestore trim */
-		if (wagg->navMaxOffset == RPR_NAV_OFFSET_RETAIN_ALL)
-			ExplainPropertyText("Nav Mark Lookback", "retain all", es);
-		else if (wagg->navMaxOffset == RPR_NAV_OFFSET_NEEDS_EVAL)
-			ExplainPropertyText("Nav Mark Lookback", "runtime", es);
-		else
-			ExplainPropertyInteger("Nav Mark Lookback", NULL,
-								   wagg->navMaxOffset, es);
+		/*
+		 * Show navigation offsets for tuplestore trim.  For EXPLAIN ANALYZE,
+		 * use the executor-resolved values (which may differ from the plan
+		 * when NEEDS_EVAL was resolved to FIXED or RETAIN_ALL at init).
+		 */
+		{
+			RPRNavOffsetKind maxKind = wagg->navMaxOffsetKind;
+			int64		maxOffset = wagg->navMaxOffset;
+			RPRNavOffsetKind firstKind = wagg->navFirstOffsetKind;
+			int64		firstOffset = wagg->navFirstOffset;
+
+			if (es->analyze)
+			{
+				maxKind = planstate->navMaxOffsetKind;
+				maxOffset = planstate->navMaxOffset;
+				firstKind = planstate->navFirstOffsetKind;
+				firstOffset = planstate->navFirstOffset;
+			}
+
+			switch (maxKind)
+			{
+				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,
+										   maxOffset, es);
+					break;
+				default:
+					elog(ERROR, "unrecognized RPR nav offset kind: %d",
+						 maxKind);
+					break;
+			}
+
+			if (wagg->hasFirstNav)
+			{
+				switch (firstKind)
+				{
+					case RPR_NAV_OFFSET_NEEDS_EVAL:
+						ExplainPropertyText("Nav Mark Lookahead", "runtime",
+											es);
+						break;
+					case RPR_NAV_OFFSET_RETAIN_ALL:
+						ExplainPropertyText("Nav Mark Lookahead", "retain all",
+											es);
+						break;
+					case RPR_NAV_OFFSET_FIXED:
+						ExplainPropertyInteger("Nav Mark Lookahead", NULL,
+											   firstOffset, es);
+						break;
+					default:
+						elog(ERROR, "unrecognized RPR nav offset kind: %d",
+							 firstKind);
+						break;
+				}
+			}
+		}
 	}
 }
 
diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index dbed4f48a0f..6349a564a98 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -1225,12 +1225,16 @@ ExecInitExprRec(Expr *node, ExprState *state,
 		case T_RPRNavExpr:
 			{
 				/*
-				 * RPR navigation functions (PREV/NEXT) 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.
+				 * 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.
+				 *
+				 * Default offset when offset_arg is NULL: PREV/NEXT: 1
+				 * (physical offset from currentpos) FIRST/LAST: 0 (logical
+				 * offset from match boundary)
 				 */
 				RPRNavExpr *nav = (RPRNavExpr *) node;
 				WindowAggState *winstate;
@@ -1243,25 +1247,52 @@ ExecInitExprRec(Expr *node, ExprState *state,
 				scratch.d.rpr_nav.winstate = winstate;
 				scratch.d.rpr_nav.kind = nav->kind;
 
-				if (nav->offset_arg != NULL)
+				if (nav->kind >= RPR_NAV_PREV_FIRST)
 				{
 					/*
-					 * Allocate storage for the runtime offset value.  The
-					 * offset expression is compiled below so it runs before
-					 * EEOP_RPR_NAV_SET.
+					 * Compound navigation: allocate array of 2 for inner [0]
+					 * and outer [1] offsets.
 					 */
+					Datum	   *offset_values = palloc_array(Datum, 2);
+					bool	   *offset_isnulls = palloc_array(bool, 2);
+
+					/* Inner offset (default 0 for FIRST/LAST) */
+					if (nav->offset_arg != NULL)
+						ExecInitExprRec(nav->offset_arg, state,
+										&offset_values[0], &offset_isnulls[0]);
+					else
+					{
+						offset_values[0] = Int64GetDatum(0);
+						offset_isnulls[0] = false;
+					}
+
+					/* Outer offset (default 1 for PREV/NEXT) */
+					if (nav->compound_offset_arg != NULL)
+						ExecInitExprRec(nav->compound_offset_arg, state,
+										&offset_values[1], &offset_isnulls[1]);
+					else
+					{
+						offset_values[1] = Int64GetDatum(1);
+						offset_isnulls[1] = false;
+					}
+
+					scratch.d.rpr_nav.offset_value = offset_values;
+					scratch.d.rpr_nav.offset_isnull = offset_isnulls;
+				}
+				else if (nav->offset_arg != NULL)
+				{
+					/* Simple navigation with explicit offset */
 					Datum	   *offset_value = palloc_object(Datum);
 					bool	   *offset_isnull = palloc_object(bool);
 
-					/* Compile the offset expression into the temp storage */
 					ExecInitExprRec(nav->offset_arg, state,
 									offset_value, offset_isnull);
-
 					scratch.d.rpr_nav.offset_value = offset_value;
 					scratch.d.rpr_nav.offset_isnull = offset_isnull;
 				}
 				else
 				{
+					/* Simple navigation with default offset */
 					scratch.d.rpr_nav.offset_value = NULL;
 					scratch.d.rpr_nav.offset_isnull = NULL;
 				}
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index e41faa95be3..2ec579732cc 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -5942,7 +5942,35 @@ ExecAggPlainTransByRef(AggState *aggstate, AggStatePerTrans pertrans,
 }
 
 /*
- * Evaluate RPR PREV/NEXT navigation: swap slot to target row.
+ * Extract compound (outer) offset from step data.
+ * For compound nav, offset_value is an array: [0]=inner, [1]=outer.
+ * Returns the outer offset; errors on NULL or negative.
+ * Default is 1 (like PREV/NEXT implicit offset).
+ */
+static int64
+rpr_nav_get_compound_offset(ExprEvalStep *op)
+{
+	int64		val;
+
+	Assert(op->d.rpr_nav.offset_value != NULL);
+
+	if (op->d.rpr_nav.offset_isnull[1])
+		ereport(ERROR,
+				(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+				 errmsg("row pattern navigation offset must not be null")));
+
+	val = DatumGetInt64(op->d.rpr_nav.offset_value[1]);
+
+	if (val < 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("row pattern navigation offset must not be negative")));
+
+	return val;
+}
+
+/*
+ * 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
@@ -5963,27 +5991,35 @@ ExecEvalRPRNavSet(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
 	winstate->nav_saved_outertuple = econtext->ecxt_outertuple;
 
 	/*
-	 * Determine the unsigned offset.  For 2-arg PREV/NEXT the offset
-	 * expression has already been evaluated into offset_value.  NULL or
-	 * negative offsets are errors per the SQL standard (ISO/IEC 9075-2,
-	 * Subclause 5.6.2).
+	 * Determine the inner offset.  NULL or negative offsets are errors per
+	 * the SQL standard.
+	 *
+	 * Default offset when offset_arg is NULL: PREV/NEXT: 1 (standard 5.6.2)
+	 * FIRST/LAST and compound: 0 for inner, 1 for outer
 	 */
 	if (op->d.rpr_nav.offset_value != NULL)
 	{
 		if (*op->d.rpr_nav.offset_isnull)
 			ereport(ERROR,
 					(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
-					 errmsg("PREV/NEXT offset must not be null")));
+					 errmsg("row pattern navigation offset must not be null")));
 
 		offset = DatumGetInt64(*op->d.rpr_nav.offset_value);
 
 		if (offset < 0)
 			ereport(ERROR,
 					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-					 errmsg("PREV/NEXT offset must not be negative")));
+					 errmsg("row pattern navigation offset must not be negative")));
 	}
 	else
-		offset = 1;
+	{
+		/* Default offset: 1 for simple PREV/NEXT, 0 otherwise */
+		if (op->d.rpr_nav.kind == RPR_NAV_PREV ||
+			op->d.rpr_nav.kind == RPR_NAV_NEXT)
+			offset = 1;
+		else
+			offset = 0;
+	}
 
 	/*
 	 * Calculate target position based on navigation direction.  On overflow,
@@ -5999,8 +6035,107 @@ ExecEvalRPRNavSet(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
 			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		compound_offset;
+				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;
+				}
+
+				/* Outer offset */
+				compound_offset = rpr_nav_get_compound_offset(op);
+
+				/* Apply outer: PREV subtracts, NEXT adds */
+				if (op->d.rpr_nav.kind == RPR_NAV_PREV_FIRST)
+				{
+					if (pg_sub_s64_overflow(inner_pos, compound_offset, &target_pos))
+						target_pos = -1;
+				}
+				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		compound_offset;
+				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;
+				}
+
+				/* Outer offset */
+				compound_offset = rpr_nav_get_compound_offset(op);
+
+				/* Apply outer: PREV subtracts, NEXT adds */
+				if (op->d.rpr_nav.kind == RPR_NAV_PREV_LAST)
+				{
+					if (pg_sub_s64_overflow(inner_pos, compound_offset, &target_pos))
+						target_pos = -1;
+				}
+				else
+				{
+					if (pg_add_s64_overflow(inner_pos, compound_offset, &target_pos))
+						target_pos = -1;
+				}
+			}
+			break;
+		default:
+			elog(ERROR, "unrecognized RPR navigation kind: %d",
+				 op->d.rpr_nav.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)
+		return;
+
 	/* Fetch target row slot (returns nav_null_slot if out of range) */
 	target_slot = ExecRPRNavGetSlot(winstate, target_pos);
 
@@ -6015,9 +6150,11 @@ ExecEvalRPRNavSet(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
 }
 
 /*
- * Evaluate RPR PREV/NEXT navigation: restore slot to original row.
+ * Evaluate RPR navigation: restore slot to original row.
  *
  * Restores econtext->ecxt_outertuple from the saved slot in winstate.
+ * When slot swap was elided (target == currentpos), this is a harmless
+ * no-op since saved and current slots are identical.
  * The caller is responsible for updating any local slot cache.
  */
 void
diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 5428d0e8fc4..01df2a11e0a 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -48,6 +48,7 @@
  *     - src/include/nodes/plannodes.h           (plan node definitions)
  *     - src/include/nodes/execnodes.h           (execution state definitions)
  *     - src/include/optimizer/rpr.h             (types and constants)
+ *     - src/backend/optimizer/plan/createplan.c (nav offset computation)
  *
  * ============================================================================
  *
@@ -609,22 +610,69 @@
  *       result = ExecEvalExpr(defineClause[i])
  *       varMatched[i] = (not null and true)
  *
- * To support row navigation operators such as PREV() and NEXT(),
+ * To support row navigation operators (PREV, NEXT, FIRST, LAST),
  * a 1-slot model is used: only ecxt_outertuple is set to the current
- * row.  PREV/NEXT navigation is handled by EEOP_RPR_NAV_SET/RESTORE
- * opcodes emitted during DEFINE expression compilation:
+ * row.  Navigation is handled by EEOP_RPR_NAV_SET/RESTORE opcodes
+ * emitted during DEFINE expression compilation:
  *
  *   NAV_SET:     save ecxt_outertuple, swap in target row via nav_slot
  *   (evaluate):  argument expression reads from swapped slot
  *   NAV_RESTORE: restore original ecxt_outertuple
  *
+ * Compound navigation (PREV(FIRST()), NEXT(FIRST()), PREV(LAST()),
+ * NEXT(LAST())) is flattened by the parser into a single RPRNavExpr
+ * with a compound kind (RPR_NAV_PREV_FIRST, etc.).  The executor
+ * computes the target position in two steps: first the inner reference
+ * point (match_start + N or currentpos - N) with match-range validation,
+ * then the outer adjustment (± M) with partition-range validation.
+ * If either step is out of range, the result is NULL.
+ *
  * nav_slot caches the last fetched position (nav_slot_pos) to avoid
- * redundant tuplestore lookups when multiple PREV/NEXT calls target
+ * redundant tuplestore lookups when multiple navigation calls target
  * the same row.
  *
  * The varMatched array is referenced later in Phase 1 (Match).
  *
- * VI-4. ExecRPRProcessRow(): 3-Phase Processing
+ * VI-4. Per-Context Re-evaluation (match_start-dependent variables)
+ *
+ * DEFINE variables that depend on match_start (those containing FIRST,
+ * LAST-with-offset, or compound PREV_FIRST/NEXT_FIRST/PREV_LAST/NEXT_LAST)
+ * are identified at plan time via defineMatchStartDependent.  The shared
+ * evaluation in nfa_evaluate_row() uses the head context's matchStartRow
+ * for FIRST/LAST base position.
+ *
+ * When processing a context whose matchStartRow differs from the shared
+ * value, nfa_reevaluate_dependent_vars() temporarily sets nav_match_start
+ * to that context's matchStartRow and re-evaluates only the dependent
+ * variables.  No restore is needed because contexts are ordered by
+ * matchStartRow (ascending), so no later context shares the head's value.
+ *
+ * VI-5. Tuplestore Mark and Trim (nodeWindowAgg.c)
+ *
+ * Navigation functions require access to past rows via the tuplestore.
+ * To allow tuplestore_trim() to free rows that are no longer reachable,
+ * the planner computes two offsets (see compute_nav_offsets):
+ *
+ *   navMaxOffset (Nav Mark Lookback):
+ *     Maximum backward reach from currentpos.  Contributed by PREV,
+ *     LAST-with-offset, and compound PREV_LAST/NEXT_LAST.
+ *     Mark position: currentpos - navMaxOffset.
+ *
+ *   navFirstOffset (Nav Mark Lookahead):
+ *     Minimum forward offset from match_start.  Contributed by FIRST
+ *     and compound PREV_FIRST/NEXT_FIRST.  Can be negative when
+ *     compound PREV_FIRST looks before match_start.
+ *     Mark position: oldest_context->matchStartRow + navFirstOffset.
+ *
+ * The actual mark is set to: min(lookback_mark, lookahead_mark).
+ * This ensures all rows reachable by any navigation function are retained.
+ *
+ * When offsets contain non-constant expressions (Param), the planner sets
+ * navMaxOffsetKind/navFirstOffsetKind to RPR_NAV_OFFSET_NEEDS_EVAL and the
+ * executor evaluates them at init time.  On overflow, the kind is set to
+ * RPR_NAV_OFFSET_RETAIN_ALL, disabling trim for that dimension.
+ *
+ * VI-6. ExecRPRProcessRow(): 3-Phase Processing
  *
  * NFA processing for a single row is divided into three phases:
  *
@@ -707,6 +755,21 @@
  *
  * VIII-3. Absorption Conditions
  *
+ * Planner-time prerequisites (all must hold for absorption to be enabled):
+ *
+ *   (a) SKIP PAST LAST ROW.  SKIP TO NEXT ROW creates overlapping
+ *       contexts that cannot be safely absorbed.
+ *   (b) Unbounded frame (ROWS BETWEEN CURRENT ROW AND UNBOUNDED
+ *       FOLLOWING).  Limited frames apply differently to each context,
+ *       breaking the monotonicity principle.
+ *   (c) No match_start-dependent navigation in DEFINE.  FIRST,
+ *       LAST-with-offset, and compound navigation referencing match_start
+ *       (PREV_FIRST, NEXT_FIRST, PREV_LAST/NEXT_LAST with offset)
+ *       cause different contexts to evaluate to different values for the
+ *       same row, breaking monotonicity.
+ *
+ * Runtime conditions (evaluated per context pair):
+ *
  *   (1) The pattern is marked as isAbsorbable (see IV-5)
  *   (2) allStatesAbsorbable of the target context is true
  *   (3) An earlier context "covers" all states of the target
@@ -979,6 +1042,19 @@
  *   Only INITIAL is supported, searching only for matches starting at each
  *   row position pos.
  *
+ * X-4. Bounded Frame Handling
+ *
+ *   When the frame is bounded (e.g., ROWS BETWEEN CURRENT ROW AND 5
+ *   FOLLOWING), ExecRPRProcessRow receives hasLimitedFrame=true and
+ *   frameOffset indicating the upper bound.  After the advance phase,
+ *   any context whose match has exceeded the frame boundary
+ *   (currentPos - matchStartRow >= frameOffset + 1) is finalized early.
+ *   This prevents matches from extending beyond the window frame.
+ *
+ *   Note that bounded frames also disable context absorption at the
+ *   planner level (see VIII-3(b)), since the frame boundary breaks the
+ *   monotonicity assumption required for correct absorption.
+ *
  * Chapter XI  Worked Example: Full Execution Trace
  * ============================================================================
  *
@@ -1330,6 +1406,14 @@
  *   nfa_advance_var               execRPR.c             VAR handling
  *   nfa_add_state_unique          execRPR.c             Deduplication
  *   nfa_states_covered            execRPR.c             Absorption check
+ *   nfa_reevaluate_dependent_vars execRPR.c             Per-context re-eval
+ *   ExecRPRGetHeadContext         execRPR.c             Context lookup
+ *   ExecRPRFreeContext            execRPR.c             Context deallocation
+ *   ExecRPRCleanupDeadContexts    execRPR.c             Dead context cleanup
+ *   ExecRPRFinalizeAllContexts    execRPR.c             Partition-end finalize
+ *   ExecRPRRecordContextSuccess   execRPR.c             Stats: match success
+ *   ExecRPRRecordContextFailure   execRPR.c             Stats: match failure
+ *   compute_nav_offsets           createplan.c          Trim offset computation
  *
  * Appendix B. Data Structure Relationship Diagram
  * ============================================================================
@@ -2989,6 +3073,56 @@ ExecRPRRecordContextFailure(WindowAggState *winstate, int64 failedLen)
 	}
 }
 
+/*
+ * nfa_reevaluate_dependent_vars
+ *		Re-evaluate match_start-dependent DEFINE variables for a specific
+ *		context whose matchStartRow differs from the shared evaluation's
+ *		nav_match_start.
+ *
+ * Only variables in defineMatchStartDependent are re-evaluated.  The
+ * current row's slot (ecxt_outertuple) must already be set up by
+ * nfa_evaluate_row().
+ */
+static void
+nfa_reevaluate_dependent_vars(WindowAggState *winstate, RPRNFAContext *ctx,
+							  int64 currentPos)
+{
+	ExprContext *econtext = winstate->ss.ps.ps_ExprContext;
+	int64		saved_match_start = winstate->nav_match_start;
+	int64		saved_pos = winstate->currentpos;
+	int			varIdx = 0;
+	ListCell   *lc;
+
+	/* Temporarily set nav_match_start and currentpos for FIRST/LAST */
+	winstate->nav_match_start = ctx->matchStartRow;
+	winstate->currentpos = currentPos;
+
+	/* Invalidate nav_slot cache since match_start changed */
+	winstate->nav_slot_pos = -1;
+
+	foreach(lc, winstate->defineClauseList)
+	{
+		if (bms_is_member(varIdx, winstate->defineMatchStartDependent))
+		{
+			ExprState  *exprState = (ExprState *) lfirst(lc);
+			Datum		result;
+			bool		isnull;
+
+			result = ExecEvalExpr(exprState, econtext, &isnull);
+			winstate->nfaVarMatched[varIdx] = (!isnull && DatumGetBool(result));
+		}
+
+		varIdx++;
+		if (varIdx >= list_length(winstate->defineVariableList))
+			break;
+	}
+
+	/* Restore original match_start, currentpos, and invalidate cache */
+	winstate->nav_match_start = saved_match_start;
+	winstate->currentpos = saved_pos;
+	winstate->nav_slot_pos = -1;
+}
+
 /*
  * ExecRPRProcessRow
  *
@@ -3003,6 +3137,7 @@ ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
 {
 	RPRNFAContext *ctx;
 	bool	   *varMatched = winstate->nfaVarMatched;
+	bool		hasDependent = !bms_is_empty(winstate->defineMatchStartDependent);
 
 	/* Allow query cancellation once per row for simple/low-state patterns */
 	CHECK_FOR_INTERRUPTS();
@@ -3029,6 +3164,13 @@ ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
 			}
 		}
 
+		/*
+		 * If this context has a different matchStartRow than the one used in
+		 * the shared evaluation, re-evaluate match_start-dependent variables
+		 * with this context's matchStartRow.
+		 */
+		if (hasDependent && ctx->matchStartRow != winstate->nav_match_start)
+			nfa_reevaluate_dependent_vars(winstate, ctx, currentPos);
 		nfa_match(winstate, ctx, varMatched);
 		ctx->lastProcessedRow = currentPos;
 	}
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 9787ef7756f..cdbe356abd7 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -34,6 +34,7 @@
 #include "postgres.h"
 
 #include "access/htup_details.h"
+#include "common/int.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_aggregate.h"
 #include "catalog/pg_collation_d.h"
@@ -245,8 +246,8 @@ static void update_reduced_frame(WindowObject winobj, int64 pos);
 static bool nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched);
 
 /* Forward declarations - navigation offset evaluation */
-static bool collect_prev_offset_walker(Node *node, List **offsets);
 static void eval_nav_max_offset(WindowAggState *winstate, List *defineClause);
+static void eval_nav_first_offset(WindowAggState *winstate, List *defineClause);
 
 /*
  * Not null info bit array consists of 2-bit items
@@ -932,28 +933,7 @@ eval_windowaggregates(WindowAggState *winstate)
 	 * head, so that tuplestore can discard unnecessary rows.
 	 */
 	if (agg_winobj->markptr >= 0)
-	{
-		int64		markpos = winstate->frameheadpos;
-
-		if (rpr_is_defined(winstate))
-		{
-			/*
-			 * If RPR is used, PREV may need to look at rows before the frame
-			 * head.  Adjust mark by navMaxOffset if known, otherwise retain
-			 * from position 0.
-			 */
-			if (winstate->navMaxOffset >= 0)
-			{
-				if (markpos > winstate->navMaxOffset)
-					markpos -= winstate->navMaxOffset;
-				else
-					markpos = 0;
-			}
-			else
-				markpos = 0;
-		}
-		WinSetMarkPosition(agg_winobj, markpos);
-	}
+		WinSetMarkPosition(agg_winobj, winstate->frameheadpos);
 
 	/*
 	 * Now restart the aggregates that require it.
@@ -1279,15 +1259,15 @@ prepare_tuplestore(WindowAggState *winstate)
 	if (winstate->nav_winobj)
 	{
 		/*
-		 * Allocate mark and read pointers for PREV/NEXT navigation.
+		 * Allocate mark and read pointers for RPR navigation.
 		 *
-		 * If navMaxOffset >= 0, we advance the mark to (currentpos -
-		 * navMaxOffset) as rows are processed, allowing tuplestore_trim() to
-		 * free rows that are no longer reachable.
+		 * If navMaxOffsetKind == RPR_NAV_OFFSET_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.
 		 *
-		 * If navMaxOffset < 0 (RPR_NAV_OFFSET_NEEDS_EVAL or
-		 * RPR_NAV_OFFSET_RETAIN_ALL), the mark stays at 0, retaining the
-		 * entire partition in the tuplestore.
+		 * RPR_NAV_OFFSET_NEEDS_EVAL is resolved at executor init; by this
+		 * point it is either FIXED or RETAIN_ALL.
 		 */
 		winstate->nav_winobj->markptr =
 			tuplestore_alloc_read_pointer(winstate->buffer, 0);
@@ -2527,18 +2507,40 @@ ExecWindowAgg(PlanState *pstate)
 
 		/*
 		 * Advance RPR navigation mark pointer if possible, so that
-		 * tuplestore_trim() can free rows no longer reachable by PREV.
+		 * tuplestore_trim() can free rows no longer reachable by navigation.
 		 */
 		if (winstate->nav_winobj &&
 			winstate->rpPattern != NULL &&
-			winstate->navMaxOffset >= 0)
+			winstate->navMaxOffsetKind == RPR_NAV_OFFSET_FIXED)
 		{
 			int64		navmarkpos;
 
+			/* Backward reach from PREV/LAST/compound PREV_LAST/NEXT_LAST */
 			if (winstate->currentpos > winstate->navMaxOffset)
 				navmarkpos = winstate->currentpos - winstate->navMaxOffset;
 			else
 				navmarkpos = 0;
+
+			/*
+			 * If FIRST is used, also consider match_start + navFirstOffset.
+			 * The oldest active context (nfaContext) has the smallest
+			 * matchStartRow.
+			 */
+			if (winstate->hasFirstNav &&
+				winstate->navFirstOffsetKind == RPR_NAV_OFFSET_FIXED &&
+				winstate->nfaContext != NULL)
+			{
+				int64		firstreach;
+
+				if (winstate->navFirstOffset > -winstate->nfaContext->matchStartRow)
+					firstreach = winstate->nfaContext->matchStartRow
+						+ winstate->navFirstOffset;
+				else
+					firstreach = 0;
+				if (firstreach < navmarkpos)
+					navmarkpos = firstreach;
+			}
+
 			if (navmarkpos > winstate->nav_winobj->markpos)
 				WinSetMarkPosition(winstate->nav_winobj, navmarkpos);
 		}
@@ -2779,6 +2781,7 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 		winstate->nav_null_slot = ExecStoreAllNullTuple(winstate->nav_null_slot);
 
 		winstate->nav_saved_outertuple = NULL;
+		winstate->nav_match_start = 0;
 	}
 
 	/*
@@ -2988,10 +2991,20 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->rpSkipTo = node->rpSkipTo;
 	/* Set up row pattern recognition PATTERN clause (compiled NFA) */
 	winstate->rpPattern = node->rpPattern;
-	/* Set up max PREV offset for tuplestore trim */
+	/* Set up nav offsets for tuplestore trim */
+	winstate->navMaxOffsetKind = node->navMaxOffsetKind;
 	winstate->navMaxOffset = node->navMaxOffset;
-	if (winstate->navMaxOffset == RPR_NAV_OFFSET_NEEDS_EVAL)
+	if (winstate->navMaxOffsetKind == RPR_NAV_OFFSET_NEEDS_EVAL)
 		eval_nav_max_offset(winstate, node->defineClause);
+	winstate->hasFirstNav = node->hasFirstNav;
+	winstate->navFirstOffsetKind = node->navFirstOffsetKind;
+	winstate->navFirstOffset = node->navFirstOffset;
+	if (winstate->hasFirstNav &&
+		winstate->navFirstOffsetKind == RPR_NAV_OFFSET_NEEDS_EVAL)
+		eval_nav_first_offset(winstate, node->defineClause);
+
+	/* Copy match_start dependency bitmapset for per-context evaluation */
+	winstate->defineMatchStartDependent = bms_copy(node->defineMatchStartDependent);
 
 	/* Calculate NFA state size and allocate cycle detection bitmap */
 	if (node->rpPattern != NULL)
@@ -3903,84 +3916,254 @@ put_notnull_info(WindowObject winobj, int64 pos, int argno, bool isnull)
 }
 
 /*
- * collect_prev_offset_walker
- *		Walk expression tree to collect PREV offset_arg expressions.
+ * eval_nav_offset_helper
+ *		Evaluate an offset expression at executor init time for trim
+ *		optimization.  Returns the offset value, or 0 for NULL/negative
+ *		(these will cause a runtime error during actual navigation, so the
+ *		trim value is irrelevant).
+ */
+static int64
+eval_nav_offset_helper(WindowAggState *winstate, Expr *offset_expr,
+					   int64 defaultOffset)
+{
+	ExprContext *econtext = winstate->ss.ps.ps_ExprContext;
+	ExprState  *estate;
+	Datum		val;
+	bool		isnull;
+	int64		offset;
+
+	if (offset_expr == NULL)
+		return defaultOffset;
+
+	estate = ExecInitExpr(offset_expr, (PlanState *) winstate);
+	val = ExecEvalExprSwitchContext(estate, econtext, &isnull);
+
+	if (isnull)
+		return 0;
+
+	offset = DatumGetInt64(val);
+	if (offset < 0)
+		return 0;
+
+	return offset;
+}
+
+typedef struct
+{
+	WindowAggState *winstate;
+	int64		maxOffset;
+	bool		overflow;		/* true if overflow detected */
+} EvalNavMaxContext;
+
+/*
+ * eval_nav_max_offset_walker
+ *		Walk expression tree evaluating backward-reach offsets at runtime.
+ *
+ * Handles simple PREV, LAST-with-offset, and compound PREV_LAST/NEXT_LAST.
  */
 static bool
-collect_prev_offset_walker(Node *node, List **offsets)
+eval_nav_max_offset_walker(Node *node, void *ctx)
 {
+	EvalNavMaxContext *context = (EvalNavMaxContext *) ctx;
+
 	if (node == NULL)
 		return false;
 
+	/* Short-circuit if overflow already detected */
+	if (context->overflow)
+		return false;
+
 	if (IsA(node, RPRNavExpr))
 	{
 		RPRNavExpr *nav = (RPRNavExpr *) node;
+		int64		reach = 0;
+
+		if (nav->kind == RPR_NAV_PREV)
+		{
+			reach = eval_nav_offset_helper(context->winstate,
+										   nav->offset_arg, 1);
+		}
+		else if (nav->kind == RPR_NAV_LAST && nav->offset_arg != NULL)
+		{
+			reach = eval_nav_offset_helper(context->winstate,
+										   nav->offset_arg, 0);
+		}
+		else if (nav->kind == RPR_NAV_PREV_LAST ||
+				 nav->kind == RPR_NAV_NEXT_LAST)
+		{
+			int64		inner = eval_nav_offset_helper(context->winstate,
+													   nav->offset_arg, 0);
+			int64		outer = eval_nav_offset_helper(context->winstate,
+													   nav->compound_offset_arg, 1);
 
-		if (nav->kind == RPR_NAV_PREV && nav->offset_arg != NULL)
-			*offsets = lappend(*offsets, nav->offset_arg);
+			if (nav->kind == RPR_NAV_PREV_LAST)
+			{
+				if (pg_add_s64_overflow(inner, outer, &reach))
+				{
+					context->overflow = true;
+					return false;
+				}
+			}
+			else
+				reach = (inner > outer) ? inner - outer : 0;
+		}
 
-		/* Don't walk into RPRNavExpr children */
-		return false;
+		if (reach > context->maxOffset)
+			context->maxOffset = reach;
+
+		return false;			/* don't walk into children */
 	}
 
-	return expression_tree_walker(node, collect_prev_offset_walker, offsets);
+	return expression_tree_walker(node, eval_nav_max_offset_walker, ctx);
 }
 
 /*
  * eval_nav_max_offset
- *		Evaluate non-constant PREV offsets at executor init time.
+ *		Evaluate non-constant backward-reach offsets at executor init time.
+ *
+ * Called when the planner set navMaxOffsetKind to RPR_NAV_OFFSET_NEEDS_EVAL
+ * because some offset in PREV, LAST-with-offset, or compound PREV_LAST/
+ * NEXT_LAST contains a parameter or non-foldable expression.
  *
- * Called when the planner set navMaxOffset to RPR_NAV_OFFSET_NEEDS_EVAL
- * because some PREV offset contains a parameter or non-foldable expression.
- * Walks the original defineClause expression trees, compiles and evaluates
- * each PREV offset_arg, and stores the maximum in winstate->navMaxOffset.
+ * On overflow, sets navMaxOffsetKind to RPR_NAV_OFFSET_RETAIN_ALL so that
+ * tuplestore trim is disabled for backward navigation.
  */
 static void
 eval_nav_max_offset(WindowAggState *winstate, List *defineClause)
 {
-	ExprContext *econtext = winstate->ss.ps.ps_ExprContext;
-	List	   *offsets = NIL;
+	EvalNavMaxContext ctx;
 	ListCell   *lc;
-	int64		maxOffset = 0;
 
-	/* Collect all PREV offset expressions from DEFINE clause */
+	ctx.winstate = winstate;
+	ctx.maxOffset = 0;
+	ctx.overflow = false;
+
 	foreach(lc, defineClause)
 	{
 		TargetEntry *te = (TargetEntry *) lfirst(lc);
 
-		collect_prev_offset_walker((Node *) te->expr, &offsets);
+		eval_nav_max_offset_walker((Node *) te->expr, &ctx);
 	}
 
-	/* Evaluate each offset and find maximum */
-	foreach(lc, offsets)
+	if (ctx.overflow)
 	{
-		Expr	   *offset_expr = (Expr *) lfirst(lc);
-		ExprState  *estate;
-		Datum		val;
-		bool		isnull;
-		int64		offset;
+		winstate->navMaxOffsetKind = RPR_NAV_OFFSET_RETAIN_ALL;
+		winstate->navMaxOffset = 0;
+	}
+	else
+	{
+		winstate->navMaxOffsetKind = RPR_NAV_OFFSET_FIXED;
+		winstate->navMaxOffset = ctx.maxOffset;
+	}
+}
 
-		estate = ExecInitExpr(offset_expr, (PlanState *) winstate);
-		val = ExecEvalExprSwitchContext(estate, econtext, &isnull);
+typedef struct
+{
+	WindowAggState *winstate;
+	int64		minOffset;
+	bool		found;
+} EvalNavFirstContext;
 
-		/*
-		 * NULL or negative offsets will cause a runtime error when PREV is
-		 * actually evaluated.  For trim purposes, treat them as 0.
-		 */
-		if (isnull)
-			continue;
+/*
+ * eval_nav_first_offset_walker
+ *		Walk expression tree evaluating forward-from-match_start offsets.
+ *
+ * Handles simple FIRST and compound PREV_FIRST/NEXT_FIRST.
+ */
+static bool
+eval_nav_first_offset_walker(Node *node, void *ctx)
+{
+	EvalNavFirstContext *context = (EvalNavFirstContext *) ctx;
 
-		offset = DatumGetInt64(val);
-		if (offset < 0)
-			continue;
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, RPRNavExpr))
+	{
+		RPRNavExpr *nav = (RPRNavExpr *) node;
+		int64		combined = INT64_MAX;
 
-		if (offset > maxOffset)
-			maxOffset = offset;
+		if (nav->kind == RPR_NAV_FIRST)
+		{
+			context->found = true;
+			combined = eval_nav_offset_helper(context->winstate,
+											  nav->offset_arg, 0);
+		}
+		else if (nav->kind == RPR_NAV_PREV_FIRST ||
+				 nav->kind == RPR_NAV_NEXT_FIRST)
+		{
+			int64		inner = eval_nav_offset_helper(context->winstate,
+													   nav->offset_arg, 0);
+			int64		outer = eval_nav_offset_helper(context->winstate,
+													   nav->compound_offset_arg, 1);
+
+			context->found = true;
+			if (nav->kind == RPR_NAV_PREV_FIRST)
+			{
+				/*
+				 * combined = inner - outer.  Both are non-negative, so the
+				 * result >= -INT64_MAX, which cannot underflow int64.
+				 */
+				combined = inner - outer;
+			}
+			else
+			{
+				/*
+				 * NEXT_FIRST: combined = inner + outer.  This can overflow,
+				 * but the result is always >= 0, so it never updates
+				 * minOffset (which tracks the minimum).  Clamp to INT64_MAX
+				 * on overflow.
+				 */
+				if (pg_add_s64_overflow(inner, outer, &combined))
+					combined = INT64_MAX;
+			}
+		}
+
+		if (combined < context->minOffset)
+			context->minOffset = combined;
+
+		return false;
 	}
 
-	winstate->navMaxOffset = maxOffset;
+	return expression_tree_walker(node, eval_nav_first_offset_walker, ctx);
+}
+
+/*
+ * eval_nav_first_offset
+ *		Evaluate non-constant forward-from-match_start offsets at executor
+ *		init time.
+ *
+ * Called when the planner set navFirstOffsetKind to RPR_NAV_OFFSET_NEEDS_EVAL
+ * because some offset in FIRST or compound PREV_FIRST/NEXT_FIRST contains
+ * a parameter or non-foldable expression.
+ */
+static void
+eval_nav_first_offset(WindowAggState *winstate, List *defineClause)
+{
+	EvalNavFirstContext ctx;
+	ListCell   *lc;
+
+	ctx.winstate = winstate;
+	ctx.minOffset = INT64_MAX;
+	ctx.found = false;
+
+	foreach(lc, defineClause)
+	{
+		TargetEntry *te = (TargetEntry *) lfirst(lc);
 
-	list_free(offsets);
+		eval_nav_first_offset_walker((Node *) te->expr, &ctx);
+	}
+
+	if (ctx.found && ctx.minOffset < INT64_MAX)
+	{
+		winstate->navFirstOffsetKind = RPR_NAV_OFFSET_FIXED;
+		winstate->navFirstOffset = ctx.minOffset;
+	}
+	else
+	{
+		winstate->navFirstOffsetKind = RPR_NAV_OFFSET_FIXED;
+		winstate->navFirstOffset = 0;
+	}
 }
 
 /*
@@ -4210,8 +4393,14 @@ update_reduced_frame(WindowObject winobj, int64 pos)
 
 		/*
 		 * Evaluate variables for this row - done only once, shared by all
-		 * contexts
+		 * 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->nav_match_start = targetCtx->matchStartRow;
 		rowExists = nfa_evaluate_row(winobj, currentPos, winstate->nfaVarMatched);
 
 		/* No more rows in partition? Finalize all contexts */
@@ -4299,8 +4488,8 @@ register_result:
  * varMatched[i] = true if variable i matched at current row.
  *
  * Uses 1-slot model: only ecxt_outertuple is set to the current row.
- * PREV/NEXT navigation is handled by EEOP_RPR_NAV_SET/RESTORE opcodes
- * during expression evaluation, which temporarily swap the slot.
+ * PREV/NEXT/FIRST/LAST navigation is handled by EEOP_RPR_NAV_SET/RESTORE
+ * opcodes during expression evaluation, which temporarily swap the slot.
  */
 static bool
 nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched)
diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c
index d2f19584070..66d37b78898 100644
--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -2207,6 +2207,8 @@ expression_tree_walker_impl(Node *node,
 					return true;
 				if (expr->offset_arg && WALK(expr->offset_arg))
 					return true;
+				if (expr->compound_offset_arg && WALK(expr->compound_offset_arg))
+					return true;
 			}
 			break;
 		case T_SubscriptingRef:
@@ -3146,6 +3148,7 @@ expression_tree_mutator_impl(Node *node,
 				FLATCOPY(newnode, nav, RPRNavExpr);
 				MUTATE(newnode->arg, nav->arg, Expr *);
 				MUTATE(newnode->offset_arg, nav->offset_arg, Expr *);
+				MUTATE(newnode->compound_offset_arg, nav->compound_offset_arg, Expr *);
 				return (Node *) newnode;
 			}
 			break;
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 8ee3ccf6d0d..02d511269ab 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -16,6 +16,7 @@
  */
 #include "postgres.h"
 
+#include "common/int.h"
 #include "access/sysattr.h"
 #include "access/transam.h"
 #include "catalog/pg_class.h"
@@ -292,6 +293,7 @@ static WindowAgg *make_windowagg(List *tlist, WindowClause *wc,
 								 List *runCondition, RPSkipTo rpSkipTo,
 								 RPRPattern *compiledPattern,
 								 List *defineClause,
+								 Bitmapset *defineMatchStartDependent,
 								 List *qual, bool topWindow,
 								 Plan *lefttree);
 static Group *make_group(List *tlist, List *qual, int numGroupCols,
@@ -2462,19 +2464,72 @@ create_minmaxagg_plan(PlannerInfo *root, MinMaxAggPath *best_path)
 }
 
 /*
- * nav_max_offset_walker
- *		Walk expression tree to find the maximum PREV offset.
+ * NavOffsetContext - context for compute_nav_offsets walker.
  *
- * Only PREV is relevant for tuplestore trim since it looks backward;
- * NEXT looks forward and never references already-trimmed rows.
+ * Collects both backward reach (PREV, LAST-with-offset, compound
+ * PREV_LAST/NEXT_LAST) and forward-from-match-start reach (FIRST,
+ * compound PREV_FIRST/NEXT_FIRST) in a single tree walk.
+ */
+typedef struct NavOffsetContext
+{
+	int64		maxOffset;		/* max PREV/LAST backward offset (>= 0) */
+	bool		maxNeedsEval;	/* non-constant PREV/LAST offset found */
+	bool		maxOverflow;	/* constant offset overflow detected */
+	int64		firstOffset;	/* min FIRST offset (>= 0), or -1 if none */
+	bool		hasFirst;		/* any FIRST node found */
+	bool		firstNeedsEval; /* non-constant FIRST offset found */
+} NavOffsetContext;
+
+/*
+ * Helper: extract constant offset from an expression, handling NULL/negative.
+ * If expr is NULL, returns defaultOffset.
+ * Returns true if constant, false if non-constant (Param, cast, etc.).
+ */
+static bool
+extract_const_offset(Expr *expr, int64 defaultOffset, int64 *result)
+{
+	if (expr == NULL)
+	{
+		*result = defaultOffset;
+		return true;
+	}
+
+	if (IsA(expr, Const))
+	{
+		Const	   *c = (Const *) expr;
+
+		if (c->constisnull)
+			*result = 0;		/* runtime error; safe placeholder */
+		else
+		{
+			*result = DatumGetInt64(c->constvalue);
+			if (*result < 0)
+				*result = 0;	/* runtime error; safe placeholder */
+		}
+		return true;
+	}
+
+	return false;				/* non-constant */
+}
+
+/*
+ * nav_offset_walker
+ *		Expression tree walker for compute_nav_offsets.
+ *
+ * For each RPRNavExpr found, extract its constant offset(s) and update the
+ * NavOffsetContext with the maximum backward reach (maxOffset) and minimum
+ * forward reach (firstOffset).  Handles simple navigation (PREV, NEXT,
+ * FIRST, LAST) and compound forms (PREV_FIRST, NEXT_FIRST, PREV_LAST,
+ * NEXT_LAST) by combining inner and outer offsets.
  *
- * Returns true (to stop walking) if a non-constant PREV offset is found,
- * in which case *maxOffset is set to -1.  Otherwise accumulates the
- * maximum constant offset value.
+ * Non-constant offsets set maxNeedsEval or firstNeedsEval.  Overflow sets
+ * maxOverflow or firstOverflow for RETAIN_ALL fallback.
  */
 static bool
-nav_max_offset_walker(Node *node, int64 *maxOffset)
+nav_offset_walker(Node *node, void *ctx)
 {
+	NavOffsetContext *context = (NavOffsetContext *) ctx;
+
 	if (node == NULL)
 		return false;
 
@@ -2482,81 +2537,294 @@ nav_max_offset_walker(Node *node, int64 *maxOffset)
 	{
 		RPRNavExpr *nav = (RPRNavExpr *) node;
 
-		/* Only PREV looks backward; NEXT is irrelevant for trim */
-		if (nav->kind == RPR_NAV_PREV)
+		/*
+		 * Simple PREV(v, N) and LAST(v, N): backward reach from currentpos.
+		 * LAST without offset = currentpos, no backward reach. NEXT: forward
+		 * only, irrelevant for trim.
+		 */
+		if (nav->kind == RPR_NAV_PREV ||
+			(nav->kind == RPR_NAV_LAST && nav->offset_arg != NULL))
 		{
-			int64		offset;
-
-			if (nav->offset_arg == NULL)
+			if (!context->maxNeedsEval)
 			{
-				/* 1-arg form: implicit offset of 1 */
-				offset = 1;
+				int64		offset;
+
+				if (extract_const_offset(nav->offset_arg, 1, &offset))
+				{
+					if (offset > context->maxOffset)
+						context->maxOffset = offset;
+				}
+				else
+					context->maxNeedsEval = true;
 			}
-			else if (IsA(nav->offset_arg, Const))
+		}
+
+		/*
+		 * Simple FIRST(v, N): forward reach from match_start. Smaller N means
+		 * older rows needed.
+		 */
+		if (nav->kind == RPR_NAV_FIRST)
+		{
+			context->hasFirst = true;
+
+			if (!context->firstNeedsEval)
 			{
-				Const	   *c = (Const *) nav->offset_arg;
+				int64		offset;
 
-				if (c->constisnull)
+				if (extract_const_offset(nav->offset_arg, 0, &offset))
 				{
-					/*
-					 * NULL offset causes a runtime error, so this path is
-					 * never actually reached during execution.  Use 0 as a
-					 * safe placeholder for planning purposes.
-					 */
-					offset = 0;
+					if (offset < context->firstOffset)
+						context->firstOffset = offset;
 				}
 				else
+					context->firstNeedsEval = true;
+			}
+		}
+
+		/*
+		 * Compound PREV_LAST / NEXT_LAST: base = currentpos. PREV_LAST(v, N,
+		 * M): target = currentpos - N - M → lookback = N + M NEXT_LAST(v,
+		 * N, M): target = currentpos - N + M → lookback = max(N - M, 0)
+		 */
+		if (nav->kind == RPR_NAV_PREV_LAST ||
+			nav->kind == RPR_NAV_NEXT_LAST)
+		{
+			if (!context->maxNeedsEval)
+			{
+				int64		inner,
+							outer,
+							combined;
+
+				if (extract_const_offset(nav->offset_arg, 0, &inner) &&
+					extract_const_offset(nav->compound_offset_arg, 1, &outer))
 				{
-					offset = DatumGetInt64(c->constvalue);
-					if (offset < 0)
-						offset = 0; /* negative offset causes runtime error */
+					if (nav->kind == RPR_NAV_PREV_LAST)
+					{
+						if (pg_add_s64_overflow(inner, outer, &combined))
+						{
+							context->maxOverflow = true;
+							return false;
+						}
+					}
+					else
+						combined = (inner > outer) ? inner - outer : 0;
+
+					if (combined > context->maxOffset)
+						context->maxOffset = combined;
 				}
+				else
+					context->maxNeedsEval = true;
 			}
-			else
+		}
+
+		/*
+		 * Compound PREV_FIRST / NEXT_FIRST: base = match_start. PREV_FIRST(v,
+		 * N, M): target = match_start + N - M NEXT_FIRST(v, N, M): target =
+		 * match_start + N + M The combined offset (N±M) from match_start can
+		 * be negative, meaning rows before match_start are needed.
+		 */
+		if (nav->kind == RPR_NAV_PREV_FIRST ||
+			nav->kind == RPR_NAV_NEXT_FIRST)
+		{
+			context->hasFirst = true;
+
+			if (!context->firstNeedsEval)
 			{
-				/*
-				 * Non-constant offset (Param, stable function, etc.). The
-				 * parser guarantees offset is a runtime constant, so it can
-				 * be evaluated at executor init time.
-				 */
-				*maxOffset = RPR_NAV_OFFSET_NEEDS_EVAL;
-				return true;	/* stop walking */
+				int64		inner,
+							outer,
+							combined;
+
+				if (extract_const_offset(nav->offset_arg, 0, &inner) &&
+					extract_const_offset(nav->compound_offset_arg, 1, &outer))
+				{
+					if (nav->kind == RPR_NAV_PREV_FIRST)
+					{
+						/*
+						 * combined = inner - outer.  Both are non-negative,
+						 * so the result >= -INT64_MAX, which cannot underflow
+						 * int64.  No overflow check needed.
+						 */
+						combined = inner - outer;
+					}
+					else
+					{
+						/*
+						 * NEXT_FIRST: combined = inner + outer.  This can
+						 * overflow, but the result is always >= 0, so it
+						 * never updates firstOffset (which tracks the
+						 * minimum).  Clamp to INT64_MAX on overflow.
+						 */
+						if (pg_add_s64_overflow(inner, outer, &combined))
+							combined = INT64_MAX;
+					}
+
+					if (combined < context->firstOffset)
+						context->firstOffset = combined;
+				}
+				else
+					context->firstNeedsEval = true;
 			}
+		}
 
-			if (offset > *maxOffset)
-				*maxOffset = offset;
+		/* Don't walk into RPRNavExpr children */
+		return false;
+	}
+
+	return expression_tree_walker(node, nav_offset_walker, ctx);
+}
+
+/*
+ * compute_nav_offsets
+ *		Compute navigation offsets for tuplestore trim in a single pass.
+ *
+ * Walks all DEFINE clause expressions once, computing:
+ *   - maxOffset: max backward reach from PREV, LAST-with-offset,
+ *     compound PREV_LAST/NEXT_LAST
+ *   - hasFirst/firstOffset: min forward-from-match-start reach from
+ *     FIRST, compound PREV_FIRST/NEXT_FIRST
+ */
+static void
+compute_nav_offsets(List *defineClause,
+					RPRNavOffsetKind *maxKind, int64 *maxResult,
+					bool *hasFirst,
+					RPRNavOffsetKind *firstKind, int64 *firstResult)
+{
+	NavOffsetContext ctx;
+	ListCell   *lc;
+
+	ctx.maxOffset = 0;
+	ctx.maxNeedsEval = false;
+	ctx.maxOverflow = false;
+	ctx.firstOffset = INT64_MAX;	/* sentinel: no FIRST found yet */
+	ctx.hasFirst = false;
+	ctx.firstNeedsEval = false;
+
+	foreach(lc, defineClause)
+	{
+		TargetEntry *te = (TargetEntry *) lfirst(lc);
+
+		nav_offset_walker((Node *) te->expr, &ctx);
+	}
+
+	/* Max backward offset */
+	if (ctx.maxOverflow)
+	{
+		*maxKind = RPR_NAV_OFFSET_RETAIN_ALL;
+		*maxResult = 0;
+	}
+	else if (ctx.maxNeedsEval)
+	{
+		*maxKind = RPR_NAV_OFFSET_NEEDS_EVAL;
+		*maxResult = 0;
+	}
+	else
+	{
+		*maxKind = RPR_NAV_OFFSET_FIXED;
+		*maxResult = ctx.maxOffset;
+	}
+
+	/* First offset (can be negative for compound PREV_FIRST) */
+	*hasFirst = ctx.hasFirst;
+	if (ctx.hasFirst)
+	{
+		if (ctx.firstNeedsEval)
+		{
+			*firstKind = RPR_NAV_OFFSET_NEEDS_EVAL;
+			*firstResult = 0;
+		}
+		else if (ctx.firstOffset == INT64_MAX)
+		{
+			*firstKind = RPR_NAV_OFFSET_FIXED;
+			*firstResult = 0;	/* only implicit FIRST(v) */
+		}
+		else
+		{
+			*firstKind = RPR_NAV_OFFSET_FIXED;
+			*firstResult = ctx.firstOffset; /* may be negative */
 		}
+	}
+	else
+	{
+		*firstKind = RPR_NAV_OFFSET_FIXED;
+		*firstResult = 0;
+	}
+}
 
-		/* Don't walk into RPRNavExpr children - offset_arg already handled */
+/*
+ * has_match_start_dependency
+ *		Check if an expression tree contains navigation that depends on
+ *		match_start: FIRST, LAST-with-offset, or compound PREV_FIRST/
+ *		NEXT_FIRST/PREV_LAST/NEXT_LAST with offset.  Such expressions
+ *		require per-context re-evaluation during NFA processing.
+ *
+ * LAST without offset always resolves to currentpos and is
+ * match_start-independent.
+ */
+static bool
+has_match_start_dependency(Node *node, void *context)
+{
+	if (node == NULL)
 		return false;
+
+	if (IsA(node, RPRNavExpr))
+	{
+		RPRNavExpr *nav = (RPRNavExpr *) node;
+
+		if (nav->kind == RPR_NAV_FIRST)
+			return true;
+		if (nav->kind == RPR_NAV_LAST && nav->offset_arg != NULL)
+			return true;
+
+		/* Compound kinds with FIRST base depend on match_start */
+		if (nav->kind == RPR_NAV_PREV_FIRST ||
+			nav->kind == RPR_NAV_NEXT_FIRST)
+			return true;
+
+		/*
+		 * PREV_LAST/NEXT_LAST: inner is LAST, which uses currentpos.
+		 * match_start-dependent only if inner has offset (clamped to
+		 * match_start).
+		 */
+		if ((nav->kind == RPR_NAV_PREV_LAST ||
+			 nav->kind == RPR_NAV_NEXT_LAST) &&
+			nav->offset_arg != NULL)
+			return true;
+
+		/* Check children (arg may contain further nav expressions) */
+		return has_match_start_dependency((Node *) nav->arg, context);
 	}
 
-	return expression_tree_walker(node, nav_max_offset_walker, maxOffset);
+	return expression_tree_walker(node, has_match_start_dependency, NULL);
 }
 
 /*
- * compute_nav_max_offset
- *		Compute the maximum PREV offset from DEFINE clause expressions.
+ * compute_match_start_dependent
+ *		Build a Bitmapset of DEFINE variable indices whose expressions
+ *		depend on match_start (contain FIRST, LAST-with-offset, or
+ *		compound PREV_FIRST/NEXT_FIRST/PREV_LAST/NEXT_LAST with offset).
  *
- * Returns the maximum constant offset found, or -1 if any PREV offset
- * cannot be determined statically.  NEXT offsets are ignored since they
- * look forward and don't affect tuplestore trim.
+ * Variables in this set require per-context re-evaluation during NFA
+ * processing, because different contexts may have different match_start
+ * values.
  */
-static int64
-compute_nav_max_offset(List *defineClause)
+static Bitmapset *
+compute_match_start_dependent(List *defineClause)
 {
-	int64		maxOffset = 0;
+	Bitmapset  *result = NULL;
 	ListCell   *lc;
+	int			varIdx = 0;
 
 	foreach(lc, defineClause)
 	{
 		TargetEntry *te = (TargetEntry *) lfirst(lc);
 
-		if (nav_max_offset_walker((Node *) te->expr, &maxOffset))
-			return maxOffset;	/* NEEDS_EVAL or RETAIN_ALL */
+		if (has_match_start_dependency((Node *) te->expr, NULL))
+			result = bms_add_member(result, varIdx);
+
+		varIdx++;
 	}
 
-	return maxOffset;
+	return result;
 }
 
 /*
@@ -2586,6 +2854,7 @@ create_windowagg_plan(PlannerInfo *root, WindowAggPath *best_path)
 	List	   *defineVariableList = NIL;
 	List	   *filteredDefineClause = NIL;
 	RPRPattern *compiledPattern = NULL;
+	Bitmapset  *matchStartDependent = NULL;
 
 
 	/*
@@ -2648,11 +2917,15 @@ create_windowagg_plan(PlannerInfo *root, WindowAggPath *best_path)
 		buildDefineVariableList(wc->defineClause, &defineVariableList);
 		filteredDefineClause = wc->defineClause;
 
+		/* Identify match_start-dependent DEFINE variables */
+		matchStartDependent = compute_match_start_dependent(wc->defineClause);
+
 		/* Compile and optimize RPR patterns */
 		compiledPattern = buildRPRPattern(wc->rpPattern,
 										  defineVariableList,
 										  wc->rpSkipTo,
-										  wc->frameOptions);
+										  wc->frameOptions,
+										  !bms_is_empty(matchStartDependent));
 	}
 
 	/* And finally we can make the WindowAgg node */
@@ -2670,6 +2943,7 @@ create_windowagg_plan(PlannerInfo *root, WindowAggPath *best_path)
 						  wc->rpSkipTo,
 						  compiledPattern,
 						  filteredDefineClause,
+						  matchStartDependent,
 						  best_path->qual,
 						  best_path->topwindow,
 						  subplan);
@@ -6742,6 +7016,7 @@ make_windowagg(List *tlist, WindowClause *wc,
 			   List *runCondition, RPSkipTo rpSkipTo,
 			   RPRPattern *compiledPattern,
 			   List *defineClause,
+			   Bitmapset *defineMatchStartDependent,
 			   List *qual, bool topWindow, Plan *lefttree)
 {
 	WindowAgg  *node = makeNode(WindowAgg);
@@ -6776,8 +7051,14 @@ make_windowagg(List *tlist, WindowClause *wc,
 
 	node->defineClause = defineClause;
 
-	/* Compute max PREV offset for tuplestore trim optimization */
-	node->navMaxOffset = compute_nav_max_offset(defineClause);
+	/* Store pre-computed match_start dependency bitmapset */
+	node->defineMatchStartDependent = defineMatchStartDependent;
+
+	/* Compute nav offsets for tuplestore trim optimization */
+	compute_nav_offsets(defineClause,
+						&node->navMaxOffsetKind, &node->navMaxOffset,
+						&node->hasFirstNav,
+						&node->navFirstOffsetKind, &node->navFirstOffset);
 
 	plan->targetlist = tlist;
 	plan->lefttree = lefttree;
diff --git a/src/backend/optimizer/plan/rpr.c b/src/backend/optimizer/plan/rpr.c
index c0e9d134aa9..767a214016c 100644
--- a/src/backend/optimizer/plan/rpr.c
+++ b/src/backend/optimizer/plan/rpr.c
@@ -1893,7 +1893,8 @@ buildDefineVariableList(List *defineClause, List **defineVariableList)
  */
 RPRPattern *
 buildRPRPattern(RPRPatternNode *pattern, List *defineVariableList,
-				RPSkipTo rpSkipTo, int frameOptions)
+				RPSkipTo rpSkipTo, int frameOptions,
+				bool hasMatchStartDependent)
 {
 	RPRPattern *result;
 	RPRPatternNode *optimized;
@@ -1947,7 +1948,8 @@ buildRPRPattern(RPRPatternNode *pattern, List *defineVariableList,
 	hasLimitedFrame = (frameOptions & FRAMEOPTION_ROWS) &&
 		!(frameOptions & FRAMEOPTION_END_UNBOUNDED_FOLLOWING);
 
-	if (rpSkipTo == ST_PAST_LAST_ROW && !hasLimitedFrame)
+	if (rpSkipTo == ST_PAST_LAST_ROW && !hasLimitedFrame &&
+		!hasMatchStartDependent)
 	{
 		/* Runtime conditions met - check structural absorbability */
 		computeAbsorbability(result);
diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c
index e14ff4dc494..aa45a98713f 100644
--- a/src/backend/parser/parse_func.c
+++ b/src/backend/parser/parse_func.c
@@ -757,35 +757,79 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs,
 	if (retset)
 		check_srf_call_placement(pstate, last_srf, location);
 
-	/* next() and prev() are only allowed in a WINDOW DEFINE clause */
+	/*
+	 * RPR navigation functions (PREV/NEXT/FIRST/LAST) are only meaningful
+	 * inside a WINDOW DEFINE clause.
+	 *
+	 * Outside DEFINE, these polymorphic placeholders can shadow column access
+	 * via functional notation (e.g., last(f) meaning f.last). For the 1-arg
+	 * form, try column projection first; if that succeeds, use it instead.
+	 * Otherwise, report a clear parser error.
+	 */
 	if (fdresult == FUNCDETAIL_NORMAL &&
 		pstate->p_expr_kind != EXPR_KIND_RPR_DEFINE &&
 		(funcid == F_PREV_ANYELEMENT || funcid == F_NEXT_ANYELEMENT ||
-		 funcid == F_PREV_ANYELEMENT_INT8 || funcid == F_NEXT_ANYELEMENT_INT8))
+		 funcid == F_PREV_ANYELEMENT_INT8 || funcid == F_NEXT_ANYELEMENT_INT8 ||
+		 funcid == F_FIRST_ANYELEMENT || funcid == F_LAST_ANYELEMENT ||
+		 funcid == F_FIRST_ANYELEMENT_INT8 || funcid == F_LAST_ANYELEMENT_INT8))
+	{
+		/* 1-arg form: try column projection before erroring out */
+		if (nargs == 1 && !agg_star && !agg_distinct && over == NULL &&
+			list_length(funcname) == 1)
+		{
+			Node	   *projection;
+
+			projection = ParseComplexProjection(pstate,
+												strVal(linitial(funcname)),
+												linitial(fargs),
+												location);
+			if (projection)
+				return projection;
+		}
+
+		/* Not a column projection — report error */
 		ereport(ERROR,
 				(errcode(ERRCODE_SYNTAX_ERROR),
 				 errmsg("%s can only be used in a DEFINE clause",
 						NameListToString(funcname)),
 				 parser_errposition(pstate, location)));
+	}
 
 	/* build the appropriate output structure */
 	if (fdresult == FUNCDETAIL_NORMAL &&
+		pstate->p_expr_kind == EXPR_KIND_RPR_DEFINE &&
 		(funcid == F_PREV_ANYELEMENT || funcid == F_NEXT_ANYELEMENT ||
-		 funcid == F_PREV_ANYELEMENT_INT8 || funcid == F_NEXT_ANYELEMENT_INT8))
+		 funcid == F_PREV_ANYELEMENT_INT8 || funcid == F_NEXT_ANYELEMENT_INT8 ||
+		 funcid == F_FIRST_ANYELEMENT || funcid == F_LAST_ANYELEMENT ||
+		 funcid == F_FIRST_ANYELEMENT_INT8 || funcid == F_LAST_ANYELEMENT_INT8))
 	{
 		/*
-		 * PREV() and NEXT() are compiled into EEOP_RPR_NAV_SET /
-		 * EEOP_RPR_NAV_RESTORE opcodes instead of a normal function call.
-		 * Represent them as RPRNavExpr nodes so that later stages can
-		 * identify them without relying on funcid comparisons.
+		 * 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.  Represent them as RPRNavExpr nodes so that later
+		 * stages can identify them without relying on funcid comparisons.
 		 */
-		bool		is_next = (funcid == F_NEXT_ANYELEMENT ||
-							   funcid == F_NEXT_ANYELEMENT_INT8);
-		bool		has_offset = (funcid == F_PREV_ANYELEMENT_INT8 ||
-								  funcid == F_NEXT_ANYELEMENT_INT8);
-		RPRNavExpr *navexpr = makeNode(RPRNavExpr);
+		RPRNavKind	kind;
+		bool		has_offset;
+		RPRNavExpr *navexpr;
+
+		if (funcid == F_PREV_ANYELEMENT || funcid == F_PREV_ANYELEMENT_INT8)
+			kind = RPR_NAV_PREV;
+		else if (funcid == F_NEXT_ANYELEMENT || funcid == F_NEXT_ANYELEMENT_INT8)
+			kind = RPR_NAV_NEXT;
+		else if (funcid == F_FIRST_ANYELEMENT || funcid == F_FIRST_ANYELEMENT_INT8)
+			kind = RPR_NAV_FIRST;
+		else
+			kind = RPR_NAV_LAST;
+
+		has_offset = (funcid == F_PREV_ANYELEMENT_INT8 ||
+					  funcid == F_NEXT_ANYELEMENT_INT8 ||
+					  funcid == F_FIRST_ANYELEMENT_INT8 ||
+					  funcid == F_LAST_ANYELEMENT_INT8);
+
+		navexpr = makeNode(RPRNavExpr);
 
-		navexpr->kind = is_next ? RPR_NAV_NEXT : RPR_NAV_PREV;
+		navexpr->kind = kind;
 		navexpr->arg = (Expr *) linitial(fargs);
 		navexpr->offset_arg = has_offset ? (Expr *) lsecond(fargs) : NULL;
 		navexpr->resulttype = rettype;
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index d1e02e52e53..05070cb04bb 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -339,8 +339,8 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 		/*
 		 * Transform the DEFINE expression.  We must NOT add the whole
 		 * expression to the query targetlist, because it may contain
-		 * RPRNavExpr nodes (PREV/NEXT) that can only be evaluated inside the
-		 * owning WindowAgg.
+		 * RPRNavExpr nodes (PREV/NEXT/FIRST/LAST) that can only be evaluated
+		 * inside the owning WindowAgg.
 		 *
 		 * Instead, we transform the expression directly and only ensure that
 		 * the individual Var nodes it references are present in the
@@ -429,14 +429,21 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 /*
  * check_rpr_nav_expr
  *		Validate a single RPRNavExpr node by walking its arg and offset_arg
- *		subtrees in a single pass each.  Checks for nested PREV/NEXT, missing
+ *		subtrees in a single pass each.  Checks for illegal nesting, missing
  *		column references, and non-constant offset expressions.
+ *
+ * Nesting rules (SQL standard 5.6.4):
+ *   - PREV/NEXT wrapping FIRST/LAST: allowed (compound navigation)
+ *   - FIRST/LAST wrapping PREV/NEXT: prohibited
+ *   - Same-category nesting (PREV inside PREV, FIRST inside FIRST, etc.):
+ *     prohibited
  */
 typedef struct
 {
-	bool		has_nav;		/* RPRNavExpr found (nesting) */
+	int			nav_count;		/* number of RPRNavExpr nodes found */
 	bool		has_column_ref; /* Var found */
-}			NavCheckResult;
+	RPRNavKind	inner_kind;		/* kind of first (outermost) nested RPRNavExpr */
+} NavCheckResult;
 
 static bool
 nav_check_walker(Node *node, void *context)
@@ -446,7 +453,11 @@ nav_check_walker(Node *node, void *context)
 	if (node == NULL)
 		return false;
 	if (IsA(node, RPRNavExpr))
-		result->has_nav = true;
+	{
+		if (result->nav_count == 0)
+			result->inner_kind = ((RPRNavExpr *) node)->kind;
+		result->nav_count++;
+	}
 	if (IsA(node, Var))
 		result->has_column_ref = true;
 
@@ -457,16 +468,93 @@ static void
 check_rpr_nav_expr(RPRNavExpr *nav, ParseState *pstate)
 {
 	NavCheckResult result;
+	bool		outer_is_physical = (nav->kind == RPR_NAV_PREV ||
+									 nav->kind == RPR_NAV_NEXT);
 
 	/* Check arg subtree: nesting + column reference in one walk */
 	memset(&result, 0, sizeof(result));
 	(void) nav_check_walker((Node *) nav->arg, &result);
 
-	if (result.has_nav)
-		ereport(ERROR,
-				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-				 errmsg("PREV and NEXT cannot be nested"),
-				 parser_errposition(pstate, nav->location)));
+	if (result.nav_count > 0)
+	{
+		bool		inner_is_physical = (result.inner_kind == RPR_NAV_PREV ||
+										 result.inner_kind == RPR_NAV_NEXT);
+
+		if (outer_is_physical && !inner_is_physical)
+		{
+			/*
+			 * PREV/NEXT wrapping FIRST/LAST: compound navigation per SQL
+			 * standard 5.6.4.  Flatten the nested RPRNavExpr into a single
+			 * compound node.  The inner RPRNavExpr must be the direct arg of
+			 * the outer; expressions like PREV(val + FIRST(v)) are not valid
+			 * compound navigation.
+			 */
+			RPRNavExpr *inner;
+
+			/* Reject triple-or-deeper nesting (e.g. PREV(FIRST(PREV(x)))) */
+			if (result.nav_count > 1)
+				ereport(ERROR,
+						(errcode(ERRCODE_SYNTAX_ERROR),
+						 errmsg("row pattern navigation cannot be nested more than two levels deep"),
+						 errhint("Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed."),
+						 parser_errposition(pstate, nav->location)));
+
+			if (!IsA(nav->arg, RPRNavExpr))
+				ereport(ERROR,
+						(errcode(ERRCODE_SYNTAX_ERROR),
+						 errmsg("row pattern navigation operation must be a direct argument of the outer navigation"),
+						 errhint("Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed."),
+						 parser_errposition(pstate, nav->location)));
+			inner = (RPRNavExpr *) nav->arg;
+
+			/* Determine compound kind */
+			if (nav->kind == RPR_NAV_PREV && inner->kind == RPR_NAV_FIRST)
+				nav->kind = RPR_NAV_PREV_FIRST;
+			else if (nav->kind == RPR_NAV_PREV && inner->kind == RPR_NAV_LAST)
+				nav->kind = RPR_NAV_PREV_LAST;
+			else if (nav->kind == RPR_NAV_NEXT && inner->kind == RPR_NAV_FIRST)
+				nav->kind = RPR_NAV_NEXT_FIRST;
+			else if (nav->kind == RPR_NAV_NEXT && inner->kind == RPR_NAV_LAST)
+				nav->kind = RPR_NAV_NEXT_LAST;
+
+			/* Move outer offset to compound_offset_arg */
+			nav->compound_offset_arg = nav->offset_arg;
+
+			/* Move inner offset and arg up */
+			nav->offset_arg = inner->offset_arg;
+			nav->arg = inner->arg;
+
+			/* No further nesting check needed - already validated */
+			return;
+		}
+		else if (!outer_is_physical && inner_is_physical)
+		{
+			/* FIRST/LAST wrapping PREV/NEXT: prohibited by standard */
+			ereport(ERROR,
+					(errcode(ERRCODE_SYNTAX_ERROR),
+					 errmsg("FIRST and LAST cannot contain PREV or NEXT"),
+					 errhint("Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed."),
+					 parser_errposition(pstate, nav->location)));
+		}
+		else if (outer_is_physical && inner_is_physical)
+		{
+			/* PREV/NEXT wrapping PREV/NEXT: prohibited */
+			ereport(ERROR,
+					(errcode(ERRCODE_SYNTAX_ERROR),
+					 errmsg("PREV and NEXT cannot contain PREV or NEXT"),
+					 errhint("Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed."),
+					 parser_errposition(pstate, nav->location)));
+		}
+		else
+		{
+			/* FIRST/LAST wrapping FIRST/LAST: prohibited */
+			ereport(ERROR,
+					(errcode(ERRCODE_SYNTAX_ERROR),
+					 errmsg("FIRST and LAST cannot contain FIRST or LAST"),
+					 errhint("Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed."),
+					 parser_errposition(pstate, nav->location)));
+		}
+	}
 	if (!result.has_column_ref)
 		ereport(ERROR,
 				(errcode(ERRCODE_SYNTAX_ERROR),
@@ -483,7 +571,7 @@ check_rpr_nav_expr(RPRNavExpr *nav, ParseState *pstate)
 			contain_volatile_functions((Node *) nav->offset_arg))
 			ereport(ERROR,
 					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-					 errmsg("PREV/NEXT offset must be a run-time constant"),
+					 errmsg("row pattern navigation offset must be a run-time constant"),
 					 parser_errposition(pstate, nav->location)));
 	}
 }
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index a4fe725646c..467736d9146 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -10106,16 +10106,83 @@ get_rule_expr(Node *node, deparse_context *context,
 		case T_RPRNavExpr:
 			{
 				RPRNavExpr *nav = (RPRNavExpr *) node;
+				const char *outer_func = NULL;
+				const char *inner_func;
 
-				appendStringInfoString(buf,
-									   nav->kind == RPR_NAV_PREV ? "PREV(" : "NEXT(");
-				get_rule_expr((Node *) nav->arg, context, showimplicit);
-				if (nav->offset_arg != NULL)
+				switch (nav->kind)
 				{
-					appendStringInfoString(buf, ", ");
-					get_rule_expr((Node *) nav->offset_arg, context, showimplicit);
+					case RPR_NAV_PREV:
+						inner_func = "PREV(";
+						break;
+					case RPR_NAV_NEXT:
+						inner_func = "NEXT(";
+						break;
+					case RPR_NAV_FIRST:
+						inner_func = "FIRST(";
+						break;
+					case RPR_NAV_LAST:
+						inner_func = "LAST(";
+						break;
+					case RPR_NAV_PREV_FIRST:
+						outer_func = "PREV(";
+						inner_func = "FIRST(";
+						break;
+					case RPR_NAV_PREV_LAST:
+						outer_func = "PREV(";
+						inner_func = "LAST(";
+						break;
+					case RPR_NAV_NEXT_FIRST:
+						outer_func = "NEXT(";
+						inner_func = "FIRST(";
+						break;
+					case RPR_NAV_NEXT_LAST:
+						outer_func = "NEXT(";
+						inner_func = "LAST(";
+						break;
+					default:
+						elog(ERROR, "unrecognized RPR navigation kind: %d",
+							 nav->kind);
+						inner_func = NULL;	/* keep compiler quiet */
+						break;
+				}
+
+				if (outer_func != NULL)
+				{
+					/*
+					 * Compound: PREV(FIRST(arg [, inner_offset]) [,
+					 * outer_offset])
+					 */
+					appendStringInfoString(buf, outer_func);
+					appendStringInfoString(buf, inner_func);
+					get_rule_expr((Node *) nav->arg, context, showimplicit);
+					if (nav->offset_arg != NULL)
+					{
+						appendStringInfoString(buf, ", ");
+						get_rule_expr((Node *) nav->offset_arg, context,
+									  showimplicit);
+					}
+					appendStringInfoChar(buf, ')');
+					if (nav->compound_offset_arg != NULL)
+					{
+						appendStringInfoString(buf, ", ");
+						get_rule_expr((Node *) nav->compound_offset_arg,
+									  context, showimplicit);
+					}
+					appendStringInfoChar(buf, ')');
+				}
+				else
+				{
+					/* Simple: FUNC(arg [, offset]) */
+					appendStringInfoString(buf, inner_func);
+					get_rule_expr((Node *) nav->arg, context, showimplicit);
+					if (nav->offset_arg != NULL)
+					{
+						appendStringInfoString(buf, ", ");
+						get_rule_expr((Node *) nav->offset_arg, context,
+									  showimplicit);
+					}
+					appendStringInfoChar(buf, ')');
 				}
-				appendStringInfoChar(buf, ')');
 			}
 			break;
 
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index 091260d2cce..420a4962395 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -785,3 +785,59 @@ window_next_offset(PG_FUNCTION_ARGS)
 			 errmsg("next() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
+
+/*
+ * first
+ * Catalog placeholder for RPR's FIRST navigation operator.
+ * See window_prev() for details.
+ */
+Datum
+window_first(PG_FUNCTION_ARGS)
+{
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("first() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
+}
+
+/*
+ * last
+ * Catalog placeholder for RPR's LAST navigation operator.
+ * See window_prev() for details.
+ */
+Datum
+window_last(PG_FUNCTION_ARGS)
+{
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("last() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
+}
+
+/*
+ * first(value, offset)
+ * Catalog placeholder for RPR's FIRST navigation operator with offset.
+ * See window_prev() for details.
+ */
+Datum
+window_first_offset(PG_FUNCTION_ARGS)
+{
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("first() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
+}
+
+/*
+ * last(value, offset)
+ * Catalog placeholder for RPR's LAST navigation operator with offset.
+ * See window_prev() for details.
+ */
+Datum
+window_last_offset(PG_FUNCTION_ARGS)
+{
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("last() can only be used in a DEFINE clause")));
+	PG_RETURN_NULL();			/* not reached */
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 8e95169b7b0..6d70ed23aeb 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -10984,6 +10984,18 @@
 { oid => '8129', descr => 'next value at offset',
   proname => 'next', provolatile => 's', proisstrict => 'f', prorettype => 'anyelement',
   proargtypes => 'anyelement int8', prosrc => 'window_next_offset' },
+{ oid => '8130', descr => 'first value in match',
+  proname => 'first', provolatile => 's', prorettype => 'anyelement',
+  proargtypes => 'anyelement', prosrc => 'window_first' },
+{ oid => '8132', descr => 'first value in match at offset',
+  proname => 'first', provolatile => 's', proisstrict => 'f', prorettype => 'anyelement',
+  proargtypes => 'anyelement int8', prosrc => 'window_first_offset' },
+{ oid => '8131', descr => 'last value in match',
+  proname => 'last', provolatile => 's', prorettype => 'anyelement',
+  proargtypes => 'anyelement', prosrc => 'window_last' },
+{ oid => '8133', descr => 'last value in match at offset',
+  proname => 'last', provolatile => 's', proisstrict => 'f', prorettype => 'anyelement',
+  proargtypes => 'anyelement int8', prosrc => 'window_last_offset' },
 
 # functions for range types
 { oid => '3832', descr => 'I/O',
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index fac37c96896..834800a4062 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -699,10 +699,10 @@ typedef struct ExprEvalStep
 		struct
 		{
 			WindowAggState *winstate;
-			RPRNavKind	kind;	/* PREV or NEXT */
-			Datum	   *offset_value;	/* 2-arg: runtime offset value, or
-										 * NULL */
-			bool	   *offset_isnull;	/* 2-arg: runtime offset null flag */
+			RPRNavKind	kind;	/* navigation kind (simple or compound) */
+			Datum	   *offset_value;	/* offset value(s), or NULL */
+			bool	   *offset_isnull;	/* offset null flag(s) */
+			/* For compound nav: offset_value[0] = inner, [1] = outer */
 		}			rpr_nav;
 
 		/* for EEOP_AGG_*DESERIALIZE */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index ff6d7d70a60..602b72a6e0d 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2638,6 +2638,9 @@ typedef struct WindowAggState
 	Size		nfaStateSize;	/* pre-calculated RPRNFAState size */
 	bool	   *nfaVarMatched;	/* per-row cache: varMatched[varId] for varId
 								 * < numDefines */
+	Bitmapset  *defineMatchStartDependent;	/* DEFINE vars needing per-context
+											 * evaluation
+											 * (match_start-dependent) */
 	bitmapword *nfaVisitedElems;	/* elemIdx visited bitmap for cycle
 									 * detection */
 	int			nfaVisitedNWords;	/* number of bitmapwords in
@@ -2692,12 +2695,17 @@ typedef struct WindowAggState
 	TupleTableSlot *temp_slot_2;
 
 	/* RPR navigation */
-	int64		navMaxOffset;	/* max PREV offset; see RPR_NAV_OFFSET_* */
+	RPRNavOffsetKind navMaxOffsetKind;	/* status of navMaxOffset */
+	int64		navMaxOffset;	/* max backward nav offset (when FIXED) */
+	bool		hasFirstNav;	/* FIRST() present in DEFINE */
+	RPRNavOffsetKind navFirstOffsetKind;	/* status of navFirstOffset */
+	int64		navFirstOffset; /* min FIRST() offset (when FIXED) */
 	struct WindowObjectData *nav_winobj;	/* winobj for RPR nav fetch */
 	int64		nav_slot_pos;	/* position cached in nav_slot, or -1 */
-	TupleTableSlot *nav_slot;	/* slot for PREV/NEXT target row */
+	TupleTableSlot *nav_slot;	/* slot for PREV/NEXT/FIRST/LAST target row */
 	TupleTableSlot *nav_saved_outertuple;	/* saved slot during nav swap */
 	TupleTableSlot *nav_null_slot;	/* all NULL slot */
+	int64		nav_match_start;	/* match_start for FIRST/LAST nav */
 
 	/* RPR current match result */
 	bool		rpr_match_valid;	/* true if a match result is set */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index b92663687a6..1fdf62575cb 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -586,9 +586,24 @@ typedef enum RPSkipTo
 	ST_NONE,					/* no AFTER MATCH clause; default for non-RPR
 								 * windows */
 	ST_NEXT_ROW,				/* SKIP TO NEXT ROW */
-	ST_PAST_LAST_ROW,			/* SKIP TO PAST LAST ROW */
+	ST_PAST_LAST_ROW			/* SKIP TO PAST LAST ROW */
 } RPSkipTo;
 
+/*
+ * RPRNavOffsetKind - status of navigation offset for tuplestore trim.
+ *
+ * The planner computes navMaxOffset/navFirstOffset for tuplestore mark
+ * optimization.  This enum tracks whether the value is a resolved constant,
+ * needs runtime evaluation, or cannot be determined (retain all rows).
+ */
+typedef enum RPRNavOffsetKind
+{
+	RPR_NAV_OFFSET_FIXED,		/* resolved constant; use the offset value */
+	RPR_NAV_OFFSET_NEEDS_EVAL,	/* non-constant offset; evaluate at executor
+								 * init */
+	RPR_NAV_OFFSET_RETAIN_ALL	/* cannot determine; retain all rows (no trim) */
+} RPRNavOffsetKind;
+
 /*
  * RPRPatternNodeType - Row Pattern Recognition pattern node types
  */
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 27a2e7b48c7..93ce505f0d4 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -1387,14 +1387,34 @@ typedef struct WindowAgg
 	List	   *defineClause;
 
 	/*
-	 * Maximum PREV offset for tuplestore mark optimization. >= 0: statically
-	 * determined max offset (mark = currentpos - offset).
-	 * RPR_NAV_OFFSET_NEEDS_EVAL: has non-constant offset; evaluate at
-	 * executor init.  RPR_NAV_OFFSET_RETAIN_ALL: must retain entire partition
-	 * (no trim possible).
+	 * Bitmapset of DEFINE variable indices whose expressions depend on
+	 * match_start (contain FIRST, LAST-with-offset, or compound
+	 * PREV_FIRST/NEXT_FIRST/PREV_LAST/NEXT_LAST with offset).  Variables in
+	 * this set require per-context re-evaluation during NFA processing.
 	 */
+	Bitmapset  *defineMatchStartDependent;
+
+	/*
+	 * Navigation offset status and values for tuplestore mark optimization.
+	 * See RPRNavOffsetKind in nodes/parsenodes.h.
+	 *
+	 * navMaxOffset: maximum backward reach from currentpos (contributed by
+	 * PREV, LAST-with-offset, compound PREV_LAST/NEXT_LAST).  Only valid when
+	 * navMaxOffsetKind == RPR_NAV_OFFSET_FIXED.
+	 *
+	 * navFirstOffset: minimum forward offset from match_start (contributed by
+	 * FIRST, compound PREV_FIRST/NEXT_FIRST).  Can be negative for compound
+	 * PREV_FIRST.  Only valid when navFirstOffsetKind == RPR_NAV_OFFSET_FIXED
+	 * and hasFirstNav == true.
+	 */
+	RPRNavOffsetKind navMaxOffsetKind;
 	int64		navMaxOffset;
 
+	/* true if FIRST-based navigation (FIRST, PREV_FIRST, NEXT_FIRST) is used */
+	bool		hasFirstNav;
+	RPRNavOffsetKind navFirstOffsetKind;
+	int64		navFirstOffset;
+
 	/*
 	 * false for all apart from the WindowAgg that's closest to the root of
 	 * the plan
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 94723a3b909..0afcfacd5d2 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -651,27 +651,50 @@ typedef struct WindowFuncRunCondition
 /*
  * RPRNavExpr
  *
- * Represents a PREV() or NEXT() navigation call in an RPR DEFINE clause.
+ * Represents a PREV/NEXT/FIRST/LAST navigation call in an RPR DEFINE clause.
  * At expression compile time this is translated into EEOP_RPR_NAV_SET /
  * EEOP_RPR_NAV_RESTORE opcodes rather than a normal function call.
  *
- * kind:       RPR_NAV_PREV or RPR_NAV_NEXT
- * arg:        the expression to evaluate against the target row
- * offset_arg: optional explicit offset expression (2-arg form); NULL for
- *             the 1-arg form which uses an implicit offset of 1
+ * Simple navigation (PREV/NEXT/FIRST/LAST):
+ *   kind:       RPR_NAV_PREV, RPR_NAV_NEXT, RPR_NAV_FIRST, or RPR_NAV_LAST
+ *   arg:        the expression to evaluate against the target row
+ *   offset_arg: optional explicit offset expression (2-arg form); NULL for
+ *               the 1-arg form (implicit offset: 1 for PREV/NEXT, 0 for
+ *               FIRST/LAST)
+ *
+ * Compound navigation (PREV/NEXT wrapping FIRST/LAST):
+ *   kind:              RPR_NAV_PREV_FIRST, PREV_LAST, NEXT_FIRST, NEXT_LAST
+ *   arg:               the expression to evaluate against the final target row
+ *   offset_arg:        inner offset (FIRST/LAST), NULL = implicit default
+ *   compound_offset_arg: outer offset (PREV/NEXT), NULL = implicit default
+ *
+ * Compound target computation:
+ *   PREV_FIRST: (match_start + inner) - outer
+ *   NEXT_FIRST: (match_start + inner) + outer
+ *   PREV_LAST:  (currentpos  - inner) - outer
+ *   NEXT_LAST:  (currentpos  - inner) + outer
  */
 typedef enum RPRNavKind
 {
 	RPR_NAV_PREV,
 	RPR_NAV_NEXT,
+	RPR_NAV_FIRST,
+	RPR_NAV_LAST,
+	/* compound: outer(inner(arg)) */
+	RPR_NAV_PREV_FIRST,
+	RPR_NAV_PREV_LAST,
+	RPR_NAV_NEXT_FIRST,
+	RPR_NAV_NEXT_LAST
 } RPRNavKind;
 
 typedef struct RPRNavExpr
 {
 	Expr		xpr;
-	RPRNavKind	kind;			/* PREV or NEXT */
+	RPRNavKind	kind;			/* navigation kind */
 	Expr	   *arg;			/* argument expression */
-	Expr	   *offset_arg;		/* offset expression, or NULL for 1-arg form */
+	Expr	   *offset_arg;		/* offset expression, or NULL for default */
+	Expr	   *compound_offset_arg;	/* outer offset for compound nav, or
+										 * NULL if simple */
 	Oid			resulttype;		/* result type (same as arg's type) */
 	/* OID of collation of result */
 	Oid			resultcollid pg_node_attr(query_jumble_ignore);
diff --git a/src/include/optimizer/rpr.h b/src/include/optimizer/rpr.h
index 00a28abe2b4..0a14cfad79b 100644
--- a/src/include/optimizer/rpr.h
+++ b/src/include/optimizer/rpr.h
@@ -55,19 +55,11 @@
 #define RPRElemIsFin(e)			((e)->varId == RPR_VARID_FIN)
 #define RPRElemCanSkip(e)		((e)->min == 0)
 
-/*
- * navMaxOffset sentinel values.
- * Non-negative values represent a statically determined maximum PREV offset.
- */
-#define RPR_NAV_OFFSET_NEEDS_EVAL	(-1)	/* has non-constant PREV offset;
-											 * evaluate at executor init */
-#define RPR_NAV_OFFSET_RETAIN_ALL	(-2)	/* must retain entire partition
-											 * (e.g., future FIRST/LAST) */
-
 extern List *collectPatternVariables(RPRPatternNode *pattern);
 extern void buildDefineVariableList(List *defineClause,
 									List **defineVariableList);
 extern RPRPattern *buildRPRPattern(RPRPatternNode *pattern, List *defineVariableList,
-								   RPSkipTo rpSkipTo, int frameOptions);
+								   RPSkipTo rpSkipTo, int frameOptions,
+								   bool hasMatchStartDependent);
 
 #endif							/* OPTIMIZER_RPR_H */
diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index c02dbd4c08d..04ec25d4cf5 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -1040,9 +1040,10 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS price > PREV(PREV(price))
 );
-ERROR:  PREV and NEXT cannot be nested
+ERROR:  PREV and NEXT cannot contain PREV or NEXT
 LINE 7:     DEFINE A AS price > PREV(PREV(price))
                                 ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
 -- Nested NEXT
 SELECT price FROM stock
 WINDOW w AS (
@@ -1052,9 +1053,10 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS price > NEXT(NEXT(price))
 );
-ERROR:  PREV and NEXT cannot be nested
+ERROR:  PREV and NEXT cannot contain PREV or NEXT
 LINE 7:     DEFINE A AS price > NEXT(NEXT(price))
                                 ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
 -- PREV nested inside NEXT
 SELECT price FROM stock
 WINDOW w AS (
@@ -1064,9 +1066,10 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS price > NEXT(PREV(price))
 );
-ERROR:  PREV and NEXT cannot be nested
+ERROR:  PREV and NEXT cannot contain PREV or NEXT
 LINE 7:     DEFINE A AS price > NEXT(PREV(price))
                                 ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
 -- PREV nested inside expression inside NEXT
 SELECT price FROM stock
 WINDOW w AS (
@@ -1076,9 +1079,10 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS price > NEXT(price * PREV(price))
 );
-ERROR:  PREV and NEXT cannot be nested
+ERROR:  PREV and NEXT cannot contain PREV or NEXT
 LINE 7:     DEFINE A AS price > NEXT(price * PREV(price))
                                 ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
 -- Triple nesting: error reported at outermost PREV
 SELECT price FROM stock
 WINDOW w AS (
@@ -1088,9 +1092,10 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS price > PREV(PREV(PREV(price)))
 );
-ERROR:  PREV and NEXT cannot be nested
+ERROR:  PREV and NEXT cannot contain PREV or NEXT
 LINE 7:     DEFINE A AS price > PREV(PREV(PREV(price)))
                                 ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
 -- No column reference in PREV/NEXT argument
 -- PREV(1): constant only, no column reference
 SELECT price FROM stock
@@ -1137,7 +1142,7 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS PREV(price, price) > 0
 );
-ERROR:  PREV/NEXT offset must be a run-time constant
+ERROR:  row pattern navigation offset must be a run-time constant
 LINE 7:     DEFINE A AS PREV(price, price) > 0
                         ^
 -- Non-constant offset: volatile function as offset
@@ -1149,7 +1154,7 @@ WINDOW w AS (
     PATTERN (A)
     DEFINE A AS PREV(price, random()::int) > 0
 );
-ERROR:  PREV/NEXT offset must be a run-time constant
+ERROR:  row pattern navigation offset must be a run-time constant
 LINE 7:     DEFINE A AS PREV(price, random()::int) > 0
                         ^
 -- Non-constant offset: subquery as offset
@@ -1442,7 +1447,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS PREV(price, -1) IS NOT NULL
 );
-ERROR:  PREV/NEXT offset must not be negative
+ERROR:  row pattern navigation offset must not be negative
 -- 2-arg PREV/NEXT: NULL offset (typed)
 SELECT company, tdate, price, first_value(price) OVER w
 FROM stock
@@ -1452,7 +1457,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS PREV(price, NULL::int8) IS NOT NULL
 );
-ERROR:  PREV/NEXT offset must not be null
+ERROR:  row pattern navigation offset must not be null
 -- 2-arg PREV/NEXT: NULL offset (untyped)
 SELECT company, tdate, price, first_value(price) OVER w
 FROM stock
@@ -1462,7 +1467,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS PREV(price, NULL) IS NOT NULL
 );
-ERROR:  PREV/NEXT offset must not be null
+ERROR:  row pattern navigation offset must not be null
 -- 2-arg PREV/NEXT: host variable negative and NULL
 PREPARE test_prev_offset(int8) AS
 SELECT company, tdate, price, first_value(price) OVER w
@@ -1474,9 +1479,9 @@ WINDOW w AS (
     DEFINE A AS price > PREV(price, $1)
 );
 EXECUTE test_prev_offset(-1);
-ERROR:  PREV/NEXT offset must not be negative
+ERROR:  row pattern navigation offset must not be negative
 EXECUTE test_prev_offset(NULL);
-ERROR:  PREV/NEXT offset must not be null
+ERROR:  row pattern navigation offset must not be null
 DEALLOCATE test_prev_offset;
 -- 2-arg PREV/NEXT: host variable with expression (0 + $1)
 PREPARE test_prev_offset(int8) AS
@@ -1489,9 +1494,9 @@ WINDOW w AS (
     DEFINE A AS price > PREV(price, 0 + $1)
 );
 EXECUTE test_prev_offset(-1);
-ERROR:  PREV/NEXT offset must not be negative
+ERROR:  row pattern navigation offset must not be negative
 EXECUTE test_prev_offset(NULL);
-ERROR:  PREV/NEXT offset must not be null
+ERROR:  row pattern navigation offset must not be null
 DEALLOCATE test_prev_offset;
 -- 2-arg PREV/NEXT: host variable with positive value
 -- Exercises RPR_NAV_OFFSET_NEEDS_EVAL -> eval_nav_max_offset() path
@@ -1630,6 +1635,604 @@ WINDOW w AS (
  company2 | 07-10-2023 |  1300 |             |            |     0
 (20 rows)
 
+--
+-- FIRST/LAST navigation
+--
+-- Test data for FIRST/LAST: values cycle back so FIRST(val) = LAST(val)
+-- at specific positions.
+CREATE TEMP TABLE rpr_nav (id int, val int);
+INSERT INTO rpr_nav VALUES (1,10),(2,20),(3,30),(4,10),(5,50),(6,10);
+-- FIRST(val) = constant: B matches when match_start has val=10
+-- match_start=1(10): A=id1, B=id2, FIRST(val)=10 → match {1,2}
+-- match_start=3(30): A=id3, B=id4, FIRST(val)=30≠10 → no match
+-- match_start=4(10): A=id4, B=id5, FIRST(val)=10 → match {4,5}
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS FIRST(val) = 10
+);
+ id | val | mf | ml 
+----+-----+----+----
+  1 |  10 |  1 |  2
+  2 |  20 |    |   
+  3 |  30 |    |   
+  4 |  10 |  4 |  5
+  5 |  50 |    |   
+  6 |  10 |    |   
+(6 rows)
+
+-- LAST(val): always equals current row's val (offset 0 default)
+-- Equivalent to: B AS val > 15
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS LAST(val) > 15
+);
+ id | val | mf | ml 
+----+-----+----+----
+  1 |  10 |  1 |  2
+  2 |  20 |    |   
+  3 |  30 |    |   
+  4 |  10 |  4 |  5
+  5 |  50 |    |   
+  6 |  10 |    |   
+(6 rows)
+
+-- Reluctant A+? with FIRST(val) = LAST(val): find shortest match where
+-- first and last rows have the same val.
+-- match_start=1(10): reluctant tries B early:
+--   id2(20≠10), id3(30≠10), id4(10=10) → match {1,2,3,4}
+-- match_start=5(50): id6(10≠50) → no match
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+? B)
+    DEFINE A AS TRUE, B AS FIRST(val) = LAST(val)
+);
+ id | val | mf | ml 
+----+-----+----+----
+  1 |  10 |  1 |  4
+  2 |  20 |    |   
+  3 |  30 |    |   
+  4 |  10 |    |   
+  5 |  50 |    |   
+  6 |  10 |    |   
+(6 rows)
+
+-- Greedy A+ with FIRST(val) = LAST(val): find longest match where
+-- first and last rows have the same val.
+-- match_start=1(10): greedy A eats all, B tries last:
+--   id6(10=10) → match {1,2,3,4,5,6}
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS TRUE, B AS FIRST(val) = LAST(val)
+);
+ id | val | mf | ml 
+----+-----+----+----
+  1 |  10 |  1 |  6
+  2 |  20 |    |   
+  3 |  30 |    |   
+  4 |  10 |    |   
+  5 |  50 |    |   
+  6 |  10 |    |   
+(6 rows)
+
+-- SKIP TO NEXT ROW with FIRST(val) = LAST(val): overlapping match attempts.
+-- With ONE ROW PER MATCH, each row shows only its first match result.
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP TO NEXT ROW
+    PATTERN (A+? B)
+    DEFINE A AS TRUE, B AS FIRST(val) = LAST(val)
+);
+ id | val | mf | ml 
+----+-----+----+----
+  1 |  10 |  1 |  4
+  2 |  20 |    |   
+  3 |  30 |    |   
+  4 |  10 |  4 |  6
+  5 |  50 |    |   
+  6 |  10 |    |   
+(6 rows)
+
+-- FIRST/LAST 2-arg offset form
+--
+-- FIRST(val, 0) = FIRST(val): match_start row
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS FIRST(val, 0) = 10
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   6
+  2 |  20 |    |   0
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- FIRST(val, 1): match_start + 1 row (second row of match)
+-- match_start=1(10): FIRST(val,1)=20, B needs val=20 → id2(20) match, id3(30) no
+-- match_start=3(30): FIRST(val,1)=10, B needs val=10 → id4(10) match
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS val = FIRST(val, 1)
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   2
+  2 |  20 |    |   0
+  3 |  30 |  3 |   2
+  4 |  10 |    |   0
+  5 |  50 |  5 |   2
+  6 |  10 |    |   0
+(6 rows)
+
+-- FIRST(val, 99): offset beyond match range → NULL, no match
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS FIRST(val, 99) IS NOT NULL
+);
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   0
+  2 |  20 |   0
+  3 |  30 |   0
+  4 |  10 |   0
+  5 |  50 |   0
+  6 |  10 |   0
+(6 rows)
+
+-- LAST(val, 0) = LAST(val): current row
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS LAST(val, 0) > 15
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   3
+  2 |  20 |    |   0
+  3 |  30 |    |   0
+  4 |  10 |  4 |   2
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- LAST(val, 1): one row back from current (previous match row)
+-- At B evaluation on id2: LAST(val,1) = val at id1 = 10
+-- B matches when previous row val < 30
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS LAST(val, 1) < 30
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   3
+  2 |  20 |    |   0
+  3 |  30 |    |   0
+  4 |  10 |  4 |   2
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- LAST(val, 99): offset before match_start → NULL
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS LAST(val, 99) IS NOT NULL
+);
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   0
+  2 |  20 |   0
+  3 |  30 |   0
+  4 |  10 |   0
+  5 |  50 |   0
+  6 |  10 |   0
+(6 rows)
+
+-- Error: NULL offset
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS FIRST(val, NULL::int8) IS NULL
+);
+ERROR:  row pattern navigation offset must not be null
+-- Error: negative offset
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS LAST(val, -1) IS NULL
+);
+ERROR:  row pattern navigation offset must not be negative
+-- FIRST/LAST outside DEFINE clause (error cases)
+SELECT first(val) FROM rpr_nav;
+ERROR:  first can only be used in a DEFINE clause
+LINE 1: SELECT first(val) FROM rpr_nav;
+               ^
+SELECT last(val) FROM rpr_nav;
+ERROR:  last can only be used in a DEFINE clause
+LINE 1: SELECT last(val) FROM rpr_nav;
+               ^
+SELECT first(val, 1) FROM rpr_nav;
+ERROR:  first can only be used in a DEFINE clause
+LINE 1: SELECT first(val, 1) FROM rpr_nav;
+               ^
+-- Functional notation: should access column, not RPR navigation
+CREATE TEMP TABLE rpr_names (prev int, next int, first text, last text);
+INSERT INTO rpr_names VALUES (1, 2, 'Joe', 'Blow');
+SELECT prev(f), next(f), first(f), last(f) FROM rpr_names f;
+ prev | next | first | last 
+------+------+-------+------
+    1 |    2 | Joe   | Blow
+(1 row)
+
+DROP TABLE rpr_names;
+-- Compound navigation: PREV(FIRST(val), M)
+-- rpr_nav: (1,10),(2,20),(3,30),(4,10),(5,50),(6,10)
+-- PREV(FIRST(val), 1): target = match_start + 0 - 1 = match_start - 1
+-- At match_start=1: target=0 → out of range → NULL
+-- At match_start=3: target=2(val=20) → 20 > 0 → true
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val), 1) > 0
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |    |   0
+  2 |  20 |  2 |   5
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- NEXT(FIRST(val, 1), 1): target = match_start + 1 + 1 = match_start + 2
+-- At match_start=1, B on id2: target=1+1+1=3(val=30), 30>0 → true
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(FIRST(val, 1), 1) > 0
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   6
+  2 |  20 |    |   0
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- PREV(LAST(val), 2): target = currentpos - 0 - 2 = currentpos - 2
+-- Same backward reach as PREV(val, 2)
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(LAST(val), 2) IS NOT NULL
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |    |   0
+  2 |  20 |  2 |   5
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- NEXT(LAST(val, 1), 2): target = currentpos - 1 + 2 = currentpos + 1
+-- Looks 1 row ahead: same as NEXT(val, 1)
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(LAST(val, 1), 2) IS NOT NULL
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   5
+  2 |  20 |    |   0
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- Compound: outer offset beyond partition (PREV far back)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val), 99) IS NOT NULL
+);
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   0
+  2 |  20 |   0
+  3 |  30 |   0
+  4 |  10 |   0
+  5 |  50 |   0
+  6 |  10 |   0
+(6 rows)
+
+-- Compound: outer offset beyond partition (NEXT far forward)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(FIRST(val), 99) IS NOT NULL
+);
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   0
+  2 |  20 |   0
+  3 |  30 |   0
+  4 |  10 |   0
+  5 |  50 |   0
+  6 |  10 |   0
+(6 rows)
+
+-- Compound: inner offset beyond match range (FIRST offset too large)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val, 99), 1) IS NOT NULL
+);
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   0
+  2 |  20 |   0
+  3 |  30 |   0
+  4 |  10 |   0
+  5 |  50 |   0
+  6 |  10 |   0
+(6 rows)
+
+-- Compound: inner offset beyond match range (LAST offset too large)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(LAST(val, 99), 1) IS NOT NULL
+);
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   0
+  2 |  20 |   0
+  3 |  30 |   0
+  4 |  10 |   0
+  5 |  50 |   0
+  6 |  10 |   0
+(6 rows)
+
+-- Compound: NULL outer offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(val), NULL::int8) IS NULL
+);
+ERROR:  row pattern navigation offset must not be null
+-- Compound: negative outer offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(LAST(val), -1) IS NULL
+);
+ERROR:  row pattern navigation offset must not be negative
+-- Compound: default offsets on both sides
+-- PREV(FIRST(val)): inner=0 (match_start), outer=1 → target = match_start - 1
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val)) IS NOT NULL
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |    |   0
+  2 |  20 |  2 |   5
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- NEXT(LAST(val)): inner=0 (currentpos), outer=1 → target = currentpos + 1
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(LAST(val)) IS NOT NULL
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   5
+  2 |  20 |    |   0
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+-- Compound: inner NULL offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(val, NULL::int8), 1) IS NULL
+);
+ERROR:  row pattern navigation offset must not be null
+-- Compound: inner negative offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(LAST(val, -1), 1) IS NULL
+);
+ERROR:  row pattern navigation offset must not be negative
+-- Compound + host variable offsets
+PREPARE test_compound_offset(int8, int8) AS
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val, $1), $2) IS NOT NULL
+);
+EXECUTE test_compound_offset(0, 1);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |    |   0
+  2 |  20 |  2 |   5
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+EXECUTE test_compound_offset(1, 1);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |  1 |   6
+  2 |  20 |    |   0
+  3 |  30 |    |   0
+  4 |  10 |    |   0
+  5 |  50 |    |   0
+  6 |  10 |    |   0
+(6 rows)
+
+DEALLOCATE test_compound_offset;
+-- Compound + SKIP TO NEXT ROW: overlapping matches with PREV(FIRST())
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP TO NEXT ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val), 1) > 0
+);
+ id | val | mf | cnt 
+----+-----+----+-----
+  1 |  10 |    |   0
+  2 |  20 |  2 |   5
+  3 |  30 |  3 |   4
+  4 |  10 |  4 |   3
+  5 |  50 |  5 |   2
+  6 |  10 |    |   0
+(6 rows)
+
+-- Compound + multiple partitions
+CREATE TEMP TABLE rpr_nav_part (gid int, id int, val int);
+INSERT INTO rpr_nav_part VALUES
+    (1,1,10),(1,2,20),(1,3,30),
+    (2,1,40),(2,2,50),(2,3,60);
+SELECT gid, id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav_part WINDOW w AS (
+    PARTITION BY gid ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(FIRST(val), 1) > 0
+);
+ gid | id | val | mf | cnt 
+-----+----+-----+----+-----
+   1 |  1 |  10 |  1 |   3
+   1 |  2 |  20 |    |   0
+   1 |  3 |  30 |    |   0
+   2 |  1 |  40 |  1 |   3
+   2 |  2 |  50 |    |   0
+   2 |  3 |  60 |    |   0
+(6 rows)
+
+DROP TABLE rpr_nav_part;
+-- Reverse nesting: FIRST wrapping PREV is prohibited
+SELECT id, val FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS FIRST(PREV(val)) > 0
+);
+ERROR:  FIRST and LAST cannot contain PREV or NEXT
+LINE 5:     DEFINE A AS TRUE, B AS FIRST(PREV(val)) > 0
+                                   ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
+-- Reverse nesting: LAST wrapping NEXT is prohibited
+SELECT id, val FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS LAST(NEXT(val)) > 0
+);
+ERROR:  FIRST and LAST cannot contain PREV or NEXT
+LINE 5:     DEFINE A AS TRUE, B AS LAST(NEXT(val)) > 0
+                                   ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
+DROP TABLE rpr_nav;
 --
 -- SKIP TO / Backtracking / Frame boundary
 --
@@ -2247,6 +2850,27 @@ FROM result WHERE match_len > 0;
             1 |         99999
 (1 row)
 
+RESET jit_above_cost;
+RESET jit;
+-- JIT compound navigation test
+SET jit = on;
+SET jit_above_cost = 0;
+SELECT count(*) AS matched_rows
+FROM (
+ SELECT v, count(*) OVER w AS match_len
+ FROM generate_series(1, 1000) AS t(v)
+ WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  PATTERN (A B+)
+  DEFINE A AS TRUE, B AS PREV(FIRST(v), 1) > 0
+ )
+) sub WHERE match_len > 0;
+ matched_rows 
+--------------
+            1
+(1 row)
+
 RESET jit_above_cost;
 RESET jit;
 --
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 1e450a07ced..0845316965e 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -1629,7 +1629,7 @@ LINE 6:     PATTERN (A{,2147483647})
 -- Expected: ERROR: quantifier bound must be between 1 and 2147483646
 DROP TABLE rpr_bounds;
 -- ============================================================
--- Navigation Functions Tests (PREV / NEXT)
+-- Navigation Functions Tests (PREV / NEXT / FIRST / LAST)
 -- ============================================================
 CREATE TABLE rpr_nav (id INT, val INT);
 INSERT INTO rpr_nav VALUES
@@ -1730,6 +1730,81 @@ ERROR:  next can only be used in a DEFINE clause
 LINE 1: SELECT NEXT(id), id, val, COUNT(*) OVER w as cnt
                ^
 -- Expected: ERROR: next can only be used in a DEFINE clause
+-- FIRST function - reference match_start row
+SELECT id, val, COUNT(*) OVER w as cnt
+FROM rpr_nav
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS val > 0,
+        B AS val > FIRST(val)
+)
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   5
+  2 |  20 |   0
+  3 |  15 |   0
+  4 |  25 |   0
+  5 |  30 |   0
+(5 rows)
+
+-- LAST function without offset - equivalent to current row's value
+SELECT id, val, COUNT(*) OVER w as cnt
+FROM rpr_nav
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS val > 0,
+        B AS LAST(val) > PREV(val)
+)
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   3
+  4 |  25 |   0
+  5 |  30 |   0
+(5 rows)
+
+-- FIRST and LAST combined
+SELECT id, val, COUNT(*) OVER w as cnt
+FROM rpr_nav
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS val > 0,
+        B AS val > FIRST(val) AND LAST(val) > PREV(val)
+)
+ORDER BY id;
+ id | val | cnt 
+----+-----+-----
+  1 |  10 |   2
+  2 |  20 |   0
+  3 |  15 |   3
+  4 |  25 |   0
+  5 |  30 |   0
+(5 rows)
+
+-- FIRST function cannot be used other than in DEFINE
+SELECT FIRST(id), id, val FROM rpr_nav;
+ERROR:  first can only be used in a DEFINE clause
+LINE 1: SELECT FIRST(id), id, val FROM rpr_nav;
+               ^
+-- Expected: ERROR: first can only be used in a DEFINE clause
+-- LAST function cannot be used other than in DEFINE
+SELECT LAST(id), id, val FROM rpr_nav;
+ERROR:  last can only be used in a DEFINE clause
+LINE 1: SELECT LAST(id), id, val FROM rpr_nav;
+               ^
+-- Expected: ERROR: last can only be used in a DEFINE clause
 DROP TABLE rpr_nav;
 -- ============================================================
 -- SKIP TO / INITIAL Tests
@@ -2227,6 +2302,150 @@ SELECT pg_get_viewdef('rpr_serial_v8'::regclass);
    d AS (val > 30) );
 (1 row)
 
+-- Navigation function serialization: PREV with offset
+CREATE VIEW rpr_serial_nav1 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS val > PREV(val, 2));
+SELECT pg_get_viewdef('rpr_serial_nav1'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN (a b+)                                                            +
+   DEFINE                                                                    +
+   a AS true,                                                                +
+   b AS (val > PREV(val, (2)::bigint)) );
+(1 row)
+
+-- Navigation function serialization: FIRST and LAST
+CREATE VIEW rpr_serial_nav2 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS FIRST(val) < LAST(val, 1));
+SELECT pg_get_viewdef('rpr_serial_nav2'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN (a b+)                                                            +
+   DEFINE                                                                    +
+   a AS true,                                                                +
+   b AS (FIRST(val) < LAST(val, (1)::bigint)) );
+(1 row)
+
+-- Navigation function serialization: compound PREV(FIRST())
+CREATE VIEW rpr_serial_nav3 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS PREV(FIRST(val, 1), 2) > 0);
+SELECT pg_get_viewdef('rpr_serial_nav3'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN (a b+)                                                            +
+   DEFINE                                                                    +
+   a AS true,                                                                +
+   b AS (PREV(FIRST(val, (1)::bigint), (2)::bigint) > 0) );
+(1 row)
+
+-- Navigation function serialization: compound NEXT(LAST())
+CREATE VIEW rpr_serial_nav4 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS NEXT(LAST(val), 2) IS NOT NULL);
+SELECT pg_get_viewdef('rpr_serial_nav4'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN (a b+)                                                            +
+   DEFINE                                                                    +
+   a AS true,                                                                +
+   b AS (NEXT(LAST(val), (2)::bigint) IS NOT NULL) );
+(1 row)
+
+-- Navigation function serialization: compound PREV(LAST())
+CREATE VIEW rpr_serial_nav5 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS PREV(LAST(val, 1), 2) > 0);
+SELECT pg_get_viewdef('rpr_serial_nav5'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN (a b+)                                                            +
+   DEFINE                                                                    +
+   a AS true,                                                                +
+   b AS (PREV(LAST(val, (1)::bigint), (2)::bigint) > 0) );
+(1 row)
+
+-- Navigation function serialization: compound NEXT(FIRST())
+CREATE VIEW rpr_serial_nav6 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS NEXT(FIRST(val), 3) > 0);
+SELECT pg_get_viewdef('rpr_serial_nav6'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN (a b+)                                                            +
+   DEFINE                                                                    +
+   a AS true,                                                                +
+   b AS (NEXT(FIRST(val), (3)::bigint) > 0) );
+(1 row)
+
 -- Reluctant {1}? quantifier deparse through ruleutils
 CREATE VIEW rpr_quant_reluctant_v AS
 SELECT id, val, count(*) OVER w
@@ -3021,6 +3240,42 @@ ORDER BY id;
 (3 rows)
 
 DROP TABLE rpr_null;
+-- Compound navigation: inner nav must be direct arg (not nested in expression)
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v + FIRST(v)) > 0
+);
+ERROR:  row pattern navigation operation must be a direct argument of the outer navigation
+LINE 6:     DEFINE A AS PREV(v + FIRST(v)) > 0
+                        ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
+-- FIRST/LAST wrapping FIRST/LAST: prohibited
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS FIRST(FIRST(v)) > 0
+);
+ERROR:  FIRST and LAST cannot contain FIRST or LAST
+LINE 6:     DEFINE A AS FIRST(FIRST(v)) > 0
+                        ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
+-- Triple nesting: prohibited (3-level deep navigation)
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(PREV(v))) > 0
+);
+ERROR:  row pattern navigation cannot be nested more than two levels deep
+LINE 6:     DEFINE A AS PREV(FIRST(PREV(v))) > 0
+                        ^
+HINT:  Only PREV(FIRST()), PREV(LAST()), NEXT(FIRST()), and NEXT(LAST()) compound forms are allowed.
 -- ============================================================
 -- Window Deduplication Tests
 -- ============================================================
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index 77ab25a2289..dc3522f930f 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -36,7 +36,7 @@
 --   Window Function Combinations
 --   DEFINE Expression Variations
 --   Large Scale Statistics Verification
---   Nav Mark Lookback (tuplestore trim)
+--   Nav Mark Lookback/Lookahead (tuplestore trim)
 -- ============================================================
 -- Filter function to normalize platform-dependent memory values (not NFA statistics).
 -- NFA statistics should not change between platforms; if they do, it could
@@ -1131,6 +1131,127 @@ WINDOW w AS (
    ->  Function Scan on generate_series s (actual rows=42.00 loops=1)
 (10 rows)
 
+-- No absorption when DEFINE uses FIRST (match_start-dependent)
+-- Same pattern as rpr_ev_ctx_absorb_unbounded but with FIRST in DEFINE.
+-- Compare: absorbed count should be 0 here vs >0 above.
+CREATE VIEW rpr_ev_ctx_no_absorb_first AS
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND v > FIRST(v)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_no_absorb_first'), E'\n')) AS line WHERE line ~ 'PATTERN';
+       line        
+-------------------
+   PATTERN (a+ b) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND v > FIRST(v)
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=50.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+ b
+   Nav Mark Lookback: 0
+   Nav Mark Lookahead: 0
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 9 peak, 151 total, 0 merged
+   NFA Contexts: 5 peak, 51 total, 0 pruned
+   NFA: 10 matched (len 5/5/5.0), 0 mismatched
+   NFA: 0 absorbed, 40 skipped (len 1/4/2.5)
+   ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
+(11 rows)
+
+-- Absorption preserved when DEFINE uses only LAST without offset
+-- LAST(v) is match_start-independent (always currentpos), so absorption
+-- remains active.  Compare: absorbed count should be >0, like the
+-- PREV-only case above.
+CREATE VIEW rpr_ev_ctx_absorb_last AS
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS LAST(v) % 5 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_last'), E'\n')) AS line WHERE line ~ 'PATTERN';
+       line        
+-------------------
+   PATTERN (a+ b) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS LAST(v) % 5 = 0
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=50.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+" b
+   Nav Mark Lookback: 0
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 3 peak, 91 total, 0 merged
+   NFA Contexts: 2 peak, 51 total, 0 pruned
+   NFA: 10 matched (len 5/5/5.0), 0 mismatched
+   NFA: 30 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
+   ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
+(10 rows)
+
+-- No absorption with compound PREV(FIRST()) (match_start-dependent)
+CREATE VIEW rpr_ev_ctx_no_absorb_compound AS
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND PREV(FIRST(v), 1) IS NOT NULL
+);
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND PREV(FIRST(v), 1) IS NOT NULL
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=50.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+ b
+   Nav Mark Lookback: 0
+   Nav Mark Lookahead: -1
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 9 peak, 151 total, 0 merged
+   NFA Contexts: 5 peak, 51 total, 0 pruned
+   NFA: 10 matched (len 4/5/4.9), 1 mismatched (len 5/5/5.0)
+   NFA: 0 absorbed, 39 skipped (len 1/4/2.5)
+   ->  Function Scan on generate_series s (actual rows=50.00 loops=1)
+(11 rows)
+
 -- ============================================================
 -- Match Length Statistics Tests
 -- ============================================================
@@ -4343,9 +4464,10 @@ SELECT * FROM (
 (9 rows)
 
 -- ============================================================
--- Nav Mark Lookback Tests
--- Verifies planner-computed navigation offset for tuplestore trim.
--- Lookback: how far back from currentpos (PREV/LAST).
+-- Nav Mark Lookback/Lookahead Tests
+-- Verifies planner-computed navigation offsets for tuplestore trim.
+-- Lookback: how far back from currentpos (PREV, LAST, compound PREV_LAST/NEXT_LAST).
+-- Lookahead: how far forward from match_start (FIRST, compound PREV_FIRST/NEXT_FIRST).
 -- ============================================================
 -- Prepare statement for host variable offset test below
 PREPARE rpr_nav_offset_prep(int8) AS
@@ -4466,3 +4588,298 @@ EXPLAIN (COSTS OFF) EXECUTE rpr_nav_offset_prep(2);
 
 RESET plan_cache_mode;
 DEALLOCATE rpr_nav_offset_prep;
+-- FIRST(v): retain all (references match_start row)
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > FIRST(v)
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 0
+   Nav Mark Lookahead: 0
+   ->  Function Scan on generate_series s
+(6 rows)
+
+-- LAST(v, 1): backward reach 1, same as PREV(v, 1)
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS LAST(v, 1) > 0
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 1
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- LAST(v) without offset + PREV(v): no match_start dependency, offset 1
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS LAST(v) > PREV(v)
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 1
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Compound PREV(FIRST(val, 1), 2): lookback from match_start, firstOffset = 1-2 = -1
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(v, 1), 2) > 0
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 0
+   Nav Mark Lookahead: -1
+   ->  Function Scan on generate_series s
+(6 rows)
+
+-- Compound NEXT(FIRST(val), 3): firstOffset = 0+3 = 3
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(FIRST(v), 3) > 0
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 0
+   Nav Mark Lookahead: 3
+   ->  Function Scan on generate_series s
+(6 rows)
+
+-- Compound PREV(LAST(val), 2): lookback = 0+2 = 2
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v), 2) > 0
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+"
+   Nav Mark Lookback: 2
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Compound NEXT(LAST(val, 1), 3): lookback = max(1-3, 0) = 0
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(LAST(v, 1), 3) > 0
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 0
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Compound PREV(LAST(val, N), M): constant near-overflow (N+M just fits int64)
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v, 4611686018427387903), 4611686018427387903) IS NOT NULL
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 9223372036854775806
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Compound PREV(LAST(val, N), M): constant overflow -> retain all
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v, 4611686018427387904), 4611686018427387904) IS NOT NULL
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: retain all
+   ->  Function Scan on generate_series s
+(5 rows)
+
+-- Compound NEXT(FIRST(val, N), M): constant lookahead overflow -> no trim impact
+-- N + M overflows int64, but target is forward from match_start so it never
+-- constrains trim.  Lookahead remains at default (0).
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(FIRST(v, 4611686018427387904), 4611686018427387904) IS NOT NULL
+);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 0
+   Nav Mark Lookahead: 0
+   ->  Function Scan on generate_series s
+(6 rows)
+
+-- Compound PREV(LAST(val, $1), $2): parameter lookback overflow -> retain all
+-- EXPLAIN shows "runtime" (plan-level); EXPLAIN ANALYZE shows "retain all"
+-- (executor-resolved).
+PREPARE test_overflow_lookback(int8, int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v, $1), $2) IS NOT NULL
+);
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF) EXECUTE test_overflow_lookback(4611686018427387904, 4611686018427387904);
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: runtime
+   ->  Function Scan on generate_series s
+(5 rows)
+
+EXPLAIN (COSTS OFF, ANALYZE, TIMING OFF, SUMMARY OFF)
+    EXECUTE test_overflow_lookback(4611686018427387904, 4611686018427387904);
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ WindowAgg (actual rows=10.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: retain all
+   Storage: Memory  Maximum Storage: 17kB
+   NFA States: 1 peak, 11 total, 0 merged
+   NFA Contexts: 2 peak, 11 total, 10 pruned
+   NFA: 0 matched, 0 mismatched
+   ->  Function Scan on generate_series s (actual rows=10.00 loops=1)
+(9 rows)
+
+RESET plan_cache_mode;
+DEALLOCATE test_overflow_lookback;
+-- Compound NEXT(FIRST(val, $1), $2): parameter lookahead overflow -> no trim impact
+PREPARE test_overflow_lookahead(int8, int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(FIRST(v, $1), $2) IS NOT NULL
+);
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF, ANALYZE, TIMING OFF, SUMMARY OFF)
+    EXECUTE test_overflow_lookahead(4611686018427387904, 4611686018427387904);
+                              QUERY PLAN                              
+----------------------------------------------------------------------
+ WindowAgg (actual rows=10.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: a+
+   Nav Mark Lookback: 0
+   Nav Mark Lookahead: 0
+   Storage: Memory  Maximum Storage: 17kB
+   NFA States: 1 peak, 11 total, 0 merged
+   NFA Contexts: 2 peak, 11 total, 10 pruned
+   NFA: 0 matched, 0 mismatched
+   ->  Function Scan on generate_series s (actual rows=10.00 loops=1)
+(10 rows)
+
+RESET plan_cache_mode;
+DEALLOCATE test_overflow_lookahead;
+-- PREV(v) + PREV(v, $1): NEEDS_EVAL path must account for implicit lookback=1
+-- Previously, eval_nav_max_offset_walker skipped PREV(v) when offset_arg was
+-- NULL, causing maxOffset=0 when $1=0, which would trim the row needed by
+-- PREV(v).  Verify this executes without "cannot fetch row before mark" error.
+PREPARE test_prev_implicit_offset(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v) IS NOT NULL AND PREV(v, $1) IS NOT NULL
+);
+EXECUTE test_prev_implicit_offset(0);
+ count 
+-------
+     0
+     9
+     0
+     0
+     0
+     0
+     0
+     0
+     0
+     0
+(10 rows)
+
+DEALLOCATE test_prev_implicit_offset;
+-- Runtime error: negative offset at execution time
+PREPARE test_runtime_neg_offset(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v, $1) IS NOT NULL
+);
+EXECUTE test_runtime_neg_offset(-1);
+ERROR:  row pattern navigation offset must not be negative
+DEALLOCATE test_runtime_neg_offset;
+-- Runtime error: null offset at execution time
+PREPARE test_runtime_null_offset(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v, $1) IS NOT NULL
+);
+EXECUTE test_runtime_null_offset(NULL);
+ERROR:  row pattern navigation offset must not be null
+DEALLOCATE test_runtime_null_offset;
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index 47f33904690..a05b429ce74 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -776,6 +776,363 @@ WINDOW w AS (
     DEFINE A AS price > PREV(price, 1) AND price < NEXT(price, 1)
 );
 
+--
+-- FIRST/LAST navigation
+--
+
+-- Test data for FIRST/LAST: values cycle back so FIRST(val) = LAST(val)
+-- at specific positions.
+CREATE TEMP TABLE rpr_nav (id int, val int);
+INSERT INTO rpr_nav VALUES (1,10),(2,20),(3,30),(4,10),(5,50),(6,10);
+
+-- FIRST(val) = constant: B matches when match_start has val=10
+-- match_start=1(10): A=id1, B=id2, FIRST(val)=10 → match {1,2}
+-- match_start=3(30): A=id3, B=id4, FIRST(val)=30≠10 → no match
+-- match_start=4(10): A=id4, B=id5, FIRST(val)=10 → match {4,5}
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS FIRST(val) = 10
+);
+
+-- LAST(val): always equals current row's val (offset 0 default)
+-- Equivalent to: B AS val > 15
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS LAST(val) > 15
+);
+
+-- Reluctant A+? with FIRST(val) = LAST(val): find shortest match where
+-- first and last rows have the same val.
+-- match_start=1(10): reluctant tries B early:
+--   id2(20≠10), id3(30≠10), id4(10=10) → match {1,2,3,4}
+-- match_start=5(50): id6(10≠50) → no match
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+? B)
+    DEFINE A AS TRUE, B AS FIRST(val) = LAST(val)
+);
+
+-- Greedy A+ with FIRST(val) = LAST(val): find longest match where
+-- first and last rows have the same val.
+-- match_start=1(10): greedy A eats all, B tries last:
+--   id6(10=10) → match {1,2,3,4,5,6}
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS TRUE, B AS FIRST(val) = LAST(val)
+);
+
+-- SKIP TO NEXT ROW with FIRST(val) = LAST(val): overlapping match attempts.
+-- With ONE ROW PER MATCH, each row shows only its first match result.
+SELECT id, val, first_value(id) OVER w AS mf, last_value(id) OVER w AS ml
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP TO NEXT ROW
+    PATTERN (A+? B)
+    DEFINE A AS TRUE, B AS FIRST(val) = LAST(val)
+);
+
+-- FIRST/LAST 2-arg offset form
+--
+-- FIRST(val, 0) = FIRST(val): match_start row
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS FIRST(val, 0) = 10
+);
+
+-- FIRST(val, 1): match_start + 1 row (second row of match)
+-- match_start=1(10): FIRST(val,1)=20, B needs val=20 → id2(20) match, id3(30) no
+-- match_start=3(30): FIRST(val,1)=10, B needs val=10 → id4(10) match
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS val = FIRST(val, 1)
+);
+
+-- FIRST(val, 99): offset beyond match range → NULL, no match
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS FIRST(val, 99) IS NOT NULL
+);
+
+-- LAST(val, 0) = LAST(val): current row
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS LAST(val, 0) > 15
+);
+
+-- LAST(val, 1): one row back from current (previous match row)
+-- At B evaluation on id2: LAST(val,1) = val at id1 = 10
+-- B matches when previous row val < 30
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS LAST(val, 1) < 30
+);
+
+-- LAST(val, 99): offset before match_start → NULL
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS LAST(val, 99) IS NOT NULL
+);
+
+-- Error: NULL offset
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS FIRST(val, NULL::int8) IS NULL
+);
+
+-- Error: negative offset
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS LAST(val, -1) IS NULL
+);
+
+-- FIRST/LAST outside DEFINE clause (error cases)
+SELECT first(val) FROM rpr_nav;
+SELECT last(val) FROM rpr_nav;
+SELECT first(val, 1) FROM rpr_nav;
+
+-- Functional notation: should access column, not RPR navigation
+CREATE TEMP TABLE rpr_names (prev int, next int, first text, last text);
+INSERT INTO rpr_names VALUES (1, 2, 'Joe', 'Blow');
+SELECT prev(f), next(f), first(f), last(f) FROM rpr_names f;
+DROP TABLE rpr_names;
+
+-- Compound navigation: PREV(FIRST(val), M)
+-- rpr_nav: (1,10),(2,20),(3,30),(4,10),(5,50),(6,10)
+-- PREV(FIRST(val), 1): target = match_start + 0 - 1 = match_start - 1
+-- At match_start=1: target=0 → out of range → NULL
+-- At match_start=3: target=2(val=20) → 20 > 0 → true
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val), 1) > 0
+);
+
+-- NEXT(FIRST(val, 1), 1): target = match_start + 1 + 1 = match_start + 2
+-- At match_start=1, B on id2: target=1+1+1=3(val=30), 30>0 → true
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(FIRST(val, 1), 1) > 0
+);
+
+-- PREV(LAST(val), 2): target = currentpos - 0 - 2 = currentpos - 2
+-- Same backward reach as PREV(val, 2)
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(LAST(val), 2) IS NOT NULL
+);
+
+-- NEXT(LAST(val, 1), 2): target = currentpos - 1 + 2 = currentpos + 1
+-- Looks 1 row ahead: same as NEXT(val, 1)
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(LAST(val, 1), 2) IS NOT NULL
+);
+
+-- Compound: outer offset beyond partition (PREV far back)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val), 99) IS NOT NULL
+);
+
+-- Compound: outer offset beyond partition (NEXT far forward)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(FIRST(val), 99) IS NOT NULL
+);
+
+-- Compound: inner offset beyond match range (FIRST offset too large)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val, 99), 1) IS NOT NULL
+);
+
+-- Compound: inner offset beyond match range (LAST offset too large)
+SELECT id, val, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(LAST(val, 99), 1) IS NOT NULL
+);
+
+-- Compound: NULL outer offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(val), NULL::int8) IS NULL
+);
+
+-- Compound: negative outer offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(LAST(val), -1) IS NULL
+);
+
+-- Compound: default offsets on both sides
+-- PREV(FIRST(val)): inner=0 (match_start), outer=1 → target = match_start - 1
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val)) IS NOT NULL
+);
+
+-- NEXT(LAST(val)): inner=0 (currentpos), outer=1 → target = currentpos + 1
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(LAST(val)) IS NOT NULL
+);
+
+-- Compound: inner NULL offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(val, NULL::int8), 1) IS NULL
+);
+
+-- Compound: inner negative offset (runtime error)
+SELECT id, val, count(*) OVER w FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(LAST(val, -1), 1) IS NULL
+);
+
+-- Compound + host variable offsets
+PREPARE test_compound_offset(int8, int8) AS
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val, $1), $2) IS NOT NULL
+);
+EXECUTE test_compound_offset(0, 1);
+EXECUTE test_compound_offset(1, 1);
+DEALLOCATE test_compound_offset;
+
+-- Compound + SKIP TO NEXT ROW: overlapping matches with PREV(FIRST())
+SELECT id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP TO NEXT ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS PREV(FIRST(val), 1) > 0
+);
+
+-- Compound + multiple partitions
+CREATE TEMP TABLE rpr_nav_part (gid int, id int, val int);
+INSERT INTO rpr_nav_part VALUES
+    (1,1,10),(1,2,20),(1,3,30),
+    (2,1,40),(2,2,50),(2,3,60);
+SELECT gid, id, val, first_value(id) OVER w AS mf, count(*) OVER w AS cnt
+FROM rpr_nav_part WINDOW w AS (
+    PARTITION BY gid ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS NEXT(FIRST(val), 1) > 0
+);
+DROP TABLE rpr_nav_part;
+
+-- Reverse nesting: FIRST wrapping PREV is prohibited
+SELECT id, val FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS FIRST(PREV(val)) > 0
+);
+
+-- Reverse nesting: LAST wrapping NEXT is prohibited
+SELECT id, val FROM rpr_nav WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B)
+    DEFINE A AS TRUE, B AS LAST(NEXT(val)) > 0
+);
+
+DROP TABLE rpr_nav;
+
 --
 -- SKIP TO / Backtracking / Frame boundary
 --
@@ -1129,6 +1486,23 @@ FROM result WHERE match_len > 0;
 RESET jit_above_cost;
 RESET jit;
 
+-- JIT compound navigation test
+SET jit = on;
+SET jit_above_cost = 0;
+SELECT count(*) AS matched_rows
+FROM (
+ SELECT v, count(*) OVER w AS match_len
+ FROM generate_series(1, 1000) AS t(v)
+ WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  PATTERN (A B+)
+  DEFINE A AS TRUE, B AS PREV(FIRST(v), 1) > 0
+ )
+) sub WHERE match_len > 0;
+RESET jit_above_cost;
+RESET jit;
+
 --
 -- IGNORE NULLS
 --
diff --git a/src/test/regress/sql/rpr_base.sql b/src/test/regress/sql/rpr_base.sql
index 3accecb73ba..f9d5aa89d7a 100644
--- a/src/test/regress/sql/rpr_base.sql
+++ b/src/test/regress/sql/rpr_base.sql
@@ -1202,7 +1202,7 @@ WINDOW w AS (
 DROP TABLE rpr_bounds;
 
 -- ============================================================
--- Navigation Functions Tests (PREV / NEXT)
+-- Navigation Functions Tests (PREV / NEXT / FIRST / LAST)
 -- ============================================================
 
 
@@ -1278,6 +1278,53 @@ WINDOW w AS (
 ORDER BY id;
 -- Expected: ERROR: next can only be used in a DEFINE clause
 
+-- FIRST function - reference match_start row
+SELECT id, val, COUNT(*) OVER w as cnt
+FROM rpr_nav
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS val > 0,
+        B AS val > FIRST(val)
+)
+ORDER BY id;
+
+-- LAST function without offset - equivalent to current row's value
+SELECT id, val, COUNT(*) OVER w as cnt
+FROM rpr_nav
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS val > 0,
+        B AS LAST(val) > PREV(val)
+)
+ORDER BY id;
+
+-- FIRST and LAST combined
+SELECT id, val, COUNT(*) OVER w as cnt
+FROM rpr_nav
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A B+)
+    DEFINE
+        A AS val > 0,
+        B AS val > FIRST(val) AND LAST(val) > PREV(val)
+)
+ORDER BY id;
+
+-- FIRST function cannot be used other than in DEFINE
+SELECT FIRST(id), id, val FROM rpr_nav;
+-- Expected: ERROR: first can only be used in a DEFINE clause
+
+-- LAST function cannot be used other than in DEFINE
+SELECT LAST(id), id, val FROM rpr_nav;
+-- Expected: ERROR: last can only be used in a DEFINE clause
+
 DROP TABLE rpr_nav;
 
 -- ============================================================
@@ -1548,6 +1595,66 @@ WINDOW w AS (
 SELECT * FROM rpr_serial_v8 ORDER BY id;
 SELECT pg_get_viewdef('rpr_serial_v8'::regclass);
 
+-- Navigation function serialization: PREV with offset
+CREATE VIEW rpr_serial_nav1 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS val > PREV(val, 2));
+SELECT pg_get_viewdef('rpr_serial_nav1'::regclass);
+
+-- Navigation function serialization: FIRST and LAST
+CREATE VIEW rpr_serial_nav2 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS FIRST(val) < LAST(val, 1));
+SELECT pg_get_viewdef('rpr_serial_nav2'::regclass);
+
+-- Navigation function serialization: compound PREV(FIRST())
+CREATE VIEW rpr_serial_nav3 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS PREV(FIRST(val, 1), 2) > 0);
+SELECT pg_get_viewdef('rpr_serial_nav3'::regclass);
+
+-- Navigation function serialization: compound NEXT(LAST())
+CREATE VIEW rpr_serial_nav4 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS NEXT(LAST(val), 2) IS NOT NULL);
+SELECT pg_get_viewdef('rpr_serial_nav4'::regclass);
+
+-- Navigation function serialization: compound PREV(LAST())
+CREATE VIEW rpr_serial_nav5 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS PREV(LAST(val, 1), 2) > 0);
+SELECT pg_get_viewdef('rpr_serial_nav5'::regclass);
+
+-- Navigation function serialization: compound NEXT(FIRST())
+CREATE VIEW rpr_serial_nav6 AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN (A B+)
+             DEFINE A AS TRUE, B AS NEXT(FIRST(val), 3) > 0);
+SELECT pg_get_viewdef('rpr_serial_nav6'::regclass);
+
 -- Reluctant {1}? quantifier deparse through ruleutils
 CREATE VIEW rpr_quant_reluctant_v AS
 SELECT id, val, count(*) OVER w
@@ -2121,6 +2228,33 @@ ORDER BY id;
 
 DROP TABLE rpr_null;
 
+-- Compound navigation: inner nav must be direct arg (not nested in expression)
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v + FIRST(v)) > 0
+);
+
+-- FIRST/LAST wrapping FIRST/LAST: prohibited
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS FIRST(FIRST(v)) > 0
+);
+
+-- Triple nesting: prohibited (3-level deep navigation)
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(PREV(v))) > 0
+);
+
 -- ============================================================
 -- Window Deduplication Tests
 -- ============================================================
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index 5082cc2b5de..a3789e92631 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -36,7 +36,7 @@
 --   Window Function Combinations
 --   DEFINE Expression Variations
 --   Large Scale Statistics Verification
---   Nav Mark Lookback (tuplestore trim)
+--   Nav Mark Lookback/Lookahead (tuplestore trim)
 -- ============================================================
 
 -- Filter function to normalize platform-dependent memory values (not NFA statistics).
@@ -705,6 +705,76 @@ WINDOW w AS (
            C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
 );');
 
+-- No absorption when DEFINE uses FIRST (match_start-dependent)
+-- Same pattern as rpr_ev_ctx_absorb_unbounded but with FIRST in DEFINE.
+-- Compare: absorbed count should be 0 here vs >0 above.
+CREATE VIEW rpr_ev_ctx_no_absorb_first AS
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND v > FIRST(v)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_no_absorb_first'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND v > FIRST(v)
+);');
+
+-- Absorption preserved when DEFINE uses only LAST without offset
+-- LAST(v) is match_start-independent (always currentpos), so absorption
+-- remains active.  Compare: absorbed count should be >0, like the
+-- PREV-only case above.
+CREATE VIEW rpr_ev_ctx_absorb_last AS
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS LAST(v) % 5 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev_ctx_absorb_last'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS LAST(v) % 5 = 0
+);');
+
+-- No absorption with compound PREV(FIRST()) (match_start-dependent)
+CREATE VIEW rpr_ev_ctx_no_absorb_compound AS
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND PREV(FIRST(v), 1) IS NOT NULL
+);
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 50) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A+ B)
+    DEFINE A AS v % 5 <> 0, B AS v % 5 = 0 AND PREV(FIRST(v), 1) IS NOT NULL
+);');
+
 -- ============================================================
 -- Match Length Statistics Tests
 -- ============================================================
@@ -2478,9 +2548,10 @@ SELECT * FROM (
 ) t WHERE cnt > 0;
 
 -- ============================================================
--- Nav Mark Lookback Tests
--- Verifies planner-computed navigation offset for tuplestore trim.
--- Lookback: how far back from currentpos (PREV/LAST).
+-- Nav Mark Lookback/Lookahead Tests
+-- Verifies planner-computed navigation offsets for tuplestore trim.
+-- Lookback: how far back from currentpos (PREV, LAST, compound PREV_LAST/NEXT_LAST).
+-- Lookahead: how far forward from match_start (FIRST, compound PREV_FIRST/NEXT_FIRST).
 -- ============================================================
 
 -- Prepare statement for host variable offset test below
@@ -2547,3 +2618,166 @@ EXPLAIN (COSTS OFF) EXECUTE rpr_nav_offset_prep(2);
 RESET plan_cache_mode;
 DEALLOCATE rpr_nav_offset_prep;
 
+-- FIRST(v): retain all (references match_start row)
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS v > FIRST(v)
+);
+
+-- LAST(v, 1): backward reach 1, same as PREV(v, 1)
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS LAST(v, 1) > 0
+);
+
+-- LAST(v) without offset + PREV(v): no match_start dependency, offset 1
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS LAST(v) > PREV(v)
+);
+
+-- Compound PREV(FIRST(val, 1), 2): lookback from match_start, firstOffset = 1-2 = -1
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(FIRST(v, 1), 2) > 0
+);
+
+-- Compound NEXT(FIRST(val), 3): firstOffset = 0+3 = 3
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(FIRST(v), 3) > 0
+);
+
+-- Compound PREV(LAST(val), 2): lookback = 0+2 = 2
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v), 2) > 0
+);
+
+-- Compound NEXT(LAST(val, 1), 3): lookback = max(1-3, 0) = 0
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(LAST(v, 1), 3) > 0
+);
+
+-- Compound PREV(LAST(val, N), M): constant near-overflow (N+M just fits int64)
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v, 4611686018427387903), 4611686018427387903) IS NOT NULL
+);
+
+-- Compound PREV(LAST(val, N), M): constant overflow -> retain all
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v, 4611686018427387904), 4611686018427387904) IS NOT NULL
+);
+
+-- Compound NEXT(FIRST(val, N), M): constant lookahead overflow -> no trim impact
+-- N + M overflows int64, but target is forward from match_start so it never
+-- constrains trim.  Lookahead remains at default (0).
+EXPLAIN (COSTS OFF) SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(FIRST(v, 4611686018427387904), 4611686018427387904) IS NOT NULL
+);
+
+-- Compound PREV(LAST(val, $1), $2): parameter lookback overflow -> retain all
+-- EXPLAIN shows "runtime" (plan-level); EXPLAIN ANALYZE shows "retain all"
+-- (executor-resolved).
+PREPARE test_overflow_lookback(int8, int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(LAST(v, $1), $2) IS NOT NULL
+);
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF) EXECUTE test_overflow_lookback(4611686018427387904, 4611686018427387904);
+EXPLAIN (COSTS OFF, ANALYZE, TIMING OFF, SUMMARY OFF)
+    EXECUTE test_overflow_lookback(4611686018427387904, 4611686018427387904);
+RESET plan_cache_mode;
+DEALLOCATE test_overflow_lookback;
+
+-- Compound NEXT(FIRST(val, $1), $2): parameter lookahead overflow -> no trim impact
+PREPARE test_overflow_lookahead(int8, int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS NEXT(FIRST(v, $1), $2) IS NOT NULL
+);
+SET plan_cache_mode = force_generic_plan;
+EXPLAIN (COSTS OFF, ANALYZE, TIMING OFF, SUMMARY OFF)
+    EXECUTE test_overflow_lookahead(4611686018427387904, 4611686018427387904);
+RESET plan_cache_mode;
+DEALLOCATE test_overflow_lookahead;
+
+-- PREV(v) + PREV(v, $1): NEEDS_EVAL path must account for implicit lookback=1
+-- Previously, eval_nav_max_offset_walker skipped PREV(v) when offset_arg was
+-- NULL, causing maxOffset=0 when $1=0, which would trim the row needed by
+-- PREV(v).  Verify this executes without "cannot fetch row before mark" error.
+PREPARE test_prev_implicit_offset(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v) IS NOT NULL AND PREV(v, $1) IS NOT NULL
+);
+EXECUTE test_prev_implicit_offset(0);
+DEALLOCATE test_prev_implicit_offset;
+
+-- Runtime error: negative offset at execution time
+PREPARE test_runtime_neg_offset(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v, $1) IS NOT NULL
+);
+EXECUTE test_runtime_neg_offset(-1);
+DEALLOCATE test_runtime_neg_offset;
+
+-- Runtime error: null offset at execution time
+PREPARE test_runtime_null_offset(int8) AS
+SELECT count(*) OVER w
+FROM generate_series(1,10) s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN (A+)
+    DEFINE A AS PREV(v, $1) IS NOT NULL
+);
+EXECUTE test_runtime_null_offset(NULL);
+DEALLOCATE test_runtime_null_offset;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 16de1421302..a3b1a855cdc 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -747,6 +747,8 @@ ErrorData
 ErrorSaveContext
 EstimateDSMForeignScan_function
 EstimationInfo
+EvalNavFirstContext
+EvalNavMaxContext
 EventTriggerCacheEntry
 EventTriggerCacheItem
 EventTriggerCacheStateType
@@ -1801,6 +1803,8 @@ NamedLWLockTrancheRequest
 NamedTuplestoreScan
 NamedTuplestoreScanState
 NamespaceInfo
+NavCheckResult
+NavOffsetContext
 NestLoop
 NestLoopParam
 NestLoopState
@@ -2479,6 +2483,7 @@ QueuePosition
 QuitSignalReason
 RPRNavExpr
 RPRNavKind
+RPRNavOffsetKind
 RBTNode
 RBTOrderControl
 RBTree
-- 
2.50.1 (Apple Git-155)


From c333424313fb8d94ff0aefffb189890db776fefe Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 13:31:44 +0900
Subject: [PATCH] Guard against int64 overflow in RPR bounded frame end
 computation

---
 src/backend/executor/execRPR.c | 16 ++++++++++++----
 1 file changed, 12 insertions(+), 4 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 01df2a11e0a..94f1b2941a2 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -22,6 +22,7 @@
  */
 #include "postgres.h"
 
+#include "common/int.h"
 #include "executor/execRPR.h"
 #include "executor/executor.h"
 #include "miscadmin.h"
@@ -1046,10 +1047,11 @@
  *
  *   When the frame is bounded (e.g., ROWS BETWEEN CURRENT ROW AND 5
  *   FOLLOWING), ExecRPRProcessRow receives hasLimitedFrame=true and
- *   frameOffset indicating the upper bound.  After the advance phase,
+ *   frameOffset indicating the upper bound.  Before the match phase,
  *   any context whose match has exceeded the frame boundary
- *   (currentPos - matchStartRow >= frameOffset + 1) is finalized early.
- *   This prevents matches from extending beyond the window frame.
+ *   (currentPos >= matchStartRow + frameOffset + 1) is finalized early
+ *   by forcing a mismatch.  This prevents matches from extending beyond
+ *   the window frame.  The sum is clamped to PG_INT64_MAX on overflow.
  *
  *   Note that bounded frames also disable context absorption at the
  *   planner level (see VIII-3(b)), since the frame boundary breaks the
@@ -3154,7 +3156,12 @@ ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
 		/* Check frame boundary - finalize if exceeded */
 		if (hasLimitedFrame)
 		{
-			int64		ctxFrameEnd = ctx->matchStartRow + frameOffset + 1;
+			int64		ctxFrameEnd;
+
+			/* Clamp to INT64_MAX on overflow */
+			if (pg_add_s64_overflow(ctx->matchStartRow, frameOffset + 1,
+									&ctxFrameEnd))
+				ctxFrameEnd = PG_INT64_MAX;
 
 			if (currentPos >= ctxFrameEnd)
 			{
@@ -3204,6 +3211,7 @@ ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
 		 * context here must be within its frame boundary.
 		 */
 		Assert(!hasLimitedFrame ||
+			   ctx->matchStartRow > PG_INT64_MAX - frameOffset - 1 ||
 			   currentPos < ctx->matchStartRow + frameOffset + 1);
 
 		nfa_advance(winstate, ctx, currentPos);
-- 
2.50.1 (Apple Git-155)


From f3d7cb40298ee33fa37b5bd75c17ad2cb7e20ffe Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 11:15:12 +0900
Subject: [PATCH] Fix RPR error message style: hint format, terminology,
 capitalization

Remove colon in errhint "Use: ROWS instead" -> "Use ROWS instead."
and add missing trailing period.  Shorten "row pattern definition
variable name" to "DEFINE variable" for consistency with other
error messages.  Capitalize navigation function names in stub
error messages (prev -> PREV, etc.) to match SQL standard keyword
style used elsewhere in the parser.
---
 src/backend/parser/parse_rpr.c         |  6 +++---
 src/backend/utils/adt/windowfuncs.c    | 16 ++++++++--------
 src/test/regress/expected/rpr_base.out | 12 ++++++------
 3 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 05070cb04bb..8fbe12e1518 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -78,7 +78,7 @@ transformRPR(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 		ereport(ERROR,
 				(errcode(ERRCODE_WINDOWING_ERROR),
 				 errmsg("FRAME option GROUPS is not permitted when row pattern recognition is used"),
-				 errhint("Use: ROWS instead"),
+				 errhint("Use ROWS instead."),
 				 parser_errposition(pstate,
 									windef->frameLocation >= 0 ?
 									windef->frameLocation : windef->location)));
@@ -86,7 +86,7 @@ transformRPR(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 		ereport(ERROR,
 				(errcode(ERRCODE_WINDOWING_ERROR),
 				 errmsg("FRAME option RANGE is not permitted when row pattern recognition is used"),
-				 errhint("Use: ROWS instead"),
+				 errhint("Use ROWS instead."),
 				 parser_errposition(pstate,
 									windef->frameLocation >= 0 ?
 									windef->frameLocation : windef->location)));
@@ -329,7 +329,7 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 			if (!strcmp(n, name))
 				ereport(ERROR,
 						(errcode(ERRCODE_SYNTAX_ERROR),
-						 errmsg("row pattern definition variable name \"%s\" appears more than once in DEFINE clause",
+						 errmsg("DEFINE variable \"%s\" appears more than once",
 								name),
 						 parser_errposition(pstate, exprLocation((Node *) r))));
 		}
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index 420a4962395..fb966cae43c 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -740,7 +740,7 @@ window_prev(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("prev() can only be used in a DEFINE clause")));
+			 errmsg("PREV() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -754,7 +754,7 @@ window_next(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("next() can only be used in a DEFINE clause")));
+			 errmsg("NEXT() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -768,7 +768,7 @@ window_prev_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("prev() can only be used in a DEFINE clause")));
+			 errmsg("PREV() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -782,7 +782,7 @@ window_next_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("next() can only be used in a DEFINE clause")));
+			 errmsg("NEXT() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -796,7 +796,7 @@ window_first(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("first() can only be used in a DEFINE clause")));
+			 errmsg("FIRST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -810,7 +810,7 @@ window_last(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("last() can only be used in a DEFINE clause")));
+			 errmsg("LAST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -824,7 +824,7 @@ window_first_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("first() can only be used in a DEFINE clause")));
+			 errmsg("FIRST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -838,6 +838,6 @@ window_last_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("last() can only be used in a DEFINE clause")));
+			 errmsg("LAST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 0845316965e..912bd7b7c77 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -232,7 +232,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS id > 0, A AS id < 10
 );
-ERROR:  row pattern definition variable name "a" appears more than once in DEFINE clause
+ERROR:  DEFINE variable "a" appears more than once
 LINE 7:     DEFINE A AS id > 0, A AS id < 10
                    ^
 -- Expected: ERROR: row pattern definition variable name "a" appears more than once in DEFINE clause
@@ -469,7 +469,7 @@ WINDOW w AS (
 ERROR:  FRAME option RANGE is not permitted when row pattern recognition is used
 LINE 5:     RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWIN...
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option RANGE is not permitted when row pattern recognition is used
 -- GROUPS frame not starting at CURRENT ROW
 SELECT COUNT(*) OVER w
@@ -483,7 +483,7 @@ WINDOW w AS (
 ERROR:  FRAME option GROUPS is not permitted when row pattern recognition is used
 LINE 5:     GROUPS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWI...
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option GROUPS is not permitted when row pattern recognition is used
 -- Starting with N PRECEDING
 SELECT COUNT(*) OVER w
@@ -640,7 +640,7 @@ ORDER BY id;
 ERROR:  FRAME option RANGE is not permitted when row pattern recognition is used
 LINE 5:     RANGE BETWEEN CURRENT ROW AND 10 FOLLOWING
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option RANGE is not permitted when row pattern recognition is used
 -- GROUPS frame with RPR (not permitted)
 SELECT id, val, COUNT(*) OVER w as cnt
@@ -656,7 +656,7 @@ ORDER BY id;
 ERROR:  FRAME option GROUPS is not permitted when row pattern recognition is used
 LINE 5:     GROUPS BETWEEN CURRENT ROW AND 1 FOLLOWING
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option GROUPS is not permitted when row pattern recognition is used
 DROP TABLE rpr_frame;
 -- ============================================================
@@ -705,7 +705,7 @@ ORDER BY id;
 ERROR:  FRAME option RANGE is not permitted when row pattern recognition is used
 LINE 6:     RANGE BETWEEN CURRENT ROW AND 10 FOLLOWING
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option RANGE is not permitted when row pattern recognition is used
 DROP TABLE rpr_partition;
 -- ============================================================
-- 
2.50.1 (Apple Git-155)


From 809e8b5c9f79e40ee114bef963e2a902800608ed Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 10:09:41 +0900
Subject: [PATCH] Fix comment typos, grammar, and inaccuracies in RPR code

---
 src/backend/executor/execRPR.c          |  7 +++----
 src/backend/executor/nodeWindowAgg.c    | 20 ++++++++++----------
 src/backend/optimizer/plan/createplan.c |  3 ++-
 3 files changed, 15 insertions(+), 15 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 94f1b2941a2..baee45ce54e 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -416,7 +416,6 @@
  * the normal loop-back (which cycle detection will eventually kill) and
  * a fast-forward exit clone that bypasses the loop entirely.
  * (See IX-4(c) for detailed runtime behavior.)
- *     - Empty match is impossible since body is not nullable
  *
  * IV-5. Absorbability Analysis (RPR_ELEM_ABSORBABLE)
  *
@@ -645,8 +644,8 @@
  * When processing a context whose matchStartRow differs from the shared
  * value, nfa_reevaluate_dependent_vars() temporarily sets nav_match_start
  * to that context's matchStartRow and re-evaluates only the dependent
- * variables.  No restore is needed because contexts are ordered by
- * matchStartRow (ascending), so no later context shares the head's value.
+ * variables.  The original nav_match_start and currentpos are saved and
+ * restored after re-evaluation.
  *
  * VI-5. Tuplestore Mark and Trim (nodeWindowAgg.c)
  *
@@ -2715,7 +2714,7 @@ nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
 		bool		reluctant = RPRElemIsReluctant(elem);
 
 		/*
-		 * Clone state for the second-priority path. For greedy, clone is the
+		 * Clone state for the first-priority path. For greedy, clone is the
 		 * loop state; for reluctant, clone is the exit state.
 		 */
 		if (reluctant)
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index cdbe356abd7..849ebf8abb0 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -3705,8 +3705,8 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 	{
 		/*
 		 * Early check if row could be out of reduced frame.  When RPR is
-		 * enabled, EXCUDE clause cannot be specified and the frame is always
-		 * contiguous.  So we can do the check followings safely. Note,
+		 * 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.
@@ -4168,7 +4168,7 @@ eval_nav_first_offset(WindowAggState *winstate, List *defineClause)
 
 /*
  * rpr_is_defined
- * return true if Row pattern recognition is defined.
+ * Return true if row pattern recognition is defined.
  */
 static bool
 rpr_is_defined(WindowAggState *winstate)
@@ -4182,14 +4182,14 @@ rpr_is_defined(WindowAggState *winstate)
  * Determine whether a row is in the current row's reduced window frame
  * according to row pattern matching
  *
- * The row must has been already determined that it is in a full window frame
- * and fetched it into slot.
+ * 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 unmatched row
+ * -1, if the row is an unmatched row
  * -2, if the row is in the reduced frame but needed to be skipped because of
  * AFTER MATCH SKIP PAST LAST ROW
  * -----------------
@@ -4204,8 +4204,8 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 	if (!rpr_is_defined(winstate))
 	{
 		/*
-		 * RPR is not defined. Assume that we are always in the the reduced
-		 * window frame.
+		 * RPR is not defined. Assume that we are always in the reduced window
+		 * frame.
 		 */
 		rtn = 0;
 		return rtn;
@@ -4938,8 +4938,8 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
  * 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. false if out of frame.
- * (also isout is set)
+ * 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,
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 02d511269ab..50668f3b7ab 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -2475,7 +2475,8 @@ typedef struct NavOffsetContext
 	int64		maxOffset;		/* max PREV/LAST backward offset (>= 0) */
 	bool		maxNeedsEval;	/* non-constant PREV/LAST offset found */
 	bool		maxOverflow;	/* constant offset overflow detected */
-	int64		firstOffset;	/* min FIRST offset (>= 0), or -1 if none */
+	int64		firstOffset;	/* min FIRST offset (may be negative for
+								 * PREV_FIRST) */
 	bool		hasFirst;		/* any FIRST node found */
 	bool		firstNeedsEval; /* non-constant FIRST offset found */
 } NavOffsetContext;
-- 
2.50.1 (Apple Git-155)


From 9a81f1981cd930fa1e25288a07f350fbace4c9a5 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 10:50:36 +0900
Subject: [PATCH] Fix RPR documentation: synopsis, grammar, and terminology

Remove erroneous comma in PATTERN synopsis.  Fix typos in
advanced.sgml (">=;" stray semicolon, "with the a row",
"For example following").  Correct PREV/NEXT description
from "within the window frame" to "within the partition"
and add missing "DEFINE clause only" note.  Capitalize
"Row Pattern Recognition" consistently across SGML files.

Fix numerous missing articles and grammar errors in
select.sgml: "after a match found" -> "after a match is
found", "do not necessarily" -> "does not necessarily",
add missing "the" before clause references.
---
 doc/src/sgml/advanced.sgml         | 14 +++++++-------
 doc/src/sgml/func/func-window.sgml | 14 ++++++++------
 doc/src/sgml/ref/select.sgml       | 28 ++++++++++++++--------------
 3 files changed, 29 insertions(+), 27 deletions(-)

diff --git a/doc/src/sgml/advanced.sgml b/doc/src/sgml/advanced.sgml
index 0caf9fdaff6..11c2416df51 100644
--- a/doc/src/sgml/advanced.sgml
+++ b/doc/src/sgml/advanced.sgml
@@ -553,8 +553,8 @@ WHERE pos &lt; 3;
    </para>
 
    <para>
-    Row pattern common syntax can be used to perform row pattern recognition
-    in a query. The row pattern common syntax includes two sub
+    Row Pattern Common Syntax can be used to perform Row Pattern Recognition
+    in a query. The Row Pattern Common Syntax includes two sub
     clauses: <literal>DEFINE</literal>
     and <literal>PATTERN</literal>. <literal>DEFINE</literal> defines
     row pattern variables along with an expression. The expression must be a
@@ -584,12 +584,12 @@ DEFINE
     Once <literal>DEFINE</literal> exists, <literal>PATTERN</literal> can be
     used. <literal>PATTERN</literal> defines a sequence of rows that satisfies
     conditions defined in the <literal>DEFINE</literal> clause.  For example
-    following <literal>PATTERN</literal> defines a sequence of rows starting
-    with the a row satisfying "LOWPRICE", then one or more rows satisfying
+    the following <literal>PATTERN</literal> defines a sequence of rows starting
+    with a row satisfying "LOWPRICE", then one or more rows satisfying
     "UP" and finally one or more rows satisfying "DOWN". Pattern variables can
     be followed by quantifiers: "+" means one or more matches, "*" means zero
     or more matches, "?" means zero or one match, "{n}" (n &gt; 0) means exactly
-    n matches, "{n,}" (n &gt;=; 0) means at least n matches, "{,m}" (m &gt; 0) means
+    n matches, "{n,}" (n &gt;= 0) means at least n matches, "{,m}" (m &gt; 0) means
     at most m matches, and "{n,m}" (0 &lt;= n &lt;= m, 0 &lt; m) means between n and m
     matches.  Patterns can be grouped using parentheses and combined using
     alternation (the vertical bar "|" for OR). For example, "(UP DOWN)+"
@@ -642,7 +642,7 @@ FROM stock
    </para>
 
    <para>
-    Row pattern recognition internally uses a nondeterministic finite
+    Row Pattern Recognition internally uses a nondeterministic finite
     automaton (NFA) to match patterns. For patterns with unbounded
     quantifiers (e.g., <literal>A+</literal> or <literal>(A B)+</literal>),
     the NFA may need to track many active matching contexts simultaneously,
@@ -676,7 +676,7 @@ FROM stock
    </para>
 
    <para>
-    When examining query plans for row pattern recognition with
+    When examining query plans for Row Pattern Recognition with
     <command>EXPLAIN</command>, the pattern output may include special
     markers that indicate optimization opportunities. A double quote
     <literal>"</literal> marks where pattern absorption can occur,
diff --git a/doc/src/sgml/func/func-window.sgml b/doc/src/sgml/func/func-window.sgml
index ab80690f7be..d109a2d22bc 100644
--- a/doc/src/sgml/func/func-window.sgml
+++ b/doc/src/sgml/func/func-window.sgml
@@ -279,9 +279,9 @@
   </para>
 
   <para>
-   Row pattern recognition navigation functions are listed in
+   Row Pattern Recognition navigation functions are listed in
    <xref linkend="functions-rpr-navigation-table"/>.  These functions
-   can be used to describe DEFINE clause of Row pattern recognition.
+   can be used to describe the DEFINE clause of Row Pattern Recognition.
   </para>
 
    <table id="functions-rpr-navigation-table">
@@ -309,12 +309,13 @@
        </para>
        <para>
         Returns the column value at the row <parameter>offset</parameter>
-        rows before the current row within the window frame;
-        returns NULL if the target row is outside the window frame.
+        rows before the current row within the partition;
+        returns NULL if the target row is outside the partition.
         <parameter>offset</parameter> defaults to 1 if omitted.
         <parameter>offset</parameter> must be a non-negative integer;
         an offset of 0 refers to the current row itself.
         <parameter>offset</parameter> must not be NULL.
+        Can only be used in a <literal>DEFINE</literal> clause.
        </para></entry>
       </row>
 
@@ -328,12 +329,13 @@
        </para>
        <para>
         Returns the column value at the row <parameter>offset</parameter>
-        rows after the current row within the window frame;
-        returns NULL if the target row is outside the window frame.
+        rows after the current row within the partition;
+        returns NULL if the target row is outside the partition.
         <parameter>offset</parameter> defaults to 1 if omitted.
         <parameter>offset</parameter> must be a non-negative integer;
         an offset of 0 refers to the current row itself.
         <parameter>offset</parameter> must not be NULL.
+        Can only be used in a <literal>DEFINE</literal> clause.
        </para></entry>
       </row>
 
diff --git a/doc/src/sgml/ref/select.sgml b/doc/src/sgml/ref/select.sgml
index 5e4ba9d3cc6..5272d6c0bfa 100644
--- a/doc/src/sgml/ref/select.sgml
+++ b/doc/src/sgml/ref/select.sgml
@@ -1133,34 +1133,34 @@ EXCLUDE NO OTHERS
    <para>
     The
     optional <replaceable class="parameter">row_pattern_common_syntax</replaceable>
-    defines the <firstterm>row pattern recognition condition</firstterm> for
+    defines the <firstterm>Row Pattern Recognition condition</firstterm> for
     this
     window. <replaceable class="parameter">row_pattern_common_syntax</replaceable>
-    includes following subclauses.
+    includes the following subclauses.
 
 <synopsis>
 [ { AFTER MATCH SKIP PAST LAST ROW | AFTER MATCH SKIP TO NEXT ROW } ]
 [ INITIAL | SEEK ]
-PATTERN ( <replaceable class="parameter">pattern_variable_name</replaceable> [ <replaceable>quantifier</replaceable> ] [, ...] )
+PATTERN ( <replaceable class="parameter">pattern_variable_name</replaceable> [ <replaceable>quantifier</replaceable> ] [ ... ] )
 DEFINE <replaceable class="parameter">definition_variable_name</replaceable> AS <replaceable class="parameter">expression</replaceable> [, ...]
 </synopsis>
     <literal>AFTER MATCH SKIP PAST LAST ROW</literal> or <literal>AFTER MATCH
-    SKIP TO NEXT ROW</literal> controls how to proceed to next row position
-    after a match found. With <literal>AFTER MATCH SKIP PAST LAST
-    ROW</literal> (the default) next row position is next to the last row of
-    previous match. On the other hand, with <literal>AFTER MATCH SKIP TO NEXT
-    ROW</literal> next row position is next to the first row of previous
-    match. <literal>INITIAL</literal> or <literal>SEEK</literal> defines how a
-    successful pattern matching starts from which row in a
-    frame. If <literal>INITIAL</literal> is specified, the match must start
+    SKIP TO NEXT ROW</literal> controls how to proceed to the next row position
+    after a match is found. With <literal>AFTER MATCH SKIP PAST LAST
+    ROW</literal> (the default) the next row position is next to the last row of
+    the previous match. On the other hand, with <literal>AFTER MATCH SKIP TO NEXT
+    ROW</literal> the next row position is next to the first row of the previous
+    match. <literal>INITIAL</literal> or <literal>SEEK</literal> specifies from
+    which row in the frame pattern matching begins.
+    If <literal>INITIAL</literal> is specified, the match must start
     from the first row in the frame. If <literal>SEEK</literal> is specified,
-    the set of matching rows do not necessarily start from the first row. The
+    the set of matching rows does not necessarily start from the first row. The
     default is <literal>INITIAL</literal>. Currently
     only <literal>INITIAL</literal> is supported. <literal>DEFINE</literal>
     defines definition variables along with a boolean
     expression. <literal>PATTERN</literal> defines a sequence of rows that
     satisfies certain conditions using variables defined
-    in <literal>DEFINE</literal> clause (an empty <literal>PATTERN()</literal>
+    in the <literal>DEFINE</literal> clause (an empty <literal>PATTERN()</literal>
     is not supported). Each pattern variable can be followed by a quantifier
     to specify how many times it should match:
     <literal>*</literal> (zero or more),
@@ -1198,7 +1198,7 @@ DEFINE <replaceable class="parameter">definition_variable_name</replaceable> AS
 
    <para>
     Note that the maximum number of unique pattern variables
-    used in <literal>PATTERN</literal> clause is 251.
+    used in the <literal>PATTERN</literal> clause is 251.
     If this limit is exceeded, an error will be raised.
     Additionally, the maximum nesting depth of pattern groups
     (parentheses) is 253 levels.
-- 
2.50.1 (Apple Git-155)


From c33aca418319816bde883d1ad6b07f7effbdddea Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 13:59:29 +0900
Subject: [PATCH] Fix nav_slot pass-by-ref dangling pointer in RPR navigation

When a DEFINE expression contains multiple navigation calls targeting
different positions (e.g., PREV(x,1) > PREV(x,2)), the second call
re-fetches nav_slot, freeing the previous tuple via pfree.  Any
pass-by-ref datum extracted from the first navigation becomes a
dangling pointer.  Fix by copying pass-by-ref results into per-tuple
memory in the RESTORE step.
---
 src/backend/executor/execExpr.c       |  5 ++
 src/backend/executor/execExprInterp.c | 20 +++++++
 src/include/executor/execExpr.h       |  2 +
 src/test/regress/expected/rpr.out     | 80 +++++++++++++++++++++++++++
 src/test/regress/sql/rpr.sql          | 34 ++++++++++++
 5 files changed, 141 insertions(+)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 6349a564a98..8d8da67e79f 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -1304,7 +1304,12 @@ ExecInitExprRec(Expr *node, ExprState *state,
 
 				/* Emit RESTORE opcode: restore original slot */
 				scratch.opcode = EEOP_RPR_NAV_RESTORE;
+				scratch.resvalue = resv;
+				scratch.resnull = resnull;
 				scratch.d.rpr_nav.winstate = winstate;
+				get_typlenbyval(nav->resulttype,
+								&scratch.d.rpr_nav.resulttyplen,
+								&scratch.d.rpr_nav.resulttypbyval);
 				ExprEvalPushStep(state, &scratch);
 				break;
 			}
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 2ec579732cc..e2d41c3098f 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -6156,6 +6156,13 @@ ExecEvalRPRNavSet(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
  * When slot swap was elided (target == currentpos), this is a harmless
  * no-op since saved and current slots are identical.
  * 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,
@@ -6164,4 +6171,17 @@ ExecEvalRPRNavRestore(ExprState *state, ExprEvalStep *op,
 	WindowAggState *winstate = op->d.rpr_nav.winstate;
 
 	econtext->ecxt_outertuple = winstate->nav_saved_outertuple;
+
+	/* Stabilize pass-by-ref result against nav_slot re-fetch */
+	if (!op->d.rpr_nav.resulttypbyval &&
+		!*op->resnull)
+	{
+		MemoryContext oldContext;
+
+		oldContext = MemoryContextSwitchTo(econtext->ecxt_per_tuple_memory);
+		*op->resvalue = datumCopy(*op->resvalue,
+								  false,
+								  op->d.rpr_nav.resulttyplen);
+		MemoryContextSwitchTo(oldContext);
+	}
 }
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index 834800a4062..e6b2ab30406 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -703,6 +703,8 @@ typedef struct ExprEvalStep
 			Datum	   *offset_value;	/* offset value(s), or NULL */
 			bool	   *offset_isnull;	/* offset null flag(s) */
 			/* For compound nav: offset_value[0] = inner, [1] = outer */
+			int16		resulttyplen;	/* RESTORE: result type length */
+			bool		resulttypbyval; /* RESTORE: result pass-by-value? */
 		}			rpr_nav;
 
 		/* for EEOP_AGG_*DESERIALIZE */
diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index 04ec25d4cf5..32aa8bc3722 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -1635,6 +1635,86 @@ WINDOW w AS (
  company2 | 07-10-2023 |  1300 |             |            |     0
 (20 rows)
 
+-- Pass-by-ref types: two PREV calls targeting different positions.
+-- Verifies that datumCopy in RESTORE prevents dangling pointers when
+-- nav_slot is re-fetched for the second navigation.
+-- tdate::text gives distinct text values per row (e.g. '07-01-2023').
+-- B matches when 1-back date text > 2-back date text (always true for
+-- ascending dates), so B+ extends the full partition after A.
+SELECT company, tdate, tdate::text AS tdate_text,
+       first_value(tdate::text) OVER w, last_value(tdate::text) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(tdate::text, 1) > PREV(tdate::text, 2)
+);
+ company  |   tdate    | tdate_text | first_value | last_value | count 
+----------+------------+------------+-------------+------------+-------
+ company1 | 07-01-2023 | 07-01-2023 |             |            |     0
+ company1 | 07-02-2023 | 07-02-2023 | 07-02-2023  | 07-10-2023 |     9
+ company1 | 07-03-2023 | 07-03-2023 |             |            |     0
+ company1 | 07-04-2023 | 07-04-2023 |             |            |     0
+ company1 | 07-05-2023 | 07-05-2023 |             |            |     0
+ company1 | 07-06-2023 | 07-06-2023 |             |            |     0
+ company1 | 07-07-2023 | 07-07-2023 |             |            |     0
+ company1 | 07-08-2023 | 07-08-2023 |             |            |     0
+ company1 | 07-09-2023 | 07-09-2023 |             |            |     0
+ company1 | 07-10-2023 | 07-10-2023 |             |            |     0
+ company2 | 07-01-2023 | 07-01-2023 |             |            |     0
+ company2 | 07-02-2023 | 07-02-2023 | 07-02-2023  | 07-10-2023 |     9
+ company2 | 07-03-2023 | 07-03-2023 |             |            |     0
+ company2 | 07-04-2023 | 07-04-2023 |             |            |     0
+ company2 | 07-05-2023 | 07-05-2023 |             |            |     0
+ company2 | 07-06-2023 | 07-06-2023 |             |            |     0
+ company2 | 07-07-2023 | 07-07-2023 |             |            |     0
+ company2 | 07-08-2023 | 07-08-2023 |             |            |     0
+ company2 | 07-09-2023 | 07-09-2023 |             |            |     0
+ company2 | 07-10-2023 | 07-10-2023 |             |            |     0
+(20 rows)
+
+-- numeric: PREV(price::numeric, 1) > PREV(price::numeric, 2)
+-- B matches when price 1-back > price 2-back (ascending pair).
+SELECT company, tdate, price::numeric AS nprice,
+       first_value(price::numeric) OVER w, last_value(price::numeric) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(price::numeric, 1) > PREV(price::numeric, 2)
+);
+ company  |   tdate    | nprice | first_value | last_value | count 
+----------+------------+--------+-------------+------------+-------
+ company1 | 07-01-2023 |    100 |             |            |     0
+ company1 | 07-02-2023 |    200 |         200 |        150 |     2
+ company1 | 07-03-2023 |    150 |             |            |     0
+ company1 | 07-04-2023 |    140 |             |            |     0
+ company1 | 07-05-2023 |    150 |         150 |         90 |     2
+ company1 | 07-06-2023 |     90 |             |            |     0
+ company1 | 07-07-2023 |    110 |         110 |        120 |     3
+ company1 | 07-08-2023 |    130 |             |            |     0
+ company1 | 07-09-2023 |    120 |             |            |     0
+ company1 | 07-10-2023 |    130 |             |            |     0
+ company2 | 07-01-2023 |     50 |             |            |     0
+ company2 | 07-02-2023 |   2000 |        2000 |       1500 |     2
+ company2 | 07-03-2023 |   1500 |             |            |     0
+ company2 | 07-04-2023 |   1400 |             |            |     0
+ company2 | 07-05-2023 |   1500 |        1500 |         60 |     2
+ company2 | 07-06-2023 |     60 |             |            |     0
+ company2 | 07-07-2023 |   1100 |        1100 |       1200 |     3
+ company2 | 07-08-2023 |   1300 |             |            |     0
+ company2 | 07-09-2023 |   1200 |             |            |     0
+ company2 | 07-10-2023 |   1300 |             |            |     0
+(20 rows)
+
 --
 -- FIRST/LAST navigation
 --
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index a05b429ce74..724d460b2da 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -776,6 +776,40 @@ WINDOW w AS (
     DEFINE A AS price > PREV(price, 1) AND price < NEXT(price, 1)
 );
 
+-- Pass-by-ref types: two PREV calls targeting different positions.
+-- Verifies that datumCopy in RESTORE prevents dangling pointers when
+-- nav_slot is re-fetched for the second navigation.
+-- tdate::text gives distinct text values per row (e.g. '07-01-2023').
+-- B matches when 1-back date text > 2-back date text (always true for
+-- ascending dates), so B+ extends the full partition after A.
+SELECT company, tdate, tdate::text AS tdate_text,
+       first_value(tdate::text) OVER w, last_value(tdate::text) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(tdate::text, 1) > PREV(tdate::text, 2)
+);
+
+-- numeric: PREV(price::numeric, 1) > PREV(price::numeric, 2)
+-- B matches when price 1-back > price 2-back (ascending pair).
+SELECT company, tdate, price::numeric AS nprice,
+       first_value(price::numeric) OVER w, last_value(price::numeric) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(price::numeric, 1) > PREV(price::numeric, 2)
+);
+
 --
 -- FIRST/LAST navigation
 --
-- 
2.50.1 (Apple Git-155)


From 76b60fb538906346144be7430b858ddd471ec3ab Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 20:49:27 +0900
Subject: [PATCH] Add inline comments for complex RPR algorithms and design
 notes

Document END chain traversal in nfa_match(), fast-forward paths
in nfa_advance_end(), absorption safety rules with navigation
lookup table, per-context evaluation strategy table, fixed-length
group unrolling rationale, and BEGIN/END pointer layout diagram.
---
 src/backend/executor/execRPR.c   | 97 ++++++++++++++++++++++++++------
 src/backend/optimizer/plan/rpr.c | 41 ++++++++++++--
 2 files changed, 118 insertions(+), 20 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index baee45ce54e..7ba7b6fb672 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -440,7 +440,14 @@
  *   Case 2: GROUP+ with fixed-length children (min == max, recursively)
  *           e.g., (A B)+, (A B{2})+, ((A (B C){2}){2})+
  *           -> ABSORBABLE_BRANCH on all elements within the group,
- *             ABSORBABLE | ABSORBABLE_BRANCH on END
+ *              ABSORBABLE | ABSORBABLE_BRANCH on END
+ *
+ *           Why this is safe: when every child has min == max, the group
+ *           is semantically equivalent to unrolling its body into {1,1}
+ *           elements.  E.g., (A B{2})+ behaves like (A B B)+.  Each
+ *           iteration consumes a fixed number of rows, so an earlier
+ *           context's count always dominates a later one's (monotonicity).
+ *
  *   Case 3: GROUP+ whose body starts with VAR+ (e.g., (A+ B)+)
  *           -> Recurses from BEGIN into the body, applying Case 1.
  *             ABSORBABLE | ABSORBABLE_BRANCH set on A.
@@ -647,6 +654,19 @@
  * variables.  The original nav_match_start and currentpos are saved and
  * restored after re-evaluation.
  *
+ * Summary of evaluation strategy by navigation content:
+ *
+ *   Navigation content               evaluation
+ *   -------------------------------------------------------
+ *   No navigation                    shared (once per row)
+ *   PREV/NEXT only                   shared (once per row)
+ *   LAST (no offset)                 shared (once per row)
+ *   LAST (with offset)               per-context
+ *   FIRST (any)                      per-context
+ *   Compound (inner FIRST)           per-context
+ *   Compound (inner LAST, no off.)   shared (once per row)
+ *   Compound (inner LAST, w/off.)    per-context
+ *
  * VI-5. Tuplestore Mark and Trim (nodeWindowAgg.c)
  *
  * Navigation functions require access to past rows via the tuplestore.
@@ -762,11 +782,26 @@
  *   (b) Unbounded frame (ROWS BETWEEN CURRENT ROW AND UNBOUNDED
  *       FOLLOWING).  Limited frames apply differently to each context,
  *       breaking the monotonicity principle.
- *   (c) No match_start-dependent navigation in DEFINE.  FIRST,
- *       LAST-with-offset, and compound navigation referencing match_start
- *       (PREV_FIRST, NEXT_FIRST, PREV_LAST/NEXT_LAST with offset)
- *       cause different contexts to evaluate to different values for the
- *       same row, breaking monotonicity.
+ *   (c) No match_start-dependent navigation in DEFINE.
+ *
+ *       Mechanism: each context has a different matchStartRow, so FIRST
+ *       resolves to a different row for each context at the same
+ *       currentpos.  An earlier context's DEFINE result no longer
+ *       subsumes a later one's, making count-dominance comparison
+ *       invalid.  Rather than comparing matchStartRow at runtime
+ *       (which would complicate the absorb path), any match_start
+ *       dependency disables absorption entirely.
+ *
+ *       Navigation content              match_start dep.  absorption
+ *       ------------------------------------------------------------
+ *       No navigation                   none              safe
+ *       PREV/NEXT only                  none              safe
+ *       LAST (no offset)                none              safe
+ *       LAST (with offset)              boundary check    unsafe
+ *       FIRST (any)                     direct            unsafe
+ *       Compound (inner FIRST)          direct            unsafe
+ *       Compound (inner LAST, no off.)  none              safe
+ *       Compound (inner LAST, w/off.)   boundary chk      unsafe
  *
  * Runtime conditions (evaluated per context pair):
  *
@@ -2260,7 +2295,13 @@ nfa_absorb_contexts(WindowAggState *winstate)
  * nfa_eval_var_match
  *
  * Evaluate if a VAR element matches the current row.
- * Undefined variables (varId >= defineVariableList length) default to TRUE.
+ *
+ * varMatched is a pre-evaluated boolean array indexed by varId, computed
+ * once per row by evaluating all DEFINE expressions.  NULL means no DEFINE
+ * clauses exist (only possible during early development/testing).
+ *
+ * Per SQL:2016 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,
@@ -2337,9 +2378,20 @@ nfa_match(WindowAggState *winstate, RPRNFAContext *ctx, bool *varMatched)
 
 				/*
 				 * For VAR at max count with END next, advance through END
-				 * chain to reach the absorption judgment point. Only
+				 * chain to reach the absorption judgment point.  Only
 				 * deterministic exits (count >= max, max finite) are handled;
 				 * unbounded VARs stay for advance phase.
+				 *
+				 * In nested patterns like ((A B){2}){3}, 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 judgment 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) &&
@@ -2561,12 +2613,25 @@ nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
 		RPRPatternElement *jumpElem;
 		RPRNFAState *ffState = NULL;
 
-		/* Snapshot state for ff path before modifying for loop-back */
+		/*
+		 * Two paths are explored in parallel when the group body is
+		 * nullable (RPR_ELEM_EMPTY_LOOP):
+		 *
+		 * 1. Primary path: loop back and 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, handled after the primary path).
+		 *
+		 * The snapshot must be taken BEFORE modifying state for the
+		 * loop-back, since both paths diverge from the same point.
+		 */
 		if (RPRElemCanEmptyLoop(elem))
 			ffState = nfa_state_create(winstate, state->elemIdx,
 									   state->counts, state->isAbsorbable);
 
-		/* Loop back for real matches (primary path) */
+		/* Primary path: loop back for real matches */
 		for (int d = depth + 1; d < pattern->maxDepth; d++)
 			state->counts[d] = 0;
 		state->elemIdx = elem->jump;
@@ -2575,12 +2640,12 @@ nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
 						  currentPos);
 
 		/*
-		 * Fast-forward fallback for nullable bodies.  E.g. (A?){2,3} when A
-		 * doesn't match: the loop-back produces empty iterations that cycle
-		 * detection would kill.  Instead, exit directly treating all
-		 * remaining required iterations as empty.  Route to elem->next (not
-		 * nfa_advance_end) to avoid creating competing greedy/reluctant loop
-		 * states.
+		 * Fast-forward path for nullable bodies.  E.g. (A?){2,3} when
+		 * A doesn't match: the primary loop-back produces empty
+		 * iterations that cycle detection would kill.  Instead, exit
+		 * directly with count satisfied.  Route to elem->next (not
+		 * nfa_advance_end) to avoid creating competing greedy/reluctant
+		 * loop states.
 		 */
 		if (ffState != NULL)
 		{
diff --git a/src/backend/optimizer/plan/rpr.c b/src/backend/optimizer/plan/rpr.c
index 767a214016c..754fcd53099 100644
--- a/src/backend/optimizer/plan/rpr.c
+++ b/src/backend/optimizer/plan/rpr.c
@@ -478,6 +478,19 @@ mergeConsecutiveAlts(List *children)
  * mergeGroupPrefixSuffix
  *		Merge sequence prefix/suffix into GROUP with matching children.
  *
+ * When a GROUP's children appear as a prefix before and/or suffix after
+ * the GROUP in a SEQ, absorb them by incrementing the GROUP's quantifier.
+ * This runs iteratively: A B A B (A B)+ A B -> (A B){5,}.
+ *
+ * Algorithm:
+ *   For each GROUP encountered in the sequence:
+ *   1. PREFIX phase: compare the last N elements already in the result
+ *      list against the GROUP's children.  On match, remove them from
+ *      result and increment the GROUP's min/max.  Repeat until no match.
+ *   2. SUFFIX phase: compare the next N elements in the input against
+ *      the GROUP's children.  On match, skip them (via skipUntil) and
+ *      increment min/max.  Repeat until no match.
+ *
  * Examples:
  *   A B (A B)+ -> (A B){2,}
  *   (A B)+ A B -> (A B){2,}
@@ -813,8 +826,16 @@ tryMultiplyQuantifiers(RPRPatternNode *pattern)
 	}
 
 	/*
-	 * Case 2/3: Safe when child is finite AND (outer is exact OR child is
-	 * {1,1})
+	 * Case 2: Outer exact (min == max): (A{2,3}){4} -> A{8,12}.
+	 *         Safe because every iteration produces the same range.
+	 *
+	 * Case 3: Child {1,1}: (A){2,5} -> A{2,5}.
+	 *         Safe because the child contributes exactly one per
+	 *         iteration, so the outer range maps directly.
+	 *
+	 * Unsafe example: (A{2}){2,3} produces counts 4 or 6 only, not
+	 * the full range 4..6, so we cannot flatten when child has a
+	 * non-trivial range AND outer is also a range.
 	 */
 	if (child->max != RPR_QUANTITY_INF &&
 		(pattern->min == pattern->max ||
@@ -824,6 +845,7 @@ tryMultiplyQuantifiers(RPRPatternNode *pattern)
 		if (new_min_64 >= RPR_QUANTITY_INF)
 			return pattern;
 
+		/* Outer unbounded: result is unbounded regardless of child */
 		if (pattern->max == RPR_QUANTITY_INF)
 			new_max_64 = RPR_QUANTITY_INF;
 		else
@@ -1186,8 +1208,19 @@ fillRPRPatternVar(RPRPatternNode *node, RPRPattern *pat, int *idx, RPRDepth dept
  * fillRPRPatternGroup
  *		Fill a GROUP pattern and its children.
  *
- * Creates elements for group content at increased depth, plus an END marker
- * if the group has a non-trivial quantifier.
+ * Creates elements for group content at increased depth, plus BEGIN/END
+ * marker pair if the group has a non-trivial quantifier (not {1,1}).
+ *
+ * Element layout for (A B){2,3}:
+ *
+ *   [BEGIN]  [A]  [B]  [END]  [next element...]
+ *     |                  |          ^
+ *     |                  +-- jump --+ (loop back to first child)
+ *     +---- jump -------------------+ (skip to after END)
+ *
+ * BEGIN.jump points past END (skip path when count >= max or min == 0).
+ * END.jump points to the first child (loop-back path).
+ * BEGIN.next and END.next are set later by finalizeRPRPattern().
  *
  * Returns true if this group is nullable.  A group is nullable when its
  * min is 0 (can be skipped entirely) or its body is nullable (every path
-- 
2.50.1 (Apple Git-155)


From a074cdb45ffaa6a46a0b60a581a2eb527d855cad Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 20:52:13 +0900
Subject: [PATCH] Remove unused include and fix header ordering in RPR files

---
 src/backend/executor/execExprInterp.c | 2 +-
 src/backend/executor/nodeWindowAgg.c  | 3 +--
 src/backend/parser/parse_rpr.c        | 3 +--
 3 files changed, 3 insertions(+), 5 deletions(-)

diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index e2d41c3098f..58b6693ed75 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -56,8 +56,8 @@
  */
 #include "postgres.h"
 
-#include "common/int.h"
 #include "access/heaptoast.h"
+#include "common/int.h"
 #include "access/tupconvert.h"
 #include "catalog/pg_type.h"
 #include "commands/sequence.h"
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 849ebf8abb0..02f17e5472c 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -34,10 +34,9 @@
 #include "postgres.h"
 
 #include "access/htup_details.h"
-#include "common/int.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_aggregate.h"
-#include "catalog/pg_collation_d.h"
+#include "common/int.h"
 #include "catalog/pg_proc.h"
 #include "executor/executor.h"
 #include "executor/execRPR.h"
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 8fbe12e1518..8864b20e6cf 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -30,9 +30,8 @@
 #include "nodes/nodeFuncs.h"
 #include "optimizer/optimizer.h"
 #include "optimizer/rpr.h"
-#include "parser/parse_clause.h"
-#include "parser/parse_collate.h"
 #include "parser/parse_coerce.h"
+#include "parser/parse_collate.h"
 #include "parser/parse_expr.h"
 #include "parser/parse_rpr.h"
 #include "parser/parse_target.h"
-- 
2.50.1 (Apple Git-155)



Attachments:

  [text/plain] nocfbot-0001-Remove-unused-regex-include.txt (760B, 3-nocfbot-0001-Remove-unused-regex-include.txt)
  download | inline diff:
From f79d4358bc0033bc4fe8b4f8c9e32904d3df6a93 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 24 Mar 2026 19:04:19 +0900
Subject: [PATCH] Remove unused regex/regex.h include from nodeWindowAgg.c

---
 src/backend/executor/nodeWindowAgg.c | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 4f882b877b1..185d7a0d5ae 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -50,7 +50,6 @@
 #include "optimizer/rpr.h"
 #include "parser/parse_agg.h"
 #include "parser/parse_coerce.h"
-#include "regex/regex.h"
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/datum.h"
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0002-CHECK_FOR_INTERRUPTS-nfa_add_state_unique.txt (826B, 4-nocfbot-0002-CHECK_FOR_INTERRUPTS-nfa_add_state_unique.txt)
  download | inline diff:
From 31e07dcbd5391b7ff9ef8293fcb090cf8f845c71 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 00:25:40 +0900
Subject: [PATCH] Add CHECK_FOR_INTERRUPTS() to nfa_add_state_unique() for
 state explosion patterns

---
 src/backend/executor/execRPR.c | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index bab5257f68f..cf54e0c76c3 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -1763,6 +1763,8 @@ nfa_add_state_unique(WindowAggState *winstate, RPRNFAContext *ctx, RPRNFAState *
 	/* 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))
 		{
 			/*
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0003-CHECK_FOR_INTERRUPTS-nfa_try_absorb_context.txt (859B, 5-nocfbot-0003-CHECK_FOR_INTERRUPTS-nfa_try_absorb_context.txt)
  download | inline diff:
From 0f15fdabc01fc1503f2a13253df65844ece4c86d Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 11:03:39 +0900
Subject: [PATCH] Add CHECK_FOR_INTERRUPTS() to nfa_try_absorb_context() loop

---
 src/backend/executor/execRPR.c | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index cf54e0c76c3..58f9da0b814 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -2084,6 +2084,8 @@ nfa_try_absorb_context(WindowAggState *winstate, RPRNFAContext *ctx)
 
 	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
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0004-Fix-defineClause-TargetEntry-copy.txt (1.4K, 6-nocfbot-0004-Fix-defineClause-TargetEntry-copy.txt)
  download | inline diff:
From 6601dda3d297ee8928bbe1c035102d683c78251f Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 00:20:05 +0900
Subject: [PATCH] Fix in-place modification of defineClause TargetEntry in
 setrefs.c

---
 src/backend/optimizer/plan/setrefs.c | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 69cd1861e9b..813a326bd78 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -2633,7 +2633,7 @@ set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset)
 					   NUM_EXEC_QUAL(plan));
 
 	/*
-	 * Modifies an expression tree in each DEFINE clause so that all Var
+	 * Replace an expression tree in each DEFINE clause so that all Var
 	 * nodes's varno refers to OUTER_VAR.
 	 */
 	if (IsA(plan, WindowAgg))
@@ -2646,6 +2646,7 @@ set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset)
 			{
 				TargetEntry *tle = (TargetEntry *) lfirst(l);
 
+				tle = flatCopyTargetEntry(tle);
 				tle->expr = (Expr *)
 					fix_upper_expr(root,
 								   (Node *) tle->expr,
@@ -2654,6 +2655,7 @@ set_upper_references(PlannerInfo *root, Plan *plan, int rtoffset)
 								   rtoffset,
 								   NRM_EQUAL,
 								   NUM_EXEC_QUAL(plan));
+				lfirst(l) = tle;
 			}
 		}
 	}
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0005-Fix-mark-handling-last_value-RPR.txt (1.9K, 7-nocfbot-0005-Fix-mark-handling-last_value-RPR.txt)
  download | inline diff:
From 31e7dacb8b0fa6ead63ff92c19aa5dfb0cde76a1 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 25 Mar 2026 00:37:50 +0900
Subject: [PATCH] Fix mark handling for last_value() under RPR

Enable mark advancement in window_last_value() for
better tuplestore memory usage in non-RPR cases, while
adding a guard in WinGetFuncArgInFrame to suppress it
for RPR SEEK_TAIL to prevent position invalidation
from reduced frame shifts.
---
 src/backend/executor/nodeWindowAgg.c | 10 ++++++++++
 src/backend/utils/adt/windowfuncs.c  |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 185d7a0d5ae..aed7cbef99a 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -4932,7 +4932,17 @@ WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
 	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:
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index efb60c99052..74ef109f72e 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -682,7 +682,7 @@ window_last_value(PG_FUNCTION_ARGS)
 
 	WinCheckAndInitializeNullTreatment(winobj, true, fcinfo);
 	result = WinGetFuncArgInFrame(winobj, 0,
-								  0, WINDOW_SEEK_TAIL, false,
+								  0, WINDOW_SEEK_TAIL, true,
 								  &isnull, NULL);
 	if (isnull)
 		PG_RETURN_NULL();
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0006-Fix-DEFINE-expression-handling-RPR-window-planning.txt (8.2K, 8-nocfbot-0006-Fix-DEFINE-expression-handling-RPR-window-planning.txt)
  download | inline diff:
From b0f0184ef082dbd0a4744d08f6b0b7d5366c2733 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 11:55:02 +0900
Subject: [PATCH] Fix DEFINE expression handling in RPR window planning

transformDefineClause() added full DEFINE expressions including
RPRNavExpr (PREV/NEXT) nodes to the query targetlist.  These
propagated to upper WindowAgg nodes that lack RPR navigation state,
causing a SIGSEGV when RPR and non-RPR windows coexist in the same
query.

Add only the Var nodes referenced by DEFINE expressions to the
targetlist, and protect those Vars from removal by
remove_unused_subquery_outputs() so they remain available in the
tuplestore slot for pattern matching evaluation.

Move the subquery wrapping tests from rpr.sql to rpr_integration.sql.
---
 src/backend/optimizer/path/allpaths.c |  68 +++++++++++++++++
 src/backend/parser/parse_rpr.c        | 106 ++++++++++++++------------
 2 files changed, 126 insertions(+), 48 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index f42a2bae14a..470029e42e0 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -4750,6 +4750,74 @@ remove_unused_subquery_outputs(Query *subquery, RelOptInfo *rel,
 		if (contain_volatile_functions(texpr))
 			continue;
 
+		/*
+		 * If any RPR (Row Pattern Recognition) window clause references this
+		 * column in its DEFINE clause, don't remove it.  The DEFINE
+		 * expression needs these columns in the tuplestore slot for pattern
+		 * matching evaluation, even if the outer query doesn't reference
+		 * them.
+		 */
+		if (IsA(texpr, Var))
+		{
+			Var		   *var = (Var *) texpr;
+			ListCell   *wlc;
+			bool		needed_by_define = false;
+
+			foreach(wlc, subquery->windowClause)
+			{
+				WindowClause *wc = lfirst_node(WindowClause, wlc);
+
+				if (wc->defineClause != NIL)
+				{
+					List	   *vars = pull_var_clause((Node *) wc->defineClause, 0);
+					ListCell   *vlc;
+
+					foreach(vlc, vars)
+					{
+						Var		   *dvar = (Var *) lfirst(vlc);
+
+						if (dvar->varattno == var->varattno)
+						{
+							needed_by_define = true;
+							break;
+						}
+					}
+					list_free(vars);
+					if (needed_by_define)
+						break;
+				}
+			}
+			if (needed_by_define)
+				continue;
+		}
+
+		/*
+		 * If it's a window function referencing a window clause with RPR,
+		 * don't remove it.  Even when the window function result is unused by
+		 * the outer query, the RPR pattern matching (frame reduction via
+		 * DEFINE/PATTERN) must still execute.  Replacing this with NULL would
+		 * leave no active window functions for the WindowClause, causing the
+		 * planner to omit the WindowAgg node entirely.
+		 */
+		if (IsA(texpr, WindowFunc))
+		{
+			WindowFunc *wfunc = (WindowFunc *) texpr;
+			ListCell   *wlc;
+
+			foreach(wlc, subquery->windowClause)
+			{
+				WindowClause *wc = lfirst_node(WindowClause, wlc);
+
+				if (wc->winref == wfunc->winref &&
+					wc->defineClause != NIL)
+				{
+					break;
+				}
+			}
+			if (wlc != NULL)
+				continue;
+		}
+
 		/*
 		 * OK, we don't need it.  Replace the expression with a NULL constant.
 		 * Preserve the exposed type of the expression, in case something
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 55283ab4bbe..db1309ca311 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -28,6 +28,7 @@
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
+#include "optimizer/optimizer.h"
 #include "optimizer/rpr.h"
 #include "parser/parse_clause.h"
 #include "parser/parse_collate.h"
@@ -310,9 +311,7 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 	restargets = NIL;
 	foreach(lc, windef->rpCommonSyntax->rpDefs)
 	{
-		TargetEntry *te,
-				   *teDefine;
-		int			defineExprLocation;
+		TargetEntry *teDefine;
 
 		restarget = (ResTarget *) lfirst(lc);
 		name = restarget->name;
@@ -335,57 +334,68 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 		restargets = lappend(restargets, restarget);
 
 		/*
-		 * Transform the DEFINE expression (restarget->val) and add it to the
-		 * targetlist as a TargetEntry if not already present, so the planner
-		 * can propagate the referenced columns to the outer plan's
-		 * targetlist.
+		 * Transform the DEFINE expression.  We must NOT add the whole
+		 * expression to the query targetlist, because it may contain
+		 * RPRNavExpr nodes (PREV/NEXT) that can only be evaluated inside the
+		 * owning WindowAgg.
 		 *
-		 * Note: findTargetlistEntrySQL99 transforms and clobbers
-		 * restarget->val.
+		 * Instead, we transform the expression directly and only ensure that
+		 * the individual Var nodes it references are present in the
+		 * targetlist, so the planner can propagate the referenced columns.
 		 */
+		{
+			Node	   *expr;
+			List	   *vars;
+			ListCell   *lc2;
 
-		/*
-		 * Save the original expression location before transformation.
-		 * findTargetlistEntrySQL99 may return an existing TargetEntry whose
-		 * location points to where it was originally created (e.g., ORDER
-		 * BY), not the DEFINE clause. We need to preserve the DEFINE location
-		 * for accurate error reporting.
-		 */
-		defineExprLocation = exprLocation(restarget->val);
+			expr = transformExpr(pstate, restarget->val,
+								 EXPR_KIND_RPR_DEFINE);
+
+			/*
+			 * Pull out Var nodes from the transformed expression and ensure
+			 * each one is present in the targetlist.  This is needed so the
+			 * planner propagates the referenced columns through the plan
+			 * tree, making them available to the WindowAgg's DEFINE
+			 * evaluation.
+			 */
+			vars = pull_var_clause(expr, 0);
+			foreach(lc2, vars)
+			{
+				Var		   *var = (Var *) lfirst(lc2);
+				bool		found = false;
+				ListCell   *tl;
 
-		te = findTargetlistEntrySQL99(pstate, restarget->val,
-									  targetlist, EXPR_KIND_RPR_DEFINE);
+				foreach(tl, *targetlist)
+				{
+					TargetEntry *tle = (TargetEntry *) lfirst(tl);
 
-		/* -------------------
-		 * Copy the TargetEntry for defineClause and always set the pattern
-		 * variable name. We use copyObject so the original targetlist entry
-		 * is not modified.
-		 *
-		 * Note: We must always set resname to the pattern variable name.
-		 * findTargetlistEntrySQL99 creates new TEs with resname = NULL
-		 * (resjunk entries), but returns existing TEs unchanged when the
-		 * expression already exists in targetlist.
-		 *
-		 * Example: "SELECT id, flag, ... WINDOW w AS (... DEFINE T AS flag)"
-		 *
-		 * 1. SELECT list processing creates: TE{resname="flag", expr=flag}
-		 * 2. DEFINE T AS flag: findTargetlistEntrySQL99 finds existing TE
-		 * 3. te->resname is "flag" (from SELECT), not NULL
-		 * 4. Without unconditionally setting resname, teDefine->resname
-		 *    would remain "flag" instead of pattern variable name "T"
-		 * 5. buildRPRPattern builds defineVariableList from resname, so
-		 *    it would contain ["flag"] instead of ["T"]
-		 * 6. Pattern variable "T" not found -> Assert failure crash
-		 */
-		teDefine = copyObject(te);
-		teDefine->resname = pstrdup(name);
+					if (IsA(tle->expr, Var) &&
+						((Var *) tle->expr)->varno == var->varno &&
+						((Var *) tle->expr)->varattno == var->varattno)
+					{
+						found = true;
+						break;
+					}
+				}
+				if (!found)
+				{
+					TargetEntry *newtle;
 
-		/*
-		 * Update the expression location to point to the DEFINE clause. This
-		 * ensures error messages reference the correct source location.
-		 */
-		if (defineExprLocation >= 0 && IsA(teDefine->expr, Var))
-			((Var *) teDefine->expr)->location = defineExprLocation;
+					newtle = makeTargetEntry((Expr *) copyObject(var),
+											 list_length(*targetlist) + 1,
+											 NULL,
+											 true);
+					*targetlist = lappend(*targetlist, newtle);
+				}
+			}
+			list_free(vars);
+
+			/* Build the defineClause entry directly from the transformed expr */
+			teDefine = makeTargetEntry((Expr *) expr,
+									   list_length(defineClause) + 1,
+									   pstrdup(name),
+									   true);
+		}
 
 		/* build transformed DEFINE clause (list of TargetEntry) */
 		defineClause = lappend(defineClause, teDefine);
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0007-Add-RPR-planner-integration-tests.txt (89.6K, 9-nocfbot-0007-Add-RPR-planner-integration-tests.txt)
  download

  [text/plain] nocfbot-0008-Replace-reduced-frame-map-with-single-match-result.txt (21.1K, 10-nocfbot-0008-Replace-reduced-frame-map-with-single-match-result.txt)
  download | inline diff:
From ca171084b7c52e27a1f0bb2a17a8747f375e6a2f Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 14:09:12 +0900
Subject: [PATCH] Replace reduced frame map with single match result

The reduced frame map was a per-row byte array tracking match status.
Since rows are processed sequentially and only one match is active
at a time, replace it with four scalar fields: valid, matched,
start, and length.

Also distinguish empty matches (FIN reached with zero rows consumed)
from unmatched rows via RF_EMPTY_MATCH, counted as matched in NFA
statistics.

Widen row_is_in_reduced_frame() return type from int to int64,
since it returns rpr_match_length which is int64.
---
 src/backend/executor/execRPR.c            |  56 +++---
 src/backend/executor/nodeWindowAgg.c      | 233 +++++++++-------------
 src/include/nodes/execnodes.h             |  21 +-
 src/test/regress/expected/rpr_explain.out |   8 +-
 4 files changed, 132 insertions(+), 186 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 58f9da0b814..7d0f8fd401c 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -549,7 +549,7 @@
  *
  *   (1) Find or create a context for the target row
  *   (2) Enter the row processing loop
- *   (3) After the loop ends, record the result in reduced_frame_map
+ *   (3) After the loop ends, record the match result
  *
  * Pseudocode of the row processing loop:
  *
@@ -923,18 +923,19 @@
  * Chapter X  Match Result Processing
  * ============================================================================
  *
- * X-1. Reduced Frame Map
+ * X-1. Match Result
  *
- * RPR match results are recorded in a byte array called reduced_frame_map.
- * One byte is allocated per row, and the value is one of the following:
+ * RPR tracks the current match result as a single entry in WindowAggState
+ * with four fields: rpr_match_valid, rpr_match_matched, rpr_match_start,
+ * and rpr_match_length.  When rpr_match_valid is true, the entry describes
+ * the match result for the position at rpr_match_start: rpr_match_matched
+ * indicates success or failure, and rpr_match_length gives the number of
+ * rows consumed.  A match with rpr_match_length 0 represents an empty match
+ * (pattern matched but consumed no rows).  When rpr_match_valid is false,
+ * the position has not been evaluated yet (RF_NOT_DETERMINED).
  *
- *   RF_NOT_DETERMINED (0)  Not yet processed
- *   RF_FRAME_HEAD     (1)  Start row of the match
- *   RF_SKIPPED        (2)  Interior row of the match (skipped in frame)
- *   RF_UNMATCHED      (3)  Match failure
- *
- * The window function references this map to determine frame inclusion for
- * each row.
+ * A row's status against the current match result can be obtained by
+ * calling get_reduced_frame_status().
  *
  * X-2. AFTER MATCH SKIP
  *
@@ -1028,8 +1029,7 @@
  *     Phase 3 (Advance): skipped (no states)
  *
  *   C0.states is empty, so the loop terminates.
- *   matchEndRow < matchStartRow -> RF_UNMATCHED.
- *   register_reduced_frame_map(0, RF_UNMATCHED).
+ *   matchEndRow < matchStartRow -> unmatched.
  *
  * --- Row 1 (price=110) ---
  *
@@ -1113,9 +1113,7 @@
  *
  *   C1.states is empty and matchEndRow=3 >= matchStartRow=1 -> match succeeds.
  *
- *   register_reduced_frame_map(1, RF_FRAME_HEAD)
- *   register_reduced_frame_map(2, RF_SKIPPED)
- *   register_reduced_frame_map(3, RF_SKIPPED)
+ *   rpr_match_start = 1, rpr_match_length = 3
  *
  * --- Row 4 (price=130) ---
  *
@@ -1128,15 +1126,15 @@
  *     B: 130 < PREV(115) -> false
  *
  *   ... No subsequent rows, so ExecRPRFinalizeAllContexts() is called.
- *   Match incomplete -> RF_UNMATCHED.
+ *   Match incomplete -> unmatched.
  *
  * XI-5. Final Result
  *
- *   Row 0: RF_UNMATCHED  -> frame = the row itself
- *   Row 1: RF_FRAME_HEAD -> frame = rows 1 through 3
- *   Row 2: RF_SKIPPED    -> inside row 1's match
- *   Row 3: RF_SKIPPED    -> inside row 1's match
- *   Row 4: RF_UNMATCHED  -> frame = the row itself
+ *   Row 0: unmatched     -> frame = the row itself
+ *   Row 1: match head    -> frame = rows 1 through 3
+ *   Row 2: inside match  -> skipped
+ *   Row 3: inside match  -> skipped
+ *   Row 4: unmatched     -> frame = the row itself
  *
  * Chapter XII  Summary of Key Design Decisions
  * ============================================================================
@@ -1579,12 +1577,14 @@ static void nfa_advance(WindowAggState *winstate, RPRNFAContext *ctx,
  *
  * - Empty match handling: The initial advance uses currentPos =
  *   startPos - 1 (before any row is consumed). If FIN is reached via
- *   epsilon transitions alone, matchEndRow = startPos - 1 < matchStartRow,
- *   resulting in UNMATCHED. For reluctant min=0 patterns (A*?, A??),
- *   the skip path reaches FIN first and early termination prunes enter
- *   paths, yielding an immediate empty (unmatched) result. For
- *   greedy patterns (A*), the enter path adds VAR states first, then
- *   the skip FIN is recorded but VAR states survive for later matching.
+ *   epsilon transitions alone, matchEndRow = startPos - 1 < matchStartRow.
+ *   If matchedState is set (FIN was reached), this is an empty match
+ *   (RF_EMPTY_MATCH); otherwise it is unmatched (RF_UNMATCHED).
+ *   For reluctant min=0 patterns (A*?, A??), the skip path reaches
+ *   FIN first and early termination prunes enter paths, yielding an
+ *   immediate empty match result. For greedy patterns (A*), the enter
+ *   path adds VAR states first, then the skip FIN is recorded but VAR
+ *   states survive for later matching.
  *
  * Context Absorption Runtime:
  * ---------------------------
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index aed7cbef99a..dca2de570e8 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -247,13 +247,10 @@ static void attno_map(Node *node);
 static bool attno_map_walker(Node *node, void *context);
 
 static bool rpr_is_defined(WindowAggState *winstate);
-static int	row_is_in_reduced_frame(WindowObject winobj, int64 pos);
+static int64 row_is_in_reduced_frame(WindowObject winobj, int64 pos);
 
-static void create_reduced_frame_map(WindowAggState *winstate);
-static void clear_reduced_frame_map(WindowAggState *winstate);
-static int	get_reduced_frame_map(WindowAggState *winstate, int64 pos);
-static void register_reduced_frame_map(WindowAggState *winstate, int64 pos,
-									   int val);
+static void clear_reduced_frame(WindowAggState *winstate);
+static int	get_reduced_frame_status(WindowAggState *winstate, int64 pos);
 static void update_reduced_frame(WindowObject winobj, int64 pos);
 
 static void check_rpr_navigation(Node *node, bool is_prev);
@@ -1035,13 +1032,7 @@ eval_windowaggregates(WindowAggState *winstate)
 	 */
 	for (;;)
 	{
-		int			ret;
-
-#ifdef RPR_DEBUG
-		printf("===== loop in frame starts: aggregatedupto: " INT64_FORMAT " aggregatedbase: " INT64_FORMAT "\n",
-			   winstate->aggregatedupto,
-			   winstate->aggregatedbase);
-#endif
+		int64		ret;
 
 		/* Fetch next row if we didn't already */
 		if (TupIsNull(agg_row_slot))
@@ -1065,27 +1056,18 @@ eval_windowaggregates(WindowAggState *winstate)
 
 		if (rpr_is_defined(winstate))
 		{
-#ifdef RPR_DEBUG
-			printf("reduced_frame_map: %d aggregatedupto: " INT64_FORMAT " aggregatedbase: " INT64_FORMAT "\n",
-				   get_reduced_frame_map(winstate,
-										 winstate->aggregatedupto),
-				   winstate->aggregatedupto,
-				   winstate->aggregatedbase);
-#endif
-
 			/*
-			 * If the row status at currentpos is already decided and current
-			 * row status is not decided yet, it means we passed the last
-			 * reduced frame. Time to break the loop.
+			 * If currentpos is already decided but aggregatedupto is not yet
+			 * determined, we've passed the last reduced frame.
 			 */
-			if (get_reduced_frame_map(winstate, winstate->currentpos)
+			if (get_reduced_frame_status(winstate, winstate->currentpos)
 				!= RF_NOT_DETERMINED &&
-				get_reduced_frame_map(winstate, winstate->aggregatedupto)
+				get_reduced_frame_status(winstate, winstate->aggregatedupto)
 				== RF_NOT_DETERMINED)
 				break;
 
 			/*
-			 * Otherwise we need to calculate the reduced frame.
+			 * Calculate the reduced frame for aggregatedupto.
 			 */
 			ret = row_is_in_reduced_frame(winstate->agg_winobj,
 										  winstate->aggregatedupto);
@@ -1093,17 +1075,13 @@ eval_windowaggregates(WindowAggState *winstate)
 				break;
 
 			/*
-			 * Check if current row needs to be skipped due to no match.
+			 * 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_map(winstate,
-									  winstate->aggregatedupto) == RF_SKIPPED &&
+			if (get_reduced_frame_status(winstate,
+										 winstate->aggregatedupto) == RF_SKIPPED &&
 				winstate->aggregatedupto == winstate->aggregatedbase)
-			{
-#ifdef RPR_DEBUG
-				printf("skip current row for aggregation\n");
-#endif
 				break;
-			}
 		}
 
 		/* Set tuple context for evaluation of aggregate arguments */
@@ -1358,7 +1336,8 @@ begin_partition(WindowAggState *winstate)
 	winstate->framehead_valid = false;
 	winstate->frametail_valid = false;
 	winstate->grouptail_valid = false;
-	create_reduced_frame_map(winstate);
+	if (rpr_is_defined(winstate))
+		clear_reduced_frame(winstate);
 	winstate->spooled_rows = 0;
 	winstate->currentpos = 0;
 	winstate->frameheadpos = 0;
@@ -1581,9 +1560,8 @@ release_partition(WindowAggState *winstate)
 	winstate->partition_spooled = false;
 	winstate->next_partition = true;
 
-	/* Reset RPR reduced frame map */
-	winstate->reduced_frame_map = NULL;
-	winstate->alloc_sz = 0;
+	/* Reset RPR match results */
+	clear_reduced_frame(winstate);
 
 	/* Reset NFA state for new partition */
 	winstate->nfaContext = NULL;
@@ -2366,11 +2344,6 @@ ExecWindowAgg(PlanState *pstate)
 
 	CHECK_FOR_INTERRUPTS();
 
-#ifdef RPR_DEBUG
-	printf("ExecWindowAgg called. pos: " INT64_FORMAT "\n",
-		   winstate->currentpos);
-#endif
-
 	if (winstate->status == WINDOWAGG_DONE)
 		return NULL;
 
@@ -2480,14 +2453,13 @@ ExecWindowAgg(PlanState *pstate)
 		if (winstate->status == WINDOWAGG_RUN)
 		{
 			/*
-			 * If RPR is defined and skip mode is next row, we need to clear
-			 * existing reduced frame info so that we newly calculate the info
-			 * starting from current row.
+			 * 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_map(winstate);
+					clear_reduced_frame(winstate);
 			}
 
 			/*
@@ -2986,9 +2958,6 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 			name = te->resname;
 			expr = te->expr;
 
-#ifdef RPR_DEBUG
-			printf("defineVariable name: %s\n", name);
-#endif
 			winstate->defineVariableList =
 				lappend(winstate->defineVariableList,
 						makeString(pstrdup(name)));
@@ -3668,7 +3637,7 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 	int			notnull_offset;
 	int			notnull_relpos;
 	int			forward;
-	int			num_reduced_frame;
+	int64		num_reduced_frame;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
@@ -3968,14 +3937,12 @@ rpr_is_defined(WindowAggState *winstate)
  * AFTER MATCH SKIP PAST LAST ROW
  * -----------------
  */
-static int
+static int64
 row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 {
 	WindowAggState *winstate = winobj->winstate;
 	int			state;
-	int			rtn;
-	int64		i;
-	int			num_reduced_rows;
+	int64		rtn;
 
 	if (!rpr_is_defined(winstate))
 	{
@@ -3984,14 +3951,10 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 		 * window frame.
 		 */
 		rtn = 0;
-#ifdef RPR_DEBUG
-		printf("row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT "\n",
-			   rtn, pos);
-#endif
 		return rtn;
 	}
 
-	state = get_reduced_frame_map(winstate, pos);
+	state = get_reduced_frame_status(winstate, pos);
 
 	if (state == RF_NOT_DETERMINED)
 	{
@@ -3999,16 +3962,12 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 		update_reduced_frame(winobj, pos);
 	}
 
-	state = get_reduced_frame_map(winstate, pos);
+	state = get_reduced_frame_status(winstate, pos);
 
 	switch (state)
 	{
 		case RF_FRAME_HEAD:
-			num_reduced_rows = 1;
-			for (i = pos + 1;
-				 get_reduced_frame_map(winstate, i) == RF_SKIPPED; i++)
-				num_reduced_rows++;
-			rtn = num_reduced_rows;
+			rtn = winstate->rpr_match_length;
 			break;
 
 		case RF_SKIPPED:
@@ -4016,6 +3975,7 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 			break;
 
 		case RF_UNMATCHED:
+		case RF_EMPTY_MATCH:
 			rtn = -1;
 			break;
 
@@ -4025,91 +3985,56 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 			break;
 	}
 
-#ifdef RPR_DEBUG
-	printf("row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT "\n",
-		   rtn, pos);
-#endif
 	return rtn;
 }
 
-#define REDUCED_FRAME_MAP_INIT_SIZE	1024L
-
 /*
- * create_reduced_frame_map
- * Create reduced frame map
+ * clear_reduced_frame
+ * Clear reduced frame status
  */
 static void
-create_reduced_frame_map(WindowAggState *winstate)
+clear_reduced_frame(WindowAggState *winstate)
 {
-	winstate->reduced_frame_map =
-		MemoryContextAlloc(winstate->partcontext,
-						   REDUCED_FRAME_MAP_INIT_SIZE);
-	winstate->alloc_sz = REDUCED_FRAME_MAP_INIT_SIZE;
-	clear_reduced_frame_map(winstate);
+	winstate->rpr_match_valid = false;
+	winstate->rpr_match_matched = false;
+	winstate->rpr_match_start = -1;
+	winstate->rpr_match_length = 0;
 }
 
 /*
- * clear_reduced_frame_map
- * Clear reduced frame map
- */
-static void
-clear_reduced_frame_map(WindowAggState *winstate)
-{
-	Assert(winstate->reduced_frame_map != NULL);
-	MemSet(winstate->reduced_frame_map, RF_NOT_DETERMINED,
-		   winstate->alloc_sz);
-}
-
-/*
- * get_reduced_frame_map
- * Get reduced frame map specified by pos
+ * 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
  */
 static int
-get_reduced_frame_map(WindowAggState *winstate, int64 pos)
+get_reduced_frame_status(WindowAggState *winstate, int64 pos)
 {
-	Assert(winstate->reduced_frame_map != NULL);
-	Assert(pos >= 0);
+	int64		start = winstate->rpr_match_start;
+	int64		length = winstate->rpr_match_length;
 
-	/*
-	 * If pos is not in the reduced frame map, it means that any info
-	 * regarding the pos has not been registered yet. So we return
-	 * RF_NOT_DETERMINED.
-	 */
-	if (pos >= winstate->alloc_sz)
+	if (!winstate->rpr_match_valid)
 		return RF_NOT_DETERMINED;
 
-	return winstate->reduced_frame_map[pos];
-}
+	/* Empty match: covers only the start position */
+	if (pos == start && winstate->rpr_match_matched && length == 0)
+		return RF_EMPTY_MATCH;
 
-/*
- * register_reduced_frame_map
- * Add/replace reduced frame map member at pos.
- * If there's no enough space, expand the map.
- */
-static void
-register_reduced_frame_map(WindowAggState *winstate, int64 pos, int val)
-{
-	int64		realloc_sz;
-
-	Assert(winstate->reduced_frame_map != NULL);
-
-	if (pos < 0)
-		elog(ERROR, "wrong pos: " INT64_FORMAT, pos);
-
-	while (pos > winstate->alloc_sz - 1)
-	{
-		realloc_sz = winstate->alloc_sz * 2;
-
-		winstate->reduced_frame_map =
-			repalloc(winstate->reduced_frame_map, realloc_sz);
+	/* Outside the result range */
+	if (pos < start || pos >= start + length)
+		return RF_NOT_DETERMINED;
 
-		MemSet(winstate->reduced_frame_map + winstate->alloc_sz,
-			   RF_NOT_DETERMINED, realloc_sz - winstate->alloc_sz);
+	if (!winstate->rpr_match_matched)
+		return RF_UNMATCHED;
 
-		winstate->alloc_sz = realloc_sz;
-	}
+	if (pos == start)
+		return RF_FRAME_HEAD;
 
-	winstate->reduced_frame_map[pos] = val;
+	return RF_SKIPPED;
 }
 
 /*
@@ -4156,7 +4081,11 @@ update_reduced_frame(WindowObject winobj, int64 pos)
 	if (winstate->nfaContext != NULL &&
 		pos < winstate->nfaContext->matchStartRow)
 	{
-		register_reduced_frame_map(winstate, pos, RF_UNMATCHED);
+		/* already processed, unmatched */
+		winstate->rpr_match_valid = true;
+		winstate->rpr_match_matched = false;
+		winstate->rpr_match_start = pos;
+		winstate->rpr_match_length = 1;
 		return;
 	}
 
@@ -4173,7 +4102,11 @@ update_reduced_frame(WindowObject winobj, int64 pos)
 		 */
 		if (pos <= winstate->nfaLastProcessedRow)
 		{
-			register_reduced_frame_map(winstate, pos, RF_UNMATCHED);
+			/* already processed, unmatched */
+			winstate->rpr_match_valid = true;
+			winstate->rpr_match_matched = false;
+			winstate->rpr_match_start = pos;
+			winstate->rpr_match_length = 1;
 			return;
 		}
 		/* Not yet processed - create new context and start fresh */
@@ -4245,26 +4178,38 @@ register_result:
 	Assert(pos == targetCtx->matchStartRow);
 
 	/*
-	 * Register reduced frame map based on match result.
+	 * Record match result.
 	 */
+	winstate->rpr_match_valid = true;
+	winstate->rpr_match_start = targetCtx->matchStartRow;
+
 	if (targetCtx->matchEndRow < targetCtx->matchStartRow)
 	{
 		matchLen = targetCtx->lastProcessedRow - targetCtx->matchStartRow + 1;
 
-		register_reduced_frame_map(winstate, targetCtx->matchStartRow, RF_UNMATCHED);
-		ExecRPRRecordContextFailure(winstate, matchLen);
+		if (targetCtx->matchedState != NULL)
+		{
+			/* Empty match: FIN reached but 0 rows consumed */
+			winstate->rpr_match_matched = true;
+			winstate->rpr_match_length = 0;
+			ExecRPRRecordContextSuccess(winstate, 0);
+		}
+		else
+		{
+			/* No match */
+			winstate->rpr_match_matched = false;
+			winstate->rpr_match_length = 1;
+			ExecRPRRecordContextFailure(winstate, matchLen);
+		}
 		ExecRPRFreeContext(winstate, targetCtx);
 		return;
 	}
 
-	/* Match succeeded - register frame map and record statistics */
+	/* Match succeeded */
 	matchLen = targetCtx->matchEndRow - targetCtx->matchStartRow + 1;
 
-	register_reduced_frame_map(winstate, targetCtx->matchStartRow, RF_FRAME_HEAD);
-	for (int64 i = targetCtx->matchStartRow + 1; i <= targetCtx->matchEndRow; i++)
-	{
-		register_reduced_frame_map(winstate, i, RF_SKIPPED);
-	}
+	winstate->rpr_match_matched = true;
+	winstate->rpr_match_length = matchLen;
 	ExecRPRRecordContextSuccess(winstate, matchLen);
 
 	/* Remove the matched context */
@@ -4747,7 +4692,7 @@ WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
 	WindowAggState *winstate;
 	int64		abs_pos;
 	int64		mark_pos;
-	int			num_reduced_frame;
+	int64		num_reduced_frame;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 33028c3f10b..c672d29f35b 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2499,10 +2499,12 @@ typedef enum WindowAggStatus
 									 * tuples during spool */
 } WindowAggStatus;
 
-#define	RF_NOT_DETERMINED	0
-#define	RF_FRAME_HEAD		1
-#define	RF_SKIPPED			2
-#define	RF_UNMATCHED		3
+/* 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
@@ -2694,12 +2696,11 @@ typedef struct WindowAggState
 	TupleTableSlot *next_slot;	/* NEXT row navigation operator */
 	TupleTableSlot *null_slot;	/* all NULL slot */
 
-	/*
-	 * Each byte corresponds to a row positioned at absolute its pos in
-	 * partition.  See above definition for RF_*. Used for RPR.
-	 */
-	char	   *reduced_frame_map;
-	int64		alloc_sz;		/* size of the map */
+	/* RPR current match result */
+	bool		rpr_match_valid;	/* true if a match result is set */
+	bool		rpr_match_matched;	/* true if the result was a match */
+	int64		rpr_match_start;	/* start position of the match result */
+	int64		rpr_match_length;	/* number of rows matched (0 = empty) */
 } WindowAggState;
 
 /* ----------------
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index bd345906133..79cbc246039 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -3348,8 +3348,8 @@ WINDOW w AS (
    Pattern: ((a' b')+" c)*
    Storage: Memory  Maximum Storage: NkB
    NFA States: 9 peak, 178 total, 0 merged
-   NFA Contexts: 4 peak, 61 total, 22 pruned
-   NFA: 1 matched (len 57/57/57.0), 0 mismatched
+   NFA Contexts: 4 peak, 61 total, 20 pruned
+   NFA: 3 matched (len 0/57/19.0), 0 mismatched
    NFA: 0 absorbed, 37 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
@@ -3385,8 +3385,8 @@ WINDOW w AS (
    Pattern: (a (b c)+)*
    Storage: Memory  Maximum Storage: NkB
    NFA States: 7 peak, 160 total, 0 merged
-   NFA Contexts: 4 peak, 61 total, 22 pruned
-   NFA: 1 matched (len 57/57/57.0), 0 mismatched
+   NFA Contexts: 4 peak, 61 total, 20 pruned
+   NFA: 3 matched (len 0/57/19.0), 0 mismatched
    NFA: 0 absorbed, 37 skipped (len 1/3/2.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0009-Add-fixed-length-group-absorption-for-RPR.txt (55.5K, 11-nocfbot-0009-Add-fixed-length-group-absorption-for-RPR.txt)
  download | inline diff:
From 5d9be742c6266d8fbff887a0577c37743d429690 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Sat, 4 Apr 2026 21:47:01 +0900
Subject: [PATCH] Add fixed-length group absorption for RPR

Extend context absorption to unbounded groups with fixed-length
children (min == max, recursively).  Patterns like (A B{2})+ or
((A (B C){2}){2})+ are now absorbable, equivalent to unrolling to
{1,1} VARs at compile time without actually unrolling.

isFixedLengthChildren() recursively verifies min == max for all
children including nested subgroups, extending the existing Case 2
in isUnboundedStart().

Absorption comparison in nfa_states_covered requires states to be at
an ABSORBABLE judgment point, where count-dominance is guaranteed.
The inline advance in nfa_match is generalized to advance bounded
VARs within the absorbable region through END chains to reach the
judgment point.

Fix isAbsorbable propagation in nfa_advance_var and nfa_advance_end
exit paths, where reusing a state object skipped recomputation.

Mark VAR elements in the DFS visited bitmap at nfa_add_state_unique
instead of at nfa_advance_state entry, so that loop-back through ALT
to the same VAR is not incorrectly blocked by cycle detection.
---
 src/backend/executor/execRPR.c            | 110 ++++++--
 src/backend/optimizer/plan/rpr.c          | 136 +++++++--
 src/test/regress/expected/rpr_base.out    |  83 +++++-
 src/test/regress/expected/rpr_explain.out | 206 +++++++++++++-
 src/test/regress/expected/rpr_nfa.out     | 321 ++++++++++++++++++++++
 src/test/regress/sql/rpr_base.sql         |  36 +++
 src/test/regress/sql/rpr_explain.sql      | 114 ++++++++
 src/test/regress/sql/rpr_nfa.sql          | 168 +++++++++++
 8 files changed, 1111 insertions(+), 63 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 7d0f8fd401c..aec1057e1b2 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -1760,6 +1760,10 @@ nfa_add_state_unique(WindowAggState *winstate, RPRNFAContext *ctx, RPRNFAState *
 	RPRNFAState *s;
 	RPRNFAState *tail = NULL;
 
+	/* Mark VAR in visited before duplicate check to prevent DFS loops */
+	winstate->nfaVisitedElems[WORDNUM(state->elemIdx)] |=
+		((bitmapword) 1 << BITNUM(state->elemIdx));
+
 	/* Check for duplicate and find tail */
 	for (s = ctx->states; s != NULL; s = s->next)
 	{
@@ -2033,6 +2037,14 @@ nfa_states_covered(RPRPattern *pattern, RPRNFAContext *older, RPRNFAContext *new
 		elem = &pattern->elements[newerState->elemIdx];
 		depth = elem->depth;
 
+		/*
+		 * Only compare at absorption judgment points (RPR_ELEM_ABSORBABLE).
+		 * Judgment 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();
@@ -2175,9 +2187,10 @@ nfa_eval_var_match(WindowAggState *winstate, RPRPatternElement *elem,
  *   - not matched: remove state (exit alternatives already exist from
  *     previous advance when count >= min was satisfied)
  *
- * For simple VARs (min=max=1) followed by END:
- *   - Advance to END and update group count before absorb phase
- *   - This ensures absorption can compare states by group completion
+ * For VARs that reached max count followed by END:
+ *   - Advance through END chain to reach absorption judgment point
+ *   - Only deterministic exits (count >= max, max != INF) are handled
+ *   - Chains through END elements while count >= max (must-exit path)
  *
  * Non-VAR elements (ALT, END, FIN) are kept as-is for advance phase.
  */
@@ -2191,9 +2204,9 @@ nfa_match(WindowAggState *winstate, RPRNFAContext *ctx, bool *varMatched)
 	RPRNFAState *nextState;
 
 	/*
-	 * Evaluate VAR elements against current row. For simple VARs with END
-	 * next, advance to END and update group count inline so absorb phase can
-	 * compare states properly.
+	 * Evaluate VAR elements against current row. For VARs that reach max
+	 * count with END next, advance through END chain inline so absorb phase
+	 * can compare states at judgment points.
 	 */
 	for (state = ctx->states; state != NULL; state = nextState)
 	{
@@ -2223,34 +2236,61 @@ nfa_match(WindowAggState *winstate, RPRNFAContext *ctx, bool *varMatched)
 				state->counts[depth] = count;
 
 				/*
-				 * For simple VAR (min=max=1) with END next, advance to END
-				 * and update group count inline. This keeps state in place,
-				 * preserving lexical order.
+				 * For VAR at max count with END next, advance through END
+				 * chain to reach the absorption judgment point. Only
+				 * deterministic exits (count >= max, max finite) are handled;
+				 * unbounded VARs stay for advance phase.
 				 */
-				if (elem->min == 1 && elem->max == 1 &&
+				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];
 
-					Assert(count == 1);
-
-					/* Increment group count with overflow protection */
+					/* Increment group count */
 					if (endCount < RPR_COUNT_MAX)
 						endCount++;
-
-					/*
-					 * END's max can never be exceeded here because
-					 * nfa_advance_end only loops when count < max, so
-					 * endCount entering inline advance is at most max-1, and
-					 * incrementing yields at most max.
-					 */
 					Assert(endElem->max == RPR_QUANTITY_INF ||
 						   endCount <= endElem->max);
 
 					state->elemIdx = elem->next;
 					state->counts[endDepth] = endCount;
+
+					/*
+					 * Chain through END elements within the absorbable region
+					 * (ABSORBABLE_BRANCH) until reaching the judgment 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];
+
+						/* Reset exited group's count */
+						state->counts[endDepth] = 0;
+
+						/* Increment outer group count */
+						if (outerCount < RPR_COUNT_MAX)
+							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 */
 			}
@@ -2468,6 +2508,10 @@ nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
 		state->elemIdx = elem->next;
 		nextElem = &elements[state->elemIdx];
 
+		/* Update isAbsorbable for target element (monotonic) */
+		state->isAbsorbable = state->isAbsorbable &&
+			RPRElemIsAbsorbableBranch(nextElem);
+
 		/* END->END: increment outer END's count */
 		if (RPRElemIsEnd(nextElem) && state->counts[nextElem->depth] < RPR_COUNT_MAX)
 			state->counts[nextElem->depth]++;
@@ -2621,6 +2665,13 @@ nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
 			state->elemIdx = elem->next;
 			nextElem = &elements[state->elemIdx];
 
+			/*
+			 * Update isAbsorbable for target element (monotonic: AND
+			 * preserves false)
+			 */
+			state->isAbsorbable = state->isAbsorbable &&
+				RPRElemIsAbsorbableBranch(nextElem);
+
 			/*
 			 * When exiting directly to an outer END, increment its iteration
 			 * count.  Simple VARs (min=max=1) handle this via inline advance
@@ -2650,6 +2701,13 @@ nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
 		state->elemIdx = elem->next;
 		nextElem = &elements[state->elemIdx];
 
+		/*
+		 * Update isAbsorbable for target element (monotonic: AND preserves
+		 * false)
+		 */
+		state->isAbsorbable = state->isAbsorbable &&
+			RPRElemIsAbsorbableBranch(nextElem);
+
 		/* See comment above: increment outer END count for quantified VARs */
 		if (RPRElemIsEnd(nextElem))
 		{
@@ -2686,11 +2744,19 @@ nfa_advance_state(WindowAggState *winstate, RPRNFAContext *ctx,
 		nfa_state_free(winstate, state);
 		return;
 	}
-	winstate->nfaVisitedElems[WORDNUM(state->elemIdx)] |=
-		((bitmapword) 1 << BITNUM(state->elemIdx));
 
 	elem = &pattern->elements[state->elemIdx];
 
+	/*
+	 * Mark epsilon elements (END, ALT, BEGIN, FIN) in visited to prevent
+	 * infinite epsilon cycles.  VAR elements are marked later when added to
+	 * the state list (nfa_add_state_unique), allowing legitimate loop-back to
+	 * the same VAR in a new iteration.
+	 */
+	if (!RPRElemIsVar(elem))
+		winstate->nfaVisitedElems[WORDNUM(state->elemIdx)] |=
+			((bitmapword) 1 << BITNUM(state->elemIdx));
+
 	switch (elem->varId)
 	{
 		case RPR_VARID_FIN:
diff --git a/src/backend/optimizer/plan/rpr.c b/src/backend/optimizer/plan/rpr.c
index 2728a0b9fca..c0e9d134aa9 100644
--- a/src/backend/optimizer/plan/rpr.c
+++ b/src/backend/optimizer/plan/rpr.c
@@ -92,6 +92,8 @@ static bool fillRPRPattern(RPRPatternNode *node, RPRPattern *pat,
 static void finalizeRPRPattern(RPRPattern *result);
 
 /* Forward declarations - context absorption */
+static bool isFixedLengthChildren(RPRPattern *pattern, RPRElemIdx idx,
+								  RPRDepth scopeDepth);
 static bool isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx);
 static void computeAbsorbabilityRecursive(RPRPattern *pattern,
 										  RPRElemIdx startIdx,
@@ -1524,6 +1526,70 @@ finalizeRPRPattern(RPRPattern *result)
  *-------------------------------------------------------------------------
  */
 
+/*
+ * isFixedLengthChildren
+ *		Check if all children at scopeDepth have fixed-length quantifiers
+ *		(min == max), recursively for nested subgroups.
+ *
+ * A fixed-length group is semantically equivalent to unrolling each child
+ * to {1,1} copies, which is the existing Case 2 already proven correct
+ * for absorption.  This check recognizes fixed-length groups at compile
+ * time without actually unrolling them.
+ *
+ * Traverses the flat element array starting at idx.  For VAR elements,
+ * checks min == max.  For BEGIN elements (nested subgroups), recurses
+ * into the subgroup and also checks the subgroup's END quantifier.
+ * ALT elements are rejected (alternation inside absorbable group is
+ * not supported).
+ *
+ * Returns true if all children are fixed-length, stopping at the END
+ * element at scopeDepth - 1.
+ */
+static bool
+isFixedLengthChildren(RPRPattern *pattern, RPRElemIdx idx, RPRDepth scopeDepth)
+{
+	RPRPatternElement *e = &pattern->elements[idx];
+
+	check_stack_depth();
+
+	while (e->depth == scopeDepth)
+	{
+		if (RPRElemIsVar(e))
+		{
+			if (e->min != e->max)
+				return false;
+		}
+		else if (RPRElemIsBegin(e))
+		{
+			RPRElemIdx	childIdx = e->next;
+
+			/* Recurse into subgroup children at scopeDepth + 1 */
+			if (!isFixedLengthChildren(pattern, childIdx, scopeDepth + 1))
+				return false;
+
+			/* Advance past the subgroup to its END element */
+			e = &pattern->elements[e->next];
+			while (e->depth > scopeDepth)
+				e = &pattern->elements[e->next];
+
+			/* e is now the END at scopeDepth; check its quantifier */
+			Assert(RPRElemIsEnd(e) && e->depth == scopeDepth);
+			if (e->min != e->max)
+				return false;
+		}
+		else
+		{
+			/* ALT inside group: not supported for absorption */
+			return false;
+		}
+
+		Assert(e->next != RPR_ELEMIDX_INVALID);
+		e = &pattern->elements[e->next];
+	}
+
+	return true;
+}
+
 /*
  * isUnboundedStart
  *		Check if the element at idx starts an unbounded greedy sequence.
@@ -1533,29 +1599,31 @@ finalizeRPRPattern(RPRPattern *result)
  *   - Greedy (not reluctant)
  *   - At the start of current scope
  *
- * Algorithm:
- *   - Traverse elements within current scope (parentDepth to startDepth)
- *   - For GROUP: must be unbounded greedy AND contain only simple {1,1} VARs
- *   - Sets ABSORBABLE and ABSORBABLE_BRANCH flags on matching elements
- *
  * Two cases are handled:
  *   1. Simple VAR: A+ B C - A has max=INF, gets both flags
- *   2. Group: (A B)+ C - END has max=INF, all children are {1,1} VARs
- *      A,B,END get ABSORBABLE_BRANCH, only END gets ABSORBABLE
+ *   2. Unbounded GROUP with fixed-length children: (A B{2})+ C
+ *      All children must have min == max (recursively for nested subgroups).
+ *      This is equivalent to unrolling to {1,1} VARs, e.g., (A B B)+ C.
+ *      All elements within the group get ABSORBABLE_BRANCH.
+ *      Only the unbounded END gets ABSORBABLE (judgment point).
+ *      Examples:
+ *        (A B{2})+ C          - B{2} has min==max, step=3
+ *        (A (B C){2} D)+ E    - nested {2} subgroup, step=6
+ *        ((A (B C){2}){2})+   - doubly nested {2}, step=10
+ *        (A ((B C{3}){2} D){2} E)+ F  - deep nesting, step=20
  *
  * Returns false for patterns where absorption cannot work:
  *   - A B+ (unbounded not at start)
  *   - A+? B (reluctant quantifier)
  *   - (A | B)+ (ALT inside group)
- *   - (A B+)+ (unbounded element inside group)
- *   - ((A B)+ C)+ (nested unbounded groups)
+ *   - (A B+)+ (variable-length element inside group)
+ *   - (A B{2,5})+ (min != max inside group)
  */
 static bool
 isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx)
 {
 	RPRPatternElement *elem = &pattern->elements[idx];
 	RPRDepth	startDepth = elem->depth;
-	RPRPatternElement *nextElem;
 	RPRPatternElement *e;
 
 	/* Case 1: Simple unbounded VAR at start (greedy only) */
@@ -1568,21 +1636,19 @@ isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx)
 	}
 
 	/*
-	 * Case 2: Unbounded GROUP - traverse siblings at startDepth and check if
-	 * they're all simple {1,1} VARs, then check if END at startDepth - 1 is
-	 * unbounded greedy.
+	 * Case 2: Unbounded GROUP with fixed-length children.  Each child must
+	 * have min == max (recursively for nested subgroups), ensuring a fixed
+	 * step size per iteration so that count-dominance holds.
 	 */
-	for (e = elem; e->depth == startDepth; e = nextElem)
-	{
-		/* Must be simple {1,1} VAR */
-		if (!RPRElemIsVar(e) || e->min != 1 || e->max != 1)
-			return false;
+	if (!isFixedLengthChildren(pattern, idx, startDepth))
+		return false;
 
-		Assert(e->next != RPR_ELEMIDX_INVALID);
-		nextElem = &pattern->elements[e->next];
-	}
+	/* Find the END element at startDepth - 1 */
+	e = &pattern->elements[idx];
+	while (e->depth >= startDepth)
+		e = &pattern->elements[e->next];
 
-	/* Now e should be END at startDepth - 1 */
+	/* END must be unbounded greedy */
 	if (e->depth == startDepth - 1 &&
 		RPRElemIsEnd(e) && e->max == RPR_QUANTITY_INF &&
 		!RPRElemIsReluctant(e))
@@ -1590,7 +1656,8 @@ isUnboundedStart(RPRPattern *pattern, RPRElemIdx idx)
 		Assert(e->jump == idx); /* END points back to first child */
 
 		/* Set ABSORBABLE_BRANCH on all children, ABSORBABLE on END only */
-		for (e = elem; !RPRElemIsEnd(e); e = &pattern->elements[e->next])
+		for (e = elem; !RPRElemIsEnd(e) || e->depth >= startDepth;
+			 e = &pattern->elements[e->next])
 			e->flags |= RPR_ELEM_ABSORBABLE_BRANCH;
 		e->flags |= RPR_ELEM_ABSORBABLE_BRANCH | RPR_ELEM_ABSORBABLE;
 		return true;
@@ -1654,12 +1721,25 @@ computeAbsorbabilityRecursive(RPRPattern *pattern, RPRElemIdx startIdx,
 	}
 	else if (RPRElemIsBegin(elem))
 	{
-		/* BEGIN: skip to first child and check that */
-		computeAbsorbabilityRecursive(pattern, elem->next, hasAbsorbable);
-
-		/* Mark BEGIN element if contents are absorbable */
-		if (*hasAbsorbable)
+		/*
+		 * BEGIN: first try to treat this BEGIN's children as an unbounded
+		 * group directly (handles nested fixed-length groups like ((A{2}
+		 * B{3}){2})+).  If that fails, skip to first child and recurse as
+		 * before.
+		 */
+		if (isUnboundedStart(pattern, elem->next))
+		{
+			*hasAbsorbable = true;
 			elem->flags |= RPR_ELEM_ABSORBABLE_BRANCH;
+		}
+		else
+		{
+			computeAbsorbabilityRecursive(pattern, elem->next, hasAbsorbable);
+
+			/* Mark BEGIN element if contents are absorbable */
+			if (*hasAbsorbable)
+				elem->flags |= RPR_ELEM_ABSORBABLE_BRANCH;
+		}
 	}
 	else
 	{
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 3168468d0ae..7452cf1b3ab 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -3312,7 +3312,7 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
 -------------------------------------------------------------------------------
  WindowAgg
    Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
-   Pattern: (a{2}){2,}
+   Pattern: (a{2}'){2,}"
    ->  Sort
          Sort Key: id
          ->  Seq Scan on rpr_plan
@@ -4095,6 +4095,87 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
          ->  Seq Scan on rpr_plan
 (6 rows)
 
+-- Fixed-length group absorbable: (A{2} B{3})+ -> (a{2}' b{3}'){2,}"
+-- All children have min == max, equivalent to unrolling to {1,1}
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A{2} B{3})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a{2}' b{3}')+"
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Nested fixed-length group: (A (B C){2} D)+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A (B C){2} D)+)
+             DEFINE A AS val <= 20, B AS val <= 40, C AS val <= 60, D AS val > 60);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' (b' c'){2}' d')+"
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Nested fixed-length with inner quantifier: ((A{2} B{3}){2})+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN (((A{2} B{3}){2})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: ((a{2}' b{3}'){2}')+"
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Non-absorbable fixed-length: (A B{2,5})+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B{2,5})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a b{2,5})+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
+-- Non-absorbable fixed-length: (A B?)+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B?)+)
+             DEFINE A AS val <= 50, B AS val > 50);
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a b?)+
+   ->  Sort
+         Sort Key: id
+         ->  Seq Scan on rpr_plan
+(6 rows)
+
 -- Non-absorbable (unbounded not at start): A B+ -> a b+ (no markers)
 EXPLAIN (COSTS OFF)
 SELECT COUNT(*) OVER w FROM rpr_plan
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index 79cbc246039..560f21f44c2 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -462,10 +462,10 @@ WINDOW w AS (
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+"
    Storage: Memory  Maximum Storage: NkB
-   NFA States: 3 peak, 91 total, 0 merged
-   NFA Contexts: 2 peak, 61 total, 0 pruned
+   NFA States: 4 peak, 91 total, 0 merged
+   NFA Contexts: 3 peak, 61 total, 0 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
-   NFA: 29 absorbed (len 1/1/1.0), 30 skipped (len 1/1/1.0)
+   NFA: 29 absorbed (len 2/2/2.0), 30 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
 
@@ -904,6 +904,188 @@ WINDOW w AS (
    ->  Function Scan on generate_series s (actual rows=100.00 loops=1)
 (9 rows)
 
+-- Fixed-length group absorption: (A B B)+ C
+-- B B merged to B{2}; absorbable with fixed-length check
+-- step_size=3 (A + B + B); v % 7 cycle gives 2 iterations per match
+CREATE VIEW rpr_ev20b AS
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20b'), E'\n')) AS line WHERE line ~ 'PATTERN';
+          line           
+-------------------------
+   PATTERN ((a b b)+ c) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=70.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' b{2}')+" c
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 3 peak, 91 total, 0 merged
+   NFA Contexts: 4 peak, 71 total, 40 pruned
+   NFA: 10 matched (len 7/7/7.0), 0 mismatched
+   NFA: 10 absorbed (len 3/3/3.0), 10 skipped (len 1/1/1.0)
+   ->  Function Scan on generate_series s (actual rows=70.00 loops=1)
+(9 rows)
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- step_size = 1 + (1+1)*2 + 1 = 6; v % 13 cycle gives 2 iterations + E
+CREATE VIEW rpr_ev20c AS
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20c'), E'\n')) AS line WHERE line ~ 'PATTERN';
+              line              
+--------------------------------
+   PATTERN ((a (b c){2} d)+ e) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=65.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' (b' c'){2}' d')+" e
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 3 peak, 76 total, 0 merged
+   NFA Contexts: 4 peak, 66 total, 50 pruned
+   NFA: 5 matched (len 13/13/13.0), 0 mismatched
+   NFA: 5 absorbed (len 6/6/6.0), 5 skipped (len 1/1/1.0)
+   ->  Function Scan on generate_series s (actual rows=65.00 loops=1)
+(9 rows)
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; v % 41 cycle gives 2 iterations + F
+CREATE VIEW rpr_ev20d AS
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20d'), E'\n')) AS line WHERE line ~ 'PATTERN';
+                   line                    
+-------------------------------------------
+   PATTERN ((a ((b c c c){2} d){2} e)+ f) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=82.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: (a' ((b' c{3}'){2}' d'){2}' e')+" f
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 3 peak, 87 total, 0 merged
+   NFA Contexts: 4 peak, 83 total, 76 pruned
+   NFA: 2 matched (len 41/41/41.0), 0 mismatched
+   NFA: 2 absorbed (len 20/20/20.0), 2 skipped (len 1/1/1.0)
+   ->  Function Scan on generate_series s (actual rows=82.00 loops=1)
+(9 rows)
+
+-- 3-level END chain absorption: ((A (B C){2}){2})+
+-- step_size = (1 + (1+1)*2) * 2 = 10; v % 21 cycle gives 2 iterations
+-- END chain: END(BC{2}) -> END(A..{2}) -> END(+, ABSORBABLE)
+CREATE VIEW rpr_ev20e AS
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20e'), E'\n')) AS line WHERE line ~ 'PATTERN';
+              line               
+---------------------------------
+   PATTERN (((a (b c){2}){2})+) 
+(1 row)
+
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);');
+                          rpr_explain_filter                          
+----------------------------------------------------------------------
+ WindowAgg (actual rows=42.00 loops=1)
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: ((a' (b' c'){2}'){2}')+"
+   Storage: Memory  Maximum Storage: NkB
+   NFA States: 5 peak, 47 total, 0 merged
+   NFA Contexts: 5 peak, 43 total, 30 pruned
+   NFA: 2 matched (len 20/20/20.0), 0 mismatched
+   NFA: 2 absorbed (len 10/10/10.0), 8 skipped (len 1/5/3.0)
+   ->  Function Scan on generate_series s (actual rows=42.00 loops=1)
+(9 rows)
+
 -- ============================================================
 -- Match Length Statistics Tests
 -- ============================================================
@@ -1894,10 +2076,10 @@ WINDOW w AS (
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b' c')+"
    Storage: Memory  Maximum Storage: NkB
-   NFA States: 3 peak, 81 total, 0 merged
-   NFA Contexts: 3 peak, 61 total, 20 pruned
+   NFA States: 4 peak, 81 total, 0 merged
+   NFA Contexts: 4 peak, 61 total, 20 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
-   NFA: 19 absorbed (len 1/1/1.0), 20 skipped (len 1/1/1.0)
+   NFA: 19 absorbed (len 3/3/3.0), 20 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
 
@@ -2461,7 +2643,7 @@ WINDOW w AS (
    NFA States: 4 peak, 102 total, 0 merged
    NFA Contexts: 2 peak, 41 total, 10 pruned
    NFA: 10 matched (len 3/3/3.0), 0 mismatched
-   NFA: 20 absorbed (len 1/1/1.0), 0 skipped
+   NFA: 10 absorbed (len 1/1/1.0), 10 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
 (9 rows)
 
@@ -3158,10 +3340,10 @@ WINDOW w AS (
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    Pattern: (a' b')+"
    Storage: Memory  Maximum Storage: NkB
-   NFA States: 3 peak, 61 total, 0 merged
-   NFA Contexts: 2 peak, 41 total, 0 pruned
+   NFA States: 4 peak, 61 total, 0 merged
+   NFA Contexts: 3 peak, 41 total, 0 pruned
    NFA: 1 matched (len 40/40/40.0), 0 mismatched
-   NFA: 19 absorbed (len 1/1/1.0), 20 skipped (len 1/1/1.0)
+   NFA: 19 absorbed (len 2/2/2.0), 20 skipped (len 1/1/1.0)
    ->  Function Scan on generate_series s (actual rows=40.00 loops=1)
 (9 rows)
 
@@ -3234,12 +3416,12 @@ WINDOW w AS (
 ----------------------------------------------------------------------
  WindowAgg (actual rows=60.00 loops=1)
    Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
-   Pattern: ((a b){2})+
+   Pattern: ((a' b'){2}')+"
    Storage: Memory  Maximum Storage: NkB
    NFA States: 5 peak, 76 total, 0 merged
    NFA Contexts: 4 peak, 61 total, 15 pruned
    NFA: 1 matched (len 60/60/60.0), 0 mismatched
-   NFA: 0 absorbed, 44 skipped (len 1/4/2.3)
+   NFA: 14 absorbed (len 4/4/4.0), 30 skipped (len 1/2/1.5)
    ->  Function Scan on generate_series s (actual rows=60.00 loops=1)
 (9 rows)
 
diff --git a/src/test/regress/expected/rpr_nfa.out b/src/test/regress/expected/rpr_nfa.out
index 7b5a17fb671..250f7f131b1 100644
--- a/src/test/regress/expected/rpr_nfa.out
+++ b/src/test/regress/expected/rpr_nfa.out
@@ -447,6 +447,327 @@ WINDOW w AS (
   7 | {X}   |             |          
 (7 rows)
 
+-- Fixed-length group absorption: (A B{2})+ C
+-- B{2} has min == max, equivalent to unrolling to (A B B)+ C
+WITH test_absorb_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),
+        (11, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B{2})+ C)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        10
+  2 | {B}   |             |          
+  3 | {B}   |             |          
+  4 | {A}   |             |          
+  5 | {B}   |             |          
+  6 | {B}   |             |          
+  7 | {A}   |             |          
+  8 | {B}   |             |          
+  9 | {B}   |             |          
+ 10 | {C}   |             |          
+ 11 | {X}   |             |          
+(11 rows)
+
+-- Consecutive vars merged to fixed-length: (A B B)+ -> (A B{2})+
+-- mergeConsecutiveVars produces B{2}; now absorbable with fixed-length check
+WITH test_absorb_consecutive AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_consecutive
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |         9
+  2 | {B}   |             |          
+  3 | {B}   |             |          
+  4 | {A}   |             |          
+  5 | {B}   |             |          
+  6 | {B}   |             |          
+  7 | {A}   |             |          
+  8 | {B}   |             |          
+  9 | {B}   |             |          
+ 10 | {X}   |             |          
+(10 rows)
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- Inner group {2} has min == max; absorbable via recursive check
+-- step_size = 1 + (1+1)*2 + 1 = 6
+WITH test_absorb_nested_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),
+        (6,  ARRAY['D']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['C']),
+        (10, ARRAY['B']),
+        (11, ARRAY['C']),
+        (12, ARRAY['D']),
+        (13, ARRAY['E']),
+        (14, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_nested_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        13
+  2 | {B}   |             |          
+  3 | {C}   |             |          
+  4 | {B}   |             |          
+  5 | {C}   |             |          
+  6 | {D}   |             |          
+  7 | {A}   |             |          
+  8 | {B}   |             |          
+  9 | {C}   |             |          
+ 10 | {B}   |             |          
+ 11 | {C}   |             |          
+ 12 | {D}   |             |          
+ 13 | {E}   |             |          
+ 14 | {X}   |             |          
+(14 rows)
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; 2 iterations + F = 41 rows
+WITH test_absorb_doubly_nested AS (
+    SELECT v AS id, ARRAY[
+        CASE
+            WHEN v % 41 IN (1, 21)  THEN 'A'
+            WHEN v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35) THEN 'B'
+            WHEN v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                            23,24,25, 27,28,29, 32,33,34, 36,37,38) THEN 'C'
+            WHEN v % 41 IN (10, 19, 30, 39) THEN 'D'
+            WHEN v % 41 IN (20, 40) THEN 'E'
+            WHEN v % 41 = 0 THEN 'F'
+            ELSE 'X'
+        END
+    ] AS flags
+    FROM generate_series(1, 82) AS s(v)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_doubly_nested
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags),
+        F AS 'F' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        41
+  2 | {B}   |             |          
+  3 | {C}   |             |          
+  4 | {C}   |             |          
+  5 | {C}   |             |          
+  6 | {B}   |             |          
+  7 | {C}   |             |          
+  8 | {C}   |             |          
+  9 | {C}   |             |          
+ 10 | {D}   |             |          
+ 11 | {B}   |             |          
+ 12 | {C}   |             |          
+ 13 | {C}   |             |          
+ 14 | {C}   |             |          
+ 15 | {B}   |             |          
+ 16 | {C}   |             |          
+ 17 | {C}   |             |          
+ 18 | {C}   |             |          
+ 19 | {D}   |             |          
+ 20 | {E}   |             |          
+ 21 | {A}   |             |          
+ 22 | {B}   |             |          
+ 23 | {C}   |             |          
+ 24 | {C}   |             |          
+ 25 | {C}   |             |          
+ 26 | {B}   |             |          
+ 27 | {C}   |             |          
+ 28 | {C}   |             |          
+ 29 | {C}   |             |          
+ 30 | {D}   |             |          
+ 31 | {B}   |             |          
+ 32 | {C}   |             |          
+ 33 | {C}   |             |          
+ 34 | {C}   |             |          
+ 35 | {B}   |             |          
+ 36 | {C}   |             |          
+ 37 | {C}   |             |          
+ 38 | {C}   |             |          
+ 39 | {D}   |             |          
+ 40 | {E}   |             |          
+ 41 | {F}   |             |          
+ 42 | {A}   |          42 |        82
+ 43 | {B}   |             |          
+ 44 | {C}   |             |          
+ 45 | {C}   |             |          
+ 46 | {C}   |             |          
+ 47 | {B}   |             |          
+ 48 | {C}   |             |          
+ 49 | {C}   |             |          
+ 50 | {C}   |             |          
+ 51 | {D}   |             |          
+ 52 | {B}   |             |          
+ 53 | {C}   |             |          
+ 54 | {C}   |             |          
+ 55 | {C}   |             |          
+ 56 | {B}   |             |          
+ 57 | {C}   |             |          
+ 58 | {C}   |             |          
+ 59 | {C}   |             |          
+ 60 | {D}   |             |          
+ 61 | {E}   |             |          
+ 62 | {A}   |             |          
+ 63 | {B}   |             |          
+ 64 | {C}   |             |          
+ 65 | {C}   |             |          
+ 66 | {C}   |             |          
+ 67 | {B}   |             |          
+ 68 | {C}   |             |          
+ 69 | {C}   |             |          
+ 70 | {C}   |             |          
+ 71 | {D}   |             |          
+ 72 | {B}   |             |          
+ 73 | {C}   |             |          
+ 74 | {C}   |             |          
+ 75 | {C}   |             |          
+ 76 | {B}   |             |          
+ 77 | {C}   |             |          
+ 78 | {C}   |             |          
+ 79 | {C}   |             |          
+ 80 | {D}   |             |          
+ 81 | {E}   |             |          
+ 82 | {F}   |             |          
+(82 rows)
+
+-- 3-level END chain: ((A (B C){2}){2})+
+-- Tests END(BC{2}) -> END(A..{2}) -> END(+) chaining
+-- 2 iterations of +, each 10 rows: (A B C B C)(A B C B C)
+WITH test_absorb_3level_end AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),  -- 1st + iter, 1st {2}, A
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),  -- 1st (BC){2} done
+        (6,  ARRAY['A']),  -- 1st + iter, 2nd {2}, A
+        (7,  ARRAY['B']),
+        (8,  ARRAY['C']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),  -- 2nd (BC){2} done, 1st {2} done, 1st + iter done
+        (11, ARRAY['A']),  -- 2nd + iter, 1st {2}, A
+        (12, ARRAY['B']),
+        (13, ARRAY['C']),
+        (14, ARRAY['B']),
+        (15, ARRAY['C']),
+        (16, ARRAY['A']),  -- 2nd + iter, 2nd {2}, A
+        (17, ARRAY['B']),
+        (18, ARRAY['C']),
+        (19, ARRAY['B']),
+        (20, ARRAY['C']),  -- 2nd + iter done
+        (21, ARRAY['X'])   -- no match, + ends
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_3level_end
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+ id | flags | match_start | match_end 
+----+-------+-------------+-----------
+  1 | {A}   |           1 |        20
+  2 | {B}   |             |          
+  3 | {C}   |             |          
+  4 | {B}   |             |          
+  5 | {C}   |             |          
+  6 | {A}   |             |          
+  7 | {B}   |             |          
+  8 | {C}   |             |          
+  9 | {B}   |             |          
+ 10 | {C}   |             |          
+ 11 | {A}   |             |          
+ 12 | {B}   |             |          
+ 13 | {C}   |             |          
+ 14 | {B}   |             |          
+ 15 | {C}   |             |          
+ 16 | {A}   |             |          
+ 17 | {B}   |             |          
+ 18 | {C}   |             |          
+ 19 | {B}   |             |          
+ 20 | {C}   |             |          
+ 21 | {X}   |             |          
+(21 rows)
+
 -- Multiple unbounded: A+ B+ (first element unbounded enables absorption)
 WITH test_multi_unbounded AS (
     SELECT * FROM (VALUES
diff --git a/src/test/regress/sql/rpr_base.sql b/src/test/regress/sql/rpr_base.sql
index cf6c062ae85..8c23c7598a3 100644
--- a/src/test/regress/sql/rpr_base.sql
+++ b/src/test/regress/sql/rpr_base.sql
@@ -2600,6 +2600,42 @@ WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
              AFTER MATCH SKIP PAST LAST ROW PATTERN ((A+ B | A B)*)
              DEFINE A AS val <= 50, B AS val > 50);
 
+-- Fixed-length group absorbable: (A{2} B{3})+ -> (a{2}' b{3}'){2,}"
+-- All children have min == max, equivalent to unrolling to {1,1}
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A{2} B{3})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
+-- Nested fixed-length group: (A (B C){2} D)+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A (B C){2} D)+)
+             DEFINE A AS val <= 20, B AS val <= 40, C AS val <= 60, D AS val > 60);
+
+-- Nested fixed-length with inner quantifier: ((A{2} B{3}){2})+ -> absorbable
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN (((A{2} B{3}){2})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
+-- Non-absorbable fixed-length: (A B{2,5})+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B{2,5})+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
+-- Non-absorbable fixed-length: (A B?)+ -> no markers (min != max)
+EXPLAIN (COSTS OFF)
+SELECT COUNT(*) OVER w FROM rpr_plan
+WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             AFTER MATCH SKIP PAST LAST ROW PATTERN ((A B?)+)
+             DEFINE A AS val <= 50, B AS val > 50);
+
 -- Non-absorbable (unbounded not at start): A B+ -> a b+ (no markers)
 EXPLAIN (COSTS OFF)
 SELECT COUNT(*) OVER w FROM rpr_plan
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index 93e06b0cbdf..237f0366631 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -578,6 +578,120 @@ WINDOW w AS (
     DEFINE A AS v % 3 = 1, B AS v % 3 = 2, C AS v % 3 = 0
 );');
 
+-- Fixed-length group absorption: (A B B)+ C
+-- B B merged to B{2}; absorbable with fixed-length check
+-- step_size=3 (A + B + B); v % 7 cycle gives 2 iterations per match
+CREATE VIEW rpr_ev20b AS
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20b'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 70) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+ C)
+    DEFINE A AS v % 7 IN (1, 4), B AS v % 7 IN (2, 3, 5, 6), C AS v % 7 = 0
+);');
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- step_size = 1 + (1+1)*2 + 1 = 6; v % 13 cycle gives 2 iterations + E
+CREATE VIEW rpr_ev20c AS
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20c'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 65) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE A AS v % 13 IN (1, 7), B AS v % 13 IN (2, 4, 8, 10),
+           C AS v % 13 IN (3, 5, 9, 11), D AS v % 13 IN (6, 12),
+           E AS v % 13 = 0
+);');
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; v % 41 cycle gives 2 iterations + F
+CREATE VIEW rpr_ev20d AS
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20d'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 82) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE A AS v % 41 IN (1, 21),
+           B AS v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35),
+           C AS v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                           23,24,25, 27,28,29, 32,33,34, 36,37,38),
+           D AS v % 41 IN (10, 19, 30, 39),
+           E AS v % 41 IN (20, 40),
+           F AS v % 41 = 0
+);');
+
+-- 3-level END chain absorption: ((A (B C){2}){2})+
+-- step_size = (1 + (1+1)*2) * 2 = 10; v % 21 cycle gives 2 iterations
+-- END chain: END(BC{2}) -> END(A..{2}) -> END(+, ABSORBABLE)
+CREATE VIEW rpr_ev20e AS
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);
+SELECT line FROM unnest(string_to_array(pg_get_viewdef('rpr_ev20e'), E'\n')) AS line WHERE line ~ 'PATTERN';
+SELECT rpr_explain_filter('
+EXPLAIN (ANALYZE, BUFFERS OFF, COSTS OFF, TIMING OFF, SUMMARY OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 42) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE A AS v % 21 IN (1, 6, 11, 16),
+           B AS v % 21 IN (2, 4, 7, 9, 12, 14, 17, 19),
+           C AS v % 21 IN (3, 5, 8, 10, 13, 15, 18, 20)
+);');
+
 -- ============================================================
 -- Match Length Statistics Tests
 -- ============================================================
diff --git a/src/test/regress/sql/rpr_nfa.sql b/src/test/regress/sql/rpr_nfa.sql
index 5edcb3357e6..aaa7b44f789 100644
--- a/src/test/regress/sql/rpr_nfa.sql
+++ b/src/test/regress/sql/rpr_nfa.sql
@@ -346,6 +346,174 @@ WINDOW w AS (
         B AS 'B' = ANY(flags)
 );
 
+-- Fixed-length group absorption: (A B{2})+ C
+-- B{2} has min == max, equivalent to unrolling to (A B B)+ C
+WITH test_absorb_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),
+        (11, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B{2})+ C)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+
+-- Consecutive vars merged to fixed-length: (A B B)+ -> (A B{2})+
+-- mergeConsecutiveVars produces B{2}; now absorbable with fixed-length check
+WITH test_absorb_consecutive AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['B']),
+        (4,  ARRAY['A']),
+        (5,  ARRAY['B']),
+        (6,  ARRAY['B']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_consecutive
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A B B)+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags)
+);
+
+-- Nested fixed-length group absorption: (A (B C){2} D)+ E
+-- Inner group {2} has min == max; absorbable via recursive check
+-- step_size = 1 + (1+1)*2 + 1 = 6
+WITH test_absorb_nested_fixedlen AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),
+        (6,  ARRAY['D']),
+        (7,  ARRAY['A']),
+        (8,  ARRAY['B']),
+        (9,  ARRAY['C']),
+        (10, ARRAY['B']),
+        (11, ARRAY['C']),
+        (12, ARRAY['D']),
+        (13, ARRAY['E']),
+        (14, ARRAY['X'])
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_nested_fixedlen
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A (B C){2} D)+ E)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags)
+);
+
+-- Doubly nested fixed-length group absorption: (A ((B C{3}){2} D){2} E)+ F
+-- step_size = 1 + ((1+3)*2+1)*2 + 1 = 20; 2 iterations + F = 41 rows
+WITH test_absorb_doubly_nested AS (
+    SELECT v AS id, ARRAY[
+        CASE
+            WHEN v % 41 IN (1, 21)  THEN 'A'
+            WHEN v % 41 IN (2, 6, 11, 15, 22, 26, 31, 35) THEN 'B'
+            WHEN v % 41 IN (3,4,5, 7,8,9, 12,13,14, 16,17,18,
+                            23,24,25, 27,28,29, 32,33,34, 36,37,38) THEN 'C'
+            WHEN v % 41 IN (10, 19, 30, 39) THEN 'D'
+            WHEN v % 41 IN (20, 40) THEN 'E'
+            WHEN v % 41 = 0 THEN 'F'
+            ELSE 'X'
+        END
+    ] AS flags
+    FROM generate_series(1, 82) AS s(v)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_doubly_nested
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN ((A ((B C C C){2} D){2} E)+ F)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags),
+        D AS 'D' = ANY(flags),
+        E AS 'E' = ANY(flags),
+        F AS 'F' = ANY(flags)
+);
+
+-- 3-level END chain: ((A (B C){2}){2})+
+-- Tests END(BC{2}) -> END(A..{2}) -> END(+) chaining
+-- 2 iterations of +, each 10 rows: (A B C B C)(A B C B C)
+WITH test_absorb_3level_end AS (
+    SELECT * FROM (VALUES
+        (1,  ARRAY['A']),  -- 1st + iter, 1st {2}, A
+        (2,  ARRAY['B']),
+        (3,  ARRAY['C']),
+        (4,  ARRAY['B']),
+        (5,  ARRAY['C']),  -- 1st (BC){2} done
+        (6,  ARRAY['A']),  -- 1st + iter, 2nd {2}, A
+        (7,  ARRAY['B']),
+        (8,  ARRAY['C']),
+        (9,  ARRAY['B']),
+        (10, ARRAY['C']),  -- 2nd (BC){2} done, 1st {2} done, 1st + iter done
+        (11, ARRAY['A']),  -- 2nd + iter, 1st {2}, A
+        (12, ARRAY['B']),
+        (13, ARRAY['C']),
+        (14, ARRAY['B']),
+        (15, ARRAY['C']),
+        (16, ARRAY['A']),  -- 2nd + iter, 2nd {2}, A
+        (17, ARRAY['B']),
+        (18, ARRAY['C']),
+        (19, ARRAY['B']),
+        (20, ARRAY['C']),  -- 2nd + iter done
+        (21, ARRAY['X'])   -- no match, + ends
+    ) AS t(id, flags)
+)
+SELECT id, flags, first_value(id) OVER w AS match_start, last_value(id) OVER w AS match_end
+FROM test_absorb_3level_end
+WINDOW w AS (
+    ORDER BY id
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (((A (B C){2}){2})+)
+    DEFINE
+        A AS 'A' = ANY(flags),
+        B AS 'B' = ANY(flags),
+        C AS 'C' = ANY(flags)
+);
+
 -- Multiple unbounded: A+ B+ (first element unbounded enables absorption)
 WITH test_multi_unbounded AS (
     SELECT * FROM (VALUES
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0010-Rename-rpr_explain-test-views-to-descriptive-names.txt (127.8K, 12-nocfbot-0010-Rename-rpr_explain-test-views-to-descriptive-names.txt)
  download

  [text/plain] nocfbot-0011-Fix-quote_identifier-deparse.txt (7.3K, 13-nocfbot-0011-Fix-quote_identifier-deparse.txt)
  download | inline diff:
From 5a5e04eb3becfc5cd185b67243fe15eff5ef6f56 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 08:53:46 +0900
Subject: [PATCH] Fix quote_identifier() for RPR pattern variable name deparse

Add quote_identifier() to PATTERN and DEFINE variable name output
in ruleutils.c and explain.c.  Without quoting, mixed-case or
reserved-word variable names (e.g., "Start", "Up") lose their
case or conflict with keywords in pg_get_viewdef() output,
breaking pg_dump/pg_restore round-trips.

Add regression test with quoted identifiers ("Start", "Up") to
verify correct deparse in both pg_get_viewdef and EXPLAIN output.
---
 src/backend/commands/explain.c            |  2 +-
 src/backend/utils/adt/ruleutils.c         |  4 ++--
 src/test/regress/expected/rpr_base.out    | 24 +++++++++++++++++++++++
 src/test/regress/expected/rpr_explain.out | 19 ++++++++++++++++++
 src/test/regress/sql/rpr_base.sql         | 10 ++++++++++
 src/test/regress/sql/rpr_explain.sql      | 12 ++++++++++++
 6 files changed, 68 insertions(+), 3 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 7f0367ce546..933eadab71e 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3176,7 +3176,7 @@ deparse_rpr_var(RPRPattern *pattern, int *idx, StringInfoData *buf,
 		appendStringInfoChar(buf, ' ');
 
 	Assert(elem->varId < pattern->numVars);
-	appendStringInfoString(buf, pattern->varNames[elem->varId]);
+	appendStringInfoString(buf, quote_identifier(pattern->varNames[elem->varId]));
 	append_rpr_quantifier(buf, elem);
 	*needSpace = true;
 
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index cfe24de43cf..c755a42efd6 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -7160,7 +7160,7 @@ get_rule_pattern_node(RPRPatternNode *node, deparse_context *context)
 	switch (node->nodeType)
 	{
 		case RPR_PATTERN_VAR:
-			appendStringInfoString(buf, node->varName);
+			appendStringInfoString(buf, quote_identifier(node->varName));
 			append_pattern_quantifier(buf, node);
 			break;
 
@@ -7229,7 +7229,7 @@ get_rule_define(List *defineClause, bool force_colno, deparse_context *context)
 	{
 		TargetEntry *te = (TargetEntry *) lfirst(lc_def);
 
-		appendStringInfo(buf, "%s%s AS ", sep, te->resname);
+		appendStringInfo(buf, "%s%s AS ", sep, quote_identifier(te->resname));
 		get_rule_expr((Node *) te->expr, context, false);
 		sep = ",\n  ";
 	}
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 7452cf1b3ab..6526365dd6a 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -2252,6 +2252,30 @@ SELECT pg_get_viewdef('rpr_quant_reluctant_v'::regclass);
    b AS (val > 0) );
 (1 row)
 
+-- Quoted identifier round-trip: mixed case and reserved words need quoting
+CREATE VIEW rpr_serial_quoted AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN ("Start" "Up"+)
+             DEFINE "Start" AS TRUE, "Up" AS val > PREV(val));
+SELECT pg_get_viewdef('rpr_serial_quoted'::regclass);
+                                pg_get_viewdef                                
+------------------------------------------------------------------------------
+  SELECT id,                                                                 +
+     val,                                                                    +
+     count(*) OVER w AS count                                                +
+    FROM rpr_serial                                                          +
+   WINDOW w AS (ORDER BY id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING +
+   AFTER MATCH SKIP PAST LAST ROW                                            +
+   INITIAL                                                                   +
+   PATTERN ("Start" "Up"+)                                                   +
+   DEFINE                                                                    +
+   "Start" AS true,                                                          +
+   "Up" AS (val > prev(val)) );
+(1 row)
+
 -- Materialized view (if supported)
 CREATE TABLE rpr_mview (id INT, val INT);
 INSERT INTO rpr_mview VALUES (1, 10), (2, 20), (3, 30);
diff --git a/src/test/regress/expected/rpr_explain.out b/src/test/regress/expected/rpr_explain.out
index f66caf8908e..a68ec61e10f 100644
--- a/src/test/regress/expected/rpr_explain.out
+++ b/src/test/regress/expected/rpr_explain.out
@@ -301,6 +301,25 @@ WINDOW w AS (
    ->  Function Scan on generate_series s (actual rows=30.00 loops=1)
 (8 rows)
 
+-- Regression test: Quoted identifiers in EXPLAIN pattern deparse
+-- Mixed case names must be quoted to preserve round-trip safety
+SELECT rpr_explain_filter('
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 10) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN ("Start" "Up"+)
+    DEFINE "Start" AS TRUE, "Up" AS v > PREV(v)
+);');
+                        rpr_explain_filter                         
+-------------------------------------------------------------------
+ WindowAgg
+   Window: w AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Pattern: "Start" "Up"+
+   ->  Function Scan on generate_series s
+(4 rows)
+
 -- ============================================================
 -- State Statistics Tests (peak, total, merged)
 -- ============================================================
diff --git a/src/test/regress/sql/rpr_base.sql b/src/test/regress/sql/rpr_base.sql
index 8c23c7598a3..3accecb73ba 100644
--- a/src/test/regress/sql/rpr_base.sql
+++ b/src/test/regress/sql/rpr_base.sql
@@ -1559,6 +1559,16 @@ WINDOW w AS (ORDER BY id
              DEFINE A AS val > 0, B AS val > 0);
 SELECT pg_get_viewdef('rpr_quant_reluctant_v'::regclass);
 
+-- Quoted identifier round-trip: mixed case and reserved words need quoting
+CREATE VIEW rpr_serial_quoted AS
+SELECT id, val, count(*) OVER w
+FROM rpr_serial
+WINDOW w AS (ORDER BY id
+             ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+             PATTERN ("Start" "Up"+)
+             DEFINE "Start" AS TRUE, "Up" AS val > PREV(val));
+SELECT pg_get_viewdef('rpr_serial_quoted'::regclass);
+
 -- Materialized view (if supported)
 
 CREATE TABLE rpr_mview (id INT, val INT);
diff --git a/src/test/regress/sql/rpr_explain.sql b/src/test/regress/sql/rpr_explain.sql
index 65a775fdad9..703ecd3b23b 100644
--- a/src/test/regress/sql/rpr_explain.sql
+++ b/src/test/regress/sql/rpr_explain.sql
@@ -226,6 +226,18 @@ WINDOW w AS (
     DEFINE A AS v % 5 = 1, B AS v % 5 = 2, C AS v % 5 = 3, D AS v % 5 = 4, E AS v % 5 = 0
 );');
 
+-- Regression test: Quoted identifiers in EXPLAIN pattern deparse
+-- Mixed case names must be quoted to preserve round-trip safety
+SELECT rpr_explain_filter('
+EXPLAIN (COSTS OFF)
+SELECT count(*) OVER w
+FROM generate_series(1, 10) AS s(v)
+WINDOW w AS (
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    PATTERN ("Start" "Up"+)
+    DEFINE "Start" AS TRUE, "Up" AS v > PREV(v)
+);');
+
 -- ============================================================
 -- State Statistics Tests (peak, total, merged)
 -- ============================================================
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0012-Fix-execRPR-Makefile-ordering.txt (723B, 14-nocfbot-0012-Fix-execRPR-Makefile-ordering.txt)
  download | inline diff:
From b49e64adde991d23799fdfd309e6d996c8d2e5c6 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:14:59 +0900
Subject: [PATCH] Fix execRPR.o ordering in executor Makefile to match
 meson.build

---
 src/backend/executor/Makefile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/backend/executor/Makefile b/src/backend/executor/Makefile
index eeed9a904e5..2b257427795 100644
--- a/src/backend/executor/Makefile
+++ b/src/backend/executor/Makefile
@@ -25,8 +25,8 @@ OBJS = \
 	execParallel.o \
 	execPartition.o \
 	execProcnode.o \
-	execReplication.o \
 	execRPR.o \
+	execReplication.o \
 	execSRF.o \
 	execScan.o \
 	execTuples.o \
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0013-Remove-unused-force_colno.txt (2.7K, 15-nocfbot-0013-Remove-unused-force_colno.txt)
  download | inline diff:
From 80726d6151aa00ae6edf73fc49498aac75f9fb28 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:40:53 +0900
Subject: [PATCH] Remove unused force_colno parameter from RPR deparse
 functions

---
 src/backend/utils/adt/ruleutils.c | 15 ++++++---------
 1 file changed, 6 insertions(+), 9 deletions(-)

diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index c755a42efd6..e93c03a351c 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -449,10 +449,8 @@ static void get_rule_orderby(List *orderList, List *targetList,
 							 bool force_colno, deparse_context *context);
 static void append_pattern_quantifier(StringInfo buf, RPRPatternNode *node);
 static void get_rule_pattern_node(RPRPatternNode *node, deparse_context *context);
-static void get_rule_pattern(RPRPatternNode *rpPattern, bool force_colno,
-							 deparse_context *context);
-static void get_rule_define(List *defineClause, bool force_colno,
-							deparse_context *context);
+static void get_rule_pattern(RPRPatternNode *rpPattern, deparse_context *context);
+static void get_rule_define(List *defineClause, deparse_context *context);
 static void get_rule_windowclause(Query *query, deparse_context *context);
 static void get_rule_windowspec(WindowClause *wc, List *targetList,
 								deparse_context *context);
@@ -7203,8 +7201,7 @@ get_rule_pattern_node(RPRPatternNode *node, deparse_context *context)
  * Display a PATTERN clause.
  */
 static void
-get_rule_pattern(RPRPatternNode *rpPattern, bool force_colno,
-				 deparse_context *context)
+get_rule_pattern(RPRPatternNode *rpPattern, deparse_context *context)
 {
 	StringInfo	buf = context->buf;
 
@@ -7217,7 +7214,7 @@ get_rule_pattern(RPRPatternNode *rpPattern, bool force_colno,
  * Display a DEFINE clause.
  */
 static void
-get_rule_define(List *defineClause, bool force_colno, deparse_context *context)
+get_rule_define(List *defineClause, deparse_context *context)
 {
 	StringInfo	buf = context->buf;
 	const char *sep;
@@ -7356,7 +7353,7 @@ get_rule_windowspec(WindowClause *wc, List *targetList,
 		if (needspace)
 			appendStringInfoChar(buf, ' ');
 		appendStringInfoString(buf, "\n  PATTERN ");
-		get_rule_pattern(wc->rpPattern, false, context);
+		get_rule_pattern(wc->rpPattern, context);
 		needspace = true;
 	}
 
@@ -7365,7 +7362,7 @@ get_rule_windowspec(WindowClause *wc, List *targetList,
 		if (needspace)
 			appendStringInfoChar(buf, ' ');
 		appendStringInfoString(buf, "\n  DEFINE\n");
-		get_rule_define(wc->defineClause, false, context);
+		get_rule_define(wc->defineClause, context);
 		appendStringInfoChar(buf, ' ');
 	}
 
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0014-CHECK_FOR_INTERRUPTS-cleanup-finalize.txt (1.0K, 16-nocfbot-0014-CHECK_FOR_INTERRUPTS-cleanup-finalize.txt)
  download | inline diff:
From 5e4a1d039508c7872adc2506706a7481b3d3755b Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:43:07 +0900
Subject: [PATCH] Add CHECK_FOR_INTERRUPTS to RPR context cleanup and finalize
 loops

---
 src/backend/executor/execRPR.c | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index aec1057e1b2..4c429528b04 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -3068,6 +3068,8 @@ ExecRPRCleanupDeadContexts(WindowAggState *winstate, RPRNFAContext *excludeCtx)
 
 	for (ctx = winstate->nfaContext; ctx != NULL; ctx = next)
 	{
+		CHECK_FOR_INTERRUPTS();
+
 		next = ctx->next;
 
 		/* Skip the target context and contexts still processing */
@@ -3108,6 +3110,8 @@ ExecRPRFinalizeAllContexts(WindowAggState *winstate, int64 lastPos)
 
 	for (ctx = winstate->nfaContext; ctx != NULL; ctx = ctx->next)
 	{
+		CHECK_FOR_INTERRUPTS();
+
 		if (ctx->states != NULL)
 		{
 			nfa_match(winstate, ctx, NULL);
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0015-Narrow-variable-scope-DEFINE-loop.txt (1.3K, 17-nocfbot-0015-Narrow-variable-scope-DEFINE-loop.txt)
  download | inline diff:
From e54dd341aef218be3dcce088ee46c4d35f24d482 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 09:48:55 +0900
Subject: [PATCH] Narrow variable scope in ExecInitWindowAgg DEFINE clause loop

---
 src/backend/executor/nodeWindowAgg.c | 11 +++--------
 1 file changed, 3 insertions(+), 8 deletions(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index dca2de570e8..0202c508323 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -2628,9 +2628,6 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	TupleDesc	scanDesc;
 	ListCell   *l;
 
-	TargetEntry *te;
-	Expr	   *expr;
-
 	/* check for unsupported flags */
 	Assert(!(eflags & (EXEC_FLAG_BACKWARD | EXEC_FLAG_MARK)));
 
@@ -2951,13 +2948,11 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 		 */
 		foreach(l, node->defineClause)
 		{
-			char	   *name;
+			TargetEntry *te = lfirst(l);
+			char	   *name = te->resname;
+			Expr	   *expr = te->expr;
 			ExprState  *exps;
 
-			te = lfirst(l);
-			name = te->resname;
-			expr = te->expr;
-
 			winstate->defineVariableList =
 				lappend(winstate->defineVariableList,
 						makeString(pstrdup(name)));
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0016-Normalize-flag-macros-to-bool.txt (1.4K, 18-nocfbot-0016-Normalize-flag-macros-to-bool.txt)
  download | inline diff:
From 747b855b0bb586e0d0b6164065376079dc5473c5 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 11:32:56 +0900
Subject: [PATCH] Normalize RPR element flag macros to return bool

---
 src/include/optimizer/rpr.h | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/include/optimizer/rpr.h b/src/include/optimizer/rpr.h
index e78092678bb..360e1bb777f 100644
--- a/src/include/optimizer/rpr.h
+++ b/src/include/optimizer/rpr.h
@@ -44,10 +44,10 @@
 #define RPR_ELEM_ABSORBABLE			0x08	/* absorption judgment point */
 
 /* Accessor macros for RPRPatternElement */
-#define RPRElemIsReluctant(e)			((e)->flags & RPR_ELEM_RELUCTANT)
-#define RPRElemCanEmptyLoop(e)			((e)->flags & RPR_ELEM_EMPTY_LOOP)
-#define RPRElemIsAbsorbableBranch(e)	((e)->flags & RPR_ELEM_ABSORBABLE_BRANCH)
-#define RPRElemIsAbsorbable(e)			((e)->flags & RPR_ELEM_ABSORBABLE)
+#define RPRElemIsReluctant(e)			(((e)->flags & RPR_ELEM_RELUCTANT) != 0)
+#define RPRElemCanEmptyLoop(e)			(((e)->flags & RPR_ELEM_EMPTY_LOOP) != 0)
+#define RPRElemIsAbsorbableBranch(e)	(((e)->flags & RPR_ELEM_ABSORBABLE_BRANCH) != 0)
+#define RPRElemIsAbsorbable(e)			(((e)->flags & RPR_ELEM_ABSORBABLE) != 0)
 #define RPRElemIsVar(e)			((e)->varId <= RPR_VARID_MAX)
 #define RPRElemIsBegin(e)		((e)->varId == RPR_VARID_BEGIN)
 #define RPRElemIsEnd(e)			((e)->varId == RPR_VARID_END)
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0017-Implement-1-slot-PREV-NEXT-navigation-for-RPR.txt (85.4K, 19-nocfbot-0017-Implement-1-slot-PREV-NEXT-navigation-for-RPR.txt)
  download

  [text/plain] nocfbot-0018-JIT-support-for-PREV-NEXT.txt (7.0K, 20-nocfbot-0018-JIT-support-for-PREV-NEXT.txt)
  download | inline diff:
From efb99428cfdb41363d49d4b7ca199f9212ba5a6e Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 10:54:30 +0900
Subject: [PATCH] Add JIT compilation support for RPR PREV/NEXT navigation

---
 src/backend/jit/llvm/llvmjit_expr.c | 72 +++++++++++++++++++++--------
 src/test/regress/expected/rpr.out   | 31 +++++++++++++
 src/test/regress/sql/rpr.sql        | 27 +++++++++++
 3 files changed, 111 insertions(+), 19 deletions(-)

diff --git a/src/backend/jit/llvm/llvmjit_expr.c b/src/backend/jit/llvm/llvmjit_expr.c
index d158e37e7b5..4901b2a7ff4 100644
--- a/src/backend/jit/llvm/llvmjit_expr.c
+++ b/src/backend/jit/llvm/llvmjit_expr.c
@@ -127,6 +127,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;
@@ -300,19 +303,16 @@ llvm_compile_expr(ExprState *state)
 	 * 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 pointers become stale because the new slot has
-	 * its own tts_values/tts_isnull arrays.  Fall back to the interpreter for
-	 * these expressions.
+	 * After a slot swap, these cached pointers become stale because the new
+	 * slot has its own tts_values/tts_isnull arrays.
 	 *
-	 * XXX To JIT-compile these expressions properly, the NAV_SET and
-	 * NAV_RESTORE handlers would need to reload the tts_values and tts_isnull
-	 * pointers from the new slot.  However, LLVM uses SSA (Static Single
-	 * Assignment) form where each value is defined exactly once.  When
-	 * different basic blocks produce different values for the same pointer,
-	 * LLVM requires PHI nodes at the merge point to select the correct one.
-	 * Without that plumbing, OUTER_VAR steps after a slot swap would read
-	 * from the wrong pointer.
+	 * 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)
 	{
@@ -323,9 +323,8 @@ llvm_compile_expr(ExprState *state)
 			if (opcode == EEOP_RPR_NAV_SET ||
 				opcode == EEOP_RPR_NAV_RESTORE)
 			{
-				LLVMDeleteFunction(eval_fn);
-				LLVMDisposeBuilder(b);
-				return false;
+				has_rpr_nav = true;
+				break;
 			}
 		}
 	}
@@ -492,8 +491,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)
 					{
@@ -2467,10 +2495,16 @@ llvm_compile_expr(ExprState *state)
 				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:
-				/* unreachable: filtered out by the pre-scan above */
-				Assert(false);
-				return false;
+				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/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index d586e17e0a1..de6ce4fba8a 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -2153,6 +2153,37 @@ SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
            0 |      99998 |     99999
 (1 row)
 
+-- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
+-- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
+-- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit_above_cost = 0;
+WITH data AS (
+ SELECT i, abs(50000 - i) AS price
+ FROM generate_series(1, 100000) i
+),
+result AS (
+ SELECT i, price,
+        count(*) OVER w AS match_len,
+        first_value(price) OVER w AS match_first
+ FROM data
+ WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  INITIAL
+  PATTERN (DOWN+ UP+)
+  DEFINE
+   DOWN AS price < PREV(price),
+   UP AS price > PREV(price)
+ )
+)
+SELECT count(*) AS matched_rows, max(match_len) AS longest_match
+FROM result WHERE match_len > 0;
+ matched_rows | longest_match 
+--------------+---------------
+            1 |         99999
+(1 row)
+
+RESET jit_above_cost;
 --
 -- IGNORE NULLS
 --
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index 504476a2b02..b3bbc8254c4 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -1084,6 +1084,33 @@ result AS (
 -- Should match: A (33333 rows) + B (33333 rows) + C (33333 rows) = 99999 rows
 SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
 
+-- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
+-- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
+-- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit_above_cost = 0;
+WITH data AS (
+ SELECT i, abs(50000 - i) AS price
+ FROM generate_series(1, 100000) i
+),
+result AS (
+ SELECT i, price,
+        count(*) OVER w AS match_len,
+        first_value(price) OVER w AS match_first
+ FROM data
+ WINDOW w AS (
+  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+  AFTER MATCH SKIP PAST LAST ROW
+  INITIAL
+  PATTERN (DOWN+ UP+)
+  DEFINE
+   DOWN AS price < PREV(price),
+   UP AS price > PREV(price)
+ )
+)
+SELECT count(*) AS matched_rows, max(match_len) AS longest_match
+FROM result WHERE match_len > 0;
+RESET jit_above_cost;
+
 --
 -- IGNORE NULLS
 --
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0019-Add-tuplestore-trim-optimization-for-RPR-PREV.txt (12.3K, 21-nocfbot-0019-Add-tuplestore-trim-optimization-for-RPR-PREV.txt)
  download | inline diff:
From 7d9f1f094dbd685a03d982f9d62a86fb392c877f Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Sat, 4 Apr 2026 11:49:32 +0900
Subject: [PATCH] Add tuplestore trim optimization for RPR PREV navigation

Advance the tuplestore mark pointer based on the maximum PREV offset
found in DEFINE clause expressions, allowing tuplestore_trim() to
free rows that PREV can no longer reach.

The planner walks DEFINE expressions to find the maximum PREV offset.
If all offsets are constants, navMaxOffset is set directly. If any
offset is non-constant (parameter or expression), the planner sets
RPR_NAV_OFFSET_NEEDS_EVAL and the executor evaluates all PREV offsets
at init time. The executor then advances the mark to
(currentpos - navMaxOffset) each row.

NEXT offsets are ignored since they look forward and do not affect
trim. RPR_NAV_OFFSET_RETAIN_ALL is reserved for future navigation
functions (FIRST/LAST) that require the entire partition.
---
 src/backend/executor/nodeWindowAgg.c    | 136 ++++++++++++++++++++++--
 src/backend/optimizer/plan/createplan.c | 101 ++++++++++++++++++
 src/include/nodes/execnodes.h           |   1 +
 src/include/nodes/plannodes.h           |   9 ++
 src/include/optimizer/rpr.h             |   9 ++
 5 files changed, 246 insertions(+), 10 deletions(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 4e643df94cf..9787ef7756f 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -244,6 +244,10 @@ static void update_reduced_frame(WindowObject winobj, int64 pos);
 /* Forward declarations - NFA row evaluation */
 static bool nfa_evaluate_row(WindowObject winobj, int64 pos, bool *varMatched);
 
+/* Forward declarations - navigation offset evaluation */
+static bool collect_prev_offset_walker(Node *node, List **offsets);
+static void eval_nav_max_offset(WindowAggState *winstate, List *defineClause);
+
 /*
  * Not null info bit array consists of 2-bit items
  */
@@ -934,12 +938,18 @@ eval_windowaggregates(WindowAggState *winstate)
 		if (rpr_is_defined(winstate))
 		{
 			/*
-			 * If RPR is used, it is possible PREV wants to look at the
-			 * previous row.  So the mark pos should be frameheadpos - 1
-			 * unless it is below 0.
+			 * If RPR is used, PREV may need to look at rows before the frame
+			 * head.  Adjust mark by navMaxOffset if known, otherwise retain
+			 * from position 0.
 			 */
-			markpos -= 1;
-			if (markpos < 0)
+			if (winstate->navMaxOffset >= 0)
+			{
+				if (markpos > winstate->navMaxOffset)
+					markpos -= winstate->navMaxOffset;
+				else
+					markpos = 0;
+			}
+			else
 				markpos = 0;
 		}
 		WinSetMarkPosition(agg_winobj, markpos);
@@ -1269,12 +1279,15 @@ prepare_tuplestore(WindowAggState *winstate)
 	if (winstate->nav_winobj)
 	{
 		/*
-		 * Allocate a mark pointer pinned at position 0 so that the tuplestore
-		 * never truncates rows that a PREV(expr, N) might need.
+		 * Allocate mark and read pointers for PREV/NEXT navigation.
+		 *
+		 * If navMaxOffset >= 0, we advance the mark to (currentpos -
+		 * navMaxOffset) as rows are processed, allowing tuplestore_trim() to
+		 * free rows that are no longer reachable.
 		 *
-		 * XXX This retains the entire partition in the tuplestore.  If the
-		 * DEFINE clause only uses PREV/NEXT with small constant offsets, we
-		 * could advance the mark to (currentpos - max_offset) instead.
+		 * If navMaxOffset < 0 (RPR_NAV_OFFSET_NEEDS_EVAL or
+		 * RPR_NAV_OFFSET_RETAIN_ALL), the mark stays at 0, retaining the
+		 * entire partition in the tuplestore.
 		 */
 		winstate->nav_winobj->markptr =
 			tuplestore_alloc_read_pointer(winstate->buffer, 0);
@@ -2512,6 +2525,24 @@ ExecWindowAgg(PlanState *pstate)
 		if (winstate->grouptail_ptr >= 0)
 			update_grouptailpos(winstate);
 
+		/*
+		 * Advance RPR navigation mark pointer if possible, so that
+		 * tuplestore_trim() can free rows no longer reachable by PREV.
+		 */
+		if (winstate->nav_winobj &&
+			winstate->rpPattern != NULL &&
+			winstate->navMaxOffset >= 0)
+		{
+			int64		navmarkpos;
+
+			if (winstate->currentpos > winstate->navMaxOffset)
+				navmarkpos = winstate->currentpos - winstate->navMaxOffset;
+			else
+				navmarkpos = 0;
+			if (navmarkpos > winstate->nav_winobj->markpos)
+				WinSetMarkPosition(winstate->nav_winobj, navmarkpos);
+		}
+
 		/*
 		 * Truncate any no-longer-needed rows from the tuplestore.
 		 */
@@ -2957,6 +2988,10 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->rpSkipTo = node->rpSkipTo;
 	/* Set up row pattern recognition PATTERN clause (compiled NFA) */
 	winstate->rpPattern = node->rpPattern;
+	/* Set up max PREV offset for tuplestore trim */
+	winstate->navMaxOffset = node->navMaxOffset;
+	if (winstate->navMaxOffset == RPR_NAV_OFFSET_NEEDS_EVAL)
+		eval_nav_max_offset(winstate, node->defineClause);
 
 	/* Calculate NFA state size and allocate cycle detection bitmap */
 	if (node->rpPattern != NULL)
@@ -3867,6 +3902,87 @@ put_notnull_info(WindowObject winobj, int64 pos, int argno, bool isnull)
 	mbp[bpos] = mb;
 }
 
+/*
+ * collect_prev_offset_walker
+ *		Walk expression tree to collect PREV offset_arg expressions.
+ */
+static bool
+collect_prev_offset_walker(Node *node, List **offsets)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, RPRNavExpr))
+	{
+		RPRNavExpr *nav = (RPRNavExpr *) node;
+
+		if (nav->kind == RPR_NAV_PREV && nav->offset_arg != NULL)
+			*offsets = lappend(*offsets, nav->offset_arg);
+
+		/* Don't walk into RPRNavExpr children */
+		return false;
+	}
+
+	return expression_tree_walker(node, collect_prev_offset_walker, offsets);
+}
+
+/*
+ * eval_nav_max_offset
+ *		Evaluate non-constant PREV offsets at executor init time.
+ *
+ * Called when the planner set navMaxOffset to RPR_NAV_OFFSET_NEEDS_EVAL
+ * because some PREV offset contains a parameter or non-foldable expression.
+ * Walks the original defineClause expression trees, compiles and evaluates
+ * each PREV offset_arg, and stores the maximum in winstate->navMaxOffset.
+ */
+static void
+eval_nav_max_offset(WindowAggState *winstate, List *defineClause)
+{
+	ExprContext *econtext = winstate->ss.ps.ps_ExprContext;
+	List	   *offsets = NIL;
+	ListCell   *lc;
+	int64		maxOffset = 0;
+
+	/* Collect all PREV offset expressions from DEFINE clause */
+	foreach(lc, defineClause)
+	{
+		TargetEntry *te = (TargetEntry *) lfirst(lc);
+
+		collect_prev_offset_walker((Node *) te->expr, &offsets);
+	}
+
+	/* Evaluate each offset and find maximum */
+	foreach(lc, offsets)
+	{
+		Expr	   *offset_expr = (Expr *) lfirst(lc);
+		ExprState  *estate;
+		Datum		val;
+		bool		isnull;
+		int64		offset;
+
+		estate = ExecInitExpr(offset_expr, (PlanState *) winstate);
+		val = ExecEvalExprSwitchContext(estate, econtext, &isnull);
+
+		/*
+		 * NULL or negative offsets will cause a runtime error when PREV is
+		 * actually evaluated.  For trim purposes, treat them as 0.
+		 */
+		if (isnull)
+			continue;
+
+		offset = DatumGetInt64(val);
+		if (offset < 0)
+			continue;
+
+		if (offset > maxOffset)
+			maxOffset = offset;
+	}
+
+	winstate->navMaxOffset = maxOffset;
+
+	list_free(offsets);
+}
+
 /*
  * rpr_is_defined
  * return true if Row pattern recognition is defined.
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 9ac24cc222d..ee2d53b5924 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -2461,6 +2461,104 @@ create_minmaxagg_plan(PlannerInfo *root, MinMaxAggPath *best_path)
 	return plan;
 }
 
+/*
+ * nav_max_offset_walker
+ *		Walk expression tree to find the maximum PREV offset.
+ *
+ * Only PREV is relevant for tuplestore trim since it looks backward;
+ * NEXT looks forward and never references already-trimmed rows.
+ *
+ * Returns true (to stop walking) if a non-constant PREV offset is found,
+ * in which case *maxOffset is set to -1.  Otherwise accumulates the
+ * maximum constant offset value.
+ */
+static bool
+nav_max_offset_walker(Node *node, int64 *maxOffset)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, RPRNavExpr))
+	{
+		RPRNavExpr *nav = (RPRNavExpr *) node;
+
+		/* Only PREV looks backward; NEXT is irrelevant for trim */
+		if (nav->kind == RPR_NAV_PREV)
+		{
+			int64		offset;
+
+			if (nav->offset_arg == NULL)
+			{
+				/* 1-arg form: implicit offset of 1 */
+				offset = 1;
+			}
+			else if (IsA(nav->offset_arg, Const))
+			{
+				Const	   *c = (Const *) nav->offset_arg;
+
+				if (c->constisnull)
+				{
+					/*
+					 * NULL offset causes a runtime error, so this path is
+					 * never actually reached during execution.  Use 0 as a
+					 * safe placeholder for planning purposes.
+					 */
+					offset = 0;
+				}
+				else
+				{
+					offset = DatumGetInt64(c->constvalue);
+					if (offset < 0)
+						offset = 0; /* negative offset causes runtime error */
+				}
+			}
+			else
+			{
+				/*
+				 * Non-constant offset (Param, stable function, etc.). The
+				 * parser guarantees offset is a runtime constant, so it can
+				 * be evaluated at executor init time.
+				 */
+				*maxOffset = RPR_NAV_OFFSET_NEEDS_EVAL;
+				return true;	/* stop walking */
+			}
+
+			if (offset > *maxOffset)
+				*maxOffset = offset;
+		}
+
+		/* Don't walk into RPRNavExpr children - offset_arg already handled */
+		return false;
+	}
+
+	return expression_tree_walker(node, nav_max_offset_walker, maxOffset);
+}
+
+/*
+ * compute_nav_max_offset
+ *		Compute the maximum PREV offset from DEFINE clause expressions.
+ *
+ * Returns the maximum constant offset found, or -1 if any PREV offset
+ * cannot be determined statically.  NEXT offsets are ignored since they
+ * look forward and don't affect tuplestore trim.
+ */
+static int64
+compute_nav_max_offset(List *defineClause)
+{
+	int64		maxOffset = 0;
+	ListCell   *lc;
+
+	foreach(lc, defineClause)
+	{
+		TargetEntry *te = (TargetEntry *) lfirst(lc);
+
+		if (nav_max_offset_walker((Node *) te->expr, &maxOffset))
+			return RPR_NAV_OFFSET_NEEDS_EVAL;
+	}
+
+	return maxOffset;
+}
+
 /*
  * create_windowagg_plan
  *
@@ -6678,6 +6776,9 @@ make_windowagg(List *tlist, WindowClause *wc,
 
 	node->defineClause = defineClause;
 
+	/* Compute max PREV offset for tuplestore trim optimization */
+	node->navMaxOffset = compute_nav_max_offset(defineClause);
+
 	plan->targetlist = tlist;
 	plan->lefttree = lefttree;
 	plan->righttree = NULL;
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 74a6b682132..ff6d7d70a60 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2692,6 +2692,7 @@ typedef struct WindowAggState
 	TupleTableSlot *temp_slot_2;
 
 	/* RPR navigation */
+	int64		navMaxOffset;	/* max PREV offset; see RPR_NAV_OFFSET_* */
 	struct WindowObjectData *nav_winobj;	/* winobj for RPR nav fetch */
 	int64		nav_slot_pos;	/* position cached in nav_slot, or -1 */
 	TupleTableSlot *nav_slot;	/* slot for PREV/NEXT target row */
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index ceaab4d97b0..27a2e7b48c7 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -1386,6 +1386,15 @@ typedef struct WindowAgg
 	/* Row Pattern DEFINE clause (list of TargetEntry) */
 	List	   *defineClause;
 
+	/*
+	 * Maximum PREV offset for tuplestore mark optimization. >= 0: statically
+	 * determined max offset (mark = currentpos - offset).
+	 * RPR_NAV_OFFSET_NEEDS_EVAL: has non-constant offset; evaluate at
+	 * executor init.  RPR_NAV_OFFSET_RETAIN_ALL: must retain entire partition
+	 * (no trim possible).
+	 */
+	int64		navMaxOffset;
+
 	/*
 	 * false for all apart from the WindowAgg that's closest to the root of
 	 * the plan
diff --git a/src/include/optimizer/rpr.h b/src/include/optimizer/rpr.h
index 360e1bb777f..00a28abe2b4 100644
--- a/src/include/optimizer/rpr.h
+++ b/src/include/optimizer/rpr.h
@@ -55,6 +55,15 @@
 #define RPRElemIsFin(e)			((e)->varId == RPR_VARID_FIN)
 #define RPRElemCanSkip(e)		((e)->min == 0)
 
+/*
+ * navMaxOffset sentinel values.
+ * Non-negative values represent a statically determined maximum PREV offset.
+ */
+#define RPR_NAV_OFFSET_NEEDS_EVAL	(-1)	/* has non-constant PREV offset;
+											 * evaluate at executor init */
+#define RPR_NAV_OFFSET_RETAIN_ALL	(-2)	/* must retain entire partition
+											 * (e.g., future FIRST/LAST) */
+
 extern List *collectPatternVariables(RPRPatternNode *pattern);
 extern void buildDefineVariableList(List *defineClause,
 									List **defineVariableList);
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0020-Update-RPR-code-comments-for-1-slot-navigation.txt (5.5K, 22-nocfbot-0020-Update-RPR-code-comments-for-1-slot-navigation.txt)
  download | inline diff:
From fea456180111bfdb10d4dc580fc4062fe0ae6b15 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Thu, 2 Apr 2026 16:06:06 +0900
Subject: [PATCH] Update RPR code comments to reflect 1-slot navigation model

---
 src/backend/executor/execRPR.c | 45 ++++++++++++++++++++++------------
 src/backend/parser/parse_rpr.c |  3 ++-
 2 files changed, 32 insertions(+), 16 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 4c429528b04..5428d0e8fc4 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -191,10 +191,15 @@
  * transformDefineClause() processes each DEFINE variable as follows:
  *
  *   (1) Checks for duplicate variable names
- *   (2) Transforms the expression into a standard SQL expression
- *   (3) Coerces to Boolean type (coerce_to_boolean)
+ *   (2) Transforms the expression via transformExpr()
+ *   (3) Extracts Var nodes via pull_var_clause() and ensures each is
+ *       present in the query targetlist, so the planner propagates the
+ *       referenced columns through the plan tree
  *   (4) Wraps in a TargetEntry with the variable name set in resname
  *
+ * After all variables are processed:
+ *   (5) Coerces each expression to Boolean type (coerce_to_boolean)
+ *
  * Variables that are used in PATTERN but not defined in DEFINE are implicitly
  * evaluated as TRUE (matching all rows).
  *
@@ -431,8 +436,9 @@
  *
  *   Case 1: Simple VAR+ (e.g., A+)
  *           -> ABSORBABLE | ABSORBABLE_BRANCH set on the VAR
- *   Case 2: GROUP+ whose body consists only of {1,1} VARs (e.g., (A B)+)
- *           -> ABSORBABLE_BRANCH on children,
+ *   Case 2: GROUP+ with fixed-length children (min == max, recursively)
+ *           e.g., (A B)+, (A B{2})+, ((A (B C){2}){2})+
+ *           -> ABSORBABLE_BRANCH on all elements within the group,
  *             ABSORBABLE | ABSORBABLE_BRANCH on END
  *   Case 3: GROUP+ whose body starts with VAR+ (e.g., (A+ B)+)
  *           -> Recurses from BEGIN into the body, applying Case 1.
@@ -604,11 +610,17 @@
  *       varMatched[i] = (not null and true)
  *
  * To support row navigation operators such as PREV() and NEXT(),
- * the previous row, current row, and next row are set in separate slots:
+ * a 1-slot model is used: only ecxt_outertuple is set to the current
+ * row.  PREV/NEXT navigation is handled by EEOP_RPR_NAV_SET/RESTORE
+ * opcodes emitted during DEFINE expression compilation:
+ *
+ *   NAV_SET:     save ecxt_outertuple, swap in target row via nav_slot
+ *   (evaluate):  argument expression reads from swapped slot
+ *   NAV_RESTORE: restore original ecxt_outertuple
  *
- *   ecxt_scantuple  = previous row (for PREV reference)
- *   ecxt_outertuple = current row  (default reference)
- *   ecxt_innertuple = next row     (for NEXT reference)
+ * nav_slot caches the last fetched position (nav_slot_pos) to avoid
+ * redundant tuplestore lookups when multiple PREV/NEXT calls target
+ * the same row.
  *
  * The varMatched array is referenced later in Phase 1 (Match).
  *
@@ -908,8 +920,11 @@
  *
  *   (2) At runtime: initialize the nfaVisitedElems bitmap immediately before
  *       DFS expansion of each state within advance (once per state).
- *       During DFS, set the corresponding elemIdx bit when visiting each
- *       element.
+ *       During DFS, epsilon elements (END, ALT, BEGIN) are marked in the
+ *       bitmap at nfa_advance_state entry.  VAR elements are marked later
+ *       when added to the state list (nfa_add_state_unique), so that
+ *       legitimate loop-back to the same VAR in a new group iteration
+ *       (e.g., END -> ALT -> same VAR) is not blocked.
  *       If a previously visited elemIdx is revisited, that path is terminated.
  *
  *   Note: the bitmap tracks only elemIdx and does not consider counts.
@@ -1216,11 +1231,11 @@
  *   (3) State Deduplication (IX-5)
  *
  *     During advance, DFS may generate states with the same (elemIdx,
- *     counts) combination through multiple paths. Additionally, unlike
- *     VAR repetition, group repetition cannot perform absorption
- *     comparison using VAR states, so inline advance is performed from
- *     after Phase 1 match through to END; this process can also produce
- *     duplicate states reaching the same END.
+ *     counts) combination through multiple paths. Additionally, for
+ *     group absorption, nfa_match performs inline advance from bounded
+ *     VARs (count >= max) within the absorbable region (ABSORBABLE_BRANCH)
+ *     through END chains to reach the judgment point (ABSORBABLE END).
+ *     This process can also produce duplicate states reaching the same END.
  *     nfa_add_state_unique() blocks duplicate addition of identical states
  *     in both cases.
  *
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 3fb5d94abe9..d1e02e52e53 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -265,7 +265,8 @@ validateRPRPatternVarCount(ParseState *pstate, RPRPatternNode *node,
  *
  * Then for each DEFINE variable:
  *   2. Checks for duplicate variable names in DEFINE clause
- *   3. Transforms expressions and adds to targetlist via findTargetlistEntrySQL99
+ *   3. Transforms expression via transformExpr() and ensures referenced
+ *      Var nodes are present in the query targetlist (via pull_var_clause)
  *   4. Creates defineClause entry with proper resname (pattern variable name)
  *   5. Coerces expressions to boolean type
  *   6. Marks column origins and assigns collation information
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0021-Enable-JIT-PREV-NEXT-tests.txt (1.8K, 23-nocfbot-0021-Enable-JIT-PREV-NEXT-tests.txt)
  download | inline diff:
From 79f32ee6991e3cbf5bea0e51099e9613c13f5ec0 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Mon, 6 Apr 2026 09:48:54 +0900
Subject: [PATCH] Enable JIT compilation for PREV/NEXT navigation tests in RPR

---
 src/test/regress/expected/rpr.out | 2 ++
 src/test/regress/sql/rpr.sql      | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index de6ce4fba8a..5a460e9bd52 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -2156,6 +2156,7 @@ SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
 -- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
 -- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
 -- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit = on;
 SET jit_above_cost = 0;
 WITH data AS (
  SELECT i, abs(50000 - i) AS price
@@ -2184,6 +2185,7 @@ FROM result WHERE match_len > 0;
 (1 row)
 
 RESET jit_above_cost;
+RESET jit;
 --
 -- IGNORE NULLS
 --
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index b3bbc8254c4..e417789eb2b 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -1087,6 +1087,7 @@ SELECT match_first, match_last, match_len FROM result WHERE match_len > 0;
 -- JIT PREV/NEXT navigation test: 100K rows with PREV in DEFINE.
 -- Exercises EEOP_RPR_NAV_SET/RESTORE JIT code paths (has_rpr_nav reload)
 -- at scale. V-shape: price rises then falls, repeated across partition.
+SET jit = on;
 SET jit_above_cost = 0;
 WITH data AS (
  SELECT i, abs(50000 - i) AS price
@@ -1110,6 +1111,7 @@ result AS (
 SELECT count(*) AS matched_rows, max(match_len) AS longest_match
 FROM result WHERE match_len > 0;
 RESET jit_above_cost;
+RESET jit;
 
 --
 -- IGNORE NULLS
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0022-Add-2-arg-PREV-NEXT-host-variable-test.txt (4.8K, 24-nocfbot-0022-Add-2-arg-PREV-NEXT-host-variable-test.txt)
  download | inline diff:
From 081f70847766b2aa312b667e5b7c8e2a41088378 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Mon, 6 Apr 2026 09:53:14 +0900
Subject: [PATCH] Add 2-arg PREV/NEXT test for row pattern navigation with host
 variable

---
 src/test/regress/expected/rpr.out | 63 +++++++++++++++++++++++++++++++
 src/test/regress/sql/rpr.sql      | 16 ++++++++
 2 files changed, 79 insertions(+)

diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index 5a460e9bd52..c02dbd4c08d 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -1492,6 +1492,69 @@ EXECUTE test_prev_offset(-1);
 ERROR:  PREV/NEXT offset must not be negative
 EXECUTE test_prev_offset(NULL);
 ERROR:  PREV/NEXT offset must not be null
+DEALLOCATE test_prev_offset;
+-- 2-arg PREV/NEXT: host variable with positive value
+-- Exercises RPR_NAV_OFFSET_NEEDS_EVAL -> eval_nav_max_offset() path
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS price > PREV(price, $1)
+);
+EXECUTE test_prev_offset(1);
+ company  |   tdate    | price | first_value | count 
+----------+------------+-------+-------------+-------
+ company1 | 07-01-2023 |   100 |         100 |     2
+ company1 | 07-02-2023 |   200 |             |     0
+ company1 | 07-03-2023 |   150 |             |     0
+ company1 | 07-04-2023 |   140 |         140 |     2
+ company1 | 07-05-2023 |   150 |             |     0
+ company1 | 07-06-2023 |    90 |          90 |     3
+ company1 | 07-07-2023 |   110 |             |     0
+ company1 | 07-08-2023 |   130 |             |     0
+ company1 | 07-09-2023 |   120 |         120 |     2
+ company1 | 07-10-2023 |   130 |             |     0
+ company2 | 07-01-2023 |    50 |          50 |     2
+ company2 | 07-02-2023 |  2000 |             |     0
+ company2 | 07-03-2023 |  1500 |             |     0
+ company2 | 07-04-2023 |  1400 |        1400 |     2
+ company2 | 07-05-2023 |  1500 |             |     0
+ company2 | 07-06-2023 |    60 |          60 |     3
+ company2 | 07-07-2023 |  1100 |             |     0
+ company2 | 07-08-2023 |  1300 |             |     0
+ company2 | 07-09-2023 |  1200 |        1200 |     2
+ company2 | 07-10-2023 |  1300 |             |     0
+(20 rows)
+
+EXECUTE test_prev_offset(2);
+ company  |   tdate    | price | first_value | count 
+----------+------------+-------+-------------+-------
+ company1 | 07-01-2023 |   100 |             |     0
+ company1 | 07-02-2023 |   200 |         200 |     2
+ company1 | 07-03-2023 |   150 |             |     0
+ company1 | 07-04-2023 |   140 |             |     0
+ company1 | 07-05-2023 |   150 |             |     0
+ company1 | 07-06-2023 |    90 |             |     0
+ company1 | 07-07-2023 |   110 |         110 |     3
+ company1 | 07-08-2023 |   130 |             |     0
+ company1 | 07-09-2023 |   120 |             |     0
+ company1 | 07-10-2023 |   130 |             |     0
+ company2 | 07-01-2023 |    50 |             |     0
+ company2 | 07-02-2023 |  2000 |        2000 |     2
+ company2 | 07-03-2023 |  1500 |             |     0
+ company2 | 07-04-2023 |  1400 |             |     0
+ company2 | 07-05-2023 |  1500 |             |     0
+ company2 | 07-06-2023 |    60 |             |     0
+ company2 | 07-07-2023 |  1100 |        1100 |     3
+ company2 | 07-08-2023 |  1300 |             |     0
+ company2 | 07-09-2023 |  1200 |             |     0
+ company2 | 07-10-2023 |  1300 |             |     0
+(20 rows)
+
 DEALLOCATE test_prev_offset;
 -- 2-arg: two PREV with different offsets in same DEFINE clause
 -- B: price exceeds both 1-back and 2-back values
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index e417789eb2b..47f33904690 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -732,6 +732,22 @@ EXECUTE test_prev_offset(-1);
 EXECUTE test_prev_offset(NULL);
 DEALLOCATE test_prev_offset;
 
+-- 2-arg PREV/NEXT: host variable with positive value
+-- Exercises RPR_NAV_OFFSET_NEEDS_EVAL -> eval_nav_max_offset() path
+PREPARE test_prev_offset(int8) AS
+SELECT company, tdate, price, first_value(price) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE A AS TRUE, B AS price > PREV(price, $1)
+);
+EXECUTE test_prev_offset(1);
+EXECUTE test_prev_offset(2);
+DEALLOCATE test_prev_offset;
+
 -- 2-arg: two PREV with different offsets in same DEFINE clause
 -- B: price exceeds both 1-back and 2-back values
 SELECT company, tdate, price,
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0023-Nav-Mark-Lookback-EXPLAIN.txt (107.6K, 25-nocfbot-0023-Nav-Mark-Lookback-EXPLAIN.txt)
  download

  [text/plain] nocfbot-0024-Implement-FIRST-LAST-navigation-for-RPR.txt (165.2K, 26-nocfbot-0024-Implement-FIRST-LAST-navigation-for-RPR.txt)
  download

  [text/plain] nocfbot-0025-Guard-int64-overflow-bounded-frame.txt (2.3K, 27-nocfbot-0025-Guard-int64-overflow-bounded-frame.txt)
  download | inline diff:
From c333424313fb8d94ff0aefffb189890db776fefe Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 13:31:44 +0900
Subject: [PATCH] Guard against int64 overflow in RPR bounded frame end
 computation

---
 src/backend/executor/execRPR.c | 16 ++++++++++++----
 1 file changed, 12 insertions(+), 4 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 01df2a11e0a..94f1b2941a2 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -22,6 +22,7 @@
  */
 #include "postgres.h"
 
+#include "common/int.h"
 #include "executor/execRPR.h"
 #include "executor/executor.h"
 #include "miscadmin.h"
@@ -1046,10 +1047,11 @@
  *
  *   When the frame is bounded (e.g., ROWS BETWEEN CURRENT ROW AND 5
  *   FOLLOWING), ExecRPRProcessRow receives hasLimitedFrame=true and
- *   frameOffset indicating the upper bound.  After the advance phase,
+ *   frameOffset indicating the upper bound.  Before the match phase,
  *   any context whose match has exceeded the frame boundary
- *   (currentPos - matchStartRow >= frameOffset + 1) is finalized early.
- *   This prevents matches from extending beyond the window frame.
+ *   (currentPos >= matchStartRow + frameOffset + 1) is finalized early
+ *   by forcing a mismatch.  This prevents matches from extending beyond
+ *   the window frame.  The sum is clamped to PG_INT64_MAX on overflow.
  *
  *   Note that bounded frames also disable context absorption at the
  *   planner level (see VIII-3(b)), since the frame boundary breaks the
@@ -3154,7 +3156,12 @@ ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
 		/* Check frame boundary - finalize if exceeded */
 		if (hasLimitedFrame)
 		{
-			int64		ctxFrameEnd = ctx->matchStartRow + frameOffset + 1;
+			int64		ctxFrameEnd;
+
+			/* Clamp to INT64_MAX on overflow */
+			if (pg_add_s64_overflow(ctx->matchStartRow, frameOffset + 1,
+									&ctxFrameEnd))
+				ctxFrameEnd = PG_INT64_MAX;
 
 			if (currentPos >= ctxFrameEnd)
 			{
@@ -3204,6 +3211,7 @@ ExecRPRProcessRow(WindowAggState *winstate, int64 currentPos,
 		 * context here must be within its frame boundary.
 		 */
 		Assert(!hasLimitedFrame ||
+			   ctx->matchStartRow > PG_INT64_MAX - frameOffset - 1 ||
 			   currentPos < ctx->matchStartRow + frameOffset + 1);
 
 		nfa_advance(winstate, ctx, currentPos);
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0026-Fix-error-message-style.txt (7.4K, 28-nocfbot-0026-Fix-error-message-style.txt)
  download | inline diff:
From f3d7cb40298ee33fa37b5bd75c17ad2cb7e20ffe Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 11:15:12 +0900
Subject: [PATCH] Fix RPR error message style: hint format, terminology,
 capitalization

Remove colon in errhint "Use: ROWS instead" -> "Use ROWS instead."
and add missing trailing period.  Shorten "row pattern definition
variable name" to "DEFINE variable" for consistency with other
error messages.  Capitalize navigation function names in stub
error messages (prev -> PREV, etc.) to match SQL standard keyword
style used elsewhere in the parser.
---
 src/backend/parser/parse_rpr.c         |  6 +++---
 src/backend/utils/adt/windowfuncs.c    | 16 ++++++++--------
 src/test/regress/expected/rpr_base.out | 12 ++++++------
 3 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 05070cb04bb..8fbe12e1518 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -78,7 +78,7 @@ transformRPR(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 		ereport(ERROR,
 				(errcode(ERRCODE_WINDOWING_ERROR),
 				 errmsg("FRAME option GROUPS is not permitted when row pattern recognition is used"),
-				 errhint("Use: ROWS instead"),
+				 errhint("Use ROWS instead."),
 				 parser_errposition(pstate,
 									windef->frameLocation >= 0 ?
 									windef->frameLocation : windef->location)));
@@ -86,7 +86,7 @@ transformRPR(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 		ereport(ERROR,
 				(errcode(ERRCODE_WINDOWING_ERROR),
 				 errmsg("FRAME option RANGE is not permitted when row pattern recognition is used"),
-				 errhint("Use: ROWS instead"),
+				 errhint("Use ROWS instead."),
 				 parser_errposition(pstate,
 									windef->frameLocation >= 0 ?
 									windef->frameLocation : windef->location)));
@@ -329,7 +329,7 @@ transformDefineClause(ParseState *pstate, WindowClause *wc, WindowDef *windef,
 			if (!strcmp(n, name))
 				ereport(ERROR,
 						(errcode(ERRCODE_SYNTAX_ERROR),
-						 errmsg("row pattern definition variable name \"%s\" appears more than once in DEFINE clause",
+						 errmsg("DEFINE variable \"%s\" appears more than once",
 								name),
 						 parser_errposition(pstate, exprLocation((Node *) r))));
 		}
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index 420a4962395..fb966cae43c 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -740,7 +740,7 @@ window_prev(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("prev() can only be used in a DEFINE clause")));
+			 errmsg("PREV() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -754,7 +754,7 @@ window_next(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("next() can only be used in a DEFINE clause")));
+			 errmsg("NEXT() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -768,7 +768,7 @@ window_prev_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("prev() can only be used in a DEFINE clause")));
+			 errmsg("PREV() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -782,7 +782,7 @@ window_next_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("next() can only be used in a DEFINE clause")));
+			 errmsg("NEXT() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -796,7 +796,7 @@ window_first(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("first() can only be used in a DEFINE clause")));
+			 errmsg("FIRST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -810,7 +810,7 @@ window_last(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("last() can only be used in a DEFINE clause")));
+			 errmsg("LAST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -824,7 +824,7 @@ window_first_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("first() can only be used in a DEFINE clause")));
+			 errmsg("FIRST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
 
@@ -838,6 +838,6 @@ window_last_offset(PG_FUNCTION_ARGS)
 {
 	ereport(ERROR,
 			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-			 errmsg("last() can only be used in a DEFINE clause")));
+			 errmsg("LAST() can only be used in a DEFINE clause")));
 	PG_RETURN_NULL();			/* not reached */
 }
diff --git a/src/test/regress/expected/rpr_base.out b/src/test/regress/expected/rpr_base.out
index 0845316965e..912bd7b7c77 100644
--- a/src/test/regress/expected/rpr_base.out
+++ b/src/test/regress/expected/rpr_base.out
@@ -232,7 +232,7 @@ WINDOW w AS (
     PATTERN (A+)
     DEFINE A AS id > 0, A AS id < 10
 );
-ERROR:  row pattern definition variable name "a" appears more than once in DEFINE clause
+ERROR:  DEFINE variable "a" appears more than once
 LINE 7:     DEFINE A AS id > 0, A AS id < 10
                    ^
 -- Expected: ERROR: row pattern definition variable name "a" appears more than once in DEFINE clause
@@ -469,7 +469,7 @@ WINDOW w AS (
 ERROR:  FRAME option RANGE is not permitted when row pattern recognition is used
 LINE 5:     RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWIN...
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option RANGE is not permitted when row pattern recognition is used
 -- GROUPS frame not starting at CURRENT ROW
 SELECT COUNT(*) OVER w
@@ -483,7 +483,7 @@ WINDOW w AS (
 ERROR:  FRAME option GROUPS is not permitted when row pattern recognition is used
 LINE 5:     GROUPS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWI...
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option GROUPS is not permitted when row pattern recognition is used
 -- Starting with N PRECEDING
 SELECT COUNT(*) OVER w
@@ -640,7 +640,7 @@ ORDER BY id;
 ERROR:  FRAME option RANGE is not permitted when row pattern recognition is used
 LINE 5:     RANGE BETWEEN CURRENT ROW AND 10 FOLLOWING
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option RANGE is not permitted when row pattern recognition is used
 -- GROUPS frame with RPR (not permitted)
 SELECT id, val, COUNT(*) OVER w as cnt
@@ -656,7 +656,7 @@ ORDER BY id;
 ERROR:  FRAME option GROUPS is not permitted when row pattern recognition is used
 LINE 5:     GROUPS BETWEEN CURRENT ROW AND 1 FOLLOWING
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option GROUPS is not permitted when row pattern recognition is used
 DROP TABLE rpr_frame;
 -- ============================================================
@@ -705,7 +705,7 @@ ORDER BY id;
 ERROR:  FRAME option RANGE is not permitted when row pattern recognition is used
 LINE 6:     RANGE BETWEEN CURRENT ROW AND 10 FOLLOWING
             ^
-HINT:  Use: ROWS instead
+HINT:  Use ROWS instead.
 -- Expected: ERROR: FRAME option RANGE is not permitted when row pattern recognition is used
 DROP TABLE rpr_partition;
 -- ============================================================
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0027-Fix-comment-typos-grammar.txt (5.3K, 29-nocfbot-0027-Fix-comment-typos-grammar.txt)
  download | inline diff:
From 809e8b5c9f79e40ee114bef963e2a902800608ed Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 10:09:41 +0900
Subject: [PATCH] Fix comment typos, grammar, and inaccuracies in RPR code

---
 src/backend/executor/execRPR.c          |  7 +++----
 src/backend/executor/nodeWindowAgg.c    | 20 ++++++++++----------
 src/backend/optimizer/plan/createplan.c |  3 ++-
 3 files changed, 15 insertions(+), 15 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index 94f1b2941a2..baee45ce54e 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -416,7 +416,6 @@
  * the normal loop-back (which cycle detection will eventually kill) and
  * a fast-forward exit clone that bypasses the loop entirely.
  * (See IX-4(c) for detailed runtime behavior.)
- *     - Empty match is impossible since body is not nullable
  *
  * IV-5. Absorbability Analysis (RPR_ELEM_ABSORBABLE)
  *
@@ -645,8 +644,8 @@
  * When processing a context whose matchStartRow differs from the shared
  * value, nfa_reevaluate_dependent_vars() temporarily sets nav_match_start
  * to that context's matchStartRow and re-evaluates only the dependent
- * variables.  No restore is needed because contexts are ordered by
- * matchStartRow (ascending), so no later context shares the head's value.
+ * variables.  The original nav_match_start and currentpos are saved and
+ * restored after re-evaluation.
  *
  * VI-5. Tuplestore Mark and Trim (nodeWindowAgg.c)
  *
@@ -2715,7 +2714,7 @@ nfa_advance_var(WindowAggState *winstate, RPRNFAContext *ctx,
 		bool		reluctant = RPRElemIsReluctant(elem);
 
 		/*
-		 * Clone state for the second-priority path. For greedy, clone is the
+		 * Clone state for the first-priority path. For greedy, clone is the
 		 * loop state; for reluctant, clone is the exit state.
 		 */
 		if (reluctant)
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index cdbe356abd7..849ebf8abb0 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -3705,8 +3705,8 @@ ignorenulls_getfuncarginframe(WindowObject winobj, int argno,
 	{
 		/*
 		 * Early check if row could be out of reduced frame.  When RPR is
-		 * enabled, EXCUDE clause cannot be specified and the frame is always
-		 * contiguous.  So we can do the check followings safely. Note,
+		 * 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.
@@ -4168,7 +4168,7 @@ eval_nav_first_offset(WindowAggState *winstate, List *defineClause)
 
 /*
  * rpr_is_defined
- * return true if Row pattern recognition is defined.
+ * Return true if row pattern recognition is defined.
  */
 static bool
 rpr_is_defined(WindowAggState *winstate)
@@ -4182,14 +4182,14 @@ rpr_is_defined(WindowAggState *winstate)
  * Determine whether a row is in the current row's reduced window frame
  * according to row pattern matching
  *
- * The row must has been already determined that it is in a full window frame
- * and fetched it into slot.
+ * 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 unmatched row
+ * -1, if the row is an unmatched row
  * -2, if the row is in the reduced frame but needed to be skipped because of
  * AFTER MATCH SKIP PAST LAST ROW
  * -----------------
@@ -4204,8 +4204,8 @@ row_is_in_reduced_frame(WindowObject winobj, int64 pos)
 	if (!rpr_is_defined(winstate))
 	{
 		/*
-		 * RPR is not defined. Assume that we are always in the the reduced
-		 * window frame.
+		 * RPR is not defined. Assume that we are always in the reduced window
+		 * frame.
 		 */
 		rtn = 0;
 		return rtn;
@@ -4938,8 +4938,8 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
  * 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. false if out of frame.
- * (also isout is set)
+ * 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,
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 02d511269ab..50668f3b7ab 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -2475,7 +2475,8 @@ typedef struct NavOffsetContext
 	int64		maxOffset;		/* max PREV/LAST backward offset (>= 0) */
 	bool		maxNeedsEval;	/* non-constant PREV/LAST offset found */
 	bool		maxOverflow;	/* constant offset overflow detected */
-	int64		firstOffset;	/* min FIRST offset (>= 0), or -1 if none */
+	int64		firstOffset;	/* min FIRST offset (may be negative for
+								 * PREV_FIRST) */
 	bool		hasFirst;		/* any FIRST node found */
 	bool		firstNeedsEval; /* non-constant FIRST offset found */
 } NavOffsetContext;
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0028-Fix-documentation-synopsis-grammar.txt (9.5K, 30-nocfbot-0028-Fix-documentation-synopsis-grammar.txt)
  download | inline diff:
From 9a81f1981cd930fa1e25288a07f350fbace4c9a5 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 10:50:36 +0900
Subject: [PATCH] Fix RPR documentation: synopsis, grammar, and terminology

Remove erroneous comma in PATTERN synopsis.  Fix typos in
advanced.sgml (">=;" stray semicolon, "with the a row",
"For example following").  Correct PREV/NEXT description
from "within the window frame" to "within the partition"
and add missing "DEFINE clause only" note.  Capitalize
"Row Pattern Recognition" consistently across SGML files.

Fix numerous missing articles and grammar errors in
select.sgml: "after a match found" -> "after a match is
found", "do not necessarily" -> "does not necessarily",
add missing "the" before clause references.
---
 doc/src/sgml/advanced.sgml         | 14 +++++++-------
 doc/src/sgml/func/func-window.sgml | 14 ++++++++------
 doc/src/sgml/ref/select.sgml       | 28 ++++++++++++++--------------
 3 files changed, 29 insertions(+), 27 deletions(-)

diff --git a/doc/src/sgml/advanced.sgml b/doc/src/sgml/advanced.sgml
index 0caf9fdaff6..11c2416df51 100644
--- a/doc/src/sgml/advanced.sgml
+++ b/doc/src/sgml/advanced.sgml
@@ -553,8 +553,8 @@ WHERE pos &lt; 3;
    </para>
 
    <para>
-    Row pattern common syntax can be used to perform row pattern recognition
-    in a query. The row pattern common syntax includes two sub
+    Row Pattern Common Syntax can be used to perform Row Pattern Recognition
+    in a query. The Row Pattern Common Syntax includes two sub
     clauses: <literal>DEFINE</literal>
     and <literal>PATTERN</literal>. <literal>DEFINE</literal> defines
     row pattern variables along with an expression. The expression must be a
@@ -584,12 +584,12 @@ DEFINE
     Once <literal>DEFINE</literal> exists, <literal>PATTERN</literal> can be
     used. <literal>PATTERN</literal> defines a sequence of rows that satisfies
     conditions defined in the <literal>DEFINE</literal> clause.  For example
-    following <literal>PATTERN</literal> defines a sequence of rows starting
-    with the a row satisfying "LOWPRICE", then one or more rows satisfying
+    the following <literal>PATTERN</literal> defines a sequence of rows starting
+    with a row satisfying "LOWPRICE", then one or more rows satisfying
     "UP" and finally one or more rows satisfying "DOWN". Pattern variables can
     be followed by quantifiers: "+" means one or more matches, "*" means zero
     or more matches, "?" means zero or one match, "{n}" (n &gt; 0) means exactly
-    n matches, "{n,}" (n &gt;=; 0) means at least n matches, "{,m}" (m &gt; 0) means
+    n matches, "{n,}" (n &gt;= 0) means at least n matches, "{,m}" (m &gt; 0) means
     at most m matches, and "{n,m}" (0 &lt;= n &lt;= m, 0 &lt; m) means between n and m
     matches.  Patterns can be grouped using parentheses and combined using
     alternation (the vertical bar "|" for OR). For example, "(UP DOWN)+"
@@ -642,7 +642,7 @@ FROM stock
    </para>
 
    <para>
-    Row pattern recognition internally uses a nondeterministic finite
+    Row Pattern Recognition internally uses a nondeterministic finite
     automaton (NFA) to match patterns. For patterns with unbounded
     quantifiers (e.g., <literal>A+</literal> or <literal>(A B)+</literal>),
     the NFA may need to track many active matching contexts simultaneously,
@@ -676,7 +676,7 @@ FROM stock
    </para>
 
    <para>
-    When examining query plans for row pattern recognition with
+    When examining query plans for Row Pattern Recognition with
     <command>EXPLAIN</command>, the pattern output may include special
     markers that indicate optimization opportunities. A double quote
     <literal>"</literal> marks where pattern absorption can occur,
diff --git a/doc/src/sgml/func/func-window.sgml b/doc/src/sgml/func/func-window.sgml
index ab80690f7be..d109a2d22bc 100644
--- a/doc/src/sgml/func/func-window.sgml
+++ b/doc/src/sgml/func/func-window.sgml
@@ -279,9 +279,9 @@
   </para>
 
   <para>
-   Row pattern recognition navigation functions are listed in
+   Row Pattern Recognition navigation functions are listed in
    <xref linkend="functions-rpr-navigation-table"/>.  These functions
-   can be used to describe DEFINE clause of Row pattern recognition.
+   can be used to describe the DEFINE clause of Row Pattern Recognition.
   </para>
 
    <table id="functions-rpr-navigation-table">
@@ -309,12 +309,13 @@
        </para>
        <para>
         Returns the column value at the row <parameter>offset</parameter>
-        rows before the current row within the window frame;
-        returns NULL if the target row is outside the window frame.
+        rows before the current row within the partition;
+        returns NULL if the target row is outside the partition.
         <parameter>offset</parameter> defaults to 1 if omitted.
         <parameter>offset</parameter> must be a non-negative integer;
         an offset of 0 refers to the current row itself.
         <parameter>offset</parameter> must not be NULL.
+        Can only be used in a <literal>DEFINE</literal> clause.
        </para></entry>
       </row>
 
@@ -328,12 +329,13 @@
        </para>
        <para>
         Returns the column value at the row <parameter>offset</parameter>
-        rows after the current row within the window frame;
-        returns NULL if the target row is outside the window frame.
+        rows after the current row within the partition;
+        returns NULL if the target row is outside the partition.
         <parameter>offset</parameter> defaults to 1 if omitted.
         <parameter>offset</parameter> must be a non-negative integer;
         an offset of 0 refers to the current row itself.
         <parameter>offset</parameter> must not be NULL.
+        Can only be used in a <literal>DEFINE</literal> clause.
        </para></entry>
       </row>
 
diff --git a/doc/src/sgml/ref/select.sgml b/doc/src/sgml/ref/select.sgml
index 5e4ba9d3cc6..5272d6c0bfa 100644
--- a/doc/src/sgml/ref/select.sgml
+++ b/doc/src/sgml/ref/select.sgml
@@ -1133,34 +1133,34 @@ EXCLUDE NO OTHERS
    <para>
     The
     optional <replaceable class="parameter">row_pattern_common_syntax</replaceable>
-    defines the <firstterm>row pattern recognition condition</firstterm> for
+    defines the <firstterm>Row Pattern Recognition condition</firstterm> for
     this
     window. <replaceable class="parameter">row_pattern_common_syntax</replaceable>
-    includes following subclauses.
+    includes the following subclauses.
 
 <synopsis>
 [ { AFTER MATCH SKIP PAST LAST ROW | AFTER MATCH SKIP TO NEXT ROW } ]
 [ INITIAL | SEEK ]
-PATTERN ( <replaceable class="parameter">pattern_variable_name</replaceable> [ <replaceable>quantifier</replaceable> ] [, ...] )
+PATTERN ( <replaceable class="parameter">pattern_variable_name</replaceable> [ <replaceable>quantifier</replaceable> ] [ ... ] )
 DEFINE <replaceable class="parameter">definition_variable_name</replaceable> AS <replaceable class="parameter">expression</replaceable> [, ...]
 </synopsis>
     <literal>AFTER MATCH SKIP PAST LAST ROW</literal> or <literal>AFTER MATCH
-    SKIP TO NEXT ROW</literal> controls how to proceed to next row position
-    after a match found. With <literal>AFTER MATCH SKIP PAST LAST
-    ROW</literal> (the default) next row position is next to the last row of
-    previous match. On the other hand, with <literal>AFTER MATCH SKIP TO NEXT
-    ROW</literal> next row position is next to the first row of previous
-    match. <literal>INITIAL</literal> or <literal>SEEK</literal> defines how a
-    successful pattern matching starts from which row in a
-    frame. If <literal>INITIAL</literal> is specified, the match must start
+    SKIP TO NEXT ROW</literal> controls how to proceed to the next row position
+    after a match is found. With <literal>AFTER MATCH SKIP PAST LAST
+    ROW</literal> (the default) the next row position is next to the last row of
+    the previous match. On the other hand, with <literal>AFTER MATCH SKIP TO NEXT
+    ROW</literal> the next row position is next to the first row of the previous
+    match. <literal>INITIAL</literal> or <literal>SEEK</literal> specifies from
+    which row in the frame pattern matching begins.
+    If <literal>INITIAL</literal> is specified, the match must start
     from the first row in the frame. If <literal>SEEK</literal> is specified,
-    the set of matching rows do not necessarily start from the first row. The
+    the set of matching rows does not necessarily start from the first row. The
     default is <literal>INITIAL</literal>. Currently
     only <literal>INITIAL</literal> is supported. <literal>DEFINE</literal>
     defines definition variables along with a boolean
     expression. <literal>PATTERN</literal> defines a sequence of rows that
     satisfies certain conditions using variables defined
-    in <literal>DEFINE</literal> clause (an empty <literal>PATTERN()</literal>
+    in the <literal>DEFINE</literal> clause (an empty <literal>PATTERN()</literal>
     is not supported). Each pattern variable can be followed by a quantifier
     to specify how many times it should match:
     <literal>*</literal> (zero or more),
@@ -1198,7 +1198,7 @@ DEFINE <replaceable class="parameter">definition_variable_name</replaceable> AS
 
    <para>
     Note that the maximum number of unique pattern variables
-    used in <literal>PATTERN</literal> clause is 251.
+    used in the <literal>PATTERN</literal> clause is 251.
     If this limit is exceeded, an error will be raised.
     Additionally, the maximum nesting depth of pattern groups
     (parentheses) is 253 levels.
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0029-Fix-nav_slot-pass-by-ref-dangling-pointer.txt (10.0K, 31-nocfbot-0029-Fix-nav_slot-pass-by-ref-dangling-pointer.txt)
  download | inline diff:
From c33aca418319816bde883d1ad6b07f7effbdddea Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 13:59:29 +0900
Subject: [PATCH] Fix nav_slot pass-by-ref dangling pointer in RPR navigation

When a DEFINE expression contains multiple navigation calls targeting
different positions (e.g., PREV(x,1) > PREV(x,2)), the second call
re-fetches nav_slot, freeing the previous tuple via pfree.  Any
pass-by-ref datum extracted from the first navigation becomes a
dangling pointer.  Fix by copying pass-by-ref results into per-tuple
memory in the RESTORE step.
---
 src/backend/executor/execExpr.c       |  5 ++
 src/backend/executor/execExprInterp.c | 20 +++++++
 src/include/executor/execExpr.h       |  2 +
 src/test/regress/expected/rpr.out     | 80 +++++++++++++++++++++++++++
 src/test/regress/sql/rpr.sql          | 34 ++++++++++++
 5 files changed, 141 insertions(+)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 6349a564a98..8d8da67e79f 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -1304,7 +1304,12 @@ ExecInitExprRec(Expr *node, ExprState *state,
 
 				/* Emit RESTORE opcode: restore original slot */
 				scratch.opcode = EEOP_RPR_NAV_RESTORE;
+				scratch.resvalue = resv;
+				scratch.resnull = resnull;
 				scratch.d.rpr_nav.winstate = winstate;
+				get_typlenbyval(nav->resulttype,
+								&scratch.d.rpr_nav.resulttyplen,
+								&scratch.d.rpr_nav.resulttypbyval);
 				ExprEvalPushStep(state, &scratch);
 				break;
 			}
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 2ec579732cc..e2d41c3098f 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -6156,6 +6156,13 @@ ExecEvalRPRNavSet(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
  * When slot swap was elided (target == currentpos), this is a harmless
  * no-op since saved and current slots are identical.
  * 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,
@@ -6164,4 +6171,17 @@ ExecEvalRPRNavRestore(ExprState *state, ExprEvalStep *op,
 	WindowAggState *winstate = op->d.rpr_nav.winstate;
 
 	econtext->ecxt_outertuple = winstate->nav_saved_outertuple;
+
+	/* Stabilize pass-by-ref result against nav_slot re-fetch */
+	if (!op->d.rpr_nav.resulttypbyval &&
+		!*op->resnull)
+	{
+		MemoryContext oldContext;
+
+		oldContext = MemoryContextSwitchTo(econtext->ecxt_per_tuple_memory);
+		*op->resvalue = datumCopy(*op->resvalue,
+								  false,
+								  op->d.rpr_nav.resulttyplen);
+		MemoryContextSwitchTo(oldContext);
+	}
 }
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index 834800a4062..e6b2ab30406 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -703,6 +703,8 @@ typedef struct ExprEvalStep
 			Datum	   *offset_value;	/* offset value(s), or NULL */
 			bool	   *offset_isnull;	/* offset null flag(s) */
 			/* For compound nav: offset_value[0] = inner, [1] = outer */
+			int16		resulttyplen;	/* RESTORE: result type length */
+			bool		resulttypbyval; /* RESTORE: result pass-by-value? */
 		}			rpr_nav;
 
 		/* for EEOP_AGG_*DESERIALIZE */
diff --git a/src/test/regress/expected/rpr.out b/src/test/regress/expected/rpr.out
index 04ec25d4cf5..32aa8bc3722 100644
--- a/src/test/regress/expected/rpr.out
+++ b/src/test/regress/expected/rpr.out
@@ -1635,6 +1635,86 @@ WINDOW w AS (
  company2 | 07-10-2023 |  1300 |             |            |     0
 (20 rows)
 
+-- Pass-by-ref types: two PREV calls targeting different positions.
+-- Verifies that datumCopy in RESTORE prevents dangling pointers when
+-- nav_slot is re-fetched for the second navigation.
+-- tdate::text gives distinct text values per row (e.g. '07-01-2023').
+-- B matches when 1-back date text > 2-back date text (always true for
+-- ascending dates), so B+ extends the full partition after A.
+SELECT company, tdate, tdate::text AS tdate_text,
+       first_value(tdate::text) OVER w, last_value(tdate::text) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(tdate::text, 1) > PREV(tdate::text, 2)
+);
+ company  |   tdate    | tdate_text | first_value | last_value | count 
+----------+------------+------------+-------------+------------+-------
+ company1 | 07-01-2023 | 07-01-2023 |             |            |     0
+ company1 | 07-02-2023 | 07-02-2023 | 07-02-2023  | 07-10-2023 |     9
+ company1 | 07-03-2023 | 07-03-2023 |             |            |     0
+ company1 | 07-04-2023 | 07-04-2023 |             |            |     0
+ company1 | 07-05-2023 | 07-05-2023 |             |            |     0
+ company1 | 07-06-2023 | 07-06-2023 |             |            |     0
+ company1 | 07-07-2023 | 07-07-2023 |             |            |     0
+ company1 | 07-08-2023 | 07-08-2023 |             |            |     0
+ company1 | 07-09-2023 | 07-09-2023 |             |            |     0
+ company1 | 07-10-2023 | 07-10-2023 |             |            |     0
+ company2 | 07-01-2023 | 07-01-2023 |             |            |     0
+ company2 | 07-02-2023 | 07-02-2023 | 07-02-2023  | 07-10-2023 |     9
+ company2 | 07-03-2023 | 07-03-2023 |             |            |     0
+ company2 | 07-04-2023 | 07-04-2023 |             |            |     0
+ company2 | 07-05-2023 | 07-05-2023 |             |            |     0
+ company2 | 07-06-2023 | 07-06-2023 |             |            |     0
+ company2 | 07-07-2023 | 07-07-2023 |             |            |     0
+ company2 | 07-08-2023 | 07-08-2023 |             |            |     0
+ company2 | 07-09-2023 | 07-09-2023 |             |            |     0
+ company2 | 07-10-2023 | 07-10-2023 |             |            |     0
+(20 rows)
+
+-- numeric: PREV(price::numeric, 1) > PREV(price::numeric, 2)
+-- B matches when price 1-back > price 2-back (ascending pair).
+SELECT company, tdate, price::numeric AS nprice,
+       first_value(price::numeric) OVER w, last_value(price::numeric) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(price::numeric, 1) > PREV(price::numeric, 2)
+);
+ company  |   tdate    | nprice | first_value | last_value | count 
+----------+------------+--------+-------------+------------+-------
+ company1 | 07-01-2023 |    100 |             |            |     0
+ company1 | 07-02-2023 |    200 |         200 |        150 |     2
+ company1 | 07-03-2023 |    150 |             |            |     0
+ company1 | 07-04-2023 |    140 |             |            |     0
+ company1 | 07-05-2023 |    150 |         150 |         90 |     2
+ company1 | 07-06-2023 |     90 |             |            |     0
+ company1 | 07-07-2023 |    110 |         110 |        120 |     3
+ company1 | 07-08-2023 |    130 |             |            |     0
+ company1 | 07-09-2023 |    120 |             |            |     0
+ company1 | 07-10-2023 |    130 |             |            |     0
+ company2 | 07-01-2023 |     50 |             |            |     0
+ company2 | 07-02-2023 |   2000 |        2000 |       1500 |     2
+ company2 | 07-03-2023 |   1500 |             |            |     0
+ company2 | 07-04-2023 |   1400 |             |            |     0
+ company2 | 07-05-2023 |   1500 |        1500 |         60 |     2
+ company2 | 07-06-2023 |     60 |             |            |     0
+ company2 | 07-07-2023 |   1100 |        1100 |       1200 |     3
+ company2 | 07-08-2023 |   1300 |             |            |     0
+ company2 | 07-09-2023 |   1200 |             |            |     0
+ company2 | 07-10-2023 |   1300 |             |            |     0
+(20 rows)
+
 --
 -- FIRST/LAST navigation
 --
diff --git a/src/test/regress/sql/rpr.sql b/src/test/regress/sql/rpr.sql
index a05b429ce74..724d460b2da 100644
--- a/src/test/regress/sql/rpr.sql
+++ b/src/test/regress/sql/rpr.sql
@@ -776,6 +776,40 @@ WINDOW w AS (
     DEFINE A AS price > PREV(price, 1) AND price < NEXT(price, 1)
 );
 
+-- Pass-by-ref types: two PREV calls targeting different positions.
+-- Verifies that datumCopy in RESTORE prevents dangling pointers when
+-- nav_slot is re-fetched for the second navigation.
+-- tdate::text gives distinct text values per row (e.g. '07-01-2023').
+-- B matches when 1-back date text > 2-back date text (always true for
+-- ascending dates), so B+ extends the full partition after A.
+SELECT company, tdate, tdate::text AS tdate_text,
+       first_value(tdate::text) OVER w, last_value(tdate::text) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(tdate::text, 1) > PREV(tdate::text, 2)
+);
+
+-- numeric: PREV(price::numeric, 1) > PREV(price::numeric, 2)
+-- B matches when price 1-back > price 2-back (ascending pair).
+SELECT company, tdate, price::numeric AS nprice,
+       first_value(price::numeric) OVER w, last_value(price::numeric) OVER w, count(*) OVER w
+FROM stock
+WINDOW w AS (
+    PARTITION BY company ORDER BY tdate
+    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
+    AFTER MATCH SKIP PAST LAST ROW
+    PATTERN (A B+)
+    DEFINE
+        A AS TRUE,
+        B AS PREV(price::numeric, 1) > PREV(price::numeric, 2)
+);
+
 --
 -- FIRST/LAST navigation
 --
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0030-Add-inline-comments-design-notes.txt (11.2K, 32-nocfbot-0030-Add-inline-comments-design-notes.txt)
  download | inline diff:
From 76b60fb538906346144be7430b858ddd471ec3ab Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 20:49:27 +0900
Subject: [PATCH] Add inline comments for complex RPR algorithms and design
 notes

Document END chain traversal in nfa_match(), fast-forward paths
in nfa_advance_end(), absorption safety rules with navigation
lookup table, per-context evaluation strategy table, fixed-length
group unrolling rationale, and BEGIN/END pointer layout diagram.
---
 src/backend/executor/execRPR.c   | 97 ++++++++++++++++++++++++++------
 src/backend/optimizer/plan/rpr.c | 41 ++++++++++++--
 2 files changed, 118 insertions(+), 20 deletions(-)

diff --git a/src/backend/executor/execRPR.c b/src/backend/executor/execRPR.c
index baee45ce54e..7ba7b6fb672 100644
--- a/src/backend/executor/execRPR.c
+++ b/src/backend/executor/execRPR.c
@@ -440,7 +440,14 @@
  *   Case 2: GROUP+ with fixed-length children (min == max, recursively)
  *           e.g., (A B)+, (A B{2})+, ((A (B C){2}){2})+
  *           -> ABSORBABLE_BRANCH on all elements within the group,
- *             ABSORBABLE | ABSORBABLE_BRANCH on END
+ *              ABSORBABLE | ABSORBABLE_BRANCH on END
+ *
+ *           Why this is safe: when every child has min == max, the group
+ *           is semantically equivalent to unrolling its body into {1,1}
+ *           elements.  E.g., (A B{2})+ behaves like (A B B)+.  Each
+ *           iteration consumes a fixed number of rows, so an earlier
+ *           context's count always dominates a later one's (monotonicity).
+ *
  *   Case 3: GROUP+ whose body starts with VAR+ (e.g., (A+ B)+)
  *           -> Recurses from BEGIN into the body, applying Case 1.
  *             ABSORBABLE | ABSORBABLE_BRANCH set on A.
@@ -647,6 +654,19 @@
  * variables.  The original nav_match_start and currentpos are saved and
  * restored after re-evaluation.
  *
+ * Summary of evaluation strategy by navigation content:
+ *
+ *   Navigation content               evaluation
+ *   -------------------------------------------------------
+ *   No navigation                    shared (once per row)
+ *   PREV/NEXT only                   shared (once per row)
+ *   LAST (no offset)                 shared (once per row)
+ *   LAST (with offset)               per-context
+ *   FIRST (any)                      per-context
+ *   Compound (inner FIRST)           per-context
+ *   Compound (inner LAST, no off.)   shared (once per row)
+ *   Compound (inner LAST, w/off.)    per-context
+ *
  * VI-5. Tuplestore Mark and Trim (nodeWindowAgg.c)
  *
  * Navigation functions require access to past rows via the tuplestore.
@@ -762,11 +782,26 @@
  *   (b) Unbounded frame (ROWS BETWEEN CURRENT ROW AND UNBOUNDED
  *       FOLLOWING).  Limited frames apply differently to each context,
  *       breaking the monotonicity principle.
- *   (c) No match_start-dependent navigation in DEFINE.  FIRST,
- *       LAST-with-offset, and compound navigation referencing match_start
- *       (PREV_FIRST, NEXT_FIRST, PREV_LAST/NEXT_LAST with offset)
- *       cause different contexts to evaluate to different values for the
- *       same row, breaking monotonicity.
+ *   (c) No match_start-dependent navigation in DEFINE.
+ *
+ *       Mechanism: each context has a different matchStartRow, so FIRST
+ *       resolves to a different row for each context at the same
+ *       currentpos.  An earlier context's DEFINE result no longer
+ *       subsumes a later one's, making count-dominance comparison
+ *       invalid.  Rather than comparing matchStartRow at runtime
+ *       (which would complicate the absorb path), any match_start
+ *       dependency disables absorption entirely.
+ *
+ *       Navigation content              match_start dep.  absorption
+ *       ------------------------------------------------------------
+ *       No navigation                   none              safe
+ *       PREV/NEXT only                  none              safe
+ *       LAST (no offset)                none              safe
+ *       LAST (with offset)              boundary check    unsafe
+ *       FIRST (any)                     direct            unsafe
+ *       Compound (inner FIRST)          direct            unsafe
+ *       Compound (inner LAST, no off.)  none              safe
+ *       Compound (inner LAST, w/off.)   boundary chk      unsafe
  *
  * Runtime conditions (evaluated per context pair):
  *
@@ -2260,7 +2295,13 @@ nfa_absorb_contexts(WindowAggState *winstate)
  * nfa_eval_var_match
  *
  * Evaluate if a VAR element matches the current row.
- * Undefined variables (varId >= defineVariableList length) default to TRUE.
+ *
+ * varMatched is a pre-evaluated boolean array indexed by varId, computed
+ * once per row by evaluating all DEFINE expressions.  NULL means no DEFINE
+ * clauses exist (only possible during early development/testing).
+ *
+ * Per SQL:2016 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,
@@ -2337,9 +2378,20 @@ nfa_match(WindowAggState *winstate, RPRNFAContext *ctx, bool *varMatched)
 
 				/*
 				 * For VAR at max count with END next, advance through END
-				 * chain to reach the absorption judgment point. Only
+				 * chain to reach the absorption judgment point.  Only
 				 * deterministic exits (count >= max, max finite) are handled;
 				 * unbounded VARs stay for advance phase.
+				 *
+				 * In nested patterns like ((A B){2}){3}, 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 judgment 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) &&
@@ -2561,12 +2613,25 @@ nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
 		RPRPatternElement *jumpElem;
 		RPRNFAState *ffState = NULL;
 
-		/* Snapshot state for ff path before modifying for loop-back */
+		/*
+		 * Two paths are explored in parallel when the group body is
+		 * nullable (RPR_ELEM_EMPTY_LOOP):
+		 *
+		 * 1. Primary path: loop back and 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, handled after the primary path).
+		 *
+		 * The snapshot must be taken BEFORE modifying state for the
+		 * loop-back, since both paths diverge from the same point.
+		 */
 		if (RPRElemCanEmptyLoop(elem))
 			ffState = nfa_state_create(winstate, state->elemIdx,
 									   state->counts, state->isAbsorbable);
 
-		/* Loop back for real matches (primary path) */
+		/* Primary path: loop back for real matches */
 		for (int d = depth + 1; d < pattern->maxDepth; d++)
 			state->counts[d] = 0;
 		state->elemIdx = elem->jump;
@@ -2575,12 +2640,12 @@ nfa_advance_end(WindowAggState *winstate, RPRNFAContext *ctx,
 						  currentPos);
 
 		/*
-		 * Fast-forward fallback for nullable bodies.  E.g. (A?){2,3} when A
-		 * doesn't match: the loop-back produces empty iterations that cycle
-		 * detection would kill.  Instead, exit directly treating all
-		 * remaining required iterations as empty.  Route to elem->next (not
-		 * nfa_advance_end) to avoid creating competing greedy/reluctant loop
-		 * states.
+		 * Fast-forward path for nullable bodies.  E.g. (A?){2,3} when
+		 * A doesn't match: the primary loop-back produces empty
+		 * iterations that cycle detection would kill.  Instead, exit
+		 * directly with count satisfied.  Route to elem->next (not
+		 * nfa_advance_end) to avoid creating competing greedy/reluctant
+		 * loop states.
 		 */
 		if (ffState != NULL)
 		{
diff --git a/src/backend/optimizer/plan/rpr.c b/src/backend/optimizer/plan/rpr.c
index 767a214016c..754fcd53099 100644
--- a/src/backend/optimizer/plan/rpr.c
+++ b/src/backend/optimizer/plan/rpr.c
@@ -478,6 +478,19 @@ mergeConsecutiveAlts(List *children)
  * mergeGroupPrefixSuffix
  *		Merge sequence prefix/suffix into GROUP with matching children.
  *
+ * When a GROUP's children appear as a prefix before and/or suffix after
+ * the GROUP in a SEQ, absorb them by incrementing the GROUP's quantifier.
+ * This runs iteratively: A B A B (A B)+ A B -> (A B){5,}.
+ *
+ * Algorithm:
+ *   For each GROUP encountered in the sequence:
+ *   1. PREFIX phase: compare the last N elements already in the result
+ *      list against the GROUP's children.  On match, remove them from
+ *      result and increment the GROUP's min/max.  Repeat until no match.
+ *   2. SUFFIX phase: compare the next N elements in the input against
+ *      the GROUP's children.  On match, skip them (via skipUntil) and
+ *      increment min/max.  Repeat until no match.
+ *
  * Examples:
  *   A B (A B)+ -> (A B){2,}
  *   (A B)+ A B -> (A B){2,}
@@ -813,8 +826,16 @@ tryMultiplyQuantifiers(RPRPatternNode *pattern)
 	}
 
 	/*
-	 * Case 2/3: Safe when child is finite AND (outer is exact OR child is
-	 * {1,1})
+	 * Case 2: Outer exact (min == max): (A{2,3}){4} -> A{8,12}.
+	 *         Safe because every iteration produces the same range.
+	 *
+	 * Case 3: Child {1,1}: (A){2,5} -> A{2,5}.
+	 *         Safe because the child contributes exactly one per
+	 *         iteration, so the outer range maps directly.
+	 *
+	 * Unsafe example: (A{2}){2,3} produces counts 4 or 6 only, not
+	 * the full range 4..6, so we cannot flatten when child has a
+	 * non-trivial range AND outer is also a range.
 	 */
 	if (child->max != RPR_QUANTITY_INF &&
 		(pattern->min == pattern->max ||
@@ -824,6 +845,7 @@ tryMultiplyQuantifiers(RPRPatternNode *pattern)
 		if (new_min_64 >= RPR_QUANTITY_INF)
 			return pattern;
 
+		/* Outer unbounded: result is unbounded regardless of child */
 		if (pattern->max == RPR_QUANTITY_INF)
 			new_max_64 = RPR_QUANTITY_INF;
 		else
@@ -1186,8 +1208,19 @@ fillRPRPatternVar(RPRPatternNode *node, RPRPattern *pat, int *idx, RPRDepth dept
  * fillRPRPatternGroup
  *		Fill a GROUP pattern and its children.
  *
- * Creates elements for group content at increased depth, plus an END marker
- * if the group has a non-trivial quantifier.
+ * Creates elements for group content at increased depth, plus BEGIN/END
+ * marker pair if the group has a non-trivial quantifier (not {1,1}).
+ *
+ * Element layout for (A B){2,3}:
+ *
+ *   [BEGIN]  [A]  [B]  [END]  [next element...]
+ *     |                  |          ^
+ *     |                  +-- jump --+ (loop back to first child)
+ *     +---- jump -------------------+ (skip to after END)
+ *
+ * BEGIN.jump points past END (skip path when count >= max or min == 0).
+ * END.jump points to the first child (loop-back path).
+ * BEGIN.next and END.next are set later by finalizeRPRPattern().
  *
  * Returns true if this group is nullable.  A group is nullable when its
  * min is 0 (can be skipped entirely) or its body is nullable (every path
-- 
2.50.1 (Apple Git-155)



  [text/plain] nocfbot-0031-Remove-unused-include-fix-header-ordering.txt (1.9K, 33-nocfbot-0031-Remove-unused-include-fix-header-ordering.txt)
  download | inline diff:
From a074cdb45ffaa6a46a0b60a581a2eb527d855cad Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Tue, 7 Apr 2026 20:52:13 +0900
Subject: [PATCH] Remove unused include and fix header ordering in RPR files

---
 src/backend/executor/execExprInterp.c | 2 +-
 src/backend/executor/nodeWindowAgg.c  | 3 +--
 src/backend/parser/parse_rpr.c        | 3 +--
 3 files changed, 3 insertions(+), 5 deletions(-)

diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index e2d41c3098f..58b6693ed75 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -56,8 +56,8 @@
  */
 #include "postgres.h"
 
-#include "common/int.h"
 #include "access/heaptoast.h"
+#include "common/int.h"
 #include "access/tupconvert.h"
 #include "catalog/pg_type.h"
 #include "commands/sequence.h"
diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 849ebf8abb0..02f17e5472c 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -34,10 +34,9 @@
 #include "postgres.h"
 
 #include "access/htup_details.h"
-#include "common/int.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_aggregate.h"
-#include "catalog/pg_collation_d.h"
+#include "common/int.h"
 #include "catalog/pg_proc.h"
 #include "executor/executor.h"
 #include "executor/execRPR.h"
diff --git a/src/backend/parser/parse_rpr.c b/src/backend/parser/parse_rpr.c
index 8fbe12e1518..8864b20e6cf 100644
--- a/src/backend/parser/parse_rpr.c
+++ b/src/backend/parser/parse_rpr.c
@@ -30,9 +30,8 @@
 #include "nodes/nodeFuncs.h"
 #include "optimizer/optimizer.h"
 #include "optimizer/rpr.h"
-#include "parser/parse_clause.h"
-#include "parser/parse_collate.h"
 #include "parser/parse_coerce.h"
+#include "parser/parse_collate.h"
 #include "parser/parse_expr.h"
 #include "parser/parse_rpr.h"
 #include "parser/parse_target.h"
-- 
2.50.1 (Apple Git-155)



reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected]
  Subject: Re: Row pattern recognition
  In-Reply-To: <CAAAe_zBHrBBM2KYKJYSvP=vr=6fv7kFDr8qZZWFd7==sb4VMxg@mail.gmail.com>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox