Skip to content

Latest commit

 

History

History
734 lines (596 loc) · 41.3 KB

File metadata and controls

734 lines (596 loc) · 41.3 KB

plant — architecture guide for agents

This document orients coding agents working in the plant R package. Read it before making changes, especially changes that touch the C++/R boundary.

plant is an extensible framework for modelling size- and trait-structured demography, ecology and evolution in simulated forests. It is an individual-based model in which plant physiology and demography are mediated by traits. Plants of multiple species can be grown in isolation, in competing patches, or in metapopulations under a disturbance regime; these dynamics are integrated up to metapopulation-level estimates of invasion fitness and vegetation structure.

The performance-critical core is written in C++ and accessed from R. Reference: Falster et al. (2016) Methods in Ecology and Evolution 7:136–146, doi:10.1111/2041-210X.12525.


1. The big picture: a two-layer package

┌─────────────────────────────────────────────────────────────┐
│  R layer  (R/*.R)                                            │
│  - user-facing constructors, dispatch, hyperparameters       │
│  - run_scm (collect/refine_schedule) / run_stochastic        │
│  - tidy outputs, plotting, utilities                         │
└───────────────────────────▲─────────────────────────────────┘
                            │   RcppR6 + Rcpp generated bindings
┌───────────────────────────▼─────────────────────────────────┐
│  C++ core  (inst/include/plant/*.h  +  src/*.cpp)            │
│  - templated class hierarchy: Strategy, Environment,         │
│    Individual, Node, Species, Patch, SCM, Stochastic*        │
│  - numerics: ODE solver, adaptive quadrature, interpolators  │
└─────────────────────────────────────────────────────────────┘

The two layers are bridged by RcppR6 on top of Rcpp. The single source of truth for the interface is inst/RcppR6_classes.yml.

Critical rule: never hand-edit generated files

These files are generated and carry "do not edit by hand" headers. Editing them directly will be overwritten and is almost always a mistake:

To change the interface, edit the C++ headers and inst/RcppR6_classes.yml, then regenerate (see §6).


2. C++ core class hierarchy

All headers live in inst/include/plant/; implementations in src/. Headers are included via -isystem../inst/include/ (see src/Makevars).

The core classes are templated on two type parameters:

  • T = a Strategy type (the biological/physiological model)
  • E = an Environment type (the resource environment the strategy reads)
Strategy<E>                         the biological model for one species
  └─ concrete: FF16_Strategy, TF24_Strategy, K93_Strategy

Environment                         resource environment (e.g. light profile)
  └─ concrete: FF16_Environment, TF24_Environment, K93_Environment

Individual<T,E>                     one plant: state + rates from a Strategy
Node<T,E>                           a cohort in the method-of-characteristics
Species<T,E>                        all nodes/cohorts of one Strategy
Patch<T,E>                          all Species sharing one Environment
SCM<T,E>                            the deterministic solver (see §3)
Parameters<T,E>                     model configuration (strategies, schedule, …)
NodeSchedule                        the times at which cohorts are introduced

Key headers to know:

Concern Header
Biological model interface strategy.h
Concrete models models/ff16_strategy.h, models/tf24_strategy.h, models/k93_strategy.h
Environments models/ff16_environment.h (and tf24/k93 variants)
Single plant individual.h, per-plant state in internals.h
Cohort / species / patch node.h, species.h, patch.h
Deterministic solver scm.h, helpers in scm_utils.h
Stochastic solver stochastic_patch_runner.h, stochastic_patch.h, stochastic_species.h
Config parameters.h, control.h
Disturbance disturbances/weibull_disturbance.h, no_disturbance.h
Leaf-level physiology leaf_model.h (photosynthesis + hydraulics; used by TF24)
Numerics ode_solver/, qag.h/qk.h (adaptive quadrature), interpolator.h/adaptive_interpolator.h, uniroot.h, gradient.h

The Strategy contract

A Strategy defines how a plant grows, dies, and reproduces. When subclassing Strategy<E> (see strategy.h and FF16 as the reference), the key overridable surface is:

  • static size_t state_size() and static std::vector<std::string> state_names()keep these in sync with the actual state vector (FF16 returns 5: height, mortality, fecundity, area_heartwood, mass_heartwood).
  • aux_size() / aux_names() — derived/auxiliary outputs.
  • compute_rates(env, vars) — the ODE right-hand side for one plant.
  • competition_effect(size) — how the plant contributes to the shared environment.
  • establishment_probability, fecundity_dt, mortality_dt, initial_size.

Every Strategy carries a Control and an ExtrinsicDrivers object.


3. Two simulation modes

plant solves the same model two ways:

  1. Deterministic — SCM (Solver via method of Characteristics). Cohorts (Nodes) are introduced on a schedule and integrated as characteristic curves of the size-density PDE. Entry point: SCM<T,E> in C++, run_scm() in R. Tidied-output collection and adaptive schedule refinement are now options on run_scm() (collect = TRUE, refine_schedule = TRUE); the refinement loop itself lives in SCM::refine_schedule() in C++ (see §3.1).

  2. Stochastic — finite-size population. Individuals arrive and die as discrete events. StochasticPatchRunner<T,E> in C++; R/stochastic.R: stochastic_schedule(), run_stochastic_collect().

Both share the same Strategy, Environment, Individual, and Parameters.

3.1 Schedule refinement moved into C++ — API change (2026-06)

The deterministic solver previously split its work across R and C++: R drove the adaptive node-schedule refinement (build_schedule(), internal run_scm_error()) and a separate function returned tidied output (run_scm_collect()). This all now lives in C++ on SCM<T,E>, and the R surface is a single run_scm(). See issue #408 for background.

Migration map for callers (other repos/scripts must be updated):

Removed (R) Replacement
build_schedule(p, …) run_scm(p, …, refine_schedule = TRUE)$parameters
run_scm_collect(p, …) run_scm(p, …, collect = TRUE)
run_scm_error(p, …) run with scm$collect_errors <- TRUE, then read scm$combined_node_errors
split_times() internal to SCM::refine_schedule() (no R equivalent)

run_scm(p, env, ctrl, refine_schedule = FALSE, collect = FALSE, use_ode_times = FALSE) returns the SCM object by default, or — when collect = TRUE — the tidied results list (whose p element holds the possibly-refined parameters).

3.2 Control defaults folded into C++ — API change (2026-06)

The pragmatic "fast-ish" numeric settings that essentially every plant run uses previously lived in two R helpers (fast_control(), scm_base_control()) that layered them on top of a tight-tolerance Control() constructor. Those values are now the defaults in the C++ Control() constructor itself (src/control.cpp) — a single source of truth — and the two R helpers are removed. Control() (lowercase alias control()) is therefore the fast default; control_accurate() opts back into tight ODE/schedule tolerances for high-accuracy runs.

Migration map for callers (other repos/scripts must be updated):

Removed (R) Replacement
fast_control() Control() / control() (these defaults are now built in)
scm_base_control() Control() / control()
(high-accuracy run) control_accurate() (tightens ode_tol_rel/abs, ode_step_size_max, schedule_eps)

scm_base_parameters() is unaffected — it builds a Parameters object, not a Control.

What moved into C++ (all on Node/Species/SCM in node.h, species.h, scm.h):

  • Node records its introduction_time and patch-age density at introduction, so fitness/error calcs no longer re-derive these from node_schedule or the disturbance regime after the run.
  • SCM::run() accumulates the per-node refinement error when collect_errors is set; the combined competition + reproduction error is exposed as combined_node_errors.
  • SCM::refine_schedule() runs the adaptive loop (flag nodes whose error exceeds schedule_eps, bisect the interval below each, repeat up to schedule_nsteps) and writes the refined schedule + ode times back into its parameters so the Parameters object stays self-describing.

Known downstream breakage: regnans calls the removed functions in R/community_plant.R and scripts/example/ESA.Rmd; update per the table above.

3.3 When you change the R-facing interface — record it for downstream migration

Renaming, removing, or changing the meaning of anything a user calls (functions, arguments, argument order, Control/Parameters fields, R6 types) breaks downstream products (analysis repos, regnans, notebooks). Two obligations whenever you make such a change:

  1. Record an old -> new mapping in NEWS.md under the development version's "Breaking changes" subsection. Write it to be machine-actionable — explicit old(...) -> new(...) lines, one per affected symbol, the way §3.1/§3.2's tables and the existing NEWS entries are. This section is the single source of truth for migration; agents.md narrates the why, NEWS carries the exactly what changed. Pure-internal/perf changes go under "Internals & performance" instead and need no migration line.
  2. Downstream migration is a skill, not a manual chase. The plant-update-interface skill (.claude/skills/plant-update-interface/) reads NEWS's "Breaking changes" entries and applies them to a product repo (locate call sites, propose per-site fixes, rebuild + test, record a baseline marker). It does not memorise the API — it re-reads NEWS each run — so the only thing that keeps it working is keeping NEWS disciplined per (1). To bring a product up to date, run that skill in the product repo. If you find a breaking change with no NEWS mapping, that's a NEWS bug — fix it at the source.

How to write a Breaking-changes entry (format the skill can act on)

The migration skill reads these entries mechanically, so the format matters as much as the prose. Rules:

  • One bullet per change; PR number(s) at the end in parens, e.g. (#463).
  • State the old -> new mapping explicitly, with the call form, not just the name. run_scm_collect(p, …) -> run_scm(p, …, collect = TRUE) is actionable; "consolidated the SCM interface" is not.
  • One sub-bullet per affected symbol when a change touches several:
    * <one-line what-and-why> (#NNN). Migration:
      * `old_fn(a, b)`            -> `new_fn(a, b, flag = TRUE)`
      * `old_field`               -> `obj$new_field`
      * `helper()` (removed)      -> `<replacement, or "no equivalent">`
  • Capture side-channels, not just the renamed call. If callers read a result via an attribute, a returned list element, or a follow-up getter, map that too — a name-only mapping silently drops it. (Real example: build_schedule() returned its result with attr(., "offspring_production"); the faithful migration is scm <- run_scm(p, refine_schedule = TRUE); scm$parameters; scm$offspring_production — the $parameters-only mapping loses the offspring count.)
  • Flag semantic (not mechanical) changes loudly. A symbol whose meaning or default changed while its name stayed the same (e.g. raw Control() now means fast, not accurate) must say so in words — the skill cannot infer it from a grep and will (correctly) escalate it for human review.
  • Argument-order / signature changes get their own bullet; advise callers to switch to named arguments.
  • For larger reorganisations, also add a narrative subsection here in §3 (as §3.1/§3.2 do) and link it from the NEWS bullet — NEWS carries the exact what, agents.md the why.

4. The R interface layer

R files in R/ (ignore the two large generated files RcppR6.R, RcppExports.R):

File Responsibility
ff16.R, tf24.R, k93.R Per-model constructors (FF16_Individual, FF16_Parameters, …), hyperpar functions, expand_state, stand reports
strategy_support.R Dispatch tables mapping a model name → its functions (hyperpar, make_hyperpar, param_hyperpar, environment_type, Environment, expand_state, node-schedule helpers). These are switch() statements that must list every model.
scm_support.R run_scm (with collect / refine_schedule flags), scm_base_parameters, and the Control presets control (alias) / control_accurate. Fast settings are now C++ Control() defaults (§3.2); adaptive schedule refinement lives in C++ (SCM::refine_schedule, §3.1) — there is no longer an R build_schedule.R
stochastic.R Stochastic simulation driver
individual.R grow_individual_to_{size,height,time}, optimise_individual_rate_*, compensation points
util_model.R trait_matrix, generate_strategy, add_strategies, add_mutant (and deprecated strategy_list/expand_parameters/mutant_parameters shims)
tidy_outputs.R tidy_patch, tidy_species, tidy_env, interpolate_to_{times,heights}, integrate_over_size_distribution
tidy_plots.R Plotting helpers (plot_size_distribution)
logging.R plant_log_console and logging via loggr
benchmark.R run_plant_benchmarks
util.R, solar_model.R, qk.R, utils-pipe.R Misc utilities

How R dispatches across templated C++ types

Because the C++ classes are templated on <T,E>, RcppR6 generates one explicit instantiation per registered pair (FF16/FF16_Env, TF24/TF24_Env, K93/K93_Env). R-side constructors therefore look like:

FF16_Individual <- function(s = FF16_Strategy()) Individual("FF16", "FF16_Env")(s)
FF16_Parameters <- function(...)                 Parameters("FF16", "FF16_Env")(...)

Individual(...) / Parameters(...) return the correctly-typed generated constructor. Generic code recovers the type with extract_RcppR6_template_types(obj, "Parameters") and then dispatches (see run_scm in scm_support.R).

Hyperparameters vs. raw strategy parameters

A Strategy exposes many low-level C++ parameters, held in a nested pars sub-object (FF16_Pars/K93_Pars/TF24_Pars) and accessed from R as s$pars$lma, s$pars$rho, … (the strategy's top level only carries control, birth_rate_*, is_variable_birth_rate, collect_all_auxiliary). In C++ these are read as pars.<name>; derived/precomputed quantities (e.g. eta_c, height_0, canopy_shape, the TF24 Leaf) stay as plain strategy members set in prepare_strategy(), not in pars.

Users normally set a smaller set of ecologically meaningful traits (e.g. LMA, wood density). The hyperpar functions (FF16_hyperpar, etc.) translate traits → strategy parameters and encode trade-offs. The pipe-friendly, object-first entry points are generate_strategy(p, traits) (→ a list of strategies) and add_strategies(p, traits, …) / add_mutant(p, traits, …) (→ a Parameters object); trait_matrix() builds the traits matrix. The former strategy_list() / expand_parameters() / mutant_parameters() are deprecated shims (see NEWS). Keep hyperpar functions and the strategy_support.R switch tables consistent — a comment there flags that the scaffolder also depends on these.

Allometric/size functions (area_leaf, diameter_stem, the mass cascade) live only in C++; the R *_expand_state() helpers call them via the exported FF16/TF24_strategy_expand_allometry() (src/strategy_expand.cpp) rather than re-deriving the formulas.


5. The interface contract: RcppR6_classes.yml

inst/RcppR6_classes.yml declares, for each exposed class: its name_cpp, constructor signature, active bindings (fields/getters, often named with a trailing _ for internal/debug fields), methods, and — for templated classes — the templates: block listing the concrete <T,E> pairs to instantiate. This file is what makes a C++ class reachable from R. If a method/field is not in the yml, R cannot see it.


6. Build & regeneration workflow

Use the Makefile targets. The dependency you must internalise: changing the C++/R interface requires regeneration before recompiling.

Command What it does
make RcppR6 Regenerate R/RcppR6.R + src/RcppR6.cpp from RcppR6_classes.yml
make attributes Rcpp::compileAttributes()R/RcppExports.R + src/RcppExports.cpp
make compile pkgbuild::compile_dll() (no attribute regen)
make full_compile compile and regenerate RcppExports
make roxygen devtools::document()man/ + NAMESPACE
make rebuild The full pipeline: cleanRcppR6full_compileroxygen
make test make all then devtools::test()
make check / make build / make install R CMD check / build / INSTALL
make benchmark run scripts/benchmark.R
make scenarios run the TF24 hydraulic scenario gateway → writes a scorecard RDS (scripts/run_scenario_gateway.R)
make bless-scenarios regenerate the gateway test baseline (tests/testthat/test_data/scenario_baseline.rds) — only when a changed outcome is intended

Practical rules:

  • Edited a header signature, added a method/field, or touched the yml? run make rebuild (or at minimum make RcppR6 && make full_compile && make roxygen).
  • Edited only .cpp implementation (no interface change)? make compile is enough.
  • Edited only R code / roxygen comments? make roxygen (then devtools::load_all()).
  • During development, devtools::load_all() is the fastest way to test changes.

C++ standard is C++20 (CXX_STD = CXX20 in src/Makevars); note the README still mentions C++14. Requires R ≥ 4.5.0.


7. Adding a new model (strategy + environment)

This is the most cross-cutting change in the package because every templated class and every R dispatch table must learn the new <Strategy, Environment> pair. Use the scaffolder rather than doing it by hand:

source("scripts/new_strategy_scaffolder.R")
create_strategy_scaffold("MyModel", "FF16")   # copy FF16 as the template

# variant that reuses an existing environment (issue #274):
create_strategy_scaffold("FF16r", "FF16", environment = "FF16")

The scaffolder (scripts/new_strategy_scaffolder.R):

  • adds the new pair to the templates: blocks in RcppR6_classes.yml,
  • copies and renames the strategy (and, in own-environment mode, the environment) header + source files,
  • extends the R dispatch switch() tables and helper-plant.R lists,
  • with environment = "<model>", reuses an existing environment instead of generating a new one (no <name>_Environment files/bindings/tests).

After scaffolding, implement the biology: growth/mortality/reproduction in the new strategy, map competition_effect to the environment, wire rates into compute_rates, and update state_names()/state_size(). Then run make rebuild and add tests. The plant-new-strategy skill (.claude/skills/plant-new-strategy/) captures the full workflow, including a worked walkthrough implementing Kohyama 1993 as K93 (.claude/skills/plant-new-strategy/worked-example-k93.md).

The three shipped models:

  • FF16 — the default model (Falster et al. 2011, 2016, 2018). The reference implementation to copy from.
  • K93 — Kohyama 1993, a simpler size-structured model.
  • TF24 — newer model using the leaf-level Leaf photosynthesis/hydraulics submodel (incl. the recently added Medlyn stomatal model).

Scientific versioning — bump when the science changes

Each model carries a scientific version separate from the package Version: a static constexpr int scientific_version on the strategy class in inst/include/plant/models/, surfaced to R as model_version(type) / model_id(type) ("FF16@v1") via src/strategy_version.cpp and R/strategy_support.R. Downstream tools (notably logpile, which content-addresses archived simulations) read it to decide when to re-run: reruns follow scientific changes, not software releases.

Rules:

  • Bump scientific_version (in the model header, in the same commit) when you change equations or default parameters such that the simulation output changes for identical inputs.
  • Do NOT bump for refactors, performance work, interface renames, or serialisation-format changes — the science is unchanged.
  • FF16/K93 are scientifically frozen and should rarely move; TF24/TF24f change often and will bump frequently. Current: FF16@v1, K93@v1, TF24@v2.
  • TF24f is a compound version. It is a fast approximation of TF24 that inherits TF24's equations/parameters, so its version is "<TF24 version>.<approximation revision>" (TF24f@v2.1). The major component auto-tracks TF24_Strategy::scientific_version (a TF24 change invalidates TF24f too — the safe direction); bump approximation_revision (in tf24f_strategy.h) only for changes specific to the fast approximation. model_version() therefore returns a string ("1", "2.1"), not an integer.
  • A bump is deliberate: it invalidates that model's logpile cache and forces reruns. The drift-guard test tests/testthat/test-model-version.R fails when a default changes without a bump (snapshot of each model's default pars/control); pure equation changes are not auto-detected and rely on review. Adding a new model → add its constant and a dispatch arm in src/strategy_version.cpp (the scaffolder should do this).

8. Testing

testthat tests live in tests/testthat/, roughly one file per component (test-scm.R, test-patch.R, test-strategy-ff16.R, test-leaf.r, …). Shared setup is in helper-plant.R. Regression baselines for FF16 are in tests/testthat/FF16_reference/ and checked by test-strategy-ff16-reference-comparison.R — if you intentionally change FF16 numerics you may need to regenerate these.

Run the suite with make test or devtools::test(); a single file with devtools::test_active_file() or testthat::test_file(...).

The suite runs under testthat edition 3 and is parallelised by file (Config/testthat/parallel: true in DESCRIPTION). testthat takes the worker count from getOption("Ncpus") / the TESTTHAT_CPUS env var, defaulting to 2 — set e.g. Sys.setenv(TESTTHAT_CPUS = "8") in your ~/.Rprofile to use more cores. Parallelism only pays off with an optimised build: run make (compiles with -O2) before timing, not a bare devtools::load_all(), which builds unoptimised and is much slower.

There is also an opt-in scenario gateway test (test-scenario-gateway.R), skipped by default and enabled with PLANT_RUN_SCENARIOS=1. It runs the full SCM for every TF24 hydraulic scenario in inst/scenarios/ and diffs the per-scenario outcomes against a recorded baseline — a baseline diff, not an "all pass" assertion, so it catches both regressions and improvements (many scenarios are expected to fail by design). When a changed outcome is intended, re-bless the baseline with make bless-scenarios. See notes/plan-tf24-scenario-framework.md.

CI: .github/workflows/R-CMD-check.yaml and benchmarks.yml.


9. Issues & project board (GitHub)

Current development is tracked on GitHub:

Agents can read both through the gh CLI (installed; the repo's .claude/settings.json pre-allows read-only gh commands so they run without prompting):

gh issue list -R traitecoevo/plant --state open
gh issue view <n> -R traitecoevo/plant
gh project item-list 5 --owner traitecoevo

Requirements: gh must be authenticated (gh auth login). Projects v2 needs the project scope, which is not granted by default — add it with gh auth refresh -s project,read:project. Writing to issues/PRs/the board (create, comment, edit, move) is intentionally not in the allowlist and will prompt for confirmation.

10. Documentation & website

Where each kind of information lives

Documentation is split across several homes — put new content in the right one:

Content Home Source
Narrative docs — task-oriented guides, theory/maths, worked examples (the former vignettes/: plant overview, individuals, patch, demography, parameters, extrinsic_drivers, emergent, self_thinning) Overstorey https://github.com/traitecoevo/overstorey (Quarto)
Blog / dated experiments — posts pinned to the plant version they were built against (was vignettes/blog/) Overstorey's "Adaptively" notebook same repo
Extending the model — adding a new strategy/environment plant-new-strategy skill (see §7) this repo (.claude/skills/plant-new-strategy/)
Migrating downstream code to a newer plant interface plant-update-interface skill (see §3.3) this repo (.claude/skills/plant-update-interface/)
Function/API reference — per-function docs generated from roxygen pkgdown site https://traitecoevo.github.io/plant/ this repo (man/, pkgdown/_pkgdown.yml)
Architecture / contributor guide this file (agents.md) this repo
Installation & citation README.md this repo
Changelog NEWS.md this repo
Roadmap / planning GitHub Projects board (see §9)

Rule of thumb: prose that a user reads → Overstorey; the docstring for a function → roxygen comment in R/ (rebuilt into the pkgdown reference); guidance for someone changing the code → this file.

The vignette content was migrated into Overstorey in 799a668 (guides, theory, and the dated posts, with committed Quarto freezes).

Generating the website (pkgdown reference)

The pkgdown site is the function reference only (narrative articles now live on Overstorey, §above). It is built from roxygen output and config in pkgdown/ (_pkgdown.yml defines the navbar + reference: layout). There is no CI workflow for it — build and deploy manually:

devtools::document()          # refresh man/ first (or: make roxygen)
pkgdown::build_site()         # renders into the gh-pages destination

_pkgdown.yml sets destination: gh-pages; the rendered site is published from the repo's gh-pages branch (pkgdown::deploy_to_branch() does the build + push in one step). The navbar links out to Overstorey for the guides.

Overstorey itself is a Quarto site with its own CI (publish.yml) — see that repo's README for how it renders, freezes (_freeze/), and version-pins posts.


11. Conventions & gotchas checklist

  • ❌ Do not edit generated files: R/RcppR6.R, R/RcppExports.R, src/RcppR6.cpp, src/RcppExports.cpp, RcppR6_pre/post.hpp, NAMESPACE, man/.
  • ✅ Edit headers in inst/include/plant/ and the yml; then regenerate.
  • ✅ Adding/removing strategy state → update state_size() and state_names().
  • ✅ Exposing a new C++ method/field to R → add it to RcppR6_classes.yml, then make RcppR6.
  • ✅ Adding a model → use the scaffolder; remember the R switch() tables in strategy_support.R and the hyperpar functions, and add a scientific_version constant + dispatch arm in src/strategy_version.cpp.
  • ✅ Changing a model's equations/default parameters (output changes for the same inputs) → bump its scientific_version in the model header (see §7).
  • ✅ After interface changes run the full make rebuild; after C++-only changes make compile.
  • ℹ️ Active bindings ending in _ generally expose internal C++ fields for inspection/testing, not part of the stable user API.
  • ℹ️ Two solvers (deterministic SCM, stochastic) share the same model classes — changes to a Strategy affect both.
  • ⚠️ Several hot-path constructs look "wrong" but are deliberate performance choices — see §12 before "tidying" them (inline helpers in headers, integer index constants, ratio-first signatures, scratch buffers).

12. Performance & optimisation strategies

The deterministic SCM solver spends almost all of its time in one nested loop (SCM::runode::derivsPatch::compute_ratesSpecies::compute_ratesNode::compute_rates/growth_rate_gradientStrategy::compute_ratesassimilation/competition). Because that loop runs for every node, every quadrature point, every timestep, small per-call costs dominate. The codebase uses a consistent set of techniques to keep it fast. Many of these make the code look more complicated than the underlying maths — do not "simplify" them back without re-profiling. The profiling workflow and the catalogue of techniques that paid off are captured in the profile-plant skill (.claude/skills/profile-plant/); the umbrella issue is #466, with follow-up #470 (LTO).

Algorithmic (the big wins):

  • Spline-based competition environment — turns O(n²) into ~O(n). Naively, computing the light each plant experiences means summing the shading of every other plant, i.e. O(n²) per timestep for n individuals. Instead Patch::compute_environment() builds a resource spline (resource_spline.h) once per timestep by evaluating cumulative competition at a fixed set of heights; each individual then queries the environment with an O(1) spline lookup (get_environment_at_height). Cost becomes O(n) to build + O(1) per query.
  • Uniform-grid O(1) spline index. tk::spline::operator() (tk/spline.h, src/tk_spline.cpp) detects an equidistant knot grid in set_points() and replaces the std::lower_bound binary search with direct index arithmetic. Falls back to binary search for adaptive/non-uniform grids. (#435)
  • Finite-difference gradient without reallocation. Node::growth_rate_gradient() (node.h) needs a mutable Individual to perturb height on; it reuses a thread_local scratch (copy-assigned each call, reusing vector storage) instead of copy-constructing fresh Internals every call.

Templated headers & inlining (this build has no LTO). src/Makevars uses CXX_STD = CXX20 with no -flto and DESCRIPTION has no UseLTO, so a function defined in a .cpp translation unit cannot be inlined into the templated Individual<T>/Node/Species code instantiated in another TU. Every such call is a real, non-inlinable call on the hot path. Consequences you will see in the code:

  • Small, hot strategy helpers are defined inline in the header, not in the .cpp: area_leaf, update_dependent_aux, the compute_competition overloads, and compute_competition_by_ratio live in ff16_strategy.h / tf24_strategy.h; util::is_finite and the Interpolator accessors are inline in their headers. Moving them back into a .cpp re-introduces a cross-TU call and measurably slows the loop.
  • The cleanest fix for the remaining large cross-TU calls (assimilation, compute_rates) is enabling LTO — tracked separately in #470 because it is a build-config change with toolchain/portability trade-offs.

Avoiding repeated per-call overhead:

  • Integer index slots instead of string-map lookups. State/aux/rate access in the hot path uses fixed integer constants (HEIGHT_INDEX, MORTALITY_INDEX, *_AUX_INDEX constexprs in the strategy headers, and the cached *_aux_index members in individual.h) rather than std::map<std::string,int>::at("name"). These constants must stay in sync with the order of state_names()/aux_names() — there are comments saying so at each declaration. Named string access is kept for the R-facing/diagnostic paths.
  • Cached dependent auxiliary state. Values that depend only on height are computed once when height is set (update_dependent_aux) and stored in aux slots: competition_effect (= area_leaf(height)) and height_inverse (= 1/height). The competition and assimilation paths read these instead of recomputing area_leaf and the division every call.
  • Eta-specialised canopy shape. canopy_shape.h (CanopyShape, shared by FF16/TF24/K93) selects a multiplication-chain implementation of u^eta once in prepare_strategy() for common integer eta (1,2,4,8,10,12), avoiding the libm pow() slow path per quadrature point; also caches 1/eta. (#465, libm pow cost from #361)
  • Ratio-first signatures. q()/Q() and the competition helpers take the height-normalised ratio u = z/H (plus a cached 1/height) directly, so the z/H division is hoisted out of inner loops rather than repeated per point.
  • No std::function in quadrature. assimilation() passes its integrand lambda to the templated QK::integrate by its own closure type, not wrapped in std::function, so the integrand inlines at each quadrature point instead of making a type-erased indirect call.
  • Hoisting loop invariants. The light-spline upper bound (canopy top) is fetched once per assimilation() call and passed into the capped get_environment_at_height(z, cap) overload, instead of re-reading spline.max() per quadrature point; within the crown integral the bounds are already guaranteed, so the unchecked spline(height) is used in place of spline.eval().
  • De-duplicated math kernels. Where two allocation-derivative terms share a pow(area_leaf, a_l2), it is computed once (see FF16_Strategy::darea_leaf_dmass_live).

Measuring. Always benchmark with make compile (matches release flags) — devtools::load_all() alone is not representative. Use scripts/profile-benchmarks.R:

make compile
PLANT_PROFILE_REPEATS=20 Rscript scripts/profile-benchmarks.R FF16

Record results following the profile-plant skill (.claude/skills/profile-plant/). Bit-identical changes are strongly preferred; where a reciprocal-multiply reorders floating-point ops, the affected reference tests were relaxed to an explicit tolerance (noted in that file).

Issue & project-board conventions

Development across plant, regnans, and overstorey is tracked on a shared project board. New issues are auto-added to the board with no Status — that's the triage queue. A maintainer sets Status (e.g. Backlog) during triage, so you don't need to set it yourself.

When opening an issue (including whenever the user asks you to create one), always:

  • Set exactly one type label. Only three labels exist in these repos — do not invent new ones:

    • bug — an existing feature not functioning as intended
    • task — a discrete task needed for a feature (the default for normal work)
    • epic — a new feature or capability, usually an umbrella over several tasks
  • Prefix the title with a theme tag in square brackets so the board sorts cleanly. Reuse an existing theme where it fits; only fall back to [other] when nothing applies:

    Tag Scope
    [TF24 hydraulics] Hydraulics component of the TF24 strategy
    [TF24 allometry] Flexible allometry for the TF24 model
    [TF24 nsc] Non-structural carbohydrate storage in TF24
    [acclimation] Acclimation of leaf and other traits
    [simplify interface] Consistent interface to the plant & regnans models
    [evol assembly] Evolutionary assembly linking plant to regnans
    [Env drivers] Driving the model with environmental drivers
    [speed] Performance — making the model run faster
    [patch variations] Multiple patch setups (multi-patch, stochastic metapopulation, continuous patch)
    [AutoDiff] Enabling automatic differentiation in plant (e.g. for gradient-based calibration)
    [forecasting] Enabling forecasting with the plant model
    [documentation] Documenting model capabilities (any of the three repos)
    [other] Anything not covered above

    A title may carry more than one tag when it genuinely spans themes (e.g. [speed] [TF24 hydraulics] …).

Create issues with gh issue create -R traitecoevo/plant --title "[tag] …" --label task (swap in bug/epic as appropriate).

Plant family

plant is part of the plant family in the traitecoevo org — a hub-and-spoke set of packages built around the plant size- and trait-structured forest model.

  • Docs hub — family user guides & theory: https://traitecoevo.github.io/overstorey/
  • Cross-package orientation — how the family fits together (who depends on whom, source-of-truth rules, cross-repo gotchas) lives in plant-meta; start with its AGENTS.md. Keep family-wide concerns there, not here.
  • Issues & board — follow the issue guide; work is tracked on board #5 (new issues auto-add with no Status = the triage queue). Labels: bug / task / epic plus blocked, needs-info, cross-package, breaking, question.