Added
-
Path patterns got a real syntax, and a guide page to go with it: Path Patterns. One position can now be a class of events instead of a single name —
[a|b|c](any of),[^a]/[^a|b](anything but),.(any event) — which replaces therename_eventsworkaround for "any of these events", damaging the stream to ask one question. Quantifying a class with*turns it into a restricted gap, a run of events like this rather than one:add_to_cart->[^support_chat]*->purchaseis "bought without ever contacting support",path_start->[^purchase]*->path_endis "never purchased"..*is the unrestricted case of the same construct, so the two compose. Works everywhere a pattern is accepted — Step Matrix / Step Sankey / Transition Graphpath_pattern, thematches_patternmetric,truncate_pathsanchors. Details: the syntax follows Python's regular expressions with one substitution, a token is an event rather than a character, so negation lives inside the brackets ([^a], never^[a]) and members are separated by|. A class occupies one position and one ordinal, soat=/occurrence=/ Step Matrix centring are unaffected — centring on[payment_error|checkout_bug]behaves as centring on an event, except that column 0 shows a distribution over the class rather than one event at 1.0; a gap is not a position and takes no ordinal, so they are unaffected by that too..and[^...]never matchpath_start/path_end, mirroring regex, where.does not match a string boundary — which is what makes".->product_view"mean "a product view that was not the path's first event", previously inexpressible. A restricted gap needs an anchor on each side, and^/$are not anchors: a path's boundaries already have names. Constructs whose scope would exceed one position ([^a->b],a->b|c->d,[a|b]*) are rejected with an explanation rather than half-supported -
An anchor is a spec, not just an event name.
truncate_paths'start_anchor/end_anchoreach accept an event name, a spec{"pattern", "at", "occurrence", "offset", "offset_side"}, or a list of either.patternis a full->pattern, so a window can open on the event that completes a sequence rather than on any occurrence of it;atpicks which of its event names anchors;occurrencechooses"first"(default) or"last";offsetmoves the bound by a number of events (10) or by time ("30m", snapping to the nearest event inside the window, clamping at the path's own boundary), andoffset_sidesays which way a time offset rounds to a real event —"start"(forward) or"end"(backward). A list keeps the narrowest window the anchors imply — the latest start, the earliest end — which is how you write both "whichever comes first" (end_anchor=["purchase", {"pattern": "add_to_cart", "offset": 10}]) and a keep-whole fallback (end_anchor=["purchase", "path_end"], cutting converters at their purchase while keeping everyone else). The same spec is whatadd_events, Step Matrix, Step Sankey andget_conversion_ratetake. Two notes on the newer keys.occurrence="all"is the third value, returning every position each token can occupy in some valid match — the union of what"first"and"last"pick — instead of choosing one; the rows then no longer describe a single match, so anything needing one position rejects it —truncate_pathsraises rather than guessing which occurrence to cut at, and points atadd_events; andoffset_sideonly affects a timeoffset, since a step offset always lands on a row.truncate_pathsstill defaults to rounding a mark inward, keeping an exact hit inside the window on both sides -
add_events(anchor=...): a fourth mode that inserts an event at a position rather than at every occurrence of an event name —anchor={"pattern": "cart->[^cart]*->shipping_details", "at": "start"}names "the cart that checkout actually followed", not every cart. This is what makes such a position usable by the rest of the library: a pattern can describe it, but only an event name can be centred on by Step Matrix, counted by a funnel, or filtered on. Withoccurrence="all"it marks every attempt rather than one per path. One anchor per call, not a list — a list intruncate_pathsis a fallback chain narrowing one window, and there is no window here to narrow, so two markers means two calls.path_colresolves the anchor per session rather than per user; a path where the anchor resolves nowhere simply gets no event, though a single anchor naming an unknown event is a hard error -
Step Matrix / Step Sankey accept
anchor=: centre everything on one position instead of laying a pattern's parts out side by side. It reaches the two thingspath_patterncannot say — which occurrence to centre on ({"pattern": "cart", "occurrence": "last"}), and an offset in events or in time — and yields a single block. Mutually exclusive withpath_pattern(ADR-0008 rule 6); a path where the anchor does not resolve has no centre and is absent, so the anchor selects as well as centres.occurrence="all"is rejected here: several centres per path would let one path count more than once while every cell is a share of paths. A pattern typed into the widget sidebar replaces an anchor passed from Python rather than colliding with it -
get_conversion_rate(start_anchor, end_anchor, within=None): "if Y happened, how often does X follow — and is that different from the baseline?" as one call instead of afilter_paths(matches_pattern("Y->.*->X"))+get_metrics+.mean()composition. Returns a row per (start, end) pair withpaths_with_start,converted,conversion_rate,base_rateandlift: the denominator ships in every row because 0.5 out of two paths and 0.5 out of five thousand are different claims, and the base rate because a rate that looks high only for an event that is common everywhere is not a finding —liftbelow 1 says the start event makes the outcome less likely.withinexpresses the window the composition could not: an int counts events, a duration counts time ("30m"), both measured from the start anchor and inclusive of the far edge. Both sides take anchor specs, soend_anchor="path_end", within=1is an exit rate andstart_anchor={"pattern": "path_start->catalog", "at": -1}restricts the question to the sessions that landed there; a list on either side is a fan-out into separate questions, one row per combination. The unit of observation is the path, not the occurrence: a path where Y happened three times counts once, so per-visit questions ("of 23,000 visits, how many were entrances") remain out of scope. Also exposed as an MCP agent tool with aconversion_rateplaybook scenario — the first one that answers in numbers instead of registering a report tab, so its docstring and the system prompt both say to quote those numbers in backticks (whichcheck_analysisexempts from the anchor-link rule) and to report the denominator and the lift rather than the rate alone.describe_tool()'s index gained ananalysis_toolskey for the same reason: a tool called directly is not a step you put in apreprocessorslist -
add_segment(metric_bins=...): split paths into a segment by any per-path metric.{"metric": {...}, "edges": [5, 15], "segment_levels": ["short", "mid", "long"]}, or"quantiles": 4for quartiles /"quantiles": [0.25, 0.75]for chosen cut points. Cut points are interior — N of them always give N+1 bins, so nothing falls outside the split (unlikepd.cut, whose out-of-range values becomeNaN, which a segment column cannot hold).segment_levelsis optional; without it bins are named"[5, 15)"/q1..qN. Paths whose metric has no value get the level"undefined", which is not counted as a bin -
in_segment_bulkpath metric: thein_segmentmembership check fanned out over whole segment columns, the wayevent_count_bulkfans out over events.{"metric": "in_segment_bulk", "metric_args": {"segment_name": "channel"}}gives one 0/1 column per level ofchannel; omittingsegment_nametoo gives one column per level of every segment column, which is the one-liner for "put all my segments into this clustering feature set / overview".segment_levels(a list) narrows it back down,mode/thresholdwork exactly as inin_segment, and the columns are namedin_segment_bulk_{segment}_{level}_{mode}. As with the other*_bulkmetrics, an explicit empty list is rejected rather than read as the wildcard, and the metric cannot appear in afilter_paths/collapse_eventscondition, which needs one comparable value per path — usein_segmentwith a namedsegment_levelthere -
transition_graph/transition_graph_dataacceptpath_pattern, restricting the graph to paths matching a"->"pattern. Unlike Step Matrix's parameter of the same name it only selects paths — a graph has no step axis to centre, so nothing is cut; everything the widget derives, event counts included, comes from the restricted set -
Cluster Analysis: the silhouette grid is now selectable, not just charted. Click any bar in the Silhouette tab to interpret that partition instead of the top-scoring one — the overview heatmap, NMF tabs, the copy-pasteable
add_clusters(...)code and "Save Clusters" all follow the pick, so what you read is what you save. The top score keeps a ★ marker and a shaded band shows every candidate within 5% of it, since a near-tie on score is not a tie on interpretability. Headlessly this iscluster_analysis_data(..., select={"n_clusters": 5}), which keeps the whole grid in the result (now carryingbest_index/selected_index) while interpreting the point you name;cluster_analysis(select=...)opens the widget on it. A selection restored from astate_filethat names a point the current grid no longer has is dropped rather than raising -
Every widget now has
render_static(), anexport_html()sibling that returns anIPython.display.HTMLobject instead of writing a file — use it in place of a live widget in any cell you want to survivejupyter nbconvert/ "Save and Export as HTML", which replay stored outputs without a kernel. Withoutstate_file, it only reflects the widget object's own state, not a live in-browser arrangement (e.g. dragged graph nodes) made on a different object -
Docs, an agent-facing copy of the site (ADR-0014).
render_pages.pynow also writesllms.txt(annotated table of contents, per the llms.txt convention) andllms-full.txt(the whole documentation as one ~200 KB file), served atretentioneering.com/llms.txtand/llms-full.txt; descriptions are each page's own opening sentence, and a guide page missing from the newGUIDESorder list fails the build. Alongside them, a documentation MCP server athttps://retentioneering.com/docs/mcp— hosted, stateless, no installation, withsearch_docs/get_doc_page/list_doc_pages. It is distinct from the library's ownrete.mcp.serve(), which is local and works on your data; the MCP Server guide explains the split. Implementation lives in retentioneering-web
Changed
-
Breaking: an eventstream has exactly one event column.
EventstreamSchema.event_cols(a list) is nowevent_col(a string), and the per-methodevent_col=override is gone from all nine methods that had it —get_event_counts,collapse_events,split_sessions,truncate_paths,filter_paths,to_daily_states,add_clusters,segment_overview_data,cluster_analysis_data. There is no deep reason behind the removal: multiple event columns were never worked through architecturally, and dropping the idea was cheaper than making it hold up. To represent the stream at a coarser grain — screens rather than taps — usecollapse_events(group_col="screen"), which names each run after the value it is a run of and returns an ordinary eventstream. A coarser column can still be matched against without changing the stream: an anchor spec and a path metric'smetric_argsboth take an optionalevent_col— see Reading event names from another column, which also covers what a run-valued column does toevent_countandmatches_pattern.event_colsis still accepted with a single element and warns (FutureWarning), as does readingschema.event_cols; a longer list raises with instructions.path_colsis unaffected and stays a list. See ADR-0004 -
Breaking: four surfaces where a mode's parameters were spread flat across the signature now group them the way ADR-0008 rule 7 requires — one mode, one mutually-exclusive argument. The flat form let a parameter belonging to another mode be accepted and silently ignored, which is exactly what the rule exists to prevent:
collapse_eventsis restructured around the same split, and further than the other three — see the entry below.split_sessions(start_event=, end_event=)→split_sessions(bounds={"start_event": ..., "end_event": ...}).separator=andtimeout=are unchanged: they are their own mode and a modifier of either, respectively. The validator's own error message already named the modes ("specify at most one boundary mode"); now the signature does.get_metric_distribution(segment_level=, complement=)→get_metric_distribution(segment_level=)orsegment_levels=[a, b], both keyword-only, withcomplementremoved. The flag carried no information — it had to agree with the type ofsegment_levelor the call raised, in both directions — while the real distinction (one level against its complement, or two levels against each other) is the singular/plural pair ADR-0008 rule 2 already documents.metricmoved to the second positional slot, since a mode argument cannot sit before a required one.InvalidComplementConfigErroris nowInvalidSegmentSelectionError(codeINVALID_SEGMENT_SELECTION), and the single-distribution return shape ({"distribution": ...}) is gone — it was unreachable under the old validation too, since a lone level always compared against its complement.add_clusters/cluster_analysis_data/cluster_analysis:n_clusters=,min_cluster_size=,cluster_selection_epsilon=→method_args={...}, keepingmethod=as the selector. This one is a documented exception to rule 7 (ADR-0008 rule 8), in the shape the library already uses formetric/metric_args: a name from an open registry plus that name's own arguments.methodstays a scalar because it is also a widget traitlet and a UI dropdown, and because burying the mode selector inside a dict would hide it from the signature.scalerandnmf_componentsstay flat — they are pipeline steps applied before clustering, valid for every method, and their old position next to hdbscan's knobs made them read as method parameters. A key that does not belong to the chosenmethodnow raises instead of being dropped:add_clusters(method="hdbscan", n_clusters=5)used to cluster with HDBSCAN's defaults and never mention thatn_clusterswas ignored. The grouping also closes a gap the flat signature had hidden — the widget acceptedmethod="hdbscan"but had no parameter formin_cluster_size/cluster_selection_epsilonand no sidebar control for them either, so the method could be selected and never configured. Grid points insidesilhouette["params"]stay flat (they are coordinates, andselect=matches them by bare name); it isbest_paramsthat gained the call shape.
-
Breaking:
collapse_eventsnow separates what to merge from what to call it. Its boundary modes used to live as sibling keys insideevent_groups, a list of group dicts, where they were invisible to the signature and could not be validated as modes;group_colandsessionswere two spellings of the same operation; andcases— conditional naming — was reachable only throughevent_groups, though nothing about it is specific to that mode. The new shape is one mode argument plus one naming argument:- Modes (exactly one):
loops(renamed fromconsecutive— a run of one repeating event is the self-loop the transition graph draws, and 3.x called the operationcollapse_loops) and the reworkedgroup_colgroup adjacent rows sharing a value;event_groupsandboundscut the path into windows. The window modes are named exactly as insplit_sessions, which is not a coincidence — both processors chunk a path through the samesession_detectioncode, so a chunking that works in one now reads the same in the other.event_groupskeeps its name but changes shape: it now takes the event names that form one group (event_groups=["a", "b"], name="checkout") instead of a list of group spec dicts, and the old shape raises with a pointer to the new one rather than silently matching nothing. Note the difference fromloops, which the old naming hid:event_groups=["a", "b"]mergesa, a, b, binto one event,loops=["a", "b"]into two. - Naming:
nametakes a literal,{"col": "<column>"}for another column's value, or a list of cases closed by an optional fallback string. It applies to every mode, so a run of rows can be labelled by what happened inside it —collapse_events(group_col="session_id", name=[{"condition": ..., "name": "buying_session"}, "browsing_session"])replaces anadd_segmentwith a windowed SQL query followed by a collapse.nameandcaseswere the same question asked twice; they are one argument now, with the fallback as the list's last entry (the shapeadd_segment(rules=...)already uses). group_colkeeps its name and absorbssessions:sessions={"session_col": "session_id", "session_type_col": "session_kind"}becomesgroup_col="session_id", name={"col": "session_kind"}. Once naming moved out, the two modes had nothing left to distinguish them.- Removed: the
separatormode. It was written by analogy withsplit_sessions, and the analogy does not hold — there a separator starts a session and the row is dropped, here it ended the window and belonged to it, so one word carried opposite meanings across two processors whose vocabulary is otherwise shared. No use case survived review: the known-events version of "collapse up to X" isevent_groups=[..., "X"], and the unknown-events version answered no question anyone had. The_ctes_separatorbuilder it was the only caller of is deleted, andbuild_session_ctes'separator_startsflag with it —split_sessions' reading is now the only one. - Removed: the
timeoutkey a group dict could carry. It was parsed and applied to all three boundary modes but never documented — absent from theevent_groupskey list in the docstring, so working and tested yet undiscoverable. Inactivity is not an intuitive way to say whatcollapse_eventsdoes; it issplit_sessions(timeout="30m")followed bycollapse_events(group_col="session_id", ...), which names both operations and leaves the session column available for everything else. The one thing the two-step cannot reproduce is a timeout combined with an event-based window (events=[...]plus a gap break) — undocumented, and no known caller. - Collapsing several different groups in one call is gone: chain the calls instead. The list never bought anything —
applyalready looped over the groups and re-ran the query per group, soevent_groups=[g1, g2]was exactly.collapse_events(**g1).collapse_events(**g2), same number of passes. - Behaviour change that falls out of the merge: the old
sessionsmode grouped by session value (GROUP BY path, session_col), so a session id that reappeared later in a path collapsed into a single event spanning the gap.group_colgroups adjacent rows only — it never was SQL'sGROUP BY, and now neither mode is — so it is two events, which is what a timeline can represent. - The undocumented, unreachable
skipkey of theeventsmode is removed.
- Modes (exactly one):
-
Breaking:
cluster_analysis_data()["best_params"]is now shaped asadd_clusterskeyword arguments —{"method": ..., "method_args": {...}, "scaler": ..., "nmf_components": ...}— rather than a flat dict of the searched values. It is documented as "pass straight toadd_clusters", and it was not:methodwas missing entirely, so splatting the result of an hdbscan analysis fell back to the kmeans default and raised "n_clusters is required";scalerwas missing too, so a non-default scaler was silently not reproduced. Read a single value asbest_params["method_args"]["n_clusters"] -
Breaking:
add_events(source_events=...)is renamedsource_event=and now takes a bare event name as well as a list —add_events("session_start", source_event="login"). The plural described the argument rather than the question: one source event is the normal case and a list is the widening of it. ADR-0008 gained the rule this follows from (rule 2): a parameter's number encodes its role, not the arity of what it accepts — singular when it names one role, whether it takes exactly one value (event,path_col) or several alternatives for filling that role (start_anchor,source_event); plural only when the collection is the concept (funnel_events— one event is not a funnel;path_cols;active_events), or when a singular of the same stem is already taken and the plural must contrast with it (segment_level/segment_levels) -
Breaking: a parameter that takes an anchor is now named
anchor, notevent:truncate_paths(start_event, end_event)→truncate_paths(start_anchor, end_anchor), and the same onget_conversion_rate, whose output columns are renamed with it. Once a parameter accepts{"pattern": "cart->[^cart]*->checkout", "at": "start", "offset": 10}, calling it*_eventmisdescribes what it takes, and the newadd_events(anchor=...)would otherwise give one concept two public names (ADR-0008). Parameters naming boundary events as a plain set of names —split_sessions(start_event=, end_event=),collapse_events's group boundaries, thetime_betweenmetric'smetric_args— are not renamed: those are not positions, and the differing names now carry that difference. No deprecation alias, following thesegment_value→segment_levelprecedent; the plain string form (truncate_paths(start_anchor="registration", ...)) is otherwise unchanged -
Breaking: a derived event now sorts before the row it came from, where it used to sort after. A marker names the moment its source opens, which is what
add_events'source_eventandsqlmodes always meant by example; the flip also makes the newanchormode possible, since a marker for "the window opens here" has to precede the event it opens on.add_events("session_start", source_event=["login"])now putssession_startimmediately before the login it marks, so path strings, step matrices and transition graphs shift by one position wherever such events are used. Two consequences, both breaking:- Churn events carry their own
event_type,"churn", instead of"synthetic". They keep their old relative position — after the last active event, and always beforepath_end— which is why they could not staysyntheticonce that came to mean "before". Anything filteringevent_type == "synthetic"to find churn markers has to look for"churn"now. The position is a property of the event type, not of the call, so there is no per-call override. EventTypessort ranks were renumbered to leave a slot on either side ofraw:path_start=0,synthetic=1,raw=2,collapsed=2,churn=3,path_end=4. The rank isschema.subindex, which orders rows sharing a timestamp; it is deliberately not injective —rawandcollapsedshare one because collapsing an event does not move it, and the distinct type only records where the row came from. Nothing compares these values to literals (they are only ever a sort key), so the visible change is thesubindexcolumn's contents in a frame you read directly.
- Churn events carry their own
-
Breaking: "a level of a segment column" is called
segment_levelon every surface now;segment_valueis gone as both ametric_argskey and a parameter name (ADR-0008). Renamed: thein_segmentmetric'smetric_argskey, matchingget_segment_levels()/rename_segment_levels()and the widget traitlet of the same name; andEventstream.get_metric_distribution(segment_value=...)→segment_level=, plus the same parameter onSegmentOverview.get_metric_distributionandClusterAnalysis.get_metric_distribution.segment_valuenever meant "the segment column's name" — that issegment_name, which is unchanged. The exception raised for an unknown level is nowSegmentLevelNotFoundError(wasSegmentValueNotFoundError), with the error codeSEGMENT_LEVEL_NOT_FOUNDand the messageSegment level '...' not found in column '...'; itsavailable_values=argument is nowavailable_levels=. The widget→kerneldist_requestpayload key changed fromsegment_valuetosegment_levelalong with it, so a Segment Overview or Cluster Analysis widget served from a stale JS bundle will fail to open a distribution — rebuild withmake build. No deprecation aliases; update{"metric": "in_segment", "metric_args": {..., "segment_value": ...}}wherever it appears, including in the widget metric editor's saved configs. Positional calls (get_metric_distribution("country", "US", metric)) are unaffected. Docstrings and guides now say "segment level" throughout, andSegmentOverview's "segment-level metrics" (meaningsegment_size/segment_share) became "per-segment metrics" to keep the phrase from reading as the new term -
Pattern matching now has a single implementation,
retentioneering/paths/anchors.py, which locates a pattern's position in each path relationally in DuckDB and returns it. Thematches_patternmetric (previously an independent RE2 implementation returning a boolean) and Step Matrix'spath_patterncentring (previously pure-Python.apply()over->-joined path strings) are now both callers of it, so they can no longer disagree, and both get the token validation only Step Matrix used to do. Semantics for patterns whose gaps are all unrestricted are unchanged, and covered by an equivalence test against an independent reference implementation. Restricted gaps needed two fixes to the resolution itself, both invisible before this release since nothing could express one:- Matching parts as a greedy chain from one end is only correct while gaps are unrestricted, since a restricted gap makes an earlier neighbour the more constraining choice rather than the less. The matcher now runs a forward and a backward feasibility pass and takes the extremum over positions that appear in some complete match, which is what
occurrencewas always defined as. - Step Matrix stopped splitting patterns on the literal string
"->.*->", and now resolves the whole pattern once and anchors every block on that single match. It used to re-resolve the pattern's prefix per block — a greedy chain with the same flaw: a prefix cannot see the constraint that follows it, so with a restricted gap the earlier blocks centred on an occurrence taking part in no match of the pattern as written. Silent, since the block still showed its own event at column 0 and the path set was unaffected: on the bundled ecom dataset,cart->[^cart]*->payment_detailsmoved the centre for 39% of paths (median 16 events), and madecartat column −1 exactly 0 — an artifact of anchoring on the first cart, not a fact about the data.
- Matching parts as a greedy chain from one end is only correct while gaps are unrestricted, since a restricted gap makes an earlier neighbour the more constraining choice rather than the less. The matcher now runs a forward and a backward feasibility pass and takes the extremum over positions that appear in some complete match, which is what
-
utils.session_detection.parse_timeoutmoved toutils.durations.parse_duration, the one parser for every user-facing duration input;parse_timeoutremains as a thin wrapper -
engine.run()now shares one DuckDB instance across the process and takes a cursor for each query, instead of building a new database every time. Opening one costs a fixed few milliseconds, so the saving grows with the number of queries rather than the size of the data: a fullpytest tests/run drops by about a quarter. The instance is built on the first query, not at import. Frames registered by a call are still private to it, but catalog objects are not: a table or a view created through a processor'ssql=argument now lives for the rest of the process, where before it died with the connection -
Docs: the MCP server guide moved from
/docs/mcpto/docs/mcp-server(docs/guide/mcp.md→docs/guide/mcp-server.md), reserving/docs/mcpfor the documentation MCP endpoint above. All in-repo links were updated; the old URL is not redirected — it 404s until the endpoint lands there -
Docs:
docs/scripts/render_pages.py'scopy_guide_pages()now wipesdocs/build/guide/before copying, so a renamed or deleted guide page no longer lingers in the build output
Fixed
-
Step Matrix drew its serrated edges from a substring test on the pattern string —
path_pattern.includes("path_end")in the JS — so a pattern that merely mentions a sentinel hid an edge that should have been shown:cart->[^path_end]*->purchaseends onpurchase, not on the path's end, but read as though it ended there. Whether the strip reaches each boundary is now computed in Python from the parsed pattern and shipped in the result (starts_at_path_start/ends_at_path_end), which is also what lets a future non-string anchor say the same thing -
MCP: the
segment_overviewtool docstring inmcp/tools.pystill showedevent_count/has_eventtaking aneventskey (a list-accepting spelling removed in 5.0), so an agent following it hitInvalidMetricConfigError. Both MCP metric catalogues now show theeventkey and list thein_segment/in_segment_bulkmetrics they were missing -
in_segment/in_segment_bulknow reject unknownmetric_argskeys instead of ignoring them. Ignoring is not neutral for these two: an omitted level key means "every level", so{"metric": "in_segment", "metric_args": {"segment_name": "country", "segment_value": "United States"}}— the pre-5.0 spelling, see the rename above — did not fail, it silently widened the metric into one 0/1 column per country, which then surfaced far from the cause (an unreadably wide overview, ormetric_distribution requires exactly one metricfromget_metric_distribution).segment_valuegets a message naming its replacement, as does each flavour used with the other's spelling (segment_levelsinin_segment, which mirrors the checkin_segment_bulkalready had forsegment_level); anything else is reported with the valid key list -
The
in_segmentmetric withsegment_levelomitted (meaning "every level of the column") crashed with a DuckDBBinder Error: Referenced column "nan" not foundon any segment column that has paths with no assigned level — the missing value was resolved as a level and interpolated into the query as a barenanidentifier. Missing values are no longer treated as a level: they produce no column, since SQL equality could never have matched one anyway -
add_segment(funnel_events=...): a path that reached a later funnel step and then revisited an earlier one (e.g.PLP -> PDP -> basket -> ... -> PDPagain) was wrongly credited only for the earlier step. The query compared the last occurrence of each event (MAX(index)), so a later revisit of an earlier step could end up positioned after the deeper step's own last occurrence, breaking the required order. It now mirrorstools/funnel.py's chained-CTEMIN(index)logic — a path reaches stepkif there's any increasing sequence of occurrences matchingfunnel_events[0..k]in order, regardless of what happens afterwards -
Funnel widget: with more steps than the default
height=420could fit, the chart and the bottom rows of the table were clipped with no way to reach them. The scroll container declaredflex: 1inside a non-flex wrapper, so it never got a definite height andoverflow-y: autostayed inert; it is now a direct flex child withmin-height: 0and the funnel content scrolls vertically inside the widget frame -
Step Sankey:
path_start/path_endwere hard-excluded from every non-anchor column, hiding real (non-zero) values — e.g.path_end's cumulative drop-off in the steps after the anchor never showed up, even though the same numbers are visible as ordinary rows in the Step Matrix table. They now compete for the top-N slots like any other event in variable columns; the two fixed anchor columns are unaffected -
Cluster Analysis widget: selecting Standard feature scaling in the sidebar raised
ValueError: Unknown scaler: std— the sidebar sends"std"while the Python side only accepted"standard"."std"is now the canonical spelling on both sides;"standard"is still accepted as a legacy alias, so code written against 5.1.0 and earlier keeps working -
add_start_end_events: when several events shared a timestamp,path_endcould land before the path's real last event (andpath_startafter its first), giving a terminal marker outgoing transitions — visible as phantompath_end -> Xedges in transition graphs. Boundary rows were selected under(path_col, timestamp, subindex)while the result ships in(path_col, index, subindex)order; they are now selected under the same order