Skip to content

v5.2.0

Latest

Choose a tag to compare

@github-actions github-actions released this 19 Aug 15:16
· 12 commits to master since this release
a59a1ca

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 the rename_events workaround 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]*->purchase is "bought without ever contacting support", path_start->[^purchase]*->path_end is "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 Graph path_pattern, the matches_pattern metric, truncate_paths anchors. 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, so at= / 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 match path_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_anchor each accept an event name, a spec {"pattern", "at", "occurrence", "offset", "offset_side"}, or a list of either. pattern is a full -> pattern, so a window can open on the event that completes a sequence rather than on any occurrence of it; at picks which of its event names anchors; occurrence chooses "first" (default) or "last"; offset moves 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), and offset_side says 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 what add_events, Step Matrix, Step Sankey and get_conversion_rate take. 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_paths raises rather than guessing which occurrence to cut at, and points at add_events; and offset_side only affects a time offset, since a step offset always lands on a row. truncate_paths still 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. With occurrence="all" it marks every attempt rather than one per path. One anchor per call, not a list — a list in truncate_paths is a fallback chain narrowing one window, and there is no window here to narrow, so two markers means two calls. path_col resolves 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 things path_pattern cannot 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 with path_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 a filter_paths(matches_pattern("Y->.*->X")) + get_metrics + .mean() composition. Returns a row per (start, end) pair with paths_with_start, converted, conversion_rate, base_rate and lift: 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 — lift below 1 says the start event makes the outcome less likely. within expresses 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, so end_anchor="path_end", within=1 is an exit rate and start_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 a conversion_rate playbook 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 (which check_analysis exempts from the anchor-link rule) and to report the denominator and the lift rather than the rate alone. describe_tool()'s index gained an analysis_tools key for the same reason: a tool called directly is not a step you put in a preprocessors list

  • 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": 4 for 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 (unlike pd.cut, whose out-of-range values become NaN, which a segment column cannot hold). segment_levels is 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_bulk path metric: the in_segment membership check fanned out over whole segment columns, the way event_count_bulk fans out over events. {"metric": "in_segment_bulk", "metric_args": {"segment_name": "channel"}} gives one 0/1 column per level of channel; omitting segment_name too 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/threshold work exactly as in in_segment, and the columns are named in_segment_bulk_{segment}_{level}_{mode}. As with the other *_bulk metrics, an explicit empty list is rejected rather than read as the wildcard, and the metric cannot appear in a filter_paths/collapse_events condition, which needs one comparable value per path — use in_segment with a named segment_level there

  • transition_graph / transition_graph_data accept path_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 is cluster_analysis_data(..., select={"n_clusters": 5}), which keeps the whole grid in the result (now carrying best_index / selected_index) while interpreting the point you name; cluster_analysis(select=...) opens the widget on it. A selection restored from a state_file that names a point the current grid no longer has is dropped rather than raising

  • Every widget now has render_static(), an export_html() sibling that returns an IPython.display.HTML object instead of writing a file — use it in place of a live widget in any cell you want to survive jupyter nbconvert / "Save and Export as HTML", which replay stored outputs without a kernel. Without state_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.py now also writes llms.txt (annotated table of contents, per the llms.txt convention) and llms-full.txt (the whole documentation as one ~200 KB file), served at retentioneering.com/llms.txt and /llms-full.txt; descriptions are each page's own opening sentence, and a guide page missing from the new GUIDES order list fails the build. Alongside them, a documentation MCP server at https://retentioneering.com/docs/mcp — hosted, stateless, no installation, with search_docs / get_doc_page / list_doc_pages. It is distinct from the library's own rete.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 now event_col (a string), and the per-method event_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 — use collapse_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's metric_args both take an optional event_col — see Reading event names from another column, which also covers what a run-valued column does to event_count and matches_pattern. event_cols is still accepted with a single element and warns (FutureWarning), as does reading schema.event_cols; a longer list raises with instructions. path_cols is 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_events is 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= and timeout= 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=) or segment_levels=[a, b], both keyword-only, with complement removed. The flag carried no information — it had to agree with the type of segment_level or 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. metric moved to the second positional slot, since a mode argument cannot sit before a required one. InvalidComplementConfigError is now InvalidSegmentSelectionError (code INVALID_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={...}, keeping method= as the selector. This one is a documented exception to rule 7 (ADR-0008 rule 8), in the shape the library already uses for metric / metric_args: a name from an open registry plus that name's own arguments. method stays 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. scaler and nmf_components stay 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 chosen method now raises instead of being dropped: add_clusters(method="hdbscan", n_clusters=5) used to cluster with HDBSCAN's defaults and never mention that n_clusters was ignored. The grouping also closes a gap the flat signature had hidden — the widget accepted method="hdbscan" but had no parameter for min_cluster_size / cluster_selection_epsilon and no sidebar control for them either, so the method could be selected and never configured. Grid points inside silhouette["params"] stay flat (they are coordinates, and select= matches them by bare name); it is best_params that gained the call shape.
  • Breaking: collapse_events now separates what to merge from what to call it. Its boundary modes used to live as sibling keys inside event_groups, a list of group dicts, where they were invisible to the signature and could not be validated as modes; group_col and sessions were two spellings of the same operation; and cases — conditional naming — was reachable only through event_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 from consecutive — a run of one repeating event is the self-loop the transition graph draws, and 3.x called the operation collapse_loops) and the reworked group_col group adjacent rows sharing a value; event_groups and bounds cut the path into windows. The window modes are named exactly as in split_sessions, which is not a coincidence — both processors chunk a path through the same session_detection code, so a chunking that works in one now reads the same in the other. event_groups keeps 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 from loops, which the old naming hid: event_groups=["a", "b"] merges a, a, b, b into one event, loops=["a", "b"] into two.
    • Naming: name takes 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 an add_segment with a windowed SQL query followed by a collapse. name and cases were the same question asked twice; they are one argument now, with the fallback as the list's last entry (the shape add_segment(rules=...) already uses).
    • group_col keeps its name and absorbs sessions: sessions={"session_col": "session_id", "session_type_col": "session_kind"} becomes group_col="session_id", name={"col": "session_kind"}. Once naming moved out, the two modes had nothing left to distinguish them.
    • Removed: the separator mode. It was written by analogy with split_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" is event_groups=[..., "X"], and the unknown-events version answered no question anyone had. The _ctes_separator builder it was the only caller of is deleted, and build_session_ctes' separator_starts flag with it — split_sessions' reading is now the only one.
    • Removed: the timeout key a group dict could carry. It was parsed and applied to all three boundary modes but never documented — absent from the event_groups key list in the docstring, so working and tested yet undiscoverable. Inactivity is not an intuitive way to say what collapse_events does; it is split_sessions(timeout="30m") followed by collapse_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 — apply already looped over the groups and re-ran the query per group, so event_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 sessions mode 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_col groups adjacent rows only — it never was SQL's GROUP BY, and now neither mode is — so it is two events, which is what a timeline can represent.
    • The undocumented, unreachable skip key of the events mode is removed.
  • Breaking: cluster_analysis_data()["best_params"] is now shaped as add_clusters keyword arguments — {"method": ..., "method_args": {...}, "scaler": ..., "nmf_components": ...} — rather than a flat dict of the searched values. It is documented as "pass straight to add_clusters", and it was not: method was missing entirely, so splatting the result of an hdbscan analysis fell back to the kmeans default and raised "n_clusters is required"; scaler was missing too, so a non-default scaler was silently not reproduced. Read a single value as best_params["method_args"]["n_clusters"]

  • Breaking: add_events(source_events=...) is renamed source_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, not event: truncate_paths(start_event, end_event)truncate_paths(start_anchor, end_anchor), and the same on get_conversion_rate, whose output columns are renamed with it. Once a parameter accepts {"pattern": "cart->[^cart]*->checkout", "at": "start", "offset": 10}, calling it *_event misdescribes what it takes, and the new add_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, the time_between metric's metric_args — are not renamed: those are not positions, and the differing names now carry that difference. No deprecation alias, following the segment_valuesegment_level precedent; 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_event and sql modes always meant by example; the flip also makes the new anchor mode 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 puts session_start immediately 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 before path_end — which is why they could not stay synthetic once that came to mean "before". Anything filtering event_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.
    • EventTypes sort ranks were renumbered to leave a slot on either side of raw: path_start=0, synthetic=1, raw=2, collapsed=2, churn=3, path_end=4. The rank is schema.subindex, which orders rows sharing a timestamp; it is deliberately not injective — raw and collapsed share 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 the subindex column's contents in a frame you read directly.
  • Breaking: "a level of a segment column" is called segment_level on every surface now; segment_value is gone as both a metric_args key and a parameter name (ADR-0008). Renamed: the in_segment metric's metric_args key, matching get_segment_levels() / rename_segment_levels() and the widget traitlet of the same name; and Eventstream.get_metric_distribution(segment_value=...)segment_level=, plus the same parameter on SegmentOverview.get_metric_distribution and ClusterAnalysis.get_metric_distribution. segment_value never meant "the segment column's name" — that is segment_name, which is unchanged. The exception raised for an unknown level is now SegmentLevelNotFoundError (was SegmentValueNotFoundError), with the error code SEGMENT_LEVEL_NOT_FOUND and the message Segment level '...' not found in column '...'; its available_values= argument is now available_levels=. The widget→kernel dist_request payload key changed from segment_value to segment_level along with it, so a Segment Overview or Cluster Analysis widget served from a stale JS bundle will fail to open a distribution — rebuild with make 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, and SegmentOverview's "segment-level metrics" (meaning segment_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. The matches_pattern metric (previously an independent RE2 implementation returning a boolean) and Step Matrix's path_pattern centring (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 occurrence was 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_details moved the centre for 39% of paths (median 16 events), and made cart at column −1 exactly 0 — an artifact of anchoring on the first cart, not a fact about the data.
  • utils.session_detection.parse_timeout moved to utils.durations.parse_duration, the one parser for every user-facing duration input; parse_timeout remains 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 full pytest 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's sql= argument now lives for the rest of the process, where before it died with the connection

  • Docs: the MCP server guide moved from /docs/mcp to /docs/mcp-server (docs/guide/mcp.mddocs/guide/mcp-server.md), reserving /docs/mcp for 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's copy_guide_pages() now wipes docs/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]*->purchase ends on purchase, 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_overview tool docstring in mcp/tools.py still showed event_count/has_event taking an events key (a list-accepting spelling removed in 5.0), so an agent following it hit InvalidMetricConfigError. Both MCP metric catalogues now show the event key and list the in_segment / in_segment_bulk metrics they were missing

  • in_segment / in_segment_bulk now reject unknown metric_args keys 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, or metric_distribution requires exactly one metric from get_metric_distribution). segment_value gets a message naming its replacement, as does each flavour used with the other's spelling (segment_levels in in_segment, which mirrors the check in_segment_bulk already had for segment_level); anything else is reported with the valid key list

  • The in_segment metric with segment_level omitted (meaning "every level of the column") crashed with a DuckDB Binder Error: Referenced column "nan" not found on 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 bare nan identifier. 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 -> ... -> PDP again) 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 mirrors tools/funnel.py's chained-CTE MIN(index) logic — a path reaches step k if there's any increasing sequence of occurrences matching funnel_events[0..k] in order, regardless of what happens afterwards

  • Funnel widget: with more steps than the default height=420 could fit, the chart and the bottom rows of the table were clipped with no way to reach them. The scroll container declared flex: 1 inside a non-flex wrapper, so it never got a definite height and overflow-y: auto stayed inert; it is now a direct flex child with min-height: 0 and the funnel content scrolls vertically inside the widget frame

  • Step Sankey: path_start/path_end were 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_end could land before the path's real last event (and path_start after its first), giving a terminal marker outgoing transitions — visible as phantom path_end -> X edges 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