Skip to content

tfd_mv / tfb_mv classes for vector-valued functions f: R -> R^d - #233

Merged
fabian-s merged 217 commits into
mainfrom
claude/jolly-planck-nvXkZ
Jul 14, 2026
Merged

tfd_mv / tfb_mv classes for vector-valued functions f: R -> R^d#233
fabian-s merged 217 commits into
mainfrom
claude/jolly-planck-nvXkZ

Conversation

@fabian-s

@fabian-s fabian-s commented May 28, 2026

Copy link
Copy Markdown
Contributor

Implementation of vector-valued functional data — curves into $\mathbb{R}^d$, e.g. movement trajectories — addressing #18 ("multivariate evaluations (curves)") and #27 (movement data, à la Joo et al. 2019). What began as a prototype is now a fairly complete feature: regular + irregular sampling, the full vctrs/tidyverse surface, registration up to elastic shape space, multivariate FPCA, and a worked vignette. Package version bumped to 0.4.2; R CMD check is clean.

Design

Composition approach: a tf_mv vector of length n bundles d univariate tf vectors (one per output dimension) as an attribute, with a custom vec_proxy / vec_restore pair (data-frame-of-components proxy). Almost every method falls out of mapping the existing univariate code over the d components — very few new numeric kernels. Both tfd and tfb representations work, regular + irregular sampling work, and per-component differing arg grids are accommodated. The three candidate designs (composition vs. matrix-valued evaluations vs. long/stacked) are compared in attic/design/multivariate.md.

Core classes & API

  • New classes tfd_mv / tfb_mv (parent tf_mv); the tf_mv ⊂ tf inheritance contract is made explicit.
  • Full vctrs integration: subset, c(), casting, ptype2, tibble columns, vec_ptype_abbr / vec_ptype_full.
  • Constructors from list-of-tf, list-of-matrices, 3-d [curve, arg, component] array, and long/wide data frames.
  • Accessors: tf_ncomp, tf_components, tf_component(<-), $ / $<-, tf_arg, tf_evaluations, tf_count, is.na.
  • [ returns a [curve, arg, component] array (classes for surfaces / images #18's "array-valued j") with a component= selector (incl. multi-component selection); matrix-index (curve, arg) pairs return one row per pair × d columns.
  • Component-wise arithmetic, Math, Summary, mean / median / sd / var, == / !=.
  • tfb_mv accepts per-component basis specs via component-named list ... args (e.g. k = list(x = 5, y = 12)).
  • Delegated verbs: tf_rebase, tf_derive, tf_integrate (definite → n × d matrix; indefinite → tf_mv), tf_smooth, tf_zoom.
  • Interop: as.matrix[curve, arg, component] array; as.data.frame(unnest = TRUE) full-outer-joins on (id, arg) (mixed reg/irreg components handled); long/wide schemas.
  • Plotting: plot.tf_mv with "facet" (one panel per component) or "trajectory" (d == 2); print/format header reports per-component value ranges.

Registration & alignment — a 4-rung ladder

A single shared time-warp is estimated per curve and applied jointly to all components, via tf_register() / tf_estimate_warps() / tf_warp() / tf_align() with accessors tf_aligned(), tf_inv_warps(), tf_template().

  • Rung 1 — tf_reparam_arclength(): constant-speed (parametrization-only) reparametrization.
  • Rung 2 — method = "cc": warp from a single 1-d reference signal (ref_component, "norm" for the pointwise Euclidean norm, or a custom function(tf_mv) → tf); also "affine", "landmark", and per-component "srvf".
  • Rung 3 — method = "srvf_mv": true multivariate elastic (Fisher–Rao / SRVF) registration that aligns the joint (hip, knee, …) trajectory using all components at once, with the multivariate Karcher-mean template.
  • Rung 4 — tf_register_shape(): full elastic shape registration — warp + rotation + scale — landing in a centered, normalized shape space; tf_rotations() / tf_scales() expose the estimated rotations and template-relative scale factors.

(srvf / srvf_mv / shape registration use the fdasrvf package, in Suggests.)

Multivariate FPCA — tfb_mfpc() (new)

  • Happ & Greven (2018) MFPCA: run the univariate FPCA per component, then combine the per-component scores into a single shared score vector per curve with vector-valued eigenfunctions $\Psi_m: \mathbb{R} \to \mathbb{R}^d$. The result is an ordinary tfb_mv whose components are tfb_fpc objects sharing identical scores, so reconstruction / printing / plotting work via the existing machinery.
  • Component weighting: "inverse_variance" (default), "snr", "equal", or a numeric vector; separate univariate (uni_pve) and multivariate (pve / npc) truncation.
  • New tfd_mv data is projected onto a fitted basis (joint re-scoring) via tf_rebase() / vec_cast().
  • Accessors tf_mfpc_scores(), tf_mfpc_efunctions(); predicate is_tfb_mfpc().

Geometry primitives

tf_norm, tf_speed, tf_inner, tf_distance, tf_tangent, tf_reparam_arclength, and tf_arclength (method = "polyline" default, or "derive"; definite and indefinite modes). Generalized to work on univariate tf too.

Vignette & docs

  • attic/vector-valued-functions.Rmd: two real-data case studies (tf::gait; dplyr::storms as 4-d (long, lat, wind, pres)), the 4-rung alignment ladder with a shape-space quotient demo, and FPC + MFPCA sections — with a proper literature bibliography (attic/references.bib).
  • pkgdown reference index updated (converters-mv, tf_register_shape, tfb_mfpc, …); design doc and prototype history live under attic/.
  • NEWS.md updated for 0.4.2.

Status

  • R CMD check: 0 errors / 0 warnings / 0 notes (tf 0.4.2).
  • Full test suite passes (0 failures), covering mv classes, vctrs/ptype/cast, verbs, geometry, registration (incl. srvf_mv and shape), MFPCA, and tibble/dplyr/tidyr interop. Relevant files: test-tfd-mv, test-tfb-mv, test-mv-vctrs, test-mv-methods, test-mv-verbs, test-mv-edge, test-mv-geom, test-mv-contract, test-mv-tidyverse, test-register-mv-srvf, test-mfpc.
  • All of the original pre-merge TODOs are resolved: end-to-end devtools::check(), srvf registration on tf_mv (now a first-class multivariate method + tests), a real-data gait walkthrough (the vignette), and the design-doc tone (now an internal attic/ record with the user-facing vignette alongside).

Future work (out of scope; tracked in attic/design/multivariate.md)

  • shared-basis tfb_mv (one basis system across components, single basis_matrix + d coefficient vectors);
  • further geometry: tf_curvature, tf_frenet, tf_rotate / tf_translate / tf_affine, tf_project, tf_is_closed, tf_self_intersection;
  • dispersing the remaining R/*-mv.R methods into the existing per-topic files.

🤖 Generated with Claude Code

claude added 15 commits May 27, 2026 16:52
Introduces a prototype representation for multivariate-output functional
data (curves into R^d, e.g. movement trajectories), addressing issues
#18 and #27. Uses a composition design: a tf_mv vector bundles d
univariate tf vectors (one per output dimension) and delegates all
numeric work to the existing univariate machinery, so both tfd and tfb
representations and regular/irregular sampling are supported with no new
numeric kernels.

- new classes tfd_mv / tfb_mv (parent tf_mv) built on vctrs::new_vctr
- custom vec_proxy/vec_restore (data-frame-of-components proxy) plus
  component-wise vec_ptype2/vec_cast for full vctrs compatibility
  (subset, c(), casting, tibble columns)
- constructors from lists of tf vectors / matrices, 3-d arrays and long
  data.frames; accessors tf_ncomp/tf_components/tf_component + $ sugar
- component-wise arithmetic, Math/Summary, mean/median/sd/var, ==/!=
- [ evaluation returns a [curve, arg, component] array (issue #18's
  array-valued j) with a component= selector; facet and trajectory plots
- design/multivariate.md compares the candidate approaches
- tests for construction, vctrs, brackets and methods

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Adds component-wise tf_mv methods for the remaining univariate verbs:
tf_rebase (so tfd_mv<->tfb_mv conversion via tf_rebase works), tf_derive,
tf_integrate (definite -> n x d matrix; indefinite -> tfd_mv), tf_smooth
and tf_zoom.

Registration is handled specially: a vector-valued curve shares one time
axis, so tf_estimate_warps.tf_mv estimates a single warp per curve from a
univariate registration signal (default: the first component; ref_component
can select another component, "norm" for the pointwise Euclidean norm, or a
custom function) and tf_warp.tf_mv / tf_align.tf_mv apply that shared warp
to every component. tf_register then composes unchanged, yielding a
tf_registration whose registered/template are tf_mv and whose warps are
univariate. Adds tests in test-mv-verbs.R and notes in design/multivariate.md.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
The previous all.equal comparison across component args was implicitly
detecting "all components are regular with the same shared grid"; check
that directly via is_irreg() instead. Same observable behaviour, clearer
intent.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Two refinements to how tf_mv accommodates irregular data:

1. new_tf_mv no longer rejects components with differing domains. By
   default it takes the union as the mv domain and widens each
   component to match (warnings about the widening are suppressed; the
   widening is intentional). Users can supply an explicit `domain` to
   tfd_mv() as long as it contains every component's observed range.
   This fixes the common case where independent irregular sampling
   yields components whose auto-derived domains differ by floating-
   point amounts.

2. tf_arg.tf_mv now collapses to a single per-curve list (length n)
   when every component is irregular AND the per-curve args agree
   across components -- the canonical "movement data with irregular
   timestamps" shape, where reporting two redundant copies was
   misleading. Per-component shapes still emerge for genuinely
   differing arg structures.

Tests cover the auto-union, user-supplied-domain, out-of-range-domain
and arg-collapse cases.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Adds an "Irregularity cases" table to design/multivariate.md covering the
four qualitatively different shapes a tf_mv can have (fully regular;
per-curve shared across components; per-component grid; per-(curve,
component) grids), what tf_arg() and tf_evaluations() return in each, and
explicitly acknowledges the storage redundancy in case 1 as the cost of
the composition design. Also updates the internal-layout description to
reflect that new_tf_mv() unions differing component domains by default
rather than rejecting them, and refreshes the files list.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Captures four follow-up items in design/multivariate.md:
- convenience verbs to regularize tf_mv args across components or entries,
- shared-basis tfb_mv,
- multivariate FPCA (MFPCA) as a first-class tfb_mv subclass,
- a proper vignette with real-data case studies (e.g. gait).

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Notes the dispersal target for each *.tf_mv method (e.g. [.tf_mv into
brackets.R, registration methods into register.R, calculus methods into
calculus.R, etc.) once the feature stabilizes, while keeping the core
constructors and shared mv helpers together.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
- tfb_mv.list short-circuits the empty-list case explicitly so the returned
  prototype is tfb_mv rather than tfd_mv (the all(map_lgl(empty, is_tf))
  check is vacuously TRUE, which previously routed empty input into
  new_tf_mv() with the default tfd_mv class).
- tfb_mv.list for non-tf input forwards ... to tfd_mv() only (not also to
  the subsequent tfb_mv.tf_mv() call), so user-supplied arg/domain are
  consumed once.
- New test-mv-edge.R covers gaps surfaced by covr::package_coverage:
  empty prototype, n=1 / d=1, NA-curve propagation through ops/subset,
  Summary group generic (sum/min/max), var/sd, unary minus and the
  incompatible-op error path, tfb_mv.list (all-tf and non-tf branches),
  c(tfb_mv, tfb_mv) and ptype_abbr/full for tfb_mv, tfd_mv re-evaluation
  on a new grid, tf_rebase with an mv basis_from, tf_evaluate direct
  call, as.matrix(arg=) and as.data.frame both modes, ref_component =
  "norm" registration, tf_component<- adding components and length-
  mismatch rejection.

mv code coverage: 63.9% -> 83.1% (the remainder is print/plot/format
visual code). Full suite 1387/1387, zero regressions.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Replace "R^d" in the print header with the actual per-component
evaluation ranges joined by " x " (e.g.
"tfd_mv<d=2>[4] (x, y): [0, 1] -> [-2.19, 1.75] x [-8.51, 10.93]"),
matching how the univariate print.tf header shows the range of f.
Empty d=0 prototype keeps "R^0".

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
…nents

Previously the unnest path assumed every component had the same long-form
(id, arg) rows and assigned them side-by-side; that failed with a row-count
mismatch when mixing tfd_reg + tfd_irreg components (or any two components
with different arg structures). Build each component's long data.frame
independently and merge() them with all = TRUE on (id, arg); components
without an observation at a given (id, arg) get NA in their column. For
already-aligned components the result is the same shape as before.

Adds a "mixed regular/irregular components work across the API" test
exercising construction, accessors, subset, c(), arithmetic, and the
joined as.data.frame.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Previously tfb_mv(f, k = 10) shared a single ... across every component
(same k, bs, sp, etc. for all dimensions); users wanting different specs
had to pre-build each component with tfb() and wrap with tfb_mv.list().
Now any ... argument that is a list named by component names is
distributed per-component, while everything else stays shared:

  tfb_mv(f, k = list(x = 5, y = 15), bs = "tp")

fits component x with k = 5 and component y with k = 15, both with
bs = "tp". A list whose names do not match the component names is
treated as a shared argument value (back-compatible).

This is "per-component basis spec, independent fits" -- distinct from
the still-TODO "one basis shared across components" item.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
For f: [a,b] -> R^d the arc length is integral_a^b ||f'(t)|| dt. The
implementation is pure composition of existing verbs -- tf_derive.tf_mv for
per-component differentiation, sqrt(Reduce("+", map(., ^2))) for the
pointwise Euclidean norm of the derivative, then tf_integrate -- so no new
numeric kernels.

Signature mirrors tf_integrate (arg, lower, upper, definite, ...):
  definite = TRUE  (default) -> numeric vector of total lengths per curve
  definite = FALSE           -> univariate tfd giving the cumulative
                                arc length s(t) = integral_a^t ||f'(u)|| du

Tests in test-mv-verbs.R: unit-circle total length (~ 2*pi), vectorised
batch (k-loop -> 2*pi*k), partial integration via lower/upper, definite
vs indefinite mode, and a 3-d helix (2*pi*sqrt(1 + c^2)).

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Picks up the per-component-basis-spec @details I added to tfb_mv and adds
tf_arclength() to the @family tf_mv-class cross-reference block on the
four sibling man pages -- both should have been committed alongside the
corresponding R changes; the stop hook caught the omission.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Three changes:

1. Add tier-1 geometric helpers in R/mv-methods.R (all are 1-3 lines of
   composition over existing univariate Ops/Math + tf_derive/tf_warp):
     tf_norm(f)     -- pointwise ||f(t)||  as univariate tfd
     tf_speed(f)    -- pointwise ||f'(t)|| as univariate tfd
     tf_inner(f, g) -- pointwise <f, g>    as univariate tfd
     tf_distance(f, g) -- pointwise ||f - g|| as univariate tfd
     tf_tangent(f)     -- unit tangent f' / ||f'|| as tf_mv
     tf_reparam_arclength(f) -- re-parametrize curve at constant speed
   Also refactors mv_registration_signal's "norm" branch to use tf_norm.

2. tf_arclength now defaults to a polyline (sum-of-segments) method
   rather than the derive+integrate composition. Polyline computes the
   sum of Euclidean lengths of segments between consecutive sample
   points in R^d, evaluating each component on each curve's grid (the
   union across components/curves when those differ). This avoids the
   compounding error of numerical differentiation followed by
   quadrature on raw tfd_mv data; the derive method is kept available
   via method = "derive" for analytic (tfb) settings or custom
   tf_integrate forwarding. New tests confirm polyline beats derive on
   the unit-circle benchmark.

3. design/multivariate.md gains a tier-2/tier-3 TODO list including
   tf_curvature, tf_frenet, tf_rotate/translate/affine, tf_project,
   tf_is_closed, tf_self_intersection, tf_align_rigid, and
   tf_landmarks_extrema.tf_mv.

Tests (test-mv-geom.R, test-mv-verbs.R additions): tf_norm on a
constant (3,4)->5 vector, tf_speed = tf_norm o tf_derive, tf_inner
dot-product identity, tf_distance(f,f) = 0, unit-circle tangent has
unit norm, tf_reparam_arclength of f(t) = (t^2, 0) gives g(0.5) =
(0.5, 0) at speed 1, polyline vs derive accuracy comparison.

Full suite 1435/1435.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
tf_mv columns work end-to-end in the tidyverse pipeline via the existing
vctrs proxy/restore plumbing -- this commit just locks that in with
asserted tests rather than relying on smoke checks.

Covers: tibble column construction & printing, dplyr::filter (incl. with
a tf_mv-derived predicate), mutate (scalar reductions like tf_arclength,
tfd reductions like tf_speed, in-place tfd_mv transforms like 2*path),
summarize (mean(path) -> length-1 tfd_mv), group_by + summarize, arrange,
slice, bind_rows, left_join, pull, distinct, tidyr::nest / unnest round
trip, and rowwise mutate.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
@codecov

codecov Bot commented May 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.71583% with 355 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.65%. Comparing base (2b4eeb5) to head (837da48).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
R/assertions.R 72.08% 79 Missing ⚠️
R/tfb-mfpc.R 88.66% 39 Missing ⚠️
R/register-mv.R 91.01% 31 Missing ⚠️
R/tfd-mv.R 89.07% 20 Missing ⚠️
R/geometry-mv.R 92.44% 17 Missing ⚠️
R/registration-class.R 86.71% 17 Missing ⚠️
R/fwise.R 71.92% 16 Missing ⚠️
R/print-format-mv.R 77.94% 15 Missing ⚠️
R/register.R 84.09% 14 Missing ⚠️
R/calculus-mv.R 84.05% 11 Missing ⚠️
... and 20 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #233      +/-   ##
==========================================
+ Coverage   85.58%   89.65%   +4.06%     
==========================================
  Files          36       51      +15     
  Lines        4365     7211    +2846     
==========================================
+ Hits         3736     6465    +2729     
- Misses        629      746     +117     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

claude and others added 10 commits May 28, 2026 15:17
Two roxygen mismatches caused R CMD check (and thus pkgdown) to fail on
every CI runner:

1. tf_rebase.tf_mv had `@rdname tf_mv-methods`, which appended its
   \usage line (with formals `object, basis_from, arg, ...`) to the
   tf_mv-methods Rd page; those args were undocumented there.
   Move the explanatory @details block onto the tf_ncomp roxygen and
   leave tf_rebase.tf_mv with a bare @export so it stays attached to
   its own generic's Rd.

2. The @PARAM list declared `f, x` but no \usage line on the Rd uses
   `x` (the `$` accessor is exported separately and not aliased to
   this page). Drop `x` from @PARAM.

Also tidies the @details to remove the contradiction between "by
default from the pointwise Euclidean norm" and the immediately
following "the registration signal is, by default, the first
component" -- only the latter is true.

R CMD check now reports only environment-induced messages (locale
warning, blocked CRAN, missing fda/fdasrvf/refund Suggests, and the
pre-existing `fdasrvf` Rd xref NOTE). Full suite 1474/1474.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
pkgdown evaluates `_pkgdown.yml` `contents:` entries as R expressions, so
a topic name with a hyphen (`tf_mv-methods`) gets parsed as the
subtraction `tf_mv - methods` and the build aborts. Renames the topic
to `tf_mv_methods` (underscore) -- four `@rdname` directives in
R/mv-methods.R, one `_pkgdown.yml` entry, and the generated Rd file
(now man/tf_mv_methods.Rd, the stale hyphenated one is removed).

Two more pkgdown errors surfaced from the topic-vs-alias mismatch on
the first roxygen block of a multi-function topic: the Rd's \name and
the implicit \alias both default to the first @export'ed object, so
`tf_mv_methods` and `tf_geom` weren't registered as aliases. Adds an
explicit `@name tf_mv_methods` / `@name tf_geom` to each topic's first
block so the topic name resolves as an alias.

Adds `tf_geom` and `tf_arclength` to the "Vector-valued functional
data" section of `_pkgdown.yml` so all new reference pages are
included in the site index (pkgdown errors out on missing topics).

Local pkgdown::build_reference() now completes without errors; full
test suite 1474/1474.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
…check

R-release / R-devel R CMD check flags `pkg::fn` references where `pkg`
is not declared in DESCRIPTION Imports or in NAMESPACE importFrom
("'::' or ':::' imports not declared from:"). plot.tf_mv and
lines.tf_mv use graphics::par, graphics::lines, and grDevices::n2mfrow;
only graphics::lines was implicitly imported (older code paths).
Add explicit @importFrom directives on the lines.tf_mv roxygen block
so roxygen2 emits the required NAMESPACE entries.

Reproduced and fixed locally: R CMD check now reports only the
environment-induced WARNING (locale) and the two pre-existing NOTEs
(fdasrvf Rd xref, missing CRAN Suggests in the sandbox).

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Two more R CMD check "'::' or ':::' imports not declared from:"
warnings:

1. test-mv-tidyverse.R uses tibble::tibble and tidyr::nest/unnest.
   R CMD check scans test files regardless of skip_if_not_installed(),
   so the suggesting packages need to be declared. Add tibble and
   tidyr to DESCRIPTION Suggests.

2. plot.tf_mv's roxygen had a [plot.tfd][tf::plot.tf] cross-reference
   that self-qualifies the host package; R CMD check treats that as
   an undeclared self-import. Use [plot.tf()] instead -- pkgdown and
   help() both resolve it cleanly.

Local R CMD check is clean modulo the environment-induced WARNING
(locale) and the pre-existing NOTEs (fdasrvf Rd xref, missing CRAN
Suggests in the sandbox). Full suite still 1474/1474.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Address review findings and add missing functionality for the
vector-valued (tfd_mv / tfb_mv) classes:

- Fix Reduce-based ops on zero-component objects: ==, tf_norm, tf_inner
  now return zero-length results instead of NULL / erroring.
- tf_reparam_arclength leaves zero-length (constant) curves unchanged
  with a clear warning instead of producing NaN warps.
- tf_count(tfb_mv) aborts with an informative message.
- Trajectory plots: recycle per-curve graphical params (col/lty/lwd via
  matlines), default to "trajectory" for d == 2, and evaluate components
  on a common grid so mixed / irregular grids no longer error.
- Add [<-.tf_mv (component-wise replacement; supports NA assignment and
  casting) and names<-.tf_mv (curve names round-trip through subset / c()).
- print.tf_mv reports per-component gridpoints + interpolator (tfd) or
  basis spec (tfb), collapsing when components agree.
- tfd_mv docs: drop GitHub-issue references, add examples for the list,
  matrix, array and data.frame constructors.
- Regression tests for all of the above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make tf_norm/tf_inner/tf_tangent S3 generics so the pointwise geometric
primitives also work on univariate tfd/tfb (norm = |f|, inner = f*g,
tangent = f'/|f'|), with .default methods that emit informative cli
errors. tf_speed/tf_distance generalize for free via the now-generic
tf_norm. tf_mv inherits from "tf", so the .tf_mv methods stay selected
for vector-valued input.

Add input validation to user-facing tf_mv functions:
- assert_tf_mv() helper
- check_component_index() for tf_component()/tf_component<-(), rejecting
  out-of-range, fractional, multi-element, NA and logical selectors;
  fixes a crash where a length>1 character selector hit `&&` coercion
- max_iter/tol checks in tf_estimate_warps.tf_mv()
- lower<=upper / finite limits in tf_arclength.tf_mv()
- explicit non-tf_mv rejection in tf_inner.tf_mv()

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
devtools::document() re-rendered all help topics with the locally
installed roxygen2 8.0.0 (Config/roxygen2/version bumped from 7.3.3),
which also reflows existing man pages to the newer link syntax. Fix the
tf_component<- multi-length-selector test to match the checkmate
"length 1" assertion message.

R CMD check: 0 errors | 0 warnings | 0 notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude added 3 commits May 31, 2026 10:34
- New pkgdown article walks through tfd_mv/tfb_mv end-to-end on the
  built-in gait data and on Atlantic hurricane tracks from dplyr::storms,
  covering construction, accessors, plotting (facet + trajectory),
  arithmetic/summaries, geometric primitives, basis fitting, and dplyr
  integration.
- tf_arclength.tf_mv: per-curve clamp to the intersection of [lower,
  upper] with each curve's observed argument range, so irregular curves
  that don't span the global domain return their actual path length
  instead of erroring on NA paired evaluations. Fix cli plural marker on
  the now-defensive abort path. Regression test added.
- plot.tf_mv (trajectory mode): honour user-supplied xlab/ylab via
  modifyList (prev: "matched by multiple actual arguments"); accept an
  alpha argument and apply it via grDevices::adjustcolor, matching
  plot.tf semantics.
- Wire the article into _pkgdown.yml and ignore built HTML/artefacts
  under vignettes/articles/. Add knitr/rmarkdown to Suggests for
  building.
pkgdown's vignette index keys nest articles under `articles/<slug>`, so
the bare `vector-valued-functions` entry in `_pkgdown.yml#articles` did
not match any known topic and broke `navbar_articles()` during the
site build.
Article (vignettes/articles/vector-valued-functions.Rmd)
- Reframed around concrete analytical questions instead of an API tour.
- Gait: pointwise mean+/-sd envelope; min/max arc-length subjects;
  variance-share / RMSE for an FPC basis; phase alignment via
  tf_register(method = "cc", ref_component = "hip"); unit-speed
  reparameterization via tf_reparam_arclength.
- Storms: project (long, lat) into per-storm local-km coordinates so
  tf_arclength reports kilometres and tf_speed reports km/h (deg/h
  artefactually overweights northward motion via the cos(lat) shrink
  of a longitude degree). Map by peak Saffir-Simpson category;
  path-length boxplot vs intensity; forward-speed time courses split
  TS/TD vs Cat 4+; tfb_mv smoothing of the longest 6 tracks.

R/mv-geom.R
- tf_reparam_arclength: out[good] <- tf_warp(...) failed with a vctrs
  ptype mismatch when tf_warp upgraded tfd_reg -> tfd_irreg.  Build
  the output by ptype-common vec_c() of warped + untouched curves
  followed by an index reordering instead of in-place subassign.

R/mv-plot.R
- plot.tf_mv(type = "facet"): prefer mfrow = c(1, d) for d <= 3 (the
  typical "small multiples in a row" layout fits standard figure widths
  without "figure margins too large"); fall back to n2mfrow for larger d.
fabian-s and others added 28 commits July 2, 2026 09:41
…review)

A curve finite at the subset-freezing iteration can still produce a
non-finite objective later (e.g. alignment pushes it outside the
evaluable range). mean() over the frozen subset then goes NA and the
is.finite(obj) guard silently disables best-tracking and the worsening
check for the rest of the run.

Fix: obj averages over the currently-finite kept curves; the worsening
comparison uses the pairwise-finite kept subset so both means cover the
SAME curves within each comparison -- the only comparability the
same-template check needs (na.rm on each side separately would
reintroduce the changing-subset incomparability #265 removed).

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
- tf_depth.tf_mv returns a named numeric(0) for zero-length input
  (previously errored in the vapply/matrix reshaping)
- depth_median_index treats non-finite depths as -Inf so a partially-NA
  curve can't poison the tie count; aborts informatively when no finite
  depth exists
- median.tf_mv keeps its documented length-1 contract on empty
  (post-filter) input via vec_init, consistent with summary/fivenum

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Registration numerics: domain preservation, same-template objective, in-loop scale equalization (#264, #265, #266)
tf_mv verbs: quantile + points implemented, tf_invert permanently stubbed (#255 follow-up)
Multivariate depth: weighted componentwise MBD, joint median, tf_order (#273)
The test ran for the first time on CI (fdasrvf absent in the dev
container, so it always skipped locally) and failed on a pure
names-attribute mismatch: aligned_arclen carries curve names, the
expected side was unname()d. Values agree.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
The standalone Rd topic added in #297 was missing from _pkgdown.yml;
pkgdown aborts on unlisted topics.

https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
Fix wave-fallout CI red: tf_scales test names + median.tf_mv pkgdown index
R CMD check requires method arguments to match the generic's
(x, arg, depth, na.rm, ...) in order, with method-specific extras
after them. The weights argument added in #297 sat before na.rm,
which failed 'checking S3 generic/method consistency' across the
whole check matrix once the test red from #298 no longer masked it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1QMfji5MpKJvzJYw5Kjb9
The #241 fix gave empty tfd prototypes the sentinel domain c(NA, NA), but
every consumer still compared domains with NA-unsafe arithmetic, so
vec_c()/vec_rbind()/c() involving tfd() crashed with "missing value where
TRUE/FALSE needed" -- a regression vs 0.4.1 for any workflow binding onto
an initially empty tfd column. This treats the sentinel as a wildcard
everywhere:

- get_larger_domain()/domain_contains()/assert_same_domains() defer to the
  known domain; ptype2/cast methods short-circuit on empty prototypes so
  no spurious irreg-casts or basis comparisons happen (mgcv would abort on
  empty grids).
- tfb_spline/tfb_fpc/tf_mv empty prototypes now carry the same sentinel
  as tfd (was the old c(0, 0)), and validate_tf accepts it for prototypes.
- new_tf_mv() unions component domains NA-safely; per review, widening a
  component's domain now warns for tfd components and aborts for tfb
  components (basis evaluation outside the fitted range extrapolates,
  i.e. fabricates values).
- new_tfd() recognizes all-NA input arriving as NULL placeholders
  (tfd(list(NA_real_)) crashed on range(integer(0))).
- Empty-input hardening in the same cluster: [.tf with matrix = TRUE
  returns a 0-row matrix instead of crashing in colnames<-, no spurious
  interpolate-warning for length-0 input, tf_mfpc_scores(m[0]) returns a
  0 x npc matrix.

New regression tests in test-empty-prototypes.R; existing tests updated
to expect the new widening warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…component args

Four review findings on the component-wise verb layer:

- tf_fmax()/tf_fmin()/tf_fmedian() on tf_mv returned a flattened length-n*d
  vector with NA-padded names (curve-major interleaving of components);
  they now return the same n x d matrix as tf_fmean()/tf_fvar()/tf_fsd().
- Summary.tf_mv built one shared NA mask across all mv operands, which
  recycled masks across operands of different (recyclable) lengths and
  dropped the wrong curves; each operand is now completed with its own
  mask (mv_missing() removed as dead code).
- The loud MFPC demotion that vec_arith/Math already performed is now
  centralized in map_components()/map2_components()/imap_components(), so
  tf_smooth()/tf_derive()/tf_zoom()/tf_integrate()/mean()/sd()/var()/
  Summary on a tfb_mfpc no longer silently produce a tfb_mv whose
  components carry the unusable per-component scoring stub -- they warn
  once and return valid demoted tfb_fpc components instead.
- tf_derive.tf_mv()/tf_integrate.tf_mv() now accept per-component `arg`
  lists via the existing tf_mv_component_arg() helper, consistent with
  tf_interpolate() and the fwise summaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MFPCA (review findings on tfb-mfpc.R):
- tfb_mfpc() and tfb_fpc() abort informatively on completely missing
  curves instead of crashing with "subscript out of bounds" in df_2_mat()
  (partially missing evaluations continue to use soft-impute SVD).
- df_2_mat() keeps factor-id row alignment, so curves whose rows are all
  incomplete stay row-aligned instead of shifting every later curve up.
- Re-scoring new data (tf_rebase()/vec_cast onto an MFPCA basis) maps
  completely missing curves to NA scores and NA entries -- previously the
  scoring silently zero-weighted them into ~0 scores; tf_mfpc_scores()
  returns NA rows for such entries instead of dropping them.
- tfb_mfpc.list()/default() forward constructor arguments (arg, domain)
  to tfd_mv() explicitly; they used to stay in `...` and collide with the
  `arg` the univariate FPCA method already receives.

Constructors (tfb-mv.R / tfd-mv.R):
- tfb_mv() on an existing tfb_mv with an explicit `basis` re-fits instead
  of silently returning the old basis; a change of basis kind converts
  through tfd() (the univariate tfb re-fitters assume same-kind
  attributes and crash on cross-kind input).
- tfb_mv.list()/default() partition `...` into constructor (arg, domain)
  vs basis arguments instead of leaking both into both calls.
- tfd_mv.array() no longer collapses length-1 curve/arg margins
  (drop = TRUE lost the curve dimension of n x 1 slices).
- "id" is now a reserved component name like "arg": a component named id
  silently collided with the curve-id column in long-format conversions
  (found via tidyfun's tf_unnest()).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… boundaries

Three review findings on the registration layer:

- tf_register() on tf_mv inputs whose components live on different grids
  failed the univariate arg assertion when re-evaluating the shared
  inverse warps; they are now represented on the sorted union of the
  component grids.
- tf_register_shape() unconditionally overwrote a user-supplied template
  with the mean of the aligned curves; tf_template() now returns exactly
  the supplied template, consistent with tf_register(method = "srvf_mv").
- The srvf_mv/shape machinery accepted d = 1 and template-free n = 1
  input and then crashed deep inside fdasrvf: single-component srvf_mv
  now delegates to univariate elastic registration, shape registration
  requires >= 2 components, and template-free single-curve registration
  is rejected with an explanation at the validator boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tf_arg() on a tf_mv keeps its univariate-matching polymorphic contract
(shared numeric grid, per-curve list, or per-component list), but internal
consumers no longer disambiguate the list shapes by comparing names --
which is ambiguous when the curve count equals the component count with
colliding names. tf_mv_curve_grids() and tf_register() now recompute the
layout from the components via mv_args_shared().

Also documents the deliberate deviation of tf's vec_cast() methods from
the strict vctrs cast invariant: casts re-express in the target's
representation but keep the source's arg-grid (decided in review -- the
alternative silently coarsens data during dplyr-style type unification).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on the geometry/calculus layer:

- tf_arclength(method = "derive") always passed its default lower/upper
  (the full domain) down to tf_integrate(), defeating the per-curve
  default-limit rescue for irregular curves that do not span the domain --
  every such curve silently returned NA. Limits are now only forwarded
  when the user supplied them.
- Indefinite tf_integrate() on irregular tfd inserted user-supplied limits
  with strict comparisons, so a limit that float-mismatches a grid point
  (0.3 vs seq()'s 0.30000000000000004) created an (almost-)duplicate arg
  value and thus an invalid tfd that aborts on any later use. Limits (and
  polyline arc-length endpoints) now snap to (almost-)coinciding grid
  points via the grid's own resolution.
- tfd_mv.array() dimension fix follow-up: preserve curve names, which the
  matrix() reshape dropped (caught by the shape-registration tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Bump to 0.5.0: the mv extension is the largest feature addition since
  0.3, and tidyfun's Depends will pin tf (>= 0.5.0).
- NEWS.md: document the pre-release-review contract changes (reserved
  component names, domain-widening warn/error policy, loud MFPC demotion,
  n x d freduce results, NA-curve aborts in fpc/mfpc) and bug fixes.
- cran-comments.md: note the mlr3fda revdep finding (test asserting the
  pre-#241 all-NA collapse; maintainers have the fix staged upstream).
- Reattach the tfbrackets roxygen block to its topic: the helper functions
  inserted above [.tf had captured it, so document() generated a spurious
  tf_bracket_i.Rd and deleted tfbrackets.Rd.
- Document the new arg/domain formals of tfb_mv()/tfb_mfpc(); regenerate
  NAMESPACE (matpoints/points imports) and Rd files; Rbuildignore .codex.

R CMD check: 0 errors, 0 warnings, 1 note (local html-tidy tooling only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- extract all_equal_to_first() helper; let mv_args_shared() accept
  precomputed per-component args
- hoist tf_arg() extraction out of the per-curve loop in
  tf_mv_curve_grids() (was O(n^2 d) for irregular components)
- tf_evaluations.tf_mv() delegates to tf_evaluate.tf_mv(), whose no-arg
  branch is the same contract (drops n*d throwaway bracket data.frames)
- pass a shared numeric caller arg through tf_evaluate.tf_mv() as-is
  instead of expanding to a length-n list (keeps the univariate tfb
  single-matrix fast path)
- assemble_mv_evals(): build each per-curve data.frame in one
  data_frame0() call instead of growing it column-by-column
- tf_bracket_j(): vec_unique() the per-curve grids before the union sort
- [.tf_mv: replace the per-component interpolate vector with a scalar --
  components are constructor-guaranteed same-kind, so it was constant
- tf_count.tf_mv: use is_tfb_mv() and drop the dead is.null(mat) branch
- drop no-op names(comps) <- comp_names after purrr maps over the
  (always-named) components

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
map_components()/map2_components() already demote tfb_mfpc operands
(with the same warn-once logic); forwarding the operation name via .op
makes the per-method pre-demotion boilerplate redundant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- new_tf_mv(): reuse the already-computed all_tfb flag in the
  domain-widening abort instead of re-mapping is_tfb per component
- tfd_mv.data.frame(): factor the thrice-repeated column-selector
  resolution into a local col_nm() helper
- tfd_mv.tf_mv(): delegate to build_components() like the other
  constructor methods
- distribute_dots(): delegate to tf_mv_component_arg(), which implements
  the identical per-component-list dispatch rule
- drop no-op names(comps) <- comp_names after maps over components

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tf_tangent.tf_mv(): compute the inverse speed directly from the single
  set of derivative evaluations instead of evaluating the derivative a
  second time through tf_norm() + re-evaluation of the speed tfd
- tf_reparam_arclength(): emit the degenerate-curve warning once, use
  plain vec_c() (which performs the same ptype_common/cast internally)
- arclength_polyline(): lengths() idiom, drop guards unreachable after
  tf_evaluate() filled the >= 2-point grids, reuse all_equal_to_first()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- format.tf_mv(): one vectorized paste() instead of an n x d
  scalar-indexing double loop
- as.data.frame.tf_mv(unnest = TRUE): build the long/wide columns in one
  pass instead of rbind-ing n (or n x d) per-curve data.frames; the
  final (id, arg[, component]) sort makes the construction order
  irrelevant, so results are identical

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The freduce factory handled tf_mv via an is_tf_mv() branch inside its
closures, while the adjacent tf_fmean/tf_fvar/tf_fsd use UseMethod()
with explicit .tf_mv methods. Align with that pattern (and with the
documented rule that tf_mv behaviour comes only from explicitly
registered .tf_mv methods); results are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tail of new_tfb_fpc_demoted() duplicated the tfb_fpc assembly of
new_tfb_fpc_shared() verbatim; delegate instead so the attribute
contract lives in one place. Also drop a no-op names() reassignment
after map2 over the named components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tf_register_srvf(): reuse srvf_mv_gamma_to_warps() for the fdasrvf
  gamma rescaling + endpoint pinning (#242 logic existed twice)
- tf_warp.tfd()/tf_align.tfd(): extract the thrice-repeated
  domain-carrying re-evaluation (#266) into retfd_keep_domain() and
  hoist it out of tf_align's duplicated if/else tails
- tf_register_shape(): reuse srvf_mv_validate_template() with a new
  scalar_only flag instead of restating its checks inline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
validate_tfb_spline()/validate_tfb_fpc() shared ~50 identical lines of
basis/arg/coefficient checks differing only in the class name shown in
messages; factor them into validate_tfb_common(). Remove the dangling
"Spec phrased this as ..." development note from validate_tf_mv().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- drop redundant names(comps) <- comp_names after purrr maps over the
  always-named components (calculus-mv, depth, rng)
- tfd_numeric_op(): iterate evaluations in fixed order and swap operands
  only at the do.call, instead of swapping map2's inputs twice
- mv-stubs.R: collapse a section header that only recorded where removed
  stubs went

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Components on differing argument grids can now be exported two ways:
"union" (default, previous behavior) evaluates every component on each
curve's union grid, interpolating inside the observed range -- the right
pairing for trajectory-style consumers; "component" evaluates each
component strictly on its own grid (or `arg`), fabricating nothing at args
a component was not observed at (absent rows in the long schema, NA cells
in the wide one) -- the faithful tabular export that tidyfun::tf_unnest()
provides, which can now delegate here instead of maintaining a parallel
outer-join implementation. Both settings agree for shared grids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… vctrs docs

Sweep from the cran-submission checklist: replace non-ASCII characters
(em-dashes, arrows, approx signs, accented names) in code comments added on
this branch -- they pass R CMD check locally but the CRAN pretest builds the
PDF manual with LaTeX and chokes on non-ASCII in sources ("Matérn" in the
tf_rgp() docs stays: it shipped in 0.4.1's CRAN-built manual). Add the two
missing @examples (median.tf_mv, the vctrs methods page); datasets carry
\format as intended, urlchecker reports all URLs correct, and the code-policy
sweep (T/F literals, options/par hygiene, :::, tempdir) found nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fabian-s
fabian-s merged commit 4180eb8 into main Jul 14, 2026
9 of 10 checks passed
@fabian-s
fabian-s deleted the claude/jolly-planck-nvXkZ branch July 14, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants